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:
8
tests/fixtures/factory.ts
vendored
8
tests/fixtures/factory.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
4
tests/fixtures/types.ts
vendored
4
tests/fixtures/types.ts
vendored
@@ -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 */
|
||||
|
||||
11
tests/fixtures/user-factory.test.ts
vendored
11
tests/fixtures/user-factory.test.ts
vendored
@@ -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', () => {
|
||||
|
||||
195
tests/unit/ipc/cleaner-handler.test.ts
Normal file
195
tests/unit/ipc/cleaner-handler.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
290
tests/unit/services/auth/auth-application-service.test.ts
Normal file
290
tests/unit/services/auth/auth-application-service.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
305
tests/unit/services/cleaner/cleaner-application-service.test.ts
Normal file
305
tests/unit/services/cleaner/cleaner-application-service.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
158
tests/unit/services/validation/validation-database.test.ts
Normal file
158
tests/unit/services/validation/validation-database.test.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user