fix(test): improve test isolation and reduce noise in unit tests

- Add logger/error-utils mocks to cleaner-handler test to suppress IPC error log noise
- Move setupServiceMocks into beforeEach for consistent default mocking in cleaner tests
- Replace vi.waitFor (2s timeout) with setImmediate microtask flush in extractor test
- Remove dead activeType assignments in validation-database test
- Align TestUser.id type with UserInfo.id (string → number) and use deterministic counter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-04-05 21:38:20 +08:00
parent d0f8ad0fef
commit 4150a13175
9 changed files with 1329 additions and 8 deletions

View File

@@ -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<string, Function> = 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')
})
})

View File

@@ -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<any>, _ctx?: any) => _fn(),
getRequestId: () => undefined,
getContext: () => undefined
}))
vi.mock('../../../../src/main/services/logger/request-context', () => ({
run: (_fn: () => Promise<any>, _ctx?: any) => _fn(),
getRequestId: () => undefined,
getContext: () => undefined,
withContext: (_fn: () => Promise<any>, _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<boolean>((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()
})
})
})

View File

@@ -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<string, any> = {}) {
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()
})
})
})

View File

@@ -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 })
})
})

View File

@@ -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<any>) => _fn(),
trackDuration: async <T>(fn: () => Promise<T>) => {
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<string> = 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')
})
})
})

View File

@@ -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"')
})
})
})