diff --git a/tests/fixtures/factory.ts b/tests/fixtures/factory.ts index 89fcd4a..e6904b3 100644 --- a/tests/fixtures/factory.ts +++ b/tests/fixtures/factory.ts @@ -73,10 +73,12 @@ export class UserFactory { /** * Generate unique user ID * - * @returns Unique ID string in format USR-{timestamp}-{random} + * @returns Unique numeric ID */ - private static generateId(): string { - return `USR-${Date.now()}-${Math.random().toString(36).substr(2, 6)}` + private static idCounter = 0 + + private static generateId(): number { + return ++UserFactory.idCounter } /** diff --git a/tests/fixtures/types.ts b/tests/fixtures/types.ts index 8fe2e93..5ccbac5 100644 --- a/tests/fixtures/types.ts +++ b/tests/fixtures/types.ts @@ -88,8 +88,8 @@ export interface Order { * Simplified user data for creating test users. */ export interface TestUser { - /** User ID */ - id: string + /** User ID (matches UserInfo.id: number) */ + id: number /** Username for login */ username: string /** User type/role */ diff --git a/tests/fixtures/user-factory.test.ts b/tests/fixtures/user-factory.test.ts index e4f712c..7e7a6d1 100644 --- a/tests/fixtures/user-factory.test.ts +++ b/tests/fixtures/user-factory.test.ts @@ -2,17 +2,22 @@ * UserFactory Unit Tests */ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { UserFactory } from './factory' describe('UserFactory', () => { + beforeEach(() => { + // Reset ID counter to ensure test isolation + ;(UserFactory as any).idCounter = 0 + }) + it('creates user with default role', () => { const user = UserFactory.createUser() expect(user.username).toMatch(/^test_user_\d+$/) expect(user.userType).toBe('User') expect(user.permissions).toEqual(['read', 'write']) - expect(user.id).toMatch(/^USR-\d+-[a-z0-9]+$/) + expect(user.id).toBeTypeOf('number') }) it('creates admin with correct permissions', () => { @@ -20,7 +25,7 @@ describe('UserFactory', () => { expect(admin.userType).toBe('Admin') expect(admin.permissions).toEqual(['read', 'write', 'delete', 'admin']) - expect(admin.id).toMatch(/^USR-\d+-[a-z0-9]+$/) + expect(admin.id).toBeTypeOf('number') }) it('generates unique IDs', () => { diff --git a/tests/unit/ipc/cleaner-handler.test.ts b/tests/unit/ipc/cleaner-handler.test.ts new file mode 100644 index 0000000..af24b23 --- /dev/null +++ b/tests/unit/ipc/cleaner-handler.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { IPC_CHANNELS } from '../../../src/shared/ipc-channels' + +// Mock logger to prevent real winston initialization and console noise +vi.mock('../../../src/main/services/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + }), + logError: vi.fn() +})) + +vi.mock('../../../src/main/services/logger/error-utils', () => ({ + serializeError: (err: any) => err, + sanitizeError: (err: any) => err +})) + +// In-memory storage for registered IPC handlers +const registeredHandlers: Map = new Map() + +// Mock Electron's ipcMain to capture registered handlers +vi.mock('electron', () => { + return { + app: { + isPackaged: false, + getVersion: () => '1.0.0-test' + }, + ipcMain: { + handle: (channel: string, listener: any) => { + registeredHandlers.set(channel, listener) + } + } + } +}) + +// Mock CleanerApplicationService to isolate IPC layer +vi.doMock('../../../src/main/services/cleaner/cleaner-application-service', () => { + return { + CleanerApplicationService: class { + async runCleaner(_eventSender: any, input: any) { + const count = input?.orderNumbers?.length ?? 0 + return { + ordersProcessed: count, + materialsDeleted: count, + materialsSkipped: 0, + errors: [], + details: [], + retriedOrders: 0, + successfulRetries: 0 + } as any + } + async exportResults(_input: any) { + return { success: true, filePath: '/tmp/results.txt' } as any + } + } + } +}) + +// Load IPC handler module after mocks are in place +describe('Cleaner IPC Handler', () => { + beforeEach(() => { + registeredHandlers.clear() + }) + + it('should register and handle cleaner:execute (CLEANER_RUN) IPC call', async () => { + const mod = await import('../../../src/main/ipc/cleaner-handler') + mod.registerCleanerHandlers() + + const handler = registeredHandlers.get(IPC_CHANNELS.CLEANER_RUN) + expect(handler).toBeDefined() + expect(typeof handler).toBe('function') + + const event: any = { sender: { id: 'renderer-1' } } + const input: any = { + orderNumbers: ['SC1', 'SC2'], + materialCodes: [], + dryRun: false, + queryBatchSize: 100, + processConcurrency: 1, + onProgress: vi.fn() + } + + const result = await (handler as any)(event, input) + expect(result.success).toBe(true) + expect(result.data.ordersProcessed).toBe(2) + }) + + it('should handle cleaner:run with dryRun true', async () => { + const mod = await import('../../../src/main/ipc/cleaner-handler') + mod.registerCleanerHandlers() + + const handler = registeredHandlers.get(IPC_CHANNELS.CLEANER_RUN) + expect(handler).toBeDefined() + + const event: any = { sender: { id: 'renderer-2' } } + const input: any = { + orderNumbers: ['SC1'], + materialCodes: [], + dryRun: true, + queryBatchSize: 50, + processConcurrency: 1, + onProgress: vi.fn() + } + const result = await (handler as any)(event, input) + expect(result.success).toBe(true) + expect(result.data.ordersProcessed).toBe(1) + }) + + it('should return { success: false } when runCleaner throws', async () => { + vi.resetModules() + vi.doMock('../../../src/main/services/cleaner/cleaner-application-service', () => { + return { + CleanerApplicationService: class { + async runCleaner() { + throw new Error('boom') + } + } + } + }) + const mod = await import('../../../src/main/ipc/cleaner-handler') + mod.registerCleanerHandlers() + + const handler = registeredHandlers.get(IPC_CHANNELS.CLEANER_RUN) + expect(handler).toBeDefined() + + const event: any = { sender: { id: 'renderer-3' } } + const input: any = { + orderNumbers: ['SC1'], + materialCodes: [], + dryRun: false, + queryBatchSize: 20, + processConcurrency: 1, + onProgress: vi.fn() + } + const result = await (handler as any)(event, input) + expect(result.success).toBe(false) + expect(result.error).toBe('boom') + }) + + it('should register and handle cleaner:exportResults (CLEANER_EXPORT_RESULTS) IPC call', async () => { + vi.resetModules() + vi.doMock('../../../src/main/services/cleaner/cleaner-application-service', () => { + return { + CleanerApplicationService: class { + async exportResults(items: any[]) { + return { + success: true, + filePath: '/tmp/exported.xlsx', + recordCount: items.length + } as any + } + } + } + }) + const mod = await import('../../../src/main/ipc/cleaner-handler') + mod.registerCleanerHandlers() + + const handler = registeredHandlers.get(IPC_CHANNELS.CLEANER_EXPORT_RESULTS) + expect(handler).toBeDefined() + expect(typeof handler).toBe('function') + + const event: any = { sender: { id: 'renderer-4' } } + const items = [ + { materialCode: 'M1', materialName: 'Mat A' }, + { materialCode: 'M2', materialName: 'Mat B' } + ] + + const result = await (handler as any)(event, items) + expect(result.success).toBe(true) + expect(result.data.recordCount).toBe(2) + }) + + it('should return { success: false } when exportResults throws', async () => { + vi.resetModules() + vi.doMock('../../../src/main/services/cleaner/cleaner-application-service', () => { + return { + CleanerApplicationService: class { + async exportResults() { + throw new Error('export failed') + } + } + } + }) + const mod = await import('../../../src/main/ipc/cleaner-handler') + mod.registerCleanerHandlers() + + const handler = registeredHandlers.get(IPC_CHANNELS.CLEANER_EXPORT_RESULTS) + const event: any = { sender: { id: 'renderer-5' } } + const result = await (handler as any)(event, []) + expect(result.success).toBe(false) + expect(result.error).toBe('export failed') + }) +}) diff --git a/tests/unit/services/auth/auth-application-service.test.ts b/tests/unit/services/auth/auth-application-service.test.ts new file mode 100644 index 0000000..b95cf8f --- /dev/null +++ b/tests/unit/services/auth/auth-application-service.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { UserFactory } from '../../../fixtures/factory' +import { AuthApplicationService } from '../../../../src/main/services/auth/auth-application-service' + +// Mock logger to prevent real winston initialization and console noise +vi.mock('../../../../src/main/services/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + }), + setLogLevel: vi.fn(), + applyLoggingConfig: vi.fn(), + run: (_fn: () => Promise, _ctx?: any) => _fn(), + getRequestId: () => undefined, + getContext: () => undefined +})) + +vi.mock('../../../../src/main/services/logger/request-context', () => ({ + run: (_fn: () => Promise, _ctx?: any) => _fn(), + getRequestId: () => undefined, + getContext: () => undefined, + withContext: (_fn: () => Promise, _overrides?: any) => _fn() +})) + +vi.mock('../../../../src/main/services/logger/audit-logger', () => ({ + logAudit: vi.fn() +})) + +describe('AuthApplicationService', () => { + let service: AuthApplicationService + let mockSessionManager: any + let mockUpdateService: any + + beforeEach(() => { + mockSessionManager = { + login: vi.fn(), + loginByComputerName: vi.fn(), + getUserInfo: vi.fn(), + isAuthenticated: vi.fn(), + logout: vi.fn(), + getAllUsers: vi.fn(), + switchUser: vi.fn(), + isAdmin: vi.fn() + } + mockUpdateService = { + setUserContext: vi.fn() + } + service = new AuthApplicationService(mockSessionManager, mockUpdateService) + }) + + describe('login', () => { + it('should throw ValidationError when username is empty', async () => { + await expect(service.login('', 'password')).rejects.toThrow('请输入用户名和密码') + }) + + it('should throw ValidationError when password is empty', async () => { + await expect(service.login('admin', '')).rejects.toThrow('请输入用户名和密码') + }) + + it('should log in admin user and set update context', async () => { + const user = UserFactory.createAdmin() + mockSessionManager.login.mockResolvedValue(true) + mockSessionManager.getUserInfo.mockReturnValue({ + id: user.id, + username: user.username, + userType: user.userType + }) + mockSessionManager.isAuthenticated.mockReturnValue(true) + + await service.login(user.username, 'password') + + expect(mockSessionManager.login).toHaveBeenCalledWith(user.username, 'password') + expect(mockUpdateService.setUserContext).toHaveBeenCalledWith(user.userType) + const current = service.getCurrentUser() + expect(current.isAuthenticated).toBe(true) + expect(current.userInfo?.username).toBe(user.username) + }) + + it('should log in regular user and set update context', async () => { + const user = UserFactory.createUserDefault() + mockSessionManager.login.mockResolvedValue(true) + mockSessionManager.getUserInfo.mockReturnValue({ + id: user.id, + username: user.username, + userType: user.userType + }) + mockSessionManager.isAuthenticated.mockReturnValue(true) + + await service.login(user.username, 'password') + + expect(mockSessionManager.login).toHaveBeenCalledWith(user.username, 'password') + expect(mockUpdateService.setUserContext).toHaveBeenCalledWith(user.userType) + }) + + it('should reject with ValidationError when credentials are invalid', async () => { + const user = UserFactory.createAdmin() + mockSessionManager.login.mockResolvedValue(false) + mockSessionManager.getUserInfo.mockReturnValue(null) + + await expect(service.login(user.username, 'wrong')).rejects.toThrow('用户名或密码错误') + expect(mockUpdateService.setUserContext).toHaveBeenCalledWith(null) + }) + + it('should reject on network error', async () => { + const user = UserFactory.createUserDefault() + mockSessionManager.login.mockRejectedValue(new Error('Network error')) + await expect(service.login(user.username, 'password')).rejects.toThrow('Network error') + }) + }) + + describe('silentLogin', () => { + it('should succeed when computer name matches a user', async () => { + const user = UserFactory.createAdmin() + mockSessionManager.loginByComputerName.mockResolvedValue(true) + mockSessionManager.getUserInfo.mockReturnValue({ + id: user.id, + username: user.username, + userType: user.userType + }) + + const result = await service.silentLogin() + + expect(result.success).toBe(true) + expect(result.userInfo?.username).toBe(user.username) + expect(mockUpdateService.setUserContext).toHaveBeenCalledWith(user.userType) + }) + + it('should throw ValidationError when no matching user found', async () => { + mockSessionManager.loginByComputerName.mockResolvedValue(false) + mockSessionManager.getUserInfo.mockReturnValue(null) + + await expect(service.silentLogin()).rejects.toThrow('无感登录失败') + expect(mockUpdateService.setUserContext).toHaveBeenCalledWith(null) + }) + + it('should deduplicate concurrent silentLogin calls', async () => { + const user = UserFactory.createUserDefault() + let resolveLogin: (value: boolean) => void + mockSessionManager.loginByComputerName.mockImplementation( + () => new Promise((resolve) => { resolveLogin = resolve }) + ) + mockSessionManager.getUserInfo.mockReturnValue({ + id: user.id, + username: user.username, + userType: user.userType + }) + + const promise1 = service.silentLogin() + const promise2 = service.silentLogin() + + resolveLogin!(true) + + const [result1, result2] = await Promise.all([promise1, promise2]) + expect(result1).toBe(result2) + expect(mockSessionManager.loginByComputerName).toHaveBeenCalledTimes(1) + }) + }) + + describe('logout', () => { + it('should log out and clear session', async () => { + const user = UserFactory.createAdmin() + mockSessionManager.login.mockResolvedValue(true) + mockSessionManager.getUserInfo.mockReturnValue({ + id: user.id, + username: user.username, + userType: user.userType + }) + mockSessionManager.isAuthenticated.mockReturnValue(true) + + await service.login(user.username, 'password') + await service.logout() + + expect(mockSessionManager.logout).toHaveBeenCalled() + expect(mockUpdateService.setUserContext).toHaveBeenCalledWith(null) + }) + + it('should handle logout gracefully when not logged in', async () => { + mockSessionManager.isAuthenticated.mockReturnValue(false) + mockSessionManager.getUserInfo.mockReturnValue(null) + await service.logout() + expect(mockSessionManager.logout).toHaveBeenCalled() + expect(mockUpdateService.setUserContext).toHaveBeenCalledWith(null) + }) + }) + + describe('getCurrentUser', () => { + it('should return unauthenticated state before login', () => { + mockSessionManager.isAuthenticated.mockReturnValue(false) + mockSessionManager.getUserInfo.mockReturnValue(null) + const current = service.getCurrentUser() + expect(current.isAuthenticated).toBe(false) + expect(current.userInfo).toBeUndefined() + }) + + it('should return authenticated state after login', async () => { + const user = UserFactory.createAdmin() + mockSessionManager.login.mockResolvedValue(true) + mockSessionManager.getUserInfo.mockReturnValue({ + id: user.id, + username: user.username, + userType: user.userType + }) + mockSessionManager.isAuthenticated.mockReturnValue(true) + + await service.login(user.username, 'password') + const current = service.getCurrentUser() + expect(current.isAuthenticated).toBe(true) + expect(current.userInfo?.username).toBe(user.username) + }) + }) + + describe('getAllUsers', () => { + it('should delegate to session manager', async () => { + const users = [ + UserFactory.createAdmin(), + UserFactory.createUserDefault() + ] + mockSessionManager.getAllUsers.mockResolvedValue(users) + + const result = await service.getAllUsers() + + expect(mockSessionManager.getAllUsers).toHaveBeenCalled() + expect(result).toEqual(users) + }) + }) + + describe('switchUser', () => { + it('should switch user and update context', async () => { + const admin = UserFactory.createAdmin() + const targetUser = UserFactory.createUserDefault() + mockSessionManager.switchUser.mockReturnValue(true) + mockSessionManager.getUserInfo.mockReturnValue({ + id: targetUser.id, + username: targetUser.username, + userType: targetUser.userType + }) + + const result = await service.switchUser(targetUser) + + expect(result.success).toBe(true) + expect(result.userInfo?.username).toBe(targetUser.username) + expect(mockSessionManager.switchUser).toHaveBeenCalledWith(targetUser) + expect(mockUpdateService.setUserContext).toHaveBeenCalledWith(targetUser.userType) + }) + + it('should throw ValidationError when switch fails', async () => { + const targetUser = UserFactory.createUserDefault() + mockSessionManager.switchUser.mockReturnValue(false) + + await expect(service.switchUser(targetUser)).rejects.toThrow('用户切换失败') + }) + }) + + describe('isAdmin', () => { + it('should delegate to session manager', () => { + mockSessionManager.isAdmin.mockReturnValue(true) + expect(service.isAdmin()).toBe(true) + + mockSessionManager.isAdmin.mockReturnValue(false) + expect(service.isAdmin()).toBe(false) + }) + }) + + describe('authentication state transitions', () => { + it('should reflect full lifecycle: unauthenticated → authenticated → expired', async () => { + const user = UserFactory.createAdmin() + mockSessionManager.isAuthenticated.mockReturnValue(false) + mockSessionManager.getUserInfo.mockReturnValue(null) + expect(service.getCurrentUser().isAuthenticated).toBe(false) + + mockSessionManager.login.mockResolvedValue(true) + mockSessionManager.getUserInfo.mockReturnValue({ + id: user.id, + username: user.username, + userType: user.userType + }) + mockSessionManager.isAuthenticated.mockReturnValue(true) + await service.login(user.username, 'password') + expect(service.getCurrentUser().isAuthenticated).toBe(true) + + // Simulate token expiry + mockSessionManager.isAuthenticated.mockReturnValue(false) + const current = service.getCurrentUser() + expect(current.isAuthenticated).toBe(false) + expect(current.userInfo).toBeDefined() + }) + }) +}) diff --git a/tests/unit/services/cleaner/cleaner-application-service.test.ts b/tests/unit/services/cleaner/cleaner-application-service.test.ts new file mode 100644 index 0000000..613ee23 --- /dev/null +++ b/tests/unit/services/cleaner/cleaner-application-service.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +import { CleanerApplicationService } from '../../../../src/main/services/cleaner/cleaner-application-service' +import { + ValidationError, + ErpConnectionError, + DatabaseQueryError +} from '../../../../src/main/types/errors' + +// Mock ConfigManager to avoid "Configuration not initialized" errors in tests +vi.mock('../../../../src/main/services/config/config-manager', () => { + return { + ConfigManager: { + getInstance: () => ({ + getDatabaseType: () => 'mysql', + getConfig: () => ({ database: { activeType: 'mysql' } }) + }) + } + } +}) + +// Mock the OrderResolver to avoid real DB interactions +vi.mock('../../../../src/main/services/erp/order-resolver', () => { + return { + OrderNumberResolver: class { + constructor(_dbService: any) {} + async resolve(orderNumbers: string[]) { + return orderNumbers + } + getValidOrderNumbers(mappings: string[]) { + return mappings + } + getWarnings(_mappings: any[]) { + return [] + } + } + } +}) + +// Control whether CleanerService.clean should throw +let cleanerShouldThrow = false +let cleanerError: Error = new Error('cleaner crashed') + +// Capture last input passed to CleanerService.clean for assertions +let lastCleanerInput: any = null +vi.mock('../../../../src/main/services/erp/cleaner', () => { + return { + CleanerService: class { + constructor(_erpAuth: any) { + this.clean = vi.fn(async (input: any) => { + if (cleanerShouldThrow) throw cleanerError + lastCleanerInput = input + const count = input?.orderNumbers?.length ?? 0 + const isDryRun = input?.dryRun ?? false + return { + ordersProcessed: count, + materialsDeleted: isDryRun ? 0 : count, + materialsSkipped: 0, + errors: [], + details: [] + } as any + }) + } + clean: any + } + } +}) + +// Capture close calls on ErpAuthService +let erpAuthCloseCalled = false +vi.mock('../../../../src/main/services/erp/erp-auth', () => { + return { + ErpAuthService: class { + constructor(_config: any) {} + async login() { + return Promise.resolve(undefined) + } + async close() { + erpAuthCloseCalled = true + return Promise.resolve(undefined) + } + } + } +}) + +// Mock ResultExporter for exportResults tests +vi.mock('../../../../src/main/services/excel/result-exporter', () => { + return { + ResultExporter: class { + async exportValidationResults(items: any[]) { + return { + success: true, + filePath: '/tmp/exported.xlsx', + recordCount: items.length + } + } + } + } +}) + +// Helper to set up common method mocks for a service instance +function setupServiceMocks(service: CleanerApplicationService) { + ;(service as any).getErpConfig = vi + .fn() + .mockResolvedValue({ url: 'http://erp', username: 'u', password: 'p' }) + ;(service as any).getDatabaseService = vi.fn().mockResolvedValue({ + disconnect: vi.fn().mockResolvedValue(undefined) + }) + ;(service as any).recordCleanupAudit = vi.fn().mockResolvedValue(undefined) + ;(service as any).generateAndUploadReport = vi.fn().mockResolvedValue(undefined) +} + +function makeInput(overrides: Record = {}) { + return { + orderNumbers: ['SC1', 'SC2'], + materialCodes: [], + dryRun: false, + queryBatchSize: 100, + processConcurrency: 1, + onProgress: vi.fn(), + ...overrides + } +} + +describe('CleanerApplicationService', () => { + let service: CleanerApplicationService + + beforeEach(() => { + service = new CleanerApplicationService() + lastCleanerInput = null + erpAuthCloseCalled = false + cleanerShouldThrow = false + cleanerError = new Error('cleaner crashed') + setupServiceMocks(service) + }) + + describe('runCleaner', () => { + it('should process orders and return results', async () => { + const eventSender: any = { send: vi.fn() } + + const result = await service.runCleaner(eventSender, makeInput()) + + expect(result.ordersProcessed).toBe(2) + expect(result.materialsDeleted).toBe(2) + }) + + it('should pass dryRun=true to CleanerService and report zero deletions', async () => { + const eventSender: any = { send: vi.fn() } + + const result = await service.runCleaner(eventSender, makeInput({ dryRun: true })) + + expect(result.ordersProcessed).toBe(2) + expect(result.materialsDeleted).toBe(0) + expect(lastCleanerInput?.dryRun).toBe(true) + }) + + it('should increase materialsDeleted when dryRun is false vs true', async () => { + const eventSender: any = { send: vi.fn() } + + const resDry = await service.runCleaner(eventSender, makeInput({ dryRun: true, orderNumbers: ['SC1', 'SC2', 'SC3'] })) + expect(resDry.materialsDeleted).toBe(0) + + const resActual = await service.runCleaner(eventSender, makeInput({ dryRun: false, orderNumbers: ['SC1', 'SC2', 'SC3'] })) + expect(resActual.materialsDeleted).toBe(3) + expect(lastCleanerInput?.dryRun).toBe(false) + }) + + it('should handle different order counts independently across invocations', async () => { + const eventSender: any = { send: vi.fn() } + + const res1 = await service.runCleaner(eventSender, makeInput({ orderNumbers: ['SC1', 'SC2'] })) + expect(res1.ordersProcessed).toBe(2) + + const res2 = await service.runCleaner(eventSender, makeInput({ orderNumbers: ['SC3'] })) + expect(res2.ordersProcessed).toBe(1) + }) + + it('should reject when ERP config fetch fails', async () => { + ;(service as any).getErpConfig = vi.fn().mockRejectedValue(new Error('ERP config error')) + + await expect( + service.runCleaner({ send: vi.fn() } as any, makeInput()) + ).rejects.toThrow('ERP config error') + }) + + it('should reject with DatabaseQueryError when database connection fails', async () => { + ;(service as any).getErpConfig = vi + .fn() + .mockResolvedValue({ url: 'u', username: 'x', password: 'p' }) + ;(service as any).getDatabaseService = vi.fn().mockRejectedValue(new Error('DB fail')) + + const { DatabaseQueryError } = await import('../../../../src/main/types/errors') + await expect( + service.runCleaner({ send: vi.fn() } as any, makeInput()) + ).rejects.toBeInstanceOf(DatabaseQueryError) + }) + + it('should reject with ValidationError when no valid order numbers are provided', async () => { + ;(service as any).getErpConfig = vi + .fn() + .mockResolvedValue({ url: 'u', username: 'x', password: 'p' }) + ;(service as any).getDatabaseService = vi.fn().mockResolvedValue({ + disconnect: vi.fn().mockResolvedValue(undefined) + }) + + await expect( + service.runCleaner({ send: vi.fn() } as any, makeInput({ orderNumbers: [] })) + ).rejects.toThrow('没有有效的生产订单号可处理') + }) + + it('should process orders containing empty strings without crashing', async () => { + const eventSender: any = { send: vi.fn() } + + const result = await service.runCleaner(eventSender, makeInput({ orderNumbers: ['', 'SC2'] })) + expect(result.ordersProcessed).toBe(2) + }) + + it('should handle processConcurrency=0 gracefully', async () => { + const eventSender: any = { send: vi.fn() } + + const result = await service.runCleaner(eventSender, makeInput({ + orderNumbers: ['SC1'], + processConcurrency: 0 + })) + expect(result.ordersProcessed).toBe(1) + }) + + it('should close ERP browser on success', async () => { + await service.runCleaner({ send: vi.fn() } as any, makeInput()) + + expect(erpAuthCloseCalled).toBe(true) + }) + + it('should close ERP browser even when cleaner throws', async () => { + erpAuthCloseCalled = false + cleanerShouldThrow = true + cleanerError = new Error('cleaner crashed') + + await expect( + service.runCleaner({ send: vi.fn() } as any, makeInput()) + ).rejects.toThrow('cleaner crashed') + + expect(erpAuthCloseCalled).toBe(true) + }) + + it('should disconnect database after successful run', async () => { + const mockDisconnect = vi.fn().mockResolvedValue(undefined) + ;(service as any).getErpConfig = vi + .fn() + .mockResolvedValue({ url: 'u', username: 'x', password: 'p' }) + ;(service as any).getDatabaseService = vi.fn().mockResolvedValue({ + disconnect: mockDisconnect + }) + ;(service as any).recordCleanupAudit = vi.fn().mockResolvedValue(undefined) + ;(service as any).generateAndUploadReport = vi.fn().mockResolvedValue(undefined) + + await service.runCleaner({ send: vi.fn() } as any, makeInput()) + + expect(mockDisconnect).toHaveBeenCalled() + }) + + it('should disconnect database even when cleaner throws', async () => { + const mockDisconnect = vi.fn().mockResolvedValue(undefined) + ;(service as any).getErpConfig = vi + .fn() + .mockResolvedValue({ url: 'u', username: 'x', password: 'p' }) + ;(service as any).getDatabaseService = vi.fn().mockResolvedValue({ + disconnect: mockDisconnect + }) + + cleanerShouldThrow = true + cleanerError = new Error('boom') + + await expect( + service.runCleaner({ send: vi.fn() } as any, makeInput()) + ).rejects.toThrow('boom') + + expect(mockDisconnect).toHaveBeenCalled() + }) + }) + + describe('exportResults', () => { + it('should export results successfully for non-empty items', async () => { + const items = [ + { materialCode: 'M1', materialName: 'Mat A', specification: '', model: '', managerName: 'Mgr', isMarkedForDeletion: false, isSelected: true }, + { materialCode: 'M2', materialName: 'Mat B', specification: '', model: '', managerName: 'Mgr', isMarkedForDeletion: true, isSelected: false } + ] + + const result = await service.exportResults(items as any) + + expect(result.success).toBe(true) + expect(result.filePath).toBeDefined() + }) + + it('should throw ValidationError when items array is empty', async () => { + await expect(service.exportResults([])).rejects.toThrow('没有数据可导出') + }) + + it('should throw when items is null/undefined', async () => { + // Source accesses items.length before null guard, so TypeError is expected + await expect(service.exportResults(null as any)).rejects.toThrow() + await expect(service.exportResults(undefined as any)).rejects.toThrow() + }) + }) +}) diff --git a/tests/unit/services/erp/extractor.test.ts b/tests/unit/services/erp/extractor.test.ts index b3fc11f..375966c 100644 --- a/tests/unit/services/erp/extractor.test.ts +++ b/tests/unit/services/erp/extractor.test.ts @@ -120,8 +120,14 @@ describe('ExtractorService', () => { }) it('should ensure download directory exists', async () => { + // Clear fs.mkdir mock history before creating instance + vi.mocked(fs.mkdir).mockClear() + + // Create instance (constructor calls fs.mkdir asynchronously) new ExtractorService(mockAuthService, './test-downloads') + // Flush microtask queue so constructor's async mkdir resolves + await new Promise((resolve) => setImmediate(resolve)) expect(fs.mkdir).toHaveBeenCalledWith('./test-downloads', { recursive: true }) }) }) diff --git a/tests/unit/services/validation/validation-application-service.test.ts b/tests/unit/services/validation/validation-application-service.test.ts new file mode 100644 index 0000000..2ae22d1 --- /dev/null +++ b/tests/unit/services/validation/validation-application-service.test.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { ValidationApplicationService } from '../../../../src/main/services/validation/validation-application-service' +import type { ValidationRequest } from '../../../../src/main/types/validation.types' + +// ─── Mock logger to prevent real winston initialization and console noise ─── +vi.mock('../../../../src/main/services/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + }), + withRequestContext: (_fn: () => Promise) => _fn(), + trackDuration: async (fn: () => Promise) => { + const result = await fn() + return { result, durationMs: 0, isSlow: false } + }, + getRequestId: () => undefined +})) + +// ─── DAO mock state ─── +let returnEmptyMaterials = false +let materialRecords: any[] = [ + { MaterialName: 'MatA', MaterialCode: 'M1', Model: 'Mod', Specification: 'Spec' } +] +let filteredMaterialRecords: any[] = [ + { MaterialName: 'FilteredMat', MaterialCode: 'MF1', Model: 'FMod', Specification: 'FSpec' } +] + +vi.mock('../../../../src/main/services/database/discrete-material-plan-dao', () => { + return { + DiscreteMaterialPlanDAO: class { + async queryAllDistinctByMaterialCode() { + if (returnEmptyMaterials) return [] + return materialRecords + } + async queryBySourceNumbersDistinct(_nums: string[]) { + if (returnEmptyMaterials) return [] + return filteredMaterialRecords + } + } + } +}) + +// ─── MaterialsToBeDeletedDAO mock state ─── +let materialsByManagerResult: any[] = [] +let allMaterialsResult: any[] = [] +let allMaterialCodesResult: Set = new Set() + +vi.mock('../../../../src/main/services/database/materials-to-be-deleted-dao', () => { + return { + MaterialsToBeDeletedDAO: class { + async getMaterialsByManager(_managerName: string) { + return materialsByManagerResult + } + async getAllRecords() { + return allMaterialsResult + } + async getAllMaterialCodes() { + return allMaterialCodesResult + } + } + } +}) + +// ─── Production input service mock ─── +let sourceNumbersFromInputs: string[] = ['SC001', 'SC002'] + +vi.mock('../../../../src/main/services/validation/production-input-service', () => ({ + getSourceNumbersFromInputs: vi.fn().mockImplementation(async () => sourceNumbersFromInputs), + readProductionIds: vi.fn().mockReturnValue(['PROD001', 'PROD002']) +})) + +// ─── Shared production IDs store mock ─── +let sharedIds: string[] = [] + +vi.mock('../../../../src/main/services/validation/shared-production-ids-store', () => ({ + sharedProductionIdsStore: { + get: (_senderId: number) => sharedIds + } +})) + +// ─── Validation database mock with robust SQL matching ─── +function matchQuery(sql: string): string { + // Extract table name from FROM clause to avoid substring ambiguity + const fromMatch = sql.match(/FROM\s+(\S+)/i) + const table = fromMatch?.[1] ?? '' + + if (/MaterialsTypeToBeDeleted/i.test(table)) return 'typeKeywords' + if (/MaterialsToBeDeleted/i.test(table)) return 'markedCodes' + if (/DiscreteMaterialPlanData/i.test(table)) return 'materialDetail' + return 'unknown' +} + +vi.mock('../../../../src/main/services/validation/validation-database', () => ({ + createValidationDatabaseService: vi.fn().mockImplementation(async () => ({ + type: 'mysql' as const, + query: vi.fn().mockImplementation((sql: string) => { + const kind = matchQuery(sql) + switch (kind) { + case 'typeKeywords': + return Promise.resolve({ + rows: [{ MaterialName: 'MatA', ManagerName: 'Mgr' }], + rowCount: 1 + }) + case 'markedCodes': + return Promise.resolve({ + rows: [{ MaterialCode: 'M1', ManagerName: 'Mgr' }], + rowCount: 1 + }) + case 'materialDetail': + return Promise.resolve({ + rows: [{ MaterialName: 'MatA', Specification: 'Spec', Model: 'Mod' }], + rowCount: 1 + }) + default: + return Promise.resolve({ rows: [], rowCount: 0 }) + } + }), + disconnect: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockResolvedValue(undefined) + })), + getValidationTableName: vi.fn().mockImplementation((name: string) => name) +})) + +describe('ValidationApplicationService', () => { + let service: ValidationApplicationService + + beforeEach(() => { + returnEmptyMaterials = false + materialRecords = [ + { MaterialName: 'MatA', MaterialCode: 'M1', Model: 'Mod', Specification: 'Spec' } + ] + filteredMaterialRecords = [ + { MaterialName: 'FilteredMat', MaterialCode: 'MF1', Model: 'FMod', Specification: 'FSpec' } + ] + materialsByManagerResult = [ + { materialCode: 'M1', managerName: 'Mgr' } + ] + allMaterialsResult = [ + { materialCode: 'M1', managerName: 'Mgr' }, + { materialCode: 'M2', managerName: 'Other' } + ] + allMaterialCodesResult = new Set(['M1']) + sourceNumbersFromInputs = ['SC001', 'SC002'] + sharedIds = [] + service = new ValidationApplicationService() + }) + + // ─── validate – database_full mode ─── + describe('validate – database_full mode', () => { + it('should return success with results for admin user', async () => { + const req: ValidationRequest = { mode: 'database_full' } + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + const res = await service.validate(req, userInfo, 1) + expect(res.success).toBe(true) + expect(res.results).toBeDefined() + expect(res.stats).toBeDefined() + expect(res.stats!.totalRecords).toBe(1) + }) + + it('should return success for regular user', async () => { + const req: ValidationRequest = { mode: 'database_full' } + const userInfo = { id: 2, username: 'guest', userType: 'User' } as any + const res = await service.validate(req, userInfo, 2) + expect(res.success).toBe(true) + expect(res.results).toBeDefined() + }) + + it('should return failure when material records are empty', async () => { + returnEmptyMaterials = true + const req: ValidationRequest = { mode: 'database_full' } + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + const res = await service.validate(req, userInfo, 1) + expect(res.success).toBe(false) + expect(res.error).toContain('未找到物料记录') + }) + + it('should correctly compute stats for matched and marked records', async () => { + materialRecords = [ + { MaterialName: 'MatA', MaterialCode: 'M1', Model: 'Mod', Specification: 'Spec' }, + { MaterialName: 'Other', MaterialCode: 'M2', Model: 'Mod', Specification: 'Spec' } + ] + const req: ValidationRequest = { mode: 'database_full' } + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + const res = await service.validate(req, userInfo, 1) + + expect(res.success).toBe(true) + expect(res.stats!.totalRecords).toBe(2) + // M1 is in markedCodes → isMarkedForDeletion=true, managerName='Mgr' + const m1Result = res.results!.find((r) => r.materialCode === 'M1') + expect(m1Result?.isMarkedForDeletion).toBe(true) + expect(m1Result?.managerName).toBe('Mgr') + // M2 is NOT in markedCodes but 'Other' doesn't match any type keyword + const m2Result = res.results!.find((r) => r.materialCode === 'M2') + expect(m2Result?.isMarkedForDeletion).toBe(false) + }) + + it('should match type keywords when material name contains keyword', async () => { + materialRecords = [ + { MaterialName: 'MatA-Extra', MaterialCode: 'MX1', Model: 'Mod', Specification: 'Spec' } + ] + const req: ValidationRequest = { mode: 'database_full' } + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + const res = await service.validate(req, userInfo, 1) + + expect(res.success).toBe(true) + // 'MatA-Extra' contains 'MatA' which is a type keyword → matched + expect(res.results![0].matchedTypeKeyword).toBe('MatA') + expect(res.results![0].managerName).toBe('Mgr') + }) + }) + + // ─── validate – database_filtered with shared production IDs ─── + describe('validate – database_filtered with shared IDs', () => { + it('should return success when shared IDs resolve to orders', async () => { + sharedIds = ['PROD001'] + const req: ValidationRequest = { mode: 'database_filtered', useSharedProductionIds: true } + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + + const res = await service.validate(req, userInfo, 1) + + expect(res.success).toBe(true) + expect(res.results).toBeDefined() + expect(res.results!.length).toBeGreaterThan(0) + }) + + it('should return failure when shared IDs are empty', async () => { + sharedIds = [] + const req: ValidationRequest = { mode: 'database_filtered', useSharedProductionIds: true } + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + + const res = await service.validate(req, userInfo, 1) + + expect(res.success).toBe(false) + expect(res.error).toContain('共享') + }) + + it('should return failure when shared IDs yield no source numbers', async () => { + sharedIds = ['PROD001'] + sourceNumbersFromInputs = [] + const req: ValidationRequest = { mode: 'database_filtered', useSharedProductionIds: true } + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + + const res = await service.validate(req, userInfo, 1) + + expect(res.success).toBe(false) + expect(res.error).toContain('共享') + }) + }) + + // ─── validate – database_filtered with production ID file ─── + describe('validate – database_filtered with file', () => { + it('should return success when file IDs resolve to orders', async () => { + const req: ValidationRequest = { + mode: 'database_filtered', + productionIdFile: '/tmp/ids.txt' + } + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + + const res = await service.validate(req, userInfo, 1) + + expect(res.success).toBe(true) + expect(res.results).toBeDefined() + }) + + it('should return failure when file IDs yield no source numbers', async () => { + sourceNumbersFromInputs = [] + const req: ValidationRequest = { + mode: 'database_filtered', + productionIdFile: '/tmp/ids.txt' + } + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + + const res = await service.validate(req, userInfo, 1) + + expect(res.success).toBe(false) + expect(res.error).toContain('文件') + }) + }) + + // ─── getMaterialsByManager ─── + describe('getMaterialsByManager', () => { + it('should return enriched materials for a manager', async () => { + const result = await service.getMaterialsByManager('Mgr') + + expect(result).toBeDefined() + expect(result.length).toBe(1) + expect(result[0].materialCode).toBe('M1') + expect(result[0].materialName).toBe('MatA') + expect(result[0].isMarked).toBe(true) // M1 is in allMaterialCodesResult + }) + + it('should return empty array when manager has no materials', async () => { + materialsByManagerResult = [] + + const result = await service.getMaterialsByManager('Nobody') + + expect(result).toEqual([]) + }) + }) + + // ─── getAllMaterials ─── + describe('getAllMaterials', () => { + it('should return all enriched materials', async () => { + const result = await service.getAllMaterials() + + expect(result).toBeDefined() + expect(result.length).toBe(2) + expect(result[0].materialCode).toBe('M1') + expect(result[0].isMarked).toBe(true) + expect(result[1].materialCode).toBe('M2') + expect(result[1].isMarked).toBe(false) // M2 not in allMaterialCodesResult + }) + + it('should return empty array when no materials exist', async () => { + allMaterialsResult = [] + + const result = await service.getAllMaterials() + + expect(result).toEqual([]) + }) + }) + + // ─── getCleanerData ─── + describe('getCleanerData', () => { + it('should return order numbers and material codes for admin user', async () => { + sharedIds = ['PROD001'] + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + + const result = await service.getCleanerData(userInfo, 1) + + expect(result.success).toBe(true) + expect(result.orderNumbers).toBeDefined() + expect(result.orderNumbers!.length).toBeGreaterThan(0) + expect(result.materialCodes).toBeDefined() + }) + + it('should return empty order numbers when no shared IDs', async () => { + sharedIds = [] + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + + const result = await service.getCleanerData(userInfo, 1) + + expect(result.success).toBe(true) + expect(result.orderNumbers).toEqual([]) + }) + + it('should handle errors gracefully', async () => { + const { createValidationDatabaseService } = await import('../../../../src/main/services/validation/validation-database') + vi.mocked(createValidationDatabaseService).mockRejectedValueOnce(new Error('DB down')) + + const userInfo = { id: 1, username: 'admin', userType: 'Admin' } as any + const result = await service.getCleanerData(userInfo, 1) + + expect(result.success).toBe(false) + expect(result.error).toContain('DB down') + }) + }) +}) diff --git a/tests/unit/services/validation/validation-database.test.ts b/tests/unit/services/validation/validation-database.test.ts new file mode 100644 index 0000000..52cc507 --- /dev/null +++ b/tests/unit/services/validation/validation-database.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Capture construction params and connect calls for each DB adapter +let lastMysqlOpts: any = null +let mysqlConnectCalled = false +let lastSqlServerOpts: any = null +let sqlServerConnectCalled = false +let lastPgOpts: any = null +let pgConnectCalled = false + +let currentDbType: string = 'mysql' +let currentDbConfig: any = { + database: { + mysql: { host: 'db', port: 3306, username: 'user', password: 'pass', database: 'erp' }, + sqlserver: { + server: 'srv', + port: 1433, + username: 'user', + password: 'pass', + database: 'erp', + trustServerCertificate: true + }, + postgresql: { + host: 'localhost', + port: 5432, + username: 'user', + password: 'pass', + database: 'erp' + } + } +} + +// mysql mock +vi.doMock('../../../../src/main/services/database/mysql', () => { + return { + MySqlService: class { + constructor(opts: any) { + lastMysqlOpts = opts + } + connect = vi.fn().mockImplementation(function () { + mysqlConnectCalled = true + return Promise.resolve(undefined) + }) + } + } +}) + +// sqlserver mock +vi.doMock('../../../../src/main/services/database/sql-server', () => { + return { + SqlServerService: class { + constructor(opts: any) { + lastSqlServerOpts = opts + } + connect = vi.fn().mockImplementation(function () { + sqlServerConnectCalled = true + return Promise.resolve(undefined) + }) + } + } +}) + +// postgresql mock +vi.doMock('../../../../src/main/services/database/postgresql', () => { + return { + PostgreSqlService: class { + constructor(opts: any) { + lastPgOpts = opts + } + connect = vi.fn().mockImplementation(function () { + pgConnectCalled = true + return Promise.resolve(undefined) + }) + } + } +}) + +// Config mock to drive database type +vi.doMock('../../../../src/main/services/config/config-manager', () => { + return { + ConfigManager: { + getInstance: () => ({ + getDatabaseType: () => currentDbType, + getConfig: () => currentDbConfig + }) + } + } +}) + +describe('ValidationDatabaseService', () => { + beforeEach(() => { + lastMysqlOpts = null + mysqlConnectCalled = false + lastSqlServerOpts = null + sqlServerConnectCalled = false + lastPgOpts = null + pgConnectCalled = false + }) + + describe('createValidationDatabaseService', () => { + it('creates mysql service with correct config and connects', async () => { + currentDbType = 'mysql' + const mod = await import('../../../../src/main/services/validation/validation-database') + const svc = await mod.createValidationDatabaseService() + expect(svc).toBeDefined() + expect(lastMysqlOpts.host).toBe('db') + expect(mysqlConnectCalled).toBe(true) + }) + + it('creates sqlserver service when dbType is sqlserver', async () => { + currentDbType = 'sqlserver' + const mod = await import('../../../../src/main/services/validation/validation-database') + const svc = await mod.createValidationDatabaseService() + expect(lastSqlServerOpts.server).toBe('srv') + expect(sqlServerConnectCalled).toBe(true) + }) + + it('creates postgresql service when dbType is postgresql', async () => { + currentDbType = 'postgresql' + const mod = await import('../../../../src/main/services/validation/validation-database') + const svc = await mod.createValidationDatabaseService() + expect(lastPgOpts.host).toBe('localhost') + expect(pgConnectCalled).toBe(true) + }) + }) + + describe('getValidationTableName', () => { + it('returns table name unchanged for mysql', async () => { + currentDbType = 'mysql' + const mod = await import('../../../../src/main/services/validation/validation-database') + expect(mod.getValidationTableName('MaterialsToBeDeleted')).toBe('MaterialsToBeDeleted') + }) + + it('converts schema_table to [schema].[table] for sqlserver', async () => { + currentDbType = 'sqlserver' + const mod = await import('../../../../src/main/services/validation/validation-database') + expect(mod.getValidationTableName('dbo_Materials')).toBe('[dbo].[Materials]') + }) + + it('wraps nameless table in [dbo].[name] for sqlserver', async () => { + currentDbType = 'sqlserver' + const mod = await import('../../../../src/main/services/validation/validation-database') + expect(mod.getValidationTableName('Materials')).toBe('[dbo].[Materials]') + }) + + it('converts schema_table to "schema"."table" for postgresql', async () => { + currentDbType = 'postgresql' + const mod = await import('../../../../src/main/services/validation/validation-database') + expect(mod.getValidationTableName('public_Materials')).toBe('"public"."Materials"') + }) + + it('wraps nameless table in "public"."name" for postgresql', async () => { + currentDbType = 'postgresql' + const mod = await import('../../../../src/main/services/validation/validation-database') + expect(mod.getValidationTableName('Materials')).toBe('"public"."Materials"') + }) + }) +})