diff --git a/tests/unit/audit-logger.test.ts b/tests/unit/audit-logger.test.ts index 463531e..83c452b 100644 --- a/tests/unit/audit-logger.test.ts +++ b/tests/unit/audit-logger.test.ts @@ -1,129 +1,65 @@ /** - * Audit Logger Unit Tests - Real File Write Integration Tests + * Audit Logger Unit Tests * - * Tests audit logger with real file writes to isolated test directory - * Verifies JSONL format, entry structure, and cleanup behavior + * Tests audit logger behavior: verifies JSONL entry content, + * status handling, metadata processing, and special characters. + * Uses spy on the module's audit logger instance instead of mocking winston, + * to avoid cross-contamination with logger.test.ts under isolate:false. */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import fs from 'fs' -import path from 'path' -import { app } from 'electron' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -// Isolated test log directory -const TEST_LOG_DIR = path.join(process.cwd(), 'test-logs') - -/** - * Create a test audit entry with all required fields - */ -function createTestEntry(overrides?: Partial>): Record { - return { - timestamp: new Date().toISOString(), - action: 'LOGIN', - userId: 'test-user-123', - username: 'test.user', - computerName: 'TEST-PC-001', - resource: 'ERP_SYSTEM', - status: 'success', - metadata: { sessionId: 'test-session-abc' }, - ...overrides - } -} - -describe('Audit Logger - Real File Integration', () => { - // Track original files in test directory - const originalFiles = new Set() +describe('Audit Logger', () => { + let auditLoggerModule: typeof import('../../src/main/services/logger/audit-logger') + let infoSpy: ReturnType beforeEach(async () => { - // Create test log directory - if (!fs.existsSync(TEST_LOG_DIR)) { - fs.mkdirSync(TEST_LOG_DIR, { recursive: true }) - } - - // Track existing files for cleanup - const files = fs.readdirSync(TEST_LOG_DIR) - files.forEach((f) => originalFiles.add(f)) - - // Clear mocks vi.clearAllMocks() + auditLoggerModule = await import('../../src/main/services/logger/audit-logger') + + // Spy on the audit logger's info method + const auditLogger = auditLoggerModule.default + infoSpy = vi.fn() + auditLogger.info = infoSpy }) - afterEach(async () => { - // Cleanup: Remove all files created during test - if (fs.existsSync(TEST_LOG_DIR)) { - const files = fs.readdirSync(TEST_LOG_DIR) - files.forEach((file) => { - if (!originalFiles.has(file)) { - const filePath = path.join(TEST_LOG_DIR, file) - try { - fs.unlinkSync(filePath) - } catch { - // Ignore cleanup errors - } - } - }) - - // Try to remove empty directory - try { - fs.rmdirSync(TEST_LOG_DIR) - } catch { - // Directory may not be empty, that's ok - } - } - + afterEach(() => { + vi.restoreAllMocks() vi.resetModules() }) - it('should export logAudit function', async () => { - const { logAudit } = await import('../../src/main/services/logger/audit-logger') - expect(logAudit).toBeDefined() - expect(typeof logAudit).toBe('function') - }) + it('should produce a valid JSONL entry with all required fields', async () => { + const { logAudit, applyAuditConfig } = auditLoggerModule - it('should export closeAuditLogger function', async () => { - const { closeAuditLogger } = await import('../../src/main/services/logger/audit-logger') - expect(closeAuditLogger).toBeDefined() - expect(typeof closeAuditLogger).toBe('function') - }) - - it('should log audit entry with all required fields', async () => { - const { logAudit, closeAuditLogger } = - await import('../../src/main/services/logger/audit-logger') - - const entry = createTestEntry() - - logAudit(entry.action as string, entry.userId as string, { - username: entry.username as string, - computerName: entry.computerName as string, - resource: entry.resource as string, - status: entry.status as 'success' | 'failure' | 'partial', - metadata: entry.metadata as Record + applyAuditConfig(30) + logAudit('LOGIN', 'user-001', { + username: 'alice', + computerName: 'PC-001', + resource: 'ERP_SYSTEM', + status: 'success', + metadata: { sessionId: 'abc' } }) - // Close logger to flush writes - closeAuditLogger() + expect(infoSpy).toHaveBeenCalledTimes(1) + const entry = JSON.parse(infoSpy.mock.calls[0][0]) - // Find the audit log file (should be today's file) - const today = new Date().toISOString().split('T')[0] - const auditFile = path.join(TEST_LOG_DIR, `audit-${today}.jsonl`) - - // Check if file exists (it may be in a different location due to electron mock) - // The actual file location depends on how electron's app.getPath('logs') is mocked expect(entry.action).toBe('LOGIN') - expect(entry.userId).toBe('test-user-123') - expect(entry.username).toBe('test.user') - expect(entry.computerName).toBe('TEST-PC-001') + expect(entry.userId).toBe('user-001') + expect(entry.username).toBe('alice') + expect(entry.computerName).toBe('PC-001') + expect(entry.appVersion).toBe('1.9.0-test') expect(entry.resource).toBe('ERP_SYSTEM') expect(entry.status).toBe('success') + expect(entry.metadata).toEqual({ sessionId: 'abc' }) + // Timestamp should be a valid ISO 8601 string + expect(new Date(entry.timestamp).toISOString()).toBe(entry.timestamp) }) - it('should handle all status values (success, failure, partial)', async () => { - const { logAudit, closeAuditLogger, applyAuditConfig } = - await import('../../src/main/services/logger/audit-logger') + it('should accept all status values: success, failure, partial', async () => { + const { logAudit, applyAuditConfig } = auditLoggerModule applyAuditConfig(30) - // Test success status logAudit('EXTRACT', 'user1', { username: 'extractor', computerName: 'PC-001', @@ -131,7 +67,6 @@ describe('Audit Logger - Real File Integration', () => { status: 'success' }) - // Test failure status logAudit('DELETE', 'user2', { username: 'cleaner', computerName: 'PC-002', @@ -140,7 +75,6 @@ describe('Audit Logger - Real File Integration', () => { metadata: { error: 'Permission denied' } }) - // Test partial status logAudit('UPDATE', 'user3', { username: 'updater', computerName: 'PC-003', @@ -149,72 +83,31 @@ describe('Audit Logger - Real File Integration', () => { metadata: { updated: 5, failed: 2 } }) - closeAuditLogger() - - // Verify logAudit executed for each entry (each call invokes app.getVersion) - expect(app.getVersion).toHaveBeenCalledTimes(3) + expect(infoSpy).toHaveBeenCalledTimes(3) + const entries = infoSpy.mock.calls.map((call: any[]) => JSON.parse(call[0])) + expect(entries[0].status).toBe('success') + expect(entries[1].status).toBe('failure') + expect(entries[2].status).toBe('partial') }) - it('should handle metadata correctly (with and without)', async () => { - const { logAudit, closeAuditLogger, applyAuditConfig } = - await import('../../src/main/services/logger/audit-logger') + it('should default to empty metadata when not provided', async () => { + const { logAudit, applyAuditConfig } = auditLoggerModule applyAuditConfig(30) - // Without metadata - logAudit('LOGIN', 'user-no-meta', { - username: 'no.meta', + logAudit('PING', 'user-no-meta', { + username: 'tester', computerName: 'PC-001', resource: 'ERP', status: 'success' }) - // With metadata - logAudit('LOGOUT', 'user-with-meta', { - username: 'with.meta', - computerName: 'PC-002', - resource: 'ERP', - status: 'success', - metadata: { sessionDuration: 3600, actionsPerformed: 15 } - }) - - closeAuditLogger() - - // Both entries processed (each call invokes app.getVersion) - expect(app.getVersion).toHaveBeenCalledTimes(2) + const entry = JSON.parse(infoSpy.mock.calls[0][0]) + expect(entry.metadata).toEqual({}) }) - it('should generate ISO 8601 timestamp', async () => { - const { logAudit, closeAuditLogger } = - await import('../../src/main/services/logger/audit-logger') - - const beforeLog = Date.now() - - logAudit('TEST', 'timestamp-user', { - username: 'timestamp.test', - computerName: 'PC-TS', - resource: 'test_resource', - status: 'success' - }) - - closeAuditLogger() - - const afterLog = Date.now() - - // Timestamp should be generated within the test execution window - expect(beforeLog).toBeLessThanOrEqual(afterLog) - }) - - it('should close audit logger without errors', async () => { - const { closeAuditLogger } = await import('../../src/main/services/logger/audit-logger') - - // Should complete without throwing - expect(() => closeAuditLogger()).not.toThrow() - }) - - it('should handle special characters in fields', async () => { - const { logAudit, closeAuditLogger, applyAuditConfig } = - await import('../../src/main/services/logger/audit-logger') + it('should handle special characters in fields without error', async () => { + const { logAudit, applyAuditConfig } = auditLoggerModule applyAuditConfig(30) @@ -226,29 +119,17 @@ describe('Audit Logger - Real File Integration', () => { metadata: { reason: '密码错误', attempt: 3 } }) - closeAuditLogger() - - // Verify special characters were processed without error - expect(app.getVersion).toHaveBeenCalledTimes(1) + expect(infoSpy).toHaveBeenCalledTimes(1) + const entry = JSON.parse(infoSpy.mock.calls[0][0]) + expect(entry.username).toBe('user.name+test@example.com') + expect(entry.computerName).toBe('DESKTOP-特殊字符-001') + expect(entry.resource).toBe('ERP/子系统') + expect(entry.metadata.reason).toBe('密码错误') }) - it('should handle empty metadata gracefully', async () => { - const { logAudit, closeAuditLogger, applyAuditConfig } = - await import('../../src/main/services/logger/audit-logger') + it('should close audit logger without errors', async () => { + const { closeAuditLogger } = auditLoggerModule - applyAuditConfig(30) - - logAudit('PING', 'ping-user', { - username: 'pinger', - computerName: 'PC-PING', - resource: 'health_check', - status: 'success', - metadata: {} - }) - - closeAuditLogger() - - // Verify empty metadata was processed without error - expect(app.getVersion).toHaveBeenCalledTimes(1) + expect(() => closeAuditLogger()).not.toThrow() }) }) diff --git a/tests/unit/config-manager.test.ts b/tests/unit/config-manager.test.ts index ac85317..950b0b9 100644 --- a/tests/unit/config-manager.test.ts +++ b/tests/unit/config-manager.test.ts @@ -1,7 +1,8 @@ /** * ConfigManager Unit Tests * - * Tests for ConfigManager service configuration and validation functionality. + * Tests for ConfigManager default configuration values, schema validation, + * and singleton behavior. * Logger is mocked to isolate ConfigManager testing. */ @@ -33,36 +34,7 @@ describe('ConfigManager', () => { vi.resetModules() }) - it('should load ConfigManager class', async () => { - const { ConfigManager } = await import('../../src/main/services/config/config-manager') - expect(ConfigManager).toBeDefined() - expect(typeof ConfigManager.getInstance).toBe('function') - }) - - it('should have logging configuration methods', async () => { - const { ConfigManager } = await import('../../src/main/services/config/config-manager') - - const manager = ConfigManager.getInstance() - - expect(manager.getLoggingConfig).toBeDefined() - expect(typeof manager.getLoggingConfig).toBe('function') - expect(manager.getDefaultConfig).toBeDefined() - expect(typeof manager.getDefaultConfig).toBe('function') - }) - - it('should return default logging config structure', async () => { - const { ConfigManager } = await import('../../src/main/services/config/config-manager') - - const manager = ConfigManager.getInstance() - const defaultConfig = manager.getDefaultConfig() - - expect(defaultConfig.logging).toBeDefined() - expect(defaultConfig.logging.level).toBe('info') - expect(defaultConfig.logging.auditRetention).toBe(30) - expect(defaultConfig.logging.appRetention).toBe(14) - }) - - it('should get default logging config values', async () => { + it('should return default config with correct logging values', async () => { const { ConfigManager } = await import('../../src/main/services/config/config-manager') const manager = ConfigManager.getInstance() @@ -73,14 +45,52 @@ describe('ConfigManager', () => { expect(defaultConfig.logging.appRetention).toBe(14) }) - it('should export fullConfigSchema for validation', async () => { - const { fullConfigSchema } = await import('../../src/main/types/config.schema') + it('should return default config with correct database defaults', async () => { + const { ConfigManager } = await import('../../src/main/services/config/config-manager') - expect(fullConfigSchema).toBeDefined() - expect(typeof fullConfigSchema.parse).toBe('function') - expect(typeof fullConfigSchema.safeParse).toBe('function') + const manager = ConfigManager.getInstance() + const defaultConfig = manager.getDefaultConfig() + + expect(defaultConfig.database.activeType).toBe('mysql') + expect(defaultConfig.database.mysql.host).toBe('localhost') + expect(defaultConfig.database.mysql.port).toBe(3306) + expect(defaultConfig.database.mysql.database).toBe('erp_db') }) + it('should return default config with correct extraction defaults', async () => { + const { ConfigManager } = await import('../../src/main/services/config/config-manager') + + const manager = ConfigManager.getInstance() + const defaultConfig = manager.getDefaultConfig() + + expect(defaultConfig.extraction.batchSize).toBe(100) + expect(defaultConfig.extraction.headless).toBe(true) + expect(defaultConfig.extraction.autoConvert).toBe(true) + }) + + it('should throw when getConfig() is called before initialize()', async () => { + const { ConfigManager } = await import('../../src/main/services/config/config-manager') + + // Reset singleton to get a fresh uninitialized instance + const FreshConfigManager = ConfigManager as any + FreshConfigManager.instance = null + + const manager = ConfigManager.getInstance() + + expect(() => manager.getConfig()).toThrow('Configuration not initialized') + }) + + it('should return the same singleton instance', async () => { + const { ConfigManager } = await import('../../src/main/services/config/config-manager') + + const a = ConfigManager.getInstance() + const b = ConfigManager.getInstance() + + expect(a).toBe(b) + }) +}) + +describe('Config Schema Validation', () => { it('should validate complete logging configuration', async () => { const { loggingConfigSchema } = await import('../../src/main/types/config.schema') @@ -99,4 +109,51 @@ describe('ConfigManager', () => { expect(result.data.appRetention).toBe(21) } }) + + it('should validate logging level enum values', async () => { + const { loggingConfigSchema } = await import('../../src/main/types/config.schema') + + const validLevels = ['error', 'warn', 'info', 'debug', 'verbose'] + + for (const level of validLevels) { + const result = loggingConfigSchema.safeParse({ level }) + expect(result.success).toBe(true) + } + }) + + it('should reject invalid logging level', async () => { + const { loggingConfigSchema } = await import('../../src/main/types/config.schema') + + const result = loggingConfigSchema.safeParse({ level: 'invalid_level' }) + expect(result.success).toBe(false) + }) + + it('should validate audit retention range (1-365)', async () => { + const { loggingConfigSchema } = await import('../../src/main/types/config.schema') + + expect(loggingConfigSchema.safeParse({ auditRetention: 1 }).success).toBe(true) + expect(loggingConfigSchema.safeParse({ auditRetention: 365 }).success).toBe(true) + expect(loggingConfigSchema.safeParse({ auditRetention: 0 }).success).toBe(false) + expect(loggingConfigSchema.safeParse({ auditRetention: 366 }).success).toBe(false) + }) + + it('should validate app retention range (1-365)', async () => { + const { loggingConfigSchema } = await import('../../src/main/types/config.schema') + + expect(loggingConfigSchema.safeParse({ appRetention: 1 }).success).toBe(true) + expect(loggingConfigSchema.safeParse({ appRetention: 365 }).success).toBe(true) + expect(loggingConfigSchema.safeParse({ appRetention: 0 }).success).toBe(false) + expect(loggingConfigSchema.safeParse({ appRetention: 366 }).success).toBe(false) + }) + + it('should use default values when logging config is partial', async () => { + const { loggingConfigSchema } = await import('../../src/main/types/config.schema') + + const result = loggingConfigSchema.safeParse({ level: 'warn' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.auditRetention).toBe(30) + expect(result.data.appRetention).toBe(14) + } + }) }) diff --git a/tests/unit/erp-auth.unit.test.ts b/tests/unit/erp-auth.unit.test.ts index ae25459..4a9985c 100644 --- a/tests/unit/erp-auth.unit.test.ts +++ b/tests/unit/erp-auth.unit.test.ts @@ -1,93 +1,28 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect } from 'vitest' import { ErpAuthService } from '../../src/main/services/erp/erp-auth' import type { ErpConfig } from '../../src/main/types/erp.types' +const testConfig: ErpConfig = { + url: 'https://test.example.com', + username: 'testuser', + password: 'testpass' +} + describe('ERP Authentication Service (Unit)', () => { - describe('Session Management', () => { - it('should create service instance with config', () => { - const config: ErpConfig = { - url: 'https://test.example.com', - username: 'testuser', - password: 'testpass' - } - - const service = new ErpAuthService(config) - - expect(service).toBeDefined() + describe('Initial State', () => { + it('should report inactive status before login', () => { + const service = new ErpAuthService(testConfig) expect(service.isActive()).toBe(false) }) it('should throw error when getting session before login', () => { - const config: ErpConfig = { - url: 'https://test.example.com', - username: 'testuser', - password: 'testpass' - } - - const service = new ErpAuthService(config) - + const service = new ErpAuthService(testConfig) expect(() => service.getSession()).toThrow('Not logged in. Call login() first.') }) - it('should report inactive status before login', () => { - const config: ErpConfig = { - url: 'https://test.example.com', - username: 'testuser', - password: 'testpass' - } - - const service = new ErpAuthService(config) - - expect(service.isActive()).toBe(false) - }) - }) - - describe('Close Method', () => { it('should handle close when no session exists', async () => { - const config: ErpConfig = { - url: 'https://test.example.com', - username: 'testuser', - password: 'testpass' - } - - const service = new ErpAuthService(config) - - // Should not throw when closing without session + const service = new ErpAuthService(testConfig) await expect(service.close()).resolves.toBeUndefined() }) }) - - describe('Class Structure', () => { - let service: ErpAuthService - - beforeEach(() => { - const config: ErpConfig = { - url: 'https://test.example.com', - username: 'testuser', - password: 'testpass' - } - service = new ErpAuthService(config) - }) - - it('should have login method that returns a Promise', () => { - expect(service.login).toBeDefined() - expect(typeof service.login).toBe('function') - expect(service.login()).toBeInstanceOf(Promise) - }) - - it('should have close method', () => { - expect(service.close).toBeDefined() - expect(typeof service.close).toBe('function') - }) - - it('should have getSession method', () => { - expect(service.getSession).toBeDefined() - expect(typeof service.getSession).toBe('function') - }) - - it('should have isActive method', () => { - expect(service.isActive).toBeDefined() - expect(typeof service.isActive).toBe('function') - }) - }) }) diff --git a/tests/unit/extractor.test.ts b/tests/unit/extractor.test.ts index 55b6ef8..71aeaed 100644 --- a/tests/unit/extractor.test.ts +++ b/tests/unit/extractor.test.ts @@ -17,60 +17,19 @@ describe('Extractor Service (Unit)', () => { extractor = new ExtractorService(authService, './test-downloads') }) - describe('Batch Creation', () => { - it('should create single batch for small order list', () => { - // This tests the createBatches method indirectly through extract - // We'll need to add a public method or test through the class - const orders = ['ORDER1', 'ORDER2', 'ORDER3'] - const batchSize = 10 - - // Expected: 1 batch with 3 orders - const expectedBatches = 1 - expect(Math.ceil(orders.length / batchSize)).toBe(expectedBatches) - }) - - it('should create multiple batches for large order list', () => { - const orders = Array.from({ length: 250 }, (_, i) => `ORDER${i}`) - const batchSize = 100 - - // Expected: 3 batches (100, 100, 50) - const expectedBatches = 3 - expect(Math.ceil(orders.length / batchSize)).toBe(expectedBatches) - }) - - it('should handle exact batch size', () => { - const orders = Array.from({ length: 200 }, (_, i) => `ORDER${i}`) - const batchSize = 100 - - // Expected: 2 batches exactly - const expectedBatches = 2 - expect(Math.ceil(orders.length / batchSize)).toBe(expectedBatches) - }) - - it('should handle empty order list', () => { - const orders: string[] = [] - const batchSize = 100 - - // Expected: 0 batches - const expectedBatches = 0 - expect(Math.ceil(orders.length / batchSize)).toBe(expectedBatches) - }) - }) - describe('Service Initialization', () => { - it('should create service instance', () => { - expect(extractor).toBeDefined() + it('should create service instance as ExtractorService', () => { expect(extractor).toBeInstanceOf(ExtractorService) }) - it('should use default download directory', () => { + it('should create service with default download directory', () => { const defaultExtractor = new ExtractorService(authService) - expect(defaultExtractor).toBeDefined() + expect(defaultExtractor).toBeInstanceOf(ExtractorService) }) - it('should use custom download directory', () => { + it('should create service with custom download directory', () => { const customExtractor = new ExtractorService(authService, './custom-downloads') - expect(customExtractor).toBeDefined() + expect(customExtractor).toBeInstanceOf(ExtractorService) }) }) @@ -83,5 +42,29 @@ describe('Extractor Service (Unit)', () => { expect(result.errors.length).toBeGreaterThan(0) expect(result.downloadedFiles).toHaveLength(0) }) + + it('should include error message when session is missing', async () => { + const result = await extractor.extract({ + orderNumbers: ['ORD-001', 'ORD-002'] + }) + + expect(result.errors).toEqual( + expect.arrayContaining([expect.stringContaining('Not logged in')]) + ) + }) + + it('should return empty result structure even on failure', async () => { + const result = await extractor.extract({ + orderNumbers: ['ORDER1'] + }) + + expect(result).toHaveProperty('downloadedFiles') + expect(result).toHaveProperty('mergedFile') + expect(result).toHaveProperty('recordCount') + expect(result).toHaveProperty('errors') + expect(result).toHaveProperty('orderRecordCounts') + expect(result.mergedFile).toBeNull() + expect(result.recordCount).toBe(0) + }) }) }) diff --git a/tests/unit/locators.test.ts b/tests/unit/locators.test.ts index 0a020ac..1489638 100644 --- a/tests/unit/locators.test.ts +++ b/tests/unit/locators.test.ts @@ -2,26 +2,28 @@ import { describe, it, expect } from 'vitest' import { ERP_LOCATORS } from '../../src/main/services/erp/locators' describe('ERP Locators', () => { - it('should have login page locators defined', () => { - expect(ERP_LOCATORS.login.usernameInput).toBeDefined() - expect(ERP_LOCATORS.login.passwordInput).toBeDefined() - expect(ERP_LOCATORS.login.submitButton).toBeDefined() + it('should have correct login page selectors', () => { + expect(ERP_LOCATORS.login.usernameInput).toBe('#username') + expect(ERP_LOCATORS.login.passwordInput).toBe('#password') + expect(ERP_LOCATORS.login.submitButton).toBe('button[type="submit"]') }) - it('should have main frame locator', () => { - expect(ERP_LOCATORS.main.mainIframe).toBeDefined() + it('should have correct main frame selectors', () => { + expect(ERP_LOCATORS.main.mainIframe).toBe('#mainiframe') + expect(ERP_LOCATORS.main.forwardFrame).toBe('#forwardFrame') + expect(ERP_LOCATORS.main.loadingText).toBe('加载中') }) - it('should have extractor page locators', () => { - expect(ERP_LOCATORS.extractor.orderNumberInputRole).toBeDefined() - expect(ERP_LOCATORS.extractor.queryButton).toBeDefined() - expect(ERP_LOCATORS.extractor.exportButton).toBeDefined() - expect(ERP_LOCATORS.extractor.confirmButton).toBeDefined() + it('should have correct extractor page selectors', () => { + expect(ERP_LOCATORS.extractor.orderNumberInputRole).toBe('来源生产订单号') + expect(ERP_LOCATORS.extractor.queryButton).toBe('.search-component-searchBtn') + expect(ERP_LOCATORS.extractor.exportButton).toBe('internal:has-text="输出"') + expect(ERP_LOCATORS.extractor.confirmButton).toBe('internal:has-text="确定(Y)"') }) - it('should have cleaner page locators', () => { - expect(ERP_LOCATORS.cleaner.orderNumberInput).toBeDefined() - expect(ERP_LOCATORS.cleaner.materialGrid).toBeDefined() - expect(ERP_LOCATORS.cleaner.saveButton).toBeDefined() + it('should have correct cleaner page selectors', () => { + expect(ERP_LOCATORS.cleaner.orderNumberInput).toBe('input[name="orderNumber"]') + expect(ERP_LOCATORS.cleaner.materialGrid).toBe('table.material-grid tbody tr') + expect(ERP_LOCATORS.cleaner.saveButton).toBe('button:has-text("保存")') }) }) diff --git a/tests/unit/logger-integration.test.ts b/tests/unit/logger-integration.test.ts index ac7d635..790ff29 100644 --- a/tests/unit/logger-integration.test.ts +++ b/tests/unit/logger-integration.test.ts @@ -1,6 +1,6 @@ /** * Logger Integration Tests - RequestContext Integration - * Verifies RequestContext is properly integrated with Logger + * Verifies RequestContext functions produce correct behavior, not just exports. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' @@ -9,50 +9,72 @@ import path from 'path' import { getLogDir } from '../../src/main/services/logger/shared' describe('Logger RequestContext Integration', () => { - it('should export run from request-context', async () => { - const { run } = await import('../../src/main/services/logger/index') - expect(run).toBeDefined() - expect(typeof run).toBe('function') + it('should generate unique request IDs inside run()', async () => { + const { run, getRequestId } = await import('../../src/main/services/logger/index') + + const outerId = await run(async () => getRequestId()) + const innerId = await run(async () => getRequestId()) + + expect(outerId).toBeTruthy() + expect(innerId).toBeTruthy() + expect(outerId).not.toBe(innerId) }) - it('should export getRequestId from request-context', async () => { + it('should return undefined for getRequestId() outside run()', async () => { const { getRequestId } = await import('../../src/main/services/logger/index') - expect(getRequestId).toBeDefined() - expect(typeof getRequestId).toBe('function') + + expect(getRequestId()).toBeUndefined() }) - it('should export getContext from request-context', async () => { - const { getContext } = await import('../../src/main/services/logger/index') - expect(getContext).toBeDefined() - expect(typeof getContext).toBe('function') + it('should propagate context through run()', async () => { + const { run, getContext } = await import('../../src/main/services/logger/index') + + const context = await run(async () => getContext(), { + userId: 'user-123', + operation: 'test-op' + }) + + expect(context).toBeDefined() + expect(context!.userId).toBe('user-123') + expect(context!.operation).toBe('test-op') }) - it('should export withContext from request-context', async () => { - const { withContext } = await import('../../src/main/services/logger/index') - expect(withContext).toBeDefined() - expect(typeof withContext).toBe('function') + it('should provide request ID inside withRequestContext()', async () => { + const { withRequestContext, getRequestId } = + await import('../../src/main/services/logger/index') + + const requestId = await withRequestContext(async () => getRequestId(), { + userId: 'user-abc', + operation: 'extract' + }) + + expect(requestId).toBeTruthy() }) - it('should export withRequestContext wrapper', async () => { - const { withRequestContext } = await import('../../src/main/services/logger/index') - expect(withRequestContext).toBeDefined() - expect(typeof withRequestContext).toBe('function') + it('should inject context into withRequestContext()', async () => { + const { withRequestContext, getContext } = await import('../../src/main/services/logger/index') + + const ctx = await withRequestContext(async () => getContext(), { + userId: 'admin', + operation: 'clean' + }) + + expect(ctx).toBeDefined() + expect(ctx!.userId).toBe('admin') + expect(ctx!.operation).toBe('clean') }) - it('should export createLogger', async () => { + it('should create a child logger that carries context metadata', async () => { const { createLogger } = await import('../../src/main/services/logger/index') - expect(createLogger).toBeDefined() - expect(typeof createLogger).toBe('function') - }) + const logger = createLogger('MyModule') - it('should have all exports available from LoggerContext type', async () => { - const loggerModule = await import('../../src/main/services/logger/index') - expect(loggerModule.run).toBeDefined() - expect(loggerModule.getRequestId).toBeDefined() - expect(loggerModule.getContext).toBeDefined() - expect(loggerModule.withContext).toBeDefined() - expect(loggerModule.withRequestContext).toBeDefined() - expect(loggerModule.createLogger).toBeDefined() + logger.info('hello', { key: 'value' }) + + // Verify the logger is functional — it has standard log methods that accept calls + expect(typeof logger.info).toBe('function') + expect(typeof logger.error).toBe('function') + expect(typeof logger.warn).toBe('function') + expect(typeof logger.debug).toBe('function') }) }) diff --git a/tests/unit/logger.test.ts b/tests/unit/logger.test.ts index adef0eb..3f29584 100644 --- a/tests/unit/logger.test.ts +++ b/tests/unit/logger.test.ts @@ -1,7 +1,8 @@ /** - * Logger Unit Tests - Enhanced for Configuration Loading + * Logger Unit Tests * - * Tests logger creation, configuration, and integration with ConfigManager + * Tests logger creation, log output content, level filtering, + * and setLogLevel behavior. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' @@ -17,15 +18,9 @@ const winstonCalls: WinstonCall[] = [] // ============================================ // Winston Format Mock - Callable Object Pattern // ============================================ -// Supports: format(), format.combine(), format.printf(), -// AND IIFE pattern: format((info) => info)() -// ============================================ function createFormatFn() { - // The format FUNCTION itself - callable with () const formatCallable = vi.fn((callback?: Function) => { if (callback) { - // Return a new callable format when callback is provided - // This simulates: format((info) => { ... })() where () calls the returned function const transform = vi.fn() as any transform.combine = vi.fn(() => formatCallable) transform.timestamp = vi.fn(() => formatCallable) @@ -37,10 +32,7 @@ function createFormatFn() { transform.errors = vi.fn(() => formatCallable) transform.metadata = vi.fn(() => formatCallable) transform.cli = vi.fn(() => formatCallable) - // When called as transform(info), pass through the callback - // Handle undefined/null info gracefully transform.mockImplementation((info: any) => { - // During initialization, logger may call with undefined - skip in that case if (!info || typeof info !== 'object') { info = { level: 'info', message: '', timestamp: new Date().toISOString() } } @@ -48,11 +40,9 @@ function createFormatFn() { }) return transform } - // Called without callback - return formatCallable for chaining return formatCallable }) as any - // Add top-level chainable methods formatCallable.combine = vi.fn(() => formatCallable) formatCallable.timestamp = vi.fn(() => formatCallable) formatCallable.colorize = vi.fn(() => formatCallable) @@ -70,7 +60,7 @@ function createFormatFn() { const format = createFormatFn() -// Mock winston since we don't need actual file logging in tests +// Mock winston vi.mock('winston', () => { const createLoggerInstance = { level: 'info', @@ -131,9 +121,6 @@ vi.mock('winston-daily-rotate-file', () => ({ default: vi.fn() as any })) -// Note: electron mock is now in tests/setup.ts (global) -// This local mock is removed to avoid conflicts - describe('Logger', () => { beforeEach(() => { vi.clearAllMocks() @@ -144,63 +131,55 @@ describe('Logger', () => { vi.resetModules() }) - it('should create a logger with context', async () => { + it('should create a child logger that logs with context metadata', async () => { const { createLogger } = await import('../../src/main/services/logger') const logger = createLogger('TestContext') - expect(logger).toBeDefined() - // Logger should have logging methods - expect(logger.info || logger.debug || logger.warn || logger.error).toBeDefined() + logger.info('Hello world', { extraKey: 'extraValue' }) + + expect(winstonCalls).toHaveLength(1) + expect(winstonCalls[0].level).toBe('info') + expect(winstonCalls[0].message).toBe('Hello world') + expect(winstonCalls[0].meta?.context).toBe('TestContext') + expect(winstonCalls[0].meta?.extraKey).toBe('extraValue') }) - it('should have all log methods', async () => { + it('should log at all severity levels with correct content', async () => { const { createLogger } = await import('../../src/main/services/logger') - const logger = createLogger('TestContext') + const logger = createLogger('LevelTest') - expect(typeof logger.info).toBe('function') - expect(typeof logger.error).toBe('function') - expect(typeof logger.warn).toBe('function') - expect(typeof logger.debug).toBe('function') + logger.debug('debug msg', { key: 'd' }) + logger.info('info msg', { key: 'i' }) + logger.warn('warn msg', { key: 'w' }) + logger.error('error msg', { key: 'e' }) + + expect(winstonCalls).toHaveLength(4) + const levels = winstonCalls.map((c) => c.level) + expect(levels).toEqual(['debug', 'info', 'warn', 'error']) + + expect(winstonCalls[0].message).toBe('debug msg') + expect(winstonCalls[1].message).toBe('info msg') + expect(winstonCalls[2].message).toBe('warn msg') + expect(winstonCalls[3].message).toBe('error msg') }) - it('should export default logger', async () => { - const logger = await import('../../src/main/services/logger') - expect(logger.default).toBeDefined() - }) - - it('should export setLogLevel function', async () => { - const { setLogLevel } = await import('../../src/main/services/logger') - expect(setLogLevel).toBeDefined() - expect(typeof setLogLevel).toBe('function') - }) - - it('should create child logger with context metadata', async () => { + it('should produce separate child loggers with independent context', async () => { const { createLogger } = await import('../../src/main/services/logger') - const logger = createLogger('MyModule') + const loggerA = createLogger('ModuleA') + const loggerB = createLogger('ModuleB') - logger.info('Test message') + loggerA.info('from A') + loggerB.warn('from B') - // Verify logger was created and called - expect(logger.info).toHaveBeenCalled() - }) - - it('should log at different levels with metadata', async () => { - const { createLogger } = await import('../../src/main/services/logger') - const logger = createLogger('TestContext') - - logger.debug('Debug message', { debugKey: 'debugValue' }) - logger.info('Info message', { infoKey: 'infoValue' }) - logger.warn('Warning message', { warnKey: 'warnValue' }) - logger.error('Error message', { errorKey: 'errorValue' }) - - expect(logger.debug).toHaveBeenCalled() - expect(logger.info).toHaveBeenCalled() - expect(logger.warn).toHaveBeenCalled() - expect(logger.error).toHaveBeenCalled() + expect(winstonCalls).toHaveLength(2) + expect(winstonCalls[0].meta?.context).toBe('ModuleA') + expect(winstonCalls[0].message).toBe('from A') + expect(winstonCalls[1].meta?.context).toBe('ModuleB') + expect(winstonCalls[1].message).toBe('from B') }) }) -describe('Logger Configuration Loading', () => { +describe('setLogLevel', () => { beforeEach(() => { vi.clearAllMocks() winstonCalls.length = 0 @@ -210,89 +189,21 @@ describe('Logger Configuration Loading', () => { vi.resetModules() }) - it('should load ConfigManager class', async () => { - const { ConfigManager } = await import('../../src/main/services/config/config-manager') - expect(ConfigManager).toBeDefined() - expect(typeof ConfigManager.getInstance).toBe('function') - }) + it('should change the root logger level', async () => { + const loggerModule = await import('../../src/main/services/logger') - it('should have logging configuration methods', async () => { - const { ConfigManager } = await import('../../src/main/services/config/config-manager') + // Default export is the root winston logger + const rootLogger = loggerModule.default - const manager = ConfigManager.getInstance() + // Default level is 'info' + expect(rootLogger.level).toBe('info') - expect(manager.getLoggingConfig).toBeDefined() - expect(typeof manager.getLoggingConfig).toBe('function') - expect(manager.getDefaultConfig).toBeDefined() - expect(typeof manager.getDefaultConfig).toBe('function') - }) + // Change to debug + loggerModule.setLogLevel('debug') + expect(rootLogger.level).toBe('debug') - it('should return default logging config structure', async () => { - const { ConfigManager } = await import('../../src/main/services/config/config-manager') - - const manager = ConfigManager.getInstance() - const defaultConfig = manager.getDefaultConfig() - - expect(defaultConfig.logging).toBeDefined() - expect(defaultConfig.logging.level).toBeDefined() - expect(defaultConfig.logging.auditRetention).toBeDefined() - expect(defaultConfig.logging.appRetention).toBeDefined() - }) - - it('should validate logging level enum values', async () => { - const { loggingConfigSchema } = await import('../../src/main/types/config.schema') - - // Test all valid log levels - const validLevels = ['error', 'warn', 'info', 'debug', 'verbose'] - - for (const level of validLevels) { - const result = loggingConfigSchema.safeParse({ level }) - expect(result.success).toBe(true) - } - }) - - it('should reject invalid logging level', async () => { - const { loggingConfigSchema } = await import('../../src/main/types/config.schema') - - const result = loggingConfigSchema.safeParse({ level: 'invalid_level' }) - expect(result.success).toBe(false) - }) - - it('should validate audit retention range (1-365)', async () => { - const { loggingConfigSchema } = await import('../../src/main/types/config.schema') - - // Valid values - expect(loggingConfigSchema.safeParse({ auditRetention: 1 }).success).toBe(true) - expect(loggingConfigSchema.safeParse({ auditRetention: 365 }).success).toBe(true) - expect(loggingConfigSchema.safeParse({ auditRetention: 30 }).success).toBe(true) - - // Invalid values - expect(loggingConfigSchema.safeParse({ auditRetention: 0 }).success).toBe(false) - expect(loggingConfigSchema.safeParse({ auditRetention: 366 }).success).toBe(false) - }) - - it('should validate app retention range (1-365)', async () => { - const { loggingConfigSchema } = await import('../../src/main/types/config.schema') - - // Valid values - expect(loggingConfigSchema.safeParse({ appRetention: 1 }).success).toBe(true) - expect(loggingConfigSchema.safeParse({ appRetention: 365 }).success).toBe(true) - expect(loggingConfigSchema.safeParse({ appRetention: 14 }).success).toBe(true) - - // Invalid values - expect(loggingConfigSchema.safeParse({ appRetention: 0 }).success).toBe(false) - expect(loggingConfigSchema.safeParse({ appRetention: 366 }).success).toBe(false) - }) - - it('should use default values when logging config is partial', async () => { - const { loggingConfigSchema } = await import('../../src/main/types/config.schema') - - // Only provide level, should default others - const result = loggingConfigSchema.safeParse({ level: 'warn' }) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.auditRetention).toBe(30) // default - expect(result.data.appRetention).toBe(14) // default - } + // Change to error + loggerModule.setLogLevel('error') + expect(rootLogger.level).toBe('error') }) })