Files
BIPMaterialManager/tests/unit/services/cleaner/cleaner-application-service.test.ts
Misaka_Company 6f49596467 feat(cleaner-history): record missing orders with production ID tracking
Record ALL input orders in history, including resolution failures (not_found)
and ERP query misses (erp_not_found). Add ProductionId column to track original
总排号 input. Add 总排号 column and new status styles to the history UI. Fix
empty result caching that prevented retry on transient query failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 10:16:49 +08:00

376 lines
12 KiB
TypeScript

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) {} // eslint-disable-line @typescript-eslint/no-empty-function
async resolve(orderNumbers: string[]) {
return orderNumbers
}
getValidOrderNumbers(mappings: string[]) {
return mappings
}
getWarnings(_mappings: any[]) {
return []
}
}
}
})
// Mock CleanerOperationHistoryDAO
vi.mock('../../../../src/main/services/database/cleaner-operation-history-dao', () => {
return {
CleanerOperationHistoryDAO: class {
async insertExecution() {
return true
}
async insertOrderRecords() {
return true
}
async updateOrderStatus() {
return true
}
async insertMaterialDetails() {
return true
}
async updateExecutionStatus() {
return true
}
async getBatchDetails() {
return {
executions: [{ attemptNumber: 1, isDryRun: false }]
}
}
}
}
})
// 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) {} // eslint-disable-line @typescript-eslint/no-empty-function
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)
;(service as any).getHistoryDao = vi.fn().mockReturnValue({
insertExecution: vi.fn().mockResolvedValue(true),
insertOrderRecords: vi.fn().mockResolvedValue(true),
updateOrderStatus: vi.fn().mockResolvedValue(true),
insertMaterialDetails: vi.fn().mockResolvedValue(true),
updateExecutionStatus: vi.fn().mockResolvedValue(true),
getBatchDetails: vi.fn().mockResolvedValue({
executions: [{ attemptNumber: 1, isDryRun: false }]
})
})
}
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 return empty result 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)
})
const result = await service.runCleaner(
{ send: vi.fn() } as any,
makeInput({ orderNumbers: [] })
)
expect(result.ordersProcessed).toBe(0)
expect(result.materialsDeleted).toBe(0)
expect(result.details).toEqual([])
})
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()
})
})
})