Compare commits
16 Commits
main
...
9349391834
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9349391834 | ||
|
|
b4d270faf6 | ||
|
|
2ecd8db79a | ||
|
|
3acdbf4b1a | ||
|
|
00f4e40505 | ||
|
|
cfc2fe7f7d | ||
|
|
550309c2e7 | ||
|
|
351c7857cb | ||
|
|
7cfbce7505 | ||
|
|
f112046178 | ||
|
|
319b5ec03b | ||
|
|
ae60273782 | ||
|
|
fb6f2bbc02 | ||
|
|
cacc53a184 | ||
|
|
b5a8c11a30 | ||
|
|
7704c0c067 |
18
.gitignore
vendored
18
.gitignore
vendored
@@ -6,4 +6,20 @@ out
|
||||
*.log*
|
||||
|
||||
#AI Agent
|
||||
.claude
|
||||
.claude
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Data
|
||||
data/temp/*
|
||||
data/output/*
|
||||
data/reports/*
|
||||
!data/temp/.gitkeep
|
||||
!data/output/.gitkeep
|
||||
!data/reports/.gitkeep
|
||||
|
||||
# Environment
|
||||
config/.env
|
||||
config/.env.local
|
||||
18
config/.env.example
Normal file
18
config/.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# SQL Server
|
||||
SQL_SERVER_SERVER=192.168.110.114
|
||||
SQL_SERVER_DATABASE=CompanyDB
|
||||
SQL_SERVER_USERNAME=peng
|
||||
SQL_SERVER_PASSWORD=your_password_here
|
||||
SQL_SERVER_DRIVER=ODBC Driver 18 for SQL Server
|
||||
SQL_SERVER_TRUST_CERT=yes
|
||||
|
||||
# MySQL
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=erp_db
|
||||
MYSQL_USERNAME=root
|
||||
MYSQL_PASSWORD=your_password_here
|
||||
|
||||
# ERP Credentials (optional, can also be entered in UI)
|
||||
ERP_USERNAME=BLDpengqiangqiang
|
||||
ERP_PASSWORD=your_password_here
|
||||
0
config/.gitkeep
Normal file
0
config/.gitkeep
Normal file
19
config/app.json
Normal file
19
config/app.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"appName": "ERPAuto",
|
||||
"version": "1.0.0",
|
||||
"logLevel": "info",
|
||||
"browser": {
|
||||
"headless": false,
|
||||
"slowMo": 50,
|
||||
"timeout": 30000
|
||||
},
|
||||
"erp": {
|
||||
"baseUrl": "",
|
||||
"ignoreHttpsErrors": false
|
||||
},
|
||||
"paths": {
|
||||
"tempDir": "./data/temp",
|
||||
"outputDir": "./data/output",
|
||||
"reportDir": "./data/reports"
|
||||
}
|
||||
}
|
||||
7
config/development.json
Normal file
7
config/development.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"logLevel": "debug",
|
||||
"browser": {
|
||||
"headless": false,
|
||||
"slowMo": 100
|
||||
}
|
||||
}
|
||||
0
data/output/.gitkeep
Normal file
0
data/output/.gitkeep
Normal file
0
data/reports/.gitkeep
Normal file
0
data/reports/.gitkeep
Normal file
0
data/temp/.gitkeep
Normal file
0
data/temp/.gitkeep
Normal file
8
jest.config.js
Normal file
8
jest.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/tests'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
};
|
||||
0
logs/.gitkeep
Normal file
0
logs/.gitkeep
Normal file
15160
package-lock.json
generated
Normal file
15160
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -14,20 +14,29 @@
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postinstall": "electron-builder install-app-deps && playwright install chromium",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux"
|
||||
"build:linux": "electron-vite build && electron-builder --linux",
|
||||
"test": "jest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0"
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"mssql": "^12.2.0",
|
||||
"mysql2": "^3.18.2",
|
||||
"playwright": "^1.58.2",
|
||||
"winston": "^3.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/mssql": "^9.1.9",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -39,9 +48,11 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"jest": "^30.2.0",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"ts-jest": "^29.4.6",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
}
|
||||
|
||||
0
src/main/config/.gitkeep
Normal file
0
src/main/config/.gitkeep
Normal file
158
src/main/config/app.config.ts
Normal file
158
src/main/config/app.config.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { AppConfig } from '../models/config.types';
|
||||
|
||||
export class ConfigManager {
|
||||
private static instance: AppConfig | null = null;
|
||||
|
||||
static load(): AppConfig {
|
||||
if (this.instance) {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load default config (minimal, no sensitive data)
|
||||
const defaultConfig = this.getDefaultConfig();
|
||||
|
||||
// Load environment-specific config
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
const envConfigPath = path.join(__dirname, `../../../config/${env}.json`);
|
||||
|
||||
let envConfig: Partial<AppConfig> = {};
|
||||
if (fs.existsSync(envConfigPath)) {
|
||||
try {
|
||||
const envConfigContent = fs.readFileSync(envConfigPath, 'utf-8');
|
||||
envConfig = JSON.parse(envConfigContent);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load env config from ${envConfigPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load environment variables (with validation)
|
||||
const envVars = this.loadFromEnv();
|
||||
|
||||
// Deep merge configurations
|
||||
this.instance = this.deepMerge(defaultConfig, envConfig, envVars);
|
||||
|
||||
// Validate required configuration
|
||||
this.validateConfig(this.instance);
|
||||
|
||||
return this.instance;
|
||||
} catch (error) {
|
||||
console.error('Failed to load configuration:', error);
|
||||
throw new Error('Configuration loading failed');
|
||||
}
|
||||
}
|
||||
|
||||
private static getDefaultConfig(): AppConfig {
|
||||
const appConfigPath = path.join(__dirname, '../../../config/app.json');
|
||||
|
||||
try {
|
||||
if (fs.existsSync(appConfigPath)) {
|
||||
const configContent = fs.readFileSync(appConfigPath, 'utf-8');
|
||||
return JSON.parse(configContent);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load app.json, using minimal defaults:`, error);
|
||||
}
|
||||
|
||||
// Minimal fallback defaults (NO sensitive data)
|
||||
return {
|
||||
appName: 'ERPAuto',
|
||||
version: '1.0.0',
|
||||
logLevel: 'info',
|
||||
browser: {
|
||||
headless: false,
|
||||
slowMo: 50,
|
||||
timeout: 30000,
|
||||
},
|
||||
databases: {
|
||||
sqlServer: {
|
||||
server: '', // MUST be set via env var
|
||||
database: '',
|
||||
username: '',
|
||||
password: '',
|
||||
driver: 'ODBC Driver 18 for SQL Server',
|
||||
trustServerCertificate: 'yes',
|
||||
},
|
||||
mysql: {
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
database: '',
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
erp: {
|
||||
baseUrl: '', // MUST be set via env var
|
||||
ignoreHttpsErrors: false,
|
||||
},
|
||||
paths: {
|
||||
tempDir: './data/temp',
|
||||
outputDir: './data/output',
|
||||
reportDir: './data/reports',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static loadFromEnv(): Partial<AppConfig> {
|
||||
const sqlServer = {
|
||||
server: process.env.SQL_SERVER_SERVER || '',
|
||||
database: process.env.SQL_SERVER_DATABASE || '',
|
||||
username: process.env.SQL_SERVER_USERNAME || '',
|
||||
password: process.env.SQL_SERVER_PASSWORD || '',
|
||||
driver: process.env.SQL_SERVER_DRIVER || 'ODBC Driver 18 for SQL Server',
|
||||
trustServerCertificate: process.env.SQL_SERVER_TRUST_CERT || 'yes',
|
||||
};
|
||||
|
||||
const mysql = {
|
||||
host: process.env.MYSQL_HOST || 'localhost',
|
||||
port: parseInt(process.env.MYSQL_PORT || '3306'),
|
||||
database: process.env.MYSQL_DATABASE || '',
|
||||
username: process.env.MYSQL_USERNAME || '',
|
||||
password: process.env.MYSQL_PASSWORD || '',
|
||||
};
|
||||
|
||||
return {
|
||||
databases: {
|
||||
sqlServer,
|
||||
mysql,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static deepMerge(...configs: Partial<AppConfig>[]): AppConfig {
|
||||
const result = configs[0] as AppConfig;
|
||||
|
||||
for (let i = 1; i < configs.length; i++) {
|
||||
const config = configs[i];
|
||||
for (const key in config) {
|
||||
if (Object.prototype.hasOwnProperty.call(config, key)) {
|
||||
const value = (config as any)[key];
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
(result as any)[key] = { ...(result as any)[key], ...value };
|
||||
} else {
|
||||
(result as any)[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static validateConfig(config: AppConfig): void {
|
||||
// Validate critical configuration
|
||||
if (!config.databases.sqlServer.server && process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SQL Server server address must be configured via environment variable');
|
||||
}
|
||||
|
||||
if (!config.databases.sqlServer.database) {
|
||||
throw new Error('SQL Server database name must be configured');
|
||||
}
|
||||
|
||||
if (!config.erp.baseUrl) {
|
||||
console.warn('Warning: ERP base URL not configured, ERP features will not work');
|
||||
}
|
||||
}
|
||||
}
|
||||
0
src/main/controllers/.gitkeep
Normal file
0
src/main/controllers/.gitkeep
Normal file
0
src/main/dao/.gitkeep
Normal file
0
src/main/dao/.gitkeep
Normal file
61
src/main/dao/materials-to-delete.dao.ts
Normal file
61
src/main/dao/materials-to-delete.dao.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { DatabaseService } from '../services/database.service';
|
||||
import { LoggerService } from '../services/logger.service';
|
||||
|
||||
export class MaterialsToDeleteDAO {
|
||||
constructor(private dbService: DatabaseService) {}
|
||||
|
||||
/**
|
||||
* Query materials_to_delete table and return distinct material names
|
||||
* @param managerNames - Optional array of manager names to filter by. Pass null to get all materials.
|
||||
* @returns Promise<string[]> - Array of distinct material names
|
||||
*/
|
||||
async getMaterialsToDeleteByManagers(
|
||||
managerNames: string[] | null
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
// Input validation
|
||||
if (managerNames && managerNames.length === 0) {
|
||||
LoggerService.warn('Empty manager names array provided, returning empty result');
|
||||
return [];
|
||||
}
|
||||
|
||||
LoggerService.info(
|
||||
`Querying materials_to_delete${managerNames ? ` for ${managerNames.length} managers` : ' (all managers)'}`
|
||||
);
|
||||
|
||||
// Build base query
|
||||
let query = `
|
||||
SELECT DISTINCT material_name
|
||||
FROM materials_to_delete
|
||||
`;
|
||||
|
||||
let params: string[] | undefined;
|
||||
|
||||
// Add WHERE clause with parameterized query if manager names provided
|
||||
// SECURITY: Use parameterized queries to prevent SQL injection
|
||||
if (managerNames && managerNames.length > 0) {
|
||||
const placeholders = managerNames.map((_, i) => `@param${i}`).join(',');
|
||||
query += ` WHERE manager_name IN (${placeholders})`;
|
||||
params = managerNames;
|
||||
}
|
||||
|
||||
// Execute query with parameters (if any)
|
||||
const result = await this.dbService.executeSQLServerQuery(query, params);
|
||||
const materials = result.rows.map((row: any) => row.material_name);
|
||||
|
||||
LoggerService.info(`Found ${materials.length} materials to delete`);
|
||||
return materials;
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to query materials_to_delete table', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all materials from materials_to_delete table without filtering
|
||||
* @returns Promise<string[]> - Array of all distinct material names
|
||||
*/
|
||||
async getAllMaterialsToDelete(): Promise<string[]> {
|
||||
return this.getMaterialsToDeleteByManagers(null);
|
||||
}
|
||||
}
|
||||
64
src/main/dao/production-order.dao.ts
Normal file
64
src/main/dao/production-order.dao.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { DatabaseService } from '../services/database.service';
|
||||
import { LoggerService } from '../services/logger.service';
|
||||
|
||||
export class ProductionOrderDAO {
|
||||
constructor(private dbService: DatabaseService) {}
|
||||
|
||||
async queryProductionOrderNumbers(productionIds: string[]): Promise<string[]> {
|
||||
try {
|
||||
LoggerService.info(`Querying production orders for ${productionIds.length} IDs`);
|
||||
|
||||
// Validate input
|
||||
if (productionIds.length === 0) {
|
||||
LoggerService.warn('No production IDs provided, returning empty array');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use parameterized query to prevent SQL injection
|
||||
const placeholders = productionIds.map((_, i) => `@param${i}`).join(',');
|
||||
const query = `
|
||||
SELECT DISTINCT production_order_no
|
||||
FROM production_orders
|
||||
WHERE production_id IN (${placeholders})
|
||||
`;
|
||||
|
||||
const result = await this.dbService.executeSQLServerQuery(query, productionIds);
|
||||
const orderNumbers = result.rows.map((row: any) => row.production_order_no);
|
||||
|
||||
LoggerService.info(`Found ${orderNumbers.length} production orders`);
|
||||
return orderNumbers;
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to query production orders', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async readProductionIds(filePath: string): Promise<string[]> {
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
// Validate file existence
|
||||
try {
|
||||
await fs.access(filePath, fs.constants.R_OK);
|
||||
} catch {
|
||||
throw new Error(`Production IDs file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const ids = content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
if (ids.length === 0) {
|
||||
LoggerService.warn('Production IDs file is empty');
|
||||
}
|
||||
|
||||
LoggerService.info(`Read ${ids.length} production IDs from file`);
|
||||
return ids;
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to read production IDs file', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,12 @@ app.whenReady().then(() => {
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
|
||||
// IPC Handler Registration
|
||||
// TODO: Register IPC handlers for:
|
||||
// - automation:clean, automation:extract, automation:stop, automation:progress
|
||||
// - database:query-production-orders, database:query-materials-to-delete
|
||||
// - auth:login, auth:logout
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
|
||||
0
src/main/models/.gitkeep
Normal file
0
src/main/models/.gitkeep
Normal file
10
src/main/models/auth.types.ts
Normal file
10
src/main/models/auth.types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
username: string;
|
||||
isLoggedIn: boolean;
|
||||
loginTime: Date;
|
||||
}
|
||||
46
src/main/models/config.types.ts
Normal file
46
src/main/models/config.types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface SQLServerConfig {
|
||||
server: string;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
driver: string;
|
||||
trustServerCertificate?: string;
|
||||
}
|
||||
|
||||
export interface MySQLConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface BrowserConfig {
|
||||
headless: boolean;
|
||||
slowMo: number;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export interface ERPConfig {
|
||||
baseUrl: string;
|
||||
ignoreHttpsErrors: boolean;
|
||||
}
|
||||
|
||||
export interface PathConfig {
|
||||
tempDir: string;
|
||||
outputDir: string;
|
||||
reportDir: string;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
appName: string;
|
||||
version: string;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
browser: BrowserConfig;
|
||||
databases: {
|
||||
sqlServer: SQLServerConfig;
|
||||
mysql: MySQLConfig;
|
||||
};
|
||||
erp: ERPConfig;
|
||||
paths: PathConfig;
|
||||
}
|
||||
9
src/main/models/database.types.ts
Normal file
9
src/main/models/database.types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface QueryResult {
|
||||
rows: any[];
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
export interface TransactionResult {
|
||||
success: boolean;
|
||||
rowsAffected: number;
|
||||
}
|
||||
10
src/main/models/ipc.types.ts
Normal file
10
src/main/models/ipc.types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface IPCChannels {
|
||||
'automation:clean': any;
|
||||
'automation:extract': any;
|
||||
'automation:stop': any;
|
||||
'automation:progress': any;
|
||||
'database:query-production-orders': any;
|
||||
'database:query-materials-to-delete': any;
|
||||
'auth:login': any;
|
||||
'auth:logout': any;
|
||||
}
|
||||
6
src/main/models/logger.types.ts
Normal file
6
src/main/models/logger.types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
17
src/main/models/playwright.types.ts
Normal file
17
src/main/models/playwright.types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Browser, BrowserContext, Page, Frame } from 'playwright';
|
||||
|
||||
export interface Credentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
browser: Browser;
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
mainFrame: Frame;
|
||||
}
|
||||
|
||||
export interface LoginResult extends Session {
|
||||
success: boolean;
|
||||
}
|
||||
0
src/main/services/.gitkeep
Normal file
0
src/main/services/.gitkeep
Normal file
76
src/main/services/auth.service.ts
Normal file
76
src/main/services/auth.service.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { PlaywrightService } from './playwright.service';
|
||||
import { Credentials, LoginResult } from '../models/playwright.types';
|
||||
import { AuthResult, SessionInfo } from '../models/auth.types';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
export class AuthService {
|
||||
private currentSession?: LoginResult;
|
||||
private sessionInfo?: SessionInfo;
|
||||
|
||||
constructor(private playwrightService: PlaywrightService) {}
|
||||
|
||||
async login(credentials: Credentials): Promise<AuthResult> {
|
||||
try {
|
||||
LoggerService.info(`Logging in user: ${credentials.username}`);
|
||||
|
||||
const session = await this.playwrightService.login(credentials);
|
||||
this.currentSession = session;
|
||||
this.sessionInfo = {
|
||||
username: credentials.username,
|
||||
isLoggedIn: true,
|
||||
loginTime: new Date(),
|
||||
};
|
||||
|
||||
LoggerService.info('Login successful');
|
||||
return { success: true, message: 'Login successful' };
|
||||
} catch (error) {
|
||||
LoggerService.error('Login failed', error);
|
||||
|
||||
// Ensure cleanup on failed login
|
||||
await this.playwrightService.closeBrowser().catch(cleanupError => {
|
||||
LoggerService.error('Failed to cleanup after failed login', cleanupError);
|
||||
});
|
||||
|
||||
this.currentSession = undefined;
|
||||
this.sessionInfo = undefined;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
if (!this.currentSession) {
|
||||
return; // Already logged out
|
||||
}
|
||||
|
||||
try {
|
||||
LoggerService.info('Logging out...');
|
||||
await this.playwrightService.closeBrowser();
|
||||
LoggerService.info('Logout successful');
|
||||
} catch (error) {
|
||||
LoggerService.error('Logout failed', error);
|
||||
throw new Error(`Logout failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
// Always clear state
|
||||
this.currentSession = undefined;
|
||||
this.sessionInfo = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
getSessionInfo(): SessionInfo | undefined {
|
||||
return this.sessionInfo;
|
||||
}
|
||||
|
||||
isLoggedIn(): boolean {
|
||||
return this.sessionInfo?.isLoggedIn ?? false;
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
await this.logout().catch(err =>
|
||||
LoggerService.error('Failed to logout during destroy', err)
|
||||
);
|
||||
}
|
||||
}
|
||||
109
src/main/services/database.service.ts
Normal file
109
src/main/services/database.service.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import sql from 'mssql';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { SQLServerConfig, MySQLConfig } from '../models/config.types';
|
||||
import { QueryResult } from '../models/database.types';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
export class DatabaseService {
|
||||
private sqlServerPool?: sql.ConnectionPool;
|
||||
private mysqlPool?: mysql.Pool;
|
||||
|
||||
constructor(
|
||||
private sqlServerConfig: SQLServerConfig,
|
||||
private mysqlConfig: MySQLConfig
|
||||
) {}
|
||||
|
||||
async connectToSQLServer(): Promise<void> {
|
||||
try {
|
||||
LoggerService.info('Connecting to SQL Server...');
|
||||
|
||||
this.sqlServerPool = await sql.connect({
|
||||
server: this.sqlServerConfig.server,
|
||||
database: this.sqlServerConfig.database,
|
||||
user: this.sqlServerConfig.username,
|
||||
password: this.sqlServerConfig.password,
|
||||
driver: this.sqlServerConfig.driver,
|
||||
options: {
|
||||
trustServerCertificate:
|
||||
this.sqlServerConfig.trustServerCertificate === 'yes',
|
||||
},
|
||||
});
|
||||
|
||||
LoggerService.info('SQL Server connection established');
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to connect to SQL Server', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async connectToMySQL(): Promise<void> {
|
||||
try {
|
||||
LoggerService.info('Connecting to MySQL...');
|
||||
|
||||
this.mysqlPool = mysql.createPool({
|
||||
host: this.mysqlConfig.host,
|
||||
port: this.mysqlConfig.port,
|
||||
database: this.mysqlConfig.database,
|
||||
user: this.mysqlConfig.username,
|
||||
password: this.mysqlConfig.password,
|
||||
});
|
||||
|
||||
LoggerService.info('MySQL connection established');
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to connect to MySQL', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async executeSQLServerQuery(query: string, params?: any[]): Promise<QueryResult> {
|
||||
if (!this.sqlServerPool) {
|
||||
throw new Error('SQL Server not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const request = this.sqlServerPool.request();
|
||||
if (params) {
|
||||
params.forEach((param, index) => {
|
||||
request.input(`param${index}`, param);
|
||||
});
|
||||
}
|
||||
|
||||
const result = await request.query(query);
|
||||
return {
|
||||
rows: result.recordset,
|
||||
rowCount: result.rowsAffected[0],
|
||||
};
|
||||
} catch (error) {
|
||||
LoggerService.error('SQL Server query failed', { query, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async executeMySQLQuery(query: string, params?: any[]): Promise<QueryResult> {
|
||||
if (!this.mysqlPool) {
|
||||
throw new Error('MySQL not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await this.mysqlPool.execute(query, params);
|
||||
return {
|
||||
rows: rows as any[],
|
||||
rowCount: (rows as any[]).length,
|
||||
};
|
||||
} catch (error) {
|
||||
LoggerService.error('MySQL query failed', { query, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async closeAll(): Promise<void> {
|
||||
if (this.sqlServerPool) {
|
||||
await this.sqlServerPool.close();
|
||||
LoggerService.info('SQL Server connection closed');
|
||||
}
|
||||
if (this.mysqlPool) {
|
||||
await this.mysqlPool.end();
|
||||
LoggerService.info('MySQL connection closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
125
src/main/services/excel-converter.service.ts
Normal file
125
src/main/services/excel-converter.service.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import path from 'path';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
export class ExcelConverterService {
|
||||
constructor(private verbose: boolean = true) {}
|
||||
|
||||
async convert(inputPath: string, outputPath?: string): Promise<any[]> {
|
||||
try {
|
||||
// Validate input file
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
// Check file exists
|
||||
try {
|
||||
await fs.access(inputPath, fs.constants.R_OK);
|
||||
} catch {
|
||||
throw new Error(`File not found: ${inputPath}`);
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if (!inputPath.match(/\.(xlsx|xls)$/i)) {
|
||||
throw new Error(`Invalid file type. Expected .xlsx or .xls file: ${inputPath}`);
|
||||
}
|
||||
|
||||
LoggerService.info(`Converting Excel file: ${inputPath}`);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.readFile(inputPath);
|
||||
|
||||
const worksheet = workbook.worksheets[0];
|
||||
if (!worksheet) {
|
||||
throw new Error('No worksheet found in Excel file');
|
||||
}
|
||||
|
||||
const data: any[] = [];
|
||||
const headers: string[] = [];
|
||||
|
||||
// Extract headers
|
||||
const headerRow = worksheet.getRow(1);
|
||||
headerRow.eachCell((cell, colNumber) => {
|
||||
headers[colNumber - 1] = cell.text;
|
||||
});
|
||||
|
||||
// Extract data rows
|
||||
worksheet.eachRow((row, rowNumber) => {
|
||||
if (rowNumber === 1) return; // Skip header row
|
||||
|
||||
const rowData: any = {};
|
||||
row.eachCell((cell, colNumber) => {
|
||||
const header = headers[colNumber - 1];
|
||||
if (header) {
|
||||
// Extract actual value from cell (handles formulas, rich text, etc.)
|
||||
const value = cell.value;
|
||||
const extractedValue = typeof value === 'object' && value !== null
|
||||
? (value as any).result || (value as any).text || value
|
||||
: value;
|
||||
rowData[header] = extractedValue;
|
||||
}
|
||||
});
|
||||
data.push(rowData);
|
||||
});
|
||||
|
||||
// Validate we got data
|
||||
if (data.length === 0) {
|
||||
LoggerService.warn('Excel file contains no data rows');
|
||||
}
|
||||
|
||||
LoggerService.info(`Extracted ${data.length} rows from Excel file`);
|
||||
|
||||
// Save to output path if provided
|
||||
if (outputPath) {
|
||||
await this.saveAsJson(data, outputPath);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
LoggerService.error('Excel conversion failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async mergeExcelFiles(filePaths: string[], outputPath: string): Promise<void> {
|
||||
try {
|
||||
LoggerService.info(`Merging ${filePaths.length} Excel files...`);
|
||||
|
||||
const allData: any[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const data = await this.convert(filePath);
|
||||
allData.push(...data);
|
||||
}
|
||||
|
||||
// Create new Excel workbook with merged data
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('Merged Data');
|
||||
|
||||
if (allData.length > 0) {
|
||||
const headers = Object.keys(allData[0]);
|
||||
worksheet.addRow(headers);
|
||||
|
||||
allData.forEach((row) => {
|
||||
const values = headers.map((header) => row[header]);
|
||||
worksheet.addRow(values);
|
||||
});
|
||||
}
|
||||
|
||||
await workbook.xlsx.writeFile(outputPath);
|
||||
LoggerService.info(`Merged data saved to: ${outputPath}`);
|
||||
} catch (error) {
|
||||
LoggerService.error('Excel merge failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveAsJson(data: any[], outputPath: string): Promise<void> {
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(outputPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
LoggerService.info(`Data saved to JSON: ${outputPath}`);
|
||||
} catch (error) {
|
||||
LoggerService.error(`Failed to save JSON to ${outputPath}`, error);
|
||||
throw new Error(`Failed to save JSON output: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
94
src/main/services/logger.service.ts
Normal file
94
src/main/services/logger.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import winston from 'winston';
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
import { LogEntry } from '../models/logger.types';
|
||||
|
||||
export class LoggerService {
|
||||
private static instance: winston.Logger | null = null;
|
||||
private static uiLogCallbacks: Set<(logEntry: LogEntry) => void> = new Set();
|
||||
|
||||
static initialize(): winston.Logger {
|
||||
if (this.instance) {
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
const logDir = path.join(app.getPath('userData'), 'logs');
|
||||
|
||||
this.instance = winston.createLogger({
|
||||
level: 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.printf(({ level, message, timestamp, stack }) => {
|
||||
if (stack) {
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}\n${stack}`;
|
||||
}
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}`;
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
),
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'app.log'),
|
||||
maxsize: 10 * 1024 * 1024,
|
||||
maxFiles: 5,
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error',
|
||||
maxsize: 10 * 1024 * 1024,
|
||||
maxFiles: 5,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
static onUILog(callback: (logEntry: LogEntry) => void): () => void {
|
||||
this.uiLogCallbacks.add(callback);
|
||||
return () => {
|
||||
this.uiLogCallbacks.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private static notifyUI(level: string, message: string, details?: any): void {
|
||||
const logEntry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
details,
|
||||
};
|
||||
|
||||
this.uiLogCallbacks.forEach((callback) => callback(logEntry));
|
||||
}
|
||||
|
||||
static info(message: string, details?: any): void {
|
||||
if (!this.instance) this.initialize();
|
||||
this.instance!.info(message, details);
|
||||
this.notifyUI('info', message, details);
|
||||
}
|
||||
|
||||
static warn(message: string, details?: any): void {
|
||||
if (!this.instance) this.initialize();
|
||||
this.instance!.warning(message, details);
|
||||
this.notifyUI('warn', message, details);
|
||||
}
|
||||
|
||||
static error(message: string, details?: any): void {
|
||||
if (!this.instance) this.initialize();
|
||||
this.instance!.error(message, details);
|
||||
this.notifyUI('error', message, details);
|
||||
}
|
||||
|
||||
static debug(message: string, details?: any): void {
|
||||
if (!this.instance) this.initialize();
|
||||
this.instance!.debug(message, details);
|
||||
this.notifyUI('debug', message, details);
|
||||
}
|
||||
}
|
||||
123
src/main/services/playwright.service.ts
Normal file
123
src/main/services/playwright.service.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { chromium, Browser, BrowserContext, Page, Frame } from 'playwright';
|
||||
import { Credentials, Session, LoginResult } from '../models/playwright.types';
|
||||
import { BrowserConfig, ERPConfig } from '../models/config.types';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
export class PlaywrightService {
|
||||
private browser?: Browser;
|
||||
private context?: BrowserContext;
|
||||
|
||||
constructor(
|
||||
private browserConfig: BrowserConfig,
|
||||
private erpConfig: ERPConfig
|
||||
) {}
|
||||
|
||||
async launchBrowser(): Promise<Browser> {
|
||||
try {
|
||||
LoggerService.info('Launching Chromium browser...');
|
||||
|
||||
this.browser = await chromium.launch({
|
||||
headless: this.browserConfig.headless,
|
||||
slowMo: this.browserConfig.slowMo,
|
||||
timeout: this.browserConfig.timeout,
|
||||
});
|
||||
|
||||
this.context = await this.browser.newContext({
|
||||
ignoreHTTPSErrors: this.erpConfig.ignoreHttpsErrors,
|
||||
});
|
||||
|
||||
LoggerService.info('Browser launched successfully');
|
||||
return this.browser;
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to launch browser', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async login(credentials: Credentials): Promise<LoginResult> {
|
||||
if (!this.browser || !this.context) {
|
||||
throw new Error('Browser not launched');
|
||||
}
|
||||
|
||||
let page: Page | undefined;
|
||||
|
||||
try {
|
||||
LoggerService.info('Attempting ERP login...');
|
||||
|
||||
page = await this.context.newPage();
|
||||
await page.goto(this.erpConfig.baseUrl, {
|
||||
timeout: this.browserConfig.timeout,
|
||||
});
|
||||
|
||||
// Wait for login form
|
||||
await page.waitForSelector('input[name="username"]', {
|
||||
timeout: this.browserConfig.timeout,
|
||||
});
|
||||
|
||||
// Fill credentials
|
||||
await page.fill('input[name="username"]', credentials.username);
|
||||
await page.fill('input[name="password"]', credentials.password);
|
||||
|
||||
// Click login button
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Get main frame
|
||||
const mainFrame = page.mainFrame();
|
||||
|
||||
LoggerService.info('Login successful');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
browser: this.browser,
|
||||
context: this.context,
|
||||
page,
|
||||
mainFrame,
|
||||
};
|
||||
} catch (error) {
|
||||
LoggerService.error('Login failed', error);
|
||||
|
||||
// Clean up page on error to prevent resource leak
|
||||
if (page) {
|
||||
try {
|
||||
await page.close();
|
||||
} catch (closeError) {
|
||||
LoggerService.error('Failed to close page after login error', closeError);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async closeBrowser(): Promise<void> {
|
||||
// Close context first
|
||||
if (this.context) {
|
||||
try {
|
||||
await this.context.close();
|
||||
LoggerService.info('Browser context closed');
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to close browser context', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Always attempt to close browser, even if context.close() failed
|
||||
if (this.browser) {
|
||||
try {
|
||||
await this.browser.close();
|
||||
LoggerService.info('Browser closed');
|
||||
} catch (error) {
|
||||
LoggerService.error('Failed to close browser', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async newPage(): Promise<Page> {
|
||||
if (!this.context) {
|
||||
throw new Error('Browser context not initialized');
|
||||
}
|
||||
return await this.context.newPage();
|
||||
}
|
||||
}
|
||||
0
src/main/utils/.gitkeep
Normal file
0
src/main/utils/.gitkeep
Normal file
0
src/preload/.gitkeep
Normal file
0
src/preload/.gitkeep
Normal file
@@ -1,8 +1,28 @@
|
||||
import { contextBridge } from 'electron'
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {}
|
||||
const API = {
|
||||
automation: {
|
||||
clean: (params: any) => ipcRenderer.invoke('automation:clean', params),
|
||||
extract: (params: any) => ipcRenderer.invoke('automation:extract', params),
|
||||
stop: () => ipcRenderer.invoke('automation:stop'),
|
||||
onProgress: (callback: (progress: any) => void) => {
|
||||
const listener = (_: any, progress: any) => callback(progress);
|
||||
ipcRenderer.on('automation:progress', listener);
|
||||
return () => ipcRenderer.removeListener('automation:progress', listener);
|
||||
},
|
||||
},
|
||||
database: {
|
||||
queryProductionOrders: (productionIds: string[]) =>
|
||||
ipcRenderer.invoke('database:query-production-orders', productionIds),
|
||||
queryMaterialsToDelete: (managerNames: string[] | null) =>
|
||||
ipcRenderer.invoke('database:query-materials-to-delete', managerNames),
|
||||
},
|
||||
auth: {
|
||||
login: (credentials: any) => ipcRenderer.invoke('auth:login', credentials),
|
||||
logout: () => ipcRenderer.invoke('auth:logout'),
|
||||
},
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
@@ -10,7 +30,7 @@ const api = {}
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
contextBridge.exposeInMainWorld('api', API)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -18,5 +38,7 @@ if (process.contextIsolated) {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
window.api = API
|
||||
}
|
||||
|
||||
export type API = typeof API
|
||||
|
||||
0
tests/.gitkeep
Normal file
0
tests/.gitkeep
Normal file
232
tests/unit/dao/materials-to-delete.dao.test.ts
Normal file
232
tests/unit/dao/materials-to-delete.dao.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { MaterialsToDeleteDAO } from '../../../src/main/dao/materials-to-delete.dao';
|
||||
import { DatabaseService } from '../../../src/main/services/database.service';
|
||||
|
||||
// Mock LoggerService to avoid Electron app dependency
|
||||
jest.mock('../../../src/main/services/logger.service', () => ({
|
||||
LoggerService: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MaterialsToDeleteDAO', () => {
|
||||
let dao: MaterialsToDeleteDAO;
|
||||
let mockDbService: jest.Mocked<DatabaseService>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create a mock DatabaseService
|
||||
mockDbService = {
|
||||
executeSQLServerQuery: jest.fn(),
|
||||
} as any;
|
||||
|
||||
dao = new MaterialsToDeleteDAO(mockDbService);
|
||||
});
|
||||
|
||||
describe('getMaterialsToDeleteByManagers', () => {
|
||||
it('should return all materials when managerNames is null', async () => {
|
||||
const mockResult = {
|
||||
rows: [
|
||||
{ material_name: 'MAT001' },
|
||||
{ material_name: 'MAT002' },
|
||||
{ material_name: 'MAT003' },
|
||||
],
|
||||
rowCount: 3,
|
||||
};
|
||||
|
||||
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await dao.getMaterialsToDeleteByManagers(null);
|
||||
|
||||
expect(result).toEqual(['MAT001', 'MAT002', 'MAT003']);
|
||||
expect(mockDbService.executeSQLServerQuery).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify the query does not contain WHERE clause
|
||||
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
|
||||
const query = queryCall[0];
|
||||
expect(query).not.toContain('WHERE');
|
||||
expect(query).toContain('SELECT DISTINCT material_name');
|
||||
expect(query).toContain('FROM materials_to_delete');
|
||||
});
|
||||
|
||||
it('should return materials filtered by manager names using parameterized query', async () => {
|
||||
const managerNames = ['Manager1', 'Manager2'];
|
||||
const mockResult = {
|
||||
rows: [
|
||||
{ material_name: 'MAT001' },
|
||||
{ material_name: 'MAT002' },
|
||||
],
|
||||
rowCount: 2,
|
||||
};
|
||||
|
||||
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await dao.getMaterialsToDeleteByManagers(managerNames);
|
||||
|
||||
expect(result).toEqual(['MAT001', 'MAT002']);
|
||||
expect(mockDbService.executeSQLServerQuery).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify parameterized query is used (SQL injection protection)
|
||||
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
|
||||
const query = queryCall[0];
|
||||
const params = queryCall[1];
|
||||
|
||||
expect(query).toContain('WHERE manager_name IN');
|
||||
expect(query).toContain('@param0');
|
||||
expect(query).toContain('@param1');
|
||||
|
||||
// Verify parameters are passed separately (not concatenated in query)
|
||||
expect(params).toEqual(managerNames);
|
||||
|
||||
// Ensure no string concatenation of values in query
|
||||
expect(query).not.toContain("'Manager1'");
|
||||
expect(query).not.toContain("'Manager2'");
|
||||
});
|
||||
|
||||
it('should return empty array when managerNames is empty', async () => {
|
||||
const result = await dao.getMaterialsToDeleteByManagers([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockDbService.executeSQLServerQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle single manager name', async () => {
|
||||
const managerNames = ['Manager1'];
|
||||
const mockResult = {
|
||||
rows: [{ material_name: 'MAT001' }],
|
||||
rowCount: 1,
|
||||
};
|
||||
|
||||
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await dao.getMaterialsToDeleteByManagers(managerNames);
|
||||
|
||||
expect(result).toEqual(['MAT001']);
|
||||
expect(mockDbService.executeSQLServerQuery).toHaveBeenCalledTimes(1);
|
||||
|
||||
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
|
||||
expect(queryCall[0]).toContain('@param0');
|
||||
expect(queryCall[1]).toEqual(['Manager1']);
|
||||
});
|
||||
|
||||
it('should return empty array when no materials found', async () => {
|
||||
const mockResult = {
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
};
|
||||
|
||||
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await dao.getMaterialsToDeleteByManagers(['Manager1']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle SQL injection attempts via parameterized query', async () => {
|
||||
const maliciousInput = [
|
||||
"Manager1'; DROP TABLE materials_to_delete; --",
|
||||
"Manager2' OR '1'='1",
|
||||
];
|
||||
|
||||
const mockResult = {
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
};
|
||||
|
||||
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
|
||||
|
||||
await dao.getMaterialsToDeleteByManagers(maliciousInput);
|
||||
|
||||
// Verify parameters are passed as values, not concatenated
|
||||
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
|
||||
const query = queryCall[0];
|
||||
const params = queryCall[1];
|
||||
|
||||
// The malicious strings should be in params, not in query
|
||||
expect(params).toEqual(maliciousInput);
|
||||
|
||||
// Query should only contain placeholders, not actual values
|
||||
expect(query).not.toContain('DROP TABLE');
|
||||
expect(query).not.toContain('OR 1=1');
|
||||
expect(query).toMatch(/@param\d+/);
|
||||
});
|
||||
|
||||
it('should throw error when database query fails', async () => {
|
||||
const dbError = new Error('Database connection failed');
|
||||
mockDbService.executeSQLServerQuery.mockRejectedValue(dbError);
|
||||
|
||||
await expect(
|
||||
dao.getMaterialsToDeleteByManagers(['Manager1'])
|
||||
).rejects.toThrow('Database connection failed');
|
||||
});
|
||||
|
||||
it('should handle large number of manager names', async () => {
|
||||
const managerNames = Array.from({ length: 100 }, (_, i) => `Manager${i}`);
|
||||
const mockResult = {
|
||||
rows: [{ material_name: 'MAT001' }],
|
||||
rowCount: 1,
|
||||
};
|
||||
|
||||
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await dao.getMaterialsToDeleteByManagers(managerNames);
|
||||
|
||||
expect(result).toEqual(['MAT001']);
|
||||
|
||||
// Verify all parameters are passed
|
||||
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
|
||||
expect(queryCall[1]).toEqual(managerNames);
|
||||
expect(queryCall[1]?.length).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllMaterialsToDelete', () => {
|
||||
it('should return all materials by calling getMaterialsToDeleteByManagers with null', async () => {
|
||||
const mockResult = {
|
||||
rows: [
|
||||
{ material_name: 'MAT001' },
|
||||
{ material_name: 'MAT002' },
|
||||
{ material_name: 'MAT003' },
|
||||
],
|
||||
rowCount: 3,
|
||||
};
|
||||
|
||||
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await dao.getAllMaterialsToDelete();
|
||||
|
||||
expect(result).toEqual(['MAT001', 'MAT002', 'MAT003']);
|
||||
expect(mockDbService.executeSQLServerQuery).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify query without filter
|
||||
const queryCall = mockDbService.executeSQLServerQuery.mock.calls[0];
|
||||
expect(queryCall[0]).not.toContain('WHERE');
|
||||
});
|
||||
|
||||
it('should propagate errors from getMaterialsToDeleteByManagers', async () => {
|
||||
const dbError = new Error('Database error');
|
||||
mockDbService.executeSQLServerQuery.mockRejectedValue(dbError);
|
||||
|
||||
await expect(dao.getAllMaterialsToDelete()).rejects.toThrow(
|
||||
'Database error'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no materials exist', async () => {
|
||||
const mockResult = {
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
};
|
||||
|
||||
mockDbService.executeSQLServerQuery.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await dao.getAllMaterialsToDelete();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
tests/unit/services/database.service.test.ts
Normal file
26
tests/unit/services/database.service.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { DatabaseService } from '../../../src/main/services/database.service';
|
||||
|
||||
describe('DatabaseService', () => {
|
||||
it('should throw error when querying without connection', async () => {
|
||||
const service = new DatabaseService(
|
||||
{
|
||||
server: 'localhost',
|
||||
database: 'test',
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
driver: 'test',
|
||||
},
|
||||
{
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
database: 'test',
|
||||
username: 'test',
|
||||
password: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.executeSQLServerQuery('SELECT 1')
|
||||
).rejects.toThrow('SQL Server not connected');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "tests"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user