feat: add TypeORM, logger, schemas, hooks and stores

- Add TypeORM integration with data-source, entities and repositories
- Add logger service for structured logging
- Add Zod validation schemas for auth, cleaner and extractor
- Add custom React hooks (useAuth, useCleaner, useExtractor, useValidation)
- Add Zustand stores (useAppStore, useUserStore)
- Add UI components (Button, Modal, Toast)
- Add error types and ErpBrowserManager
- Refactor IPC handlers and services
- Add unit tests for new modules

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-03-02 23:10:15 +08:00
parent 982fb8fde6
commit a05c8a9037
57 changed files with 5150 additions and 974 deletions

208
tests/unit/errors.test.ts Normal file
View File

@@ -0,0 +1,208 @@
/**
* Error Types Unit Tests
*/
import { describe, it, expect } from 'vitest'
import {
BaseError,
ErpConnectionError,
DatabaseQueryError,
ValidationError,
ERP_ERROR_CODES,
DATABASE_ERROR_CODES,
VALIDATION_ERROR_CODES,
isBaseError,
isErpConnectionError,
isDatabaseQueryError,
isValidationError,
getErrorMessage,
getErrorCode
} from '../../src/main/types/errors'
// Concrete implementation for testing abstract BaseError
class TestError extends BaseError {
constructor(message: string, code: string, cause?: Error) {
super('TestError', message, code, cause)
}
}
describe('Error Types', () => {
describe('BaseError', () => {
it('should create an error with name, message, and code', () => {
const error = new TestError('Test message', 'TEST_CODE')
expect(error.name).toBe('TestError')
expect(error.message).toBe('Test message')
expect(error.code).toBe('TEST_CODE')
expect(error.cause).toBeUndefined()
})
it('should capture cause when provided', () => {
const cause = new Error('Original error')
const error = new TestError('Test message', 'TEST_CODE', cause)
expect(error.cause).toBe(cause)
})
it('should serialize to JSON correctly', () => {
const error = new TestError('Test message', 'TEST_CODE')
const json = error.toJSON()
expect(json).toEqual({
name: 'TestError',
message: 'Test message',
code: 'TEST_CODE',
cause: undefined
})
})
it('should be an instance of Error', () => {
const error = new TestError('Test message', 'TEST_CODE')
expect(error).toBeInstanceOf(Error)
})
})
describe('ErpConnectionError', () => {
it('should create with default code', () => {
const error = new ErpConnectionError('Connection failed')
expect(error.name).toBe('ErpConnectionError')
expect(error.code).toBe(ERP_ERROR_CODES.CONNECTION_FAILED)
})
it('should create with specific code', () => {
const error = new ErpConnectionError('Login failed', ERP_ERROR_CODES.LOGIN_FAILED)
expect(error.code).toBe(ERP_ERROR_CODES.LOGIN_FAILED)
})
it('should accept cause', () => {
const cause = new Error('Network timeout')
const error = new ErpConnectionError('Timeout', ERP_ERROR_CODES.TIMEOUT, cause)
expect(error.cause).toBe(cause)
})
})
describe('DatabaseQueryError', () => {
it('should create with default code', () => {
const error = new DatabaseQueryError('Query failed')
expect(error.name).toBe('DatabaseQueryError')
expect(error.code).toBe(DATABASE_ERROR_CODES.QUERY_FAILED)
})
it('should create with specific code', () => {
const error = new DatabaseQueryError(
'Connection failed',
DATABASE_ERROR_CODES.CONNECTION_FAILED
)
expect(error.code).toBe(DATABASE_ERROR_CODES.CONNECTION_FAILED)
})
})
describe('ValidationError', () => {
it('should create with default code', () => {
const error = new ValidationError('Invalid input')
expect(error.name).toBe('ValidationError')
expect(error.code).toBe(VALIDATION_ERROR_CODES.INVALID_INPUT)
})
it('should create with specific code', () => {
const error = new ValidationError('Missing field', VALIDATION_ERROR_CODES.MISSING_REQUIRED)
expect(error.code).toBe(VALIDATION_ERROR_CODES.MISSING_REQUIRED)
})
})
describe('Type Guards', () => {
it('isBaseError should return true for BaseError instances', () => {
const error = new ValidationError('Test')
expect(isBaseError(error)).toBe(true)
expect(isBaseError(new Error('Test'))).toBe(false)
expect(isBaseError('string')).toBe(false)
})
it('isErpConnectionError should return true only for ErpConnectionError', () => {
const erpError = new ErpConnectionError('Test')
const dbError = new DatabaseQueryError('Test')
expect(isErpConnectionError(erpError)).toBe(true)
expect(isErpConnectionError(dbError)).toBe(false)
})
it('isDatabaseQueryError should return true only for DatabaseQueryError', () => {
const dbError = new DatabaseQueryError('Test')
const valError = new ValidationError('Test')
expect(isDatabaseQueryError(dbError)).toBe(true)
expect(isDatabaseQueryError(valError)).toBe(false)
})
it('isValidationError should return true only for ValidationError', () => {
const valError = new ValidationError('Test')
const erpError = new ErpConnectionError('Test')
expect(isValidationError(valError)).toBe(true)
expect(isValidationError(erpError)).toBe(false)
})
})
describe('Helper Functions', () => {
it('getErrorMessage should return message from BaseError', () => {
const error = new ValidationError('Invalid input')
expect(getErrorMessage(error)).toBe('Invalid input')
})
it('getErrorMessage should return message from standard Error', () => {
const error = new Error('Standard error')
// In non-production, it returns the actual message
expect(getErrorMessage(error)).toBe('Standard error')
})
it('getErrorMessage should handle unknown types', () => {
expect(getErrorMessage('string error')).toBe('string error')
expect(getErrorMessage(null)).toBe('An unknown error occurred')
expect(getErrorMessage(undefined)).toBe('An unknown error occurred')
})
it('getErrorCode should return code from BaseError', () => {
const error = new ValidationError('Test', VALIDATION_ERROR_CODES.MISSING_REQUIRED)
expect(getErrorCode(error)).toBe(VALIDATION_ERROR_CODES.MISSING_REQUIRED)
})
it('getErrorCode should return UNKNOWN_ERROR for non-BaseError', () => {
const error = new Error('Test')
expect(getErrorCode(error)).toBe('UNKNOWN_ERROR')
})
})
describe('Error Codes', () => {
it('ERP_ERROR_CODES should have all expected codes', () => {
expect(ERP_ERROR_CODES.CONNECTION_FAILED).toBe('ERP_CONNECTION_FAILED')
expect(ERP_ERROR_CODES.LOGIN_FAILED).toBe('ERP_LOGIN_FAILED')
expect(ERP_ERROR_CODES.TIMEOUT).toBe('ERP_TIMEOUT')
expect(ERP_ERROR_CODES.SESSION_EXPIRED).toBe('ERP_SESSION_EXPIRED')
})
it('DATABASE_ERROR_CODES should have all expected codes', () => {
expect(DATABASE_ERROR_CODES.CONNECTION_FAILED).toBe('DB_CONNECTION_FAILED')
expect(DATABASE_ERROR_CODES.QUERY_FAILED).toBe('DB_QUERY_FAILED')
expect(DATABASE_ERROR_CODES.TIMEOUT).toBe('DB_TIMEOUT')
})
it('VALIDATION_ERROR_CODES should have all expected codes', () => {
expect(VALIDATION_ERROR_CODES.INVALID_INPUT).toBe('VAL_INVALID_INPUT')
expect(VALIDATION_ERROR_CODES.MISSING_REQUIRED).toBe('VAL_MISSING_REQUIRED')
expect(VALIDATION_ERROR_CODES.INVALID_FORMAT).toBe('VAL_INVALID_FORMAT')
})
})
})

78
tests/unit/logger.test.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* Logger Unit Tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock winston since we don't need actual file logging in tests
vi.mock('winston', () => ({
default: {
createLogger: vi.fn(() => ({
add: vi.fn(),
child: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn()
})),
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn()
})),
format: {
combine: vi.fn(),
timestamp: vi.fn(),
colorize: vi.fn(),
printf: vi.fn(),
json: vi.fn()
},
transports: {
Console: vi.fn()
}
}
}))
vi.mock('winston-daily-rotate-file', () => ({
default: vi.fn()
}))
vi.mock('electron', () => ({
app: {
isReady: vi.fn(() => false),
getPath: vi.fn(() => './logs')
}
}))
describe('Logger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.resetModules()
})
it('should create a logger with context', async () => {
const { createLogger } = await import('../../src/main/services/logger')
const logger = createLogger('TestContext')
expect(logger).toBeDefined()
expect(logger.child).toBeDefined()
})
it('should have log methods', async () => {
const { createLogger } = await import('../../src/main/services/logger')
const logger = createLogger('TestContext')
expect(typeof logger.info).toBe('function')
expect(typeof logger.error).toBe('function')
expect(typeof logger.warn).toBe('function')
expect(typeof logger.debug).toBe('function')
})
it('should export default logger', async () => {
const logger = await import('../../src/main/services/logger')
expect(logger.default).toBeDefined()
})
})

View File

@@ -0,0 +1,67 @@
/**
* Repository Unit Tests
*
* Tests for TypeORM repository patterns.
* Note: These tests mock the database connections.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock TypeORM
vi.mock('typeorm', () => ({
DataSource: vi.fn(() => ({
initialize: vi.fn().mockResolvedValue({}),
isInitialized: false,
getRepository: vi.fn(),
destroy: vi.fn()
})),
Repository: vi.fn(),
In: vi.fn((arr) => arr)
}))
vi.mock('../../src/main/services/logger', () => ({
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn()
}))
}))
describe('MaterialsToBeDeletedRepository', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should be defined', async () => {
const { MaterialsToBeDeletedRepository } =
await import('../../src/main/services/database/repositories/MaterialsToBeDeletedRepository')
expect(MaterialsToBeDeletedRepository).toBeDefined()
})
it('should create repository instance', async () => {
const { MaterialsToBeDeletedRepository } =
await import('../../src/main/services/database/repositories/MaterialsToBeDeletedRepository')
const repo = new MaterialsToBeDeletedRepository()
expect(repo).toBeDefined()
})
})
describe('DiscreteMaterialPlanRepository', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should be defined', async () => {
const { DiscreteMaterialPlanRepository } =
await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository')
expect(DiscreteMaterialPlanRepository).toBeDefined()
})
it('should create repository instance', async () => {
const { DiscreteMaterialPlanRepository } =
await import('../../src/main/services/database/repositories/DiscreteMaterialPlanRepository')
const repo = new DiscreteMaterialPlanRepository()
expect(repo).toBeDefined()
})
})

227
tests/unit/schemas.test.ts Normal file
View File

@@ -0,0 +1,227 @@
/**
* Zod Schemas Unit Tests
*/
import { describe, it, expect } from 'vitest'
import {
ExtractorInputSchema,
validateExtractorInput
} from '../../src/main/schemas/extractor.schema'
import { CleanerInputSchema, validateCleanerInput } from '../../src/main/schemas/cleaner.schema'
import { LoginRequestSchema, validateLoginRequest } from '../../src/main/schemas/auth.schema'
describe('Extractor Schema', () => {
describe('ExtractorInputSchema', () => {
it('should validate valid input', () => {
const input = {
orderNumbers: ['SC12345678901234', 'SC98765432109876'],
batchSize: 10
}
const result = ExtractorInputSchema.safeParse(input)
expect(result.success).toBe(true)
})
it('should apply default batchSize', () => {
const input = {
orderNumbers: ['SC12345678901234']
}
const result = ExtractorInputSchema.safeParse(input)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.batchSize).toBe(10)
}
})
it('should reject empty orderNumbers array', () => {
const input = {
orderNumbers: []
}
const result = ExtractorInputSchema.safeParse(input)
expect(result.success).toBe(false)
})
it('should reject missing orderNumbers', () => {
const input = {}
const result = ExtractorInputSchema.safeParse(input)
expect(result.success).toBe(false)
})
it('should reject empty string in orderNumbers', () => {
const input = {
orderNumbers: ['', 'SC12345678901234']
}
const result = ExtractorInputSchema.safeParse(input)
expect(result.success).toBe(false)
})
})
describe('validateExtractorInput', () => {
it('should return success for valid input', () => {
const input = {
orderNumbers: ['SC12345678901234']
}
const result = validateExtractorInput(input)
expect(result.success).toBe(true)
expect(result.data).toBeDefined()
})
it('should return error message for invalid input', () => {
const input = {
orderNumbers: []
}
const result = validateExtractorInput(input)
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
})
})
describe('Cleaner Schema', () => {
describe('CleanerInputSchema', () => {
it('should validate valid input', () => {
const input = {
orderNumbers: ['SC12345678901234'],
materialCodes: ['MAT001', 'MAT002'],
dryRun: true
}
const result = CleanerInputSchema.safeParse(input)
expect(result.success).toBe(true)
})
it('should validate with empty materialCodes', () => {
const input = {
orderNumbers: ['SC12345678901234'],
materialCodes: [],
dryRun: false
}
const result = CleanerInputSchema.safeParse(input)
expect(result.success).toBe(true)
})
it('should reject missing dryRun', () => {
const input = {
orderNumbers: ['SC12345678901234'],
materialCodes: ['MAT001']
}
const result = CleanerInputSchema.safeParse(input)
expect(result.success).toBe(false)
})
it('should reject non-boolean dryRun', () => {
const input = {
orderNumbers: ['SC12345678901234'],
materialCodes: [],
dryRun: 'yes'
}
const result = CleanerInputSchema.safeParse(input)
expect(result.success).toBe(false)
})
})
describe('validateCleanerInput', () => {
it('should return success for valid input', () => {
const input = {
orderNumbers: ['SC12345678901234'],
materialCodes: ['MAT001'],
dryRun: false
}
const result = validateCleanerInput(input)
expect(result.success).toBe(true)
})
})
})
describe('Auth Schema', () => {
describe('LoginRequestSchema', () => {
it('should validate valid input', () => {
const input = {
username: 'testuser',
password: 'testpass'
}
const result = LoginRequestSchema.safeParse(input)
expect(result.success).toBe(true)
})
it('should reject empty username', () => {
const input = {
username: '',
password: 'testpass'
}
const result = LoginRequestSchema.safeParse(input)
expect(result.success).toBe(false)
})
it('should reject empty password', () => {
const input = {
username: 'testuser',
password: ''
}
const result = LoginRequestSchema.safeParse(input)
expect(result.success).toBe(false)
})
it('should reject missing fields', () => {
const input = {}
const result = LoginRequestSchema.safeParse(input)
expect(result.success).toBe(false)
})
})
describe('validateLoginRequest', () => {
it('should return success for valid input', () => {
const input = {
username: 'admin',
password: 'password123'
}
const result = validateLoginRequest(input)
expect(result.success).toBe(true)
expect(result.data).toBeDefined()
expect(result.data?.username).toBe('admin')
})
it('should return error for invalid input', () => {
const input = {
username: ''
}
const result = validateLoginRequest(input)
expect(result.success).toBe(false)
expect(result.error).toBeDefined()
})
})
})