diff --git a/core/shared/application/AsyncUseCase.test.ts b/core/shared/application/AsyncUseCase.test.ts new file mode 100644 index 000000000..02f35fc70 --- /dev/null +++ b/core/shared/application/AsyncUseCase.test.ts @@ -0,0 +1,412 @@ +import { describe, it, expect } from 'vitest'; +import { AsyncUseCase } from './AsyncUseCase'; +import { Result } from '../domain/Result'; +import { ApplicationErrorCode } from '../errors/ApplicationErrorCode'; + +describe('AsyncUseCase', () => { + describe('AsyncUseCase interface', () => { + it('should have execute method returning Promise', async () => { + // Concrete implementation for testing + class TestAsyncUseCase implements AsyncUseCase<{ id: string }, { data: string }, 'NOT_FOUND'> { + async execute(input: { id: string }): Promise>> { + if (input.id === 'not-found') { + return Result.err({ code: 'NOT_FOUND' }); + } + return Result.ok({ data: `Data for ${input.id}` }); + } + } + + const useCase = new TestAsyncUseCase(); + + const successResult = await useCase.execute({ id: 'test-123' }); + expect(successResult.isOk()).toBe(true); + expect(successResult.unwrap()).toEqual({ data: 'Data for test-123' }); + + const errorResult = await useCase.execute({ id: 'not-found' }); + expect(errorResult.isErr()).toBe(true); + expect(errorResult.unwrapErr()).toEqual({ code: 'NOT_FOUND' }); + }); + + it('should support different input types', async () => { + interface GetUserInput { + userId: string; + includeProfile?: boolean; + } + + interface UserDTO { + id: string; + name: string; + email: string; + profile?: { + avatar: string; + bio: string; + }; + } + + type GetUserErrorCode = 'USER_NOT_FOUND' | 'PERMISSION_DENIED'; + + class GetUserUseCase implements AsyncUseCase { + async execute(input: GetUserInput): Promise>> { + if (input.userId === 'not-found') { + return Result.err({ code: 'USER_NOT_FOUND' }); + } + + if (input.userId === 'no-permission') { + return Result.err({ code: 'PERMISSION_DENIED' }); + } + + const user: UserDTO = { + id: input.userId, + name: 'John Doe', + email: 'john@example.com' + }; + + if (input.includeProfile) { + user.profile = { + avatar: 'avatar.jpg', + bio: 'Software developer' + }; + } + + return Result.ok(user); + } + } + + const useCase = new GetUserUseCase(); + + // Success case with profile + const successWithProfile = await useCase.execute({ + userId: 'user-123', + includeProfile: true + }); + + expect(successWithProfile.isOk()).toBe(true); + const userWithProfile = successWithProfile.unwrap(); + expect(userWithProfile.id).toBe('user-123'); + expect(userWithProfile.profile).toBeDefined(); + expect(userWithProfile.profile?.avatar).toBe('avatar.jpg'); + + // Success case without profile + const successWithoutProfile = await useCase.execute({ + userId: 'user-456', + includeProfile: false + }); + + expect(successWithoutProfile.isOk()).toBe(true); + const userWithoutProfile = successWithoutProfile.unwrap(); + expect(userWithoutProfile.id).toBe('user-456'); + expect(userWithoutProfile.profile).toBeUndefined(); + + // Error cases + const notFoundResult = await useCase.execute({ userId: 'not-found' }); + expect(notFoundResult.isErr()).toBe(true); + expect(notFoundResult.unwrapErr()).toEqual({ code: 'USER_NOT_FOUND' }); + + const permissionResult = await useCase.execute({ userId: 'no-permission' }); + expect(permissionResult.isErr()).toBe(true); + expect(permissionResult.unwrapErr()).toEqual({ code: 'PERMISSION_DENIED' }); + }); + + it('should support complex query patterns', async () => { + interface SearchOrdersInput { + customerId?: string; + status?: 'pending' | 'completed' | 'cancelled'; + dateRange?: { start: Date; end: Date }; + page?: number; + limit?: number; + } + + interface OrderDTO { + id: string; + customerId: string; + status: string; + total: number; + items: Array<{ productId: string; quantity: number; price: number }>; + createdAt: Date; + } + + interface OrdersResult { + orders: OrderDTO[]; + total: number; + page: number; + totalPages: number; + filters: SearchOrdersInput; + } + + type SearchOrdersErrorCode = 'INVALID_FILTERS' | 'NO_ORDERS_FOUND'; + + class SearchOrdersUseCase implements AsyncUseCase { + async execute(input: SearchOrdersInput): Promise>> { + // Validate at least one filter + if (!input.customerId && !input.status && !input.dateRange) { + return Result.err({ code: 'INVALID_FILTERS' }); + } + + // Simulate database query + const allOrders: OrderDTO[] = [ + { + id: 'order-1', + customerId: 'cust-1', + status: 'completed', + total: 150, + items: [{ productId: 'prod-1', quantity: 2, price: 75 }], + createdAt: new Date('2024-01-01') + }, + { + id: 'order-2', + customerId: 'cust-1', + status: 'pending', + total: 200, + items: [{ productId: 'prod-2', quantity: 1, price: 200 }], + createdAt: new Date('2024-01-02') + }, + { + id: 'order-3', + customerId: 'cust-2', + status: 'completed', + total: 300, + items: [{ productId: 'prod-3', quantity: 3, price: 100 }], + createdAt: new Date('2024-01-03') + } + ]; + + // Apply filters + let filteredOrders = allOrders; + + if (input.customerId) { + filteredOrders = filteredOrders.filter(o => o.customerId === input.customerId); + } + + if (input.status) { + filteredOrders = filteredOrders.filter(o => o.status === input.status); + } + + if (input.dateRange) { + filteredOrders = filteredOrders.filter(o => { + const orderDate = o.createdAt.getTime(); + return orderDate >= input.dateRange!.start.getTime() && + orderDate <= input.dateRange!.end.getTime(); + }); + } + + if (filteredOrders.length === 0) { + return Result.err({ code: 'NO_ORDERS_FOUND' }); + } + + // Apply pagination + const page = input.page || 1; + const limit = input.limit || 10; + const start = (page - 1) * limit; + const end = start + limit; + const paginatedOrders = filteredOrders.slice(start, end); + + const result: OrdersResult = { + orders: paginatedOrders, + total: filteredOrders.length, + page, + totalPages: Math.ceil(filteredOrders.length / limit), + filters: input + }; + + return Result.ok(result); + } + } + + const useCase = new SearchOrdersUseCase(); + + // Success case - filter by customer + const customerResult = await useCase.execute({ customerId: 'cust-1' }); + expect(customerResult.isOk()).toBe(true); + const customerOrders = customerResult.unwrap(); + expect(customerOrders.orders).toHaveLength(2); + expect(customerOrders.total).toBe(2); + + // Success case - filter by status + const statusResult = await useCase.execute({ status: 'completed' }); + expect(statusResult.isOk()).toBe(true); + const completedOrders = statusResult.unwrap(); + expect(completedOrders.orders).toHaveLength(2); + expect(completedOrders.total).toBe(2); + + // Success case - filter by date range + const dateResult = await useCase.execute({ + dateRange: { + start: new Date('2024-01-01'), + end: new Date('2024-01-02') + } + }); + expect(dateResult.isOk()).toBe(true); + const dateOrders = dateResult.unwrap(); + expect(dateOrders.orders).toHaveLength(2); + expect(dateOrders.total).toBe(2); + + // Error case - no filters + const noFiltersResult = await useCase.execute({}); + expect(noFiltersResult.isErr()).toBe(true); + expect(noFiltersResult.unwrapErr()).toEqual({ code: 'INVALID_FILTERS' }); + + // Error case - no matching orders + const noOrdersResult = await useCase.execute({ customerId: 'nonexistent' }); + expect(noOrdersResult.isErr()).toBe(true); + expect(noOrdersResult.unwrapErr()).toEqual({ code: 'NO_ORDERS_FOUND' }); + }); + + it('should support async operations with delays', async () => { + interface ProcessBatchInput { + items: Array<{ id: string; data: string }>; + delayMs?: number; + } + + interface ProcessBatchResult { + processed: number; + failed: number; + results: Array<{ id: string; status: 'success' | 'failed'; message?: string }>; + } + + type ProcessBatchErrorCode = 'EMPTY_BATCH' | 'PROCESSING_ERROR'; + + class ProcessBatchUseCase implements AsyncUseCase { + async execute(input: ProcessBatchInput): Promise>> { + if (input.items.length === 0) { + return Result.err({ code: 'EMPTY_BATCH' }); + } + + const delay = input.delayMs || 10; + const results: Array<{ id: string; status: 'success' | 'failed'; message?: string }> = []; + let processed = 0; + let failed = 0; + + for (const item of input.items) { + // Simulate async processing with delay + await new Promise(resolve => setTimeout(resolve, delay)); + + // Simulate some failures + if (item.id === 'fail-1' || item.id === 'fail-2') { + results.push({ id: item.id, status: 'failed', message: 'Processing failed' }); + failed++; + } else { + results.push({ id: item.id, status: 'success' }); + processed++; + } + } + + return Result.ok({ + processed, + failed, + results + }); + } + } + + const useCase = new ProcessBatchUseCase(); + + // Success case + const successResult = await useCase.execute({ + items: [ + { id: 'item-1', data: 'data1' }, + { id: 'item-2', data: 'data2' }, + { id: 'item-3', data: 'data3' } + ], + delayMs: 5 + }); + + expect(successResult.isOk()).toBe(true); + const batchResult = successResult.unwrap(); + expect(batchResult.processed).toBe(3); + expect(batchResult.failed).toBe(0); + expect(batchResult.results).toHaveLength(3); + + // Mixed success/failure case + const mixedResult = await useCase.execute({ + items: [ + { id: 'item-1', data: 'data1' }, + { id: 'fail-1', data: 'data2' }, + { id: 'item-3', data: 'data3' }, + { id: 'fail-2', data: 'data4' } + ], + delayMs: 5 + }); + + expect(mixedResult.isOk()).toBe(true); + const mixedBatchResult = mixedResult.unwrap(); + expect(mixedBatchResult.processed).toBe(2); + expect(mixedBatchResult.failed).toBe(2); + expect(mixedBatchResult.results).toHaveLength(4); + + // Error case - empty batch + const emptyResult = await useCase.execute({ items: [] }); + expect(emptyResult.isErr()).toBe(true); + expect(emptyResult.unwrapErr()).toEqual({ code: 'EMPTY_BATCH' }); + }); + + it('should support streaming-like operations', async () => { + interface StreamInput { + source: string; + chunkSize?: number; + } + + interface StreamResult { + chunks: string[]; + totalSize: number; + source: string; + } + + type StreamErrorCode = 'SOURCE_NOT_FOUND' | 'STREAM_ERROR'; + + class StreamUseCase implements AsyncUseCase { + async execute(input: StreamInput): Promise>> { + if (input.source === 'not-found') { + return Result.err({ code: 'SOURCE_NOT_FOUND' }); + } + + if (input.source === 'error') { + return Result.err({ code: 'STREAM_ERROR' }); + } + + const chunkSize = input.chunkSize || 10; + const data = 'This is a test data stream that will be split into chunks'; + const chunks: string[] = []; + + for (let i = 0; i < data.length; i += chunkSize) { + // Simulate async chunk reading + await new Promise(resolve => setTimeout(resolve, 1)); + chunks.push(data.slice(i, i + chunkSize)); + } + + return Result.ok({ + chunks, + totalSize: data.length, + source: input.source + }); + } + } + + const useCase = new StreamUseCase(); + + // Success case with default chunk size + const defaultResult = await useCase.execute({ source: 'test-source' }); + expect(defaultResult.isOk()).toBe(true); + const defaultStream = defaultResult.unwrap(); + expect(defaultStream.chunks).toHaveLength(5); + expect(defaultStream.totalSize).toBe(48); + expect(defaultStream.source).toBe('test-source'); + + // Success case with custom chunk size + const customResult = await useCase.execute({ source: 'test-source', chunkSize: 15 }); + expect(customResult.isOk()).toBe(true); + const customStream = customResult.unwrap(); + expect(customStream.chunks).toHaveLength(4); + expect(customStream.totalSize).toBe(48); + + // Error case - source not found + const notFoundResult = await useCase.execute({ source: 'not-found' }); + expect(notFoundResult.isErr()).toBe(true); + expect(notFoundResult.unwrapErr()).toEqual({ code: 'SOURCE_NOT_FOUND' }); + + // Error case - stream error + const errorResult = await useCase.execute({ source: 'error' }); + expect(errorResult.isErr()).toBe(true); + expect(errorResult.unwrapErr()).toEqual({ code: 'STREAM_ERROR' }); + }); + }); +}); diff --git a/core/shared/application/ErrorReporter.test.ts b/core/shared/application/ErrorReporter.test.ts new file mode 100644 index 000000000..dff5412b6 --- /dev/null +++ b/core/shared/application/ErrorReporter.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect } from 'vitest'; +import { ErrorReporter } from './ErrorReporter'; + +describe('ErrorReporter', () => { + describe('ErrorReporter interface', () => { + it('should have report method', () => { + const errors: Array<{ error: Error; context?: unknown }> = []; + + const reporter: ErrorReporter = { + report: (error: Error, context?: unknown) => { + errors.push({ error, context }); + } + }; + + const testError = new Error('Test error'); + reporter.report(testError, { userId: 123 }); + + expect(errors).toHaveLength(1); + expect(errors[0].error).toBe(testError); + expect(errors[0].context).toEqual({ userId: 123 }); + }); + + it('should support reporting without context', () => { + const errors: Error[] = []; + + const reporter: ErrorReporter = { + report: (error: Error) => { + errors.push(error); + } + }; + + const testError = new Error('Test error'); + reporter.report(testError); + + expect(errors).toHaveLength(1); + expect(errors[0]).toBe(testError); + }); + + it('should support different error types', () => { + const errors: Array<{ error: Error; context?: unknown }> = []; + + const reporter: ErrorReporter = { + report: (error: Error, context?: unknown) => { + errors.push({ error, context }); + } + }; + + // Standard Error + const standardError = new Error('Standard error'); + reporter.report(standardError, { type: 'standard' }); + + // Custom Error + class CustomError extends Error { + constructor(message: string, public code: string) { + super(message); + this.name = 'CustomError'; + } + } + const customError = new CustomError('Custom error', 'CUSTOM_CODE'); + reporter.report(customError, { type: 'custom' }); + + // TypeError + const typeError = new TypeError('Type error'); + reporter.report(typeError, { type: 'type' }); + + expect(errors).toHaveLength(3); + expect(errors[0].error).toBe(standardError); + expect(errors[1].error).toBe(customError); + expect(errors[2].error).toBe(typeError); + }); + + it('should support complex context objects', () => { + const errors: Array<{ error: Error; context?: unknown }> = []; + + const reporter: ErrorReporter = { + report: (error: Error, context?: unknown) => { + errors.push({ error, context }); + } + }; + + const complexContext = { + user: { + id: 'user-123', + name: 'John Doe', + email: 'john@example.com' + }, + request: { + method: 'POST', + url: '/api/users', + headers: { + 'content-type': 'application/json', + 'authorization': 'Bearer token' + } + }, + timestamp: new Date('2024-01-01T12:00:00Z'), + metadata: { + retryCount: 3, + timeout: 5000 + } + }; + + const error = new Error('Request failed'); + reporter.report(error, complexContext); + + expect(errors).toHaveLength(1); + expect(errors[0].error).toBe(error); + expect(errors[0].context).toEqual(complexContext); + }); + }); + + describe('ErrorReporter behavior', () => { + it('should support logging error with stack trace', () => { + const logs: Array<{ message: string; stack?: string; context?: unknown }> = []; + + const reporter: ErrorReporter = { + report: (error: Error, context?: unknown) => { + logs.push({ + message: error.message, + stack: error.stack, + context + }); + } + }; + + const error = new Error('Database connection failed'); + reporter.report(error, { retryCount: 3 }); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('Database connection failed'); + expect(logs[0].stack).toBeDefined(); + expect(logs[0].context).toEqual({ retryCount: 3 }); + }); + + it('should support error aggregation', () => { + const errorCounts: Record = {}; + + const reporter: ErrorReporter = { + report: (error: Error) => { + const errorType = error.name || 'Unknown'; + errorCounts[errorType] = (errorCounts[errorType] || 0) + 1; + } + }; + + const error1 = new Error('Error 1'); + const error2 = new TypeError('Type error'); + const error3 = new Error('Error 2'); + const error4 = new TypeError('Another type error'); + + reporter.report(error1); + reporter.report(error2); + reporter.report(error3); + reporter.report(error4); + + expect(errorCounts['Error']).toBe(2); + expect(errorCounts['TypeError']).toBe(2); + }); + + it('should support error filtering', () => { + const criticalErrors: Error[] = []; + const warnings: Error[] = []; + + const reporter: ErrorReporter = { + report: (error: Error, context?: unknown) => { + const isCritical = context && typeof context === 'object' && 'severity' in context && + (context as { severity: string }).severity === 'critical'; + + if (isCritical) { + criticalErrors.push(error); + } else { + warnings.push(error); + } + } + }; + + const criticalError = new Error('Critical failure'); + const warningError = new Error('Warning'); + + reporter.report(criticalError, { severity: 'critical' }); + reporter.report(warningError, { severity: 'warning' }); + + expect(criticalErrors).toHaveLength(1); + expect(criticalErrors[0]).toBe(criticalError); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toBe(warningError); + }); + + it('should support error enrichment', () => { + const enrichedErrors: Array<{ error: Error; enrichedContext: unknown }> = []; + + const reporter: ErrorReporter = { + report: (error: Error, context?: unknown) => { + const enrichedContext: Record = { + errorName: error.name, + errorMessage: error.message, + timestamp: new Date().toISOString(), + environment: process.env.NODE_ENV || 'development' + }; + + if (context && typeof context === 'object') { + Object.assign(enrichedContext, context); + } + + enrichedErrors.push({ error, enrichedContext }); + } + }; + + const error = new Error('Something went wrong'); + reporter.report(error, { userId: 'user-123', action: 'login' }); + + expect(enrichedErrors).toHaveLength(1); + expect(enrichedErrors[0].error).toBe(error); + expect(enrichedErrors[0].enrichedContext).toMatchObject({ + userId: 'user-123', + action: 'login', + errorName: 'Error', + errorMessage: 'Something went wrong', + environment: 'development' + }); + }); + + it('should support error deduplication', () => { + const uniqueErrors: Error[] = []; + const seenMessages = new Set(); + + const reporter: ErrorReporter = { + report: (error: Error) => { + if (!seenMessages.has(error.message)) { + uniqueErrors.push(error); + seenMessages.add(error.message); + } + } + }; + + const error1 = new Error('Duplicate error'); + const error2 = new Error('Duplicate error'); + const error3 = new Error('Unique error'); + + reporter.report(error1); + reporter.report(error2); + reporter.report(error3); + + expect(uniqueErrors).toHaveLength(2); + expect(uniqueErrors[0].message).toBe('Duplicate error'); + expect(uniqueErrors[1].message).toBe('Unique error'); + }); + + it('should support error rate limiting', () => { + const errors: Error[] = []; + let errorCount = 0; + const rateLimit = 5; + + const reporter: ErrorReporter = { + report: (error: Error) => { + errorCount++; + if (errorCount <= rateLimit) { + errors.push(error); + } + // Silently drop errors beyond rate limit + } + }; + + // Report 10 errors + for (let i = 0; i < 10; i++) { + reporter.report(new Error(`Error ${i}`)); + } + + expect(errors).toHaveLength(rateLimit); + expect(errorCount).toBe(10); + }); + }); + + describe('ErrorReporter implementation patterns', () => { + it('should support console logger implementation', () => { + const consoleErrors: string[] = []; + const originalConsoleError = console.error; + + // Mock console.error + console.error = (...args: unknown[]) => consoleErrors.push(args.join(' ')); + + const consoleReporter: ErrorReporter = { + report: (error: Error, context?: unknown) => { + console.error('Error:', error.message, 'Context:', context); + } + }; + + const error = new Error('Test error'); + consoleReporter.report(error, { userId: 123 }); + + // Restore console.error + console.error = originalConsoleError; + + expect(consoleErrors).toHaveLength(1); + expect(consoleErrors[0]).toContain('Error:'); + expect(consoleErrors[0]).toContain('Test error'); + expect(consoleErrors[0]).toContain('Context:'); + }); + + it('should support file logger implementation', () => { + const fileLogs: Array<{ timestamp: string; error: string; context?: unknown }> = []; + + const fileReporter: ErrorReporter = { + report: (error: Error, context?: unknown) => { + fileLogs.push({ + timestamp: new Date().toISOString(), + error: error.message, + context + }); + } + }; + + const error = new Error('File error'); + fileReporter.report(error, { file: 'test.txt', line: 42 }); + + expect(fileLogs).toHaveLength(1); + expect(fileLogs[0].error).toBe('File error'); + expect(fileLogs[0].context).toEqual({ file: 'test.txt', line: 42 }); + }); + + it('should support remote reporter implementation', async () => { + const remoteErrors: Array<{ error: string; context?: unknown }> = []; + + const remoteReporter: ErrorReporter = { + report: async (error: Error, context?: unknown) => { + remoteErrors.push({ error: error.message, context }); + await new Promise(resolve => setTimeout(resolve, 1)); + } + }; + + const error = new Error('Remote error'); + await remoteReporter.report(error, { endpoint: '/api/data' }); + + expect(remoteErrors).toHaveLength(1); + expect(remoteErrors[0].error).toBe('Remote error'); + expect(remoteErrors[0].context).toEqual({ endpoint: '/api/data' }); + }); + + it('should support batch error reporting', () => { + const batchErrors: Error[] = []; + const batchSize = 3; + let currentBatch: Error[] = []; + + const reporter: ErrorReporter = { + report: (error: Error) => { + currentBatch.push(error); + + if (currentBatch.length >= batchSize) { + batchErrors.push(...currentBatch); + currentBatch = []; + } + } + }; + + // Report 7 errors + for (let i = 0; i < 7; i++) { + reporter.report(new Error(`Error ${i}`)); + } + + // Add remaining errors + if (currentBatch.length > 0) { + batchErrors.push(...currentBatch); + } + + expect(batchErrors).toHaveLength(7); + }); + }); +}); diff --git a/core/shared/application/Service.test.ts b/core/shared/application/Service.test.ts new file mode 100644 index 000000000..c2f08ce5c --- /dev/null +++ b/core/shared/application/Service.test.ts @@ -0,0 +1,451 @@ +import { describe, it, expect } from 'vitest'; +import { ApplicationService, AsyncApplicationService, AsyncResultApplicationService } from './Service'; +import { Result } from '../domain/Result'; +import { ApplicationError } from '../errors/ApplicationError'; + +describe('Service', () => { + describe('ApplicationService interface', () => { + it('should have optional serviceName property', () => { + const service: ApplicationService = { + serviceName: 'TestService' + }; + + expect(service.serviceName).toBe('TestService'); + }); + + it('should work without serviceName', () => { + const service: ApplicationService = {}; + + expect(service.serviceName).toBeUndefined(); + }); + + it('should support different service implementations', () => { + const service1: ApplicationService = { serviceName: 'Service1' }; + const service2: ApplicationService = { serviceName: 'Service2' }; + const service3: ApplicationService = {}; + + expect(service1.serviceName).toBe('Service1'); + expect(service2.serviceName).toBe('Service2'); + expect(service3.serviceName).toBeUndefined(); + }); + }); + + describe('AsyncApplicationService interface', () => { + it('should have execute method returning Promise', async () => { + const service: AsyncApplicationService = { + execute: async (input: string) => input.length + }; + + const result = await service.execute('test'); + expect(result).toBe(4); + }); + + it('should support different input and output types', async () => { + const stringService: AsyncApplicationService = { + execute: async (input: string) => input.toUpperCase() + }; + + const objectService: AsyncApplicationService<{ x: number; y: number }, number> = { + execute: async (input) => input.x + input.y + }; + + const arrayService: AsyncApplicationService = { + execute: async (input) => input.map(x => x * 2) + }; + + expect(await stringService.execute('hello')).toBe('HELLO'); + expect(await objectService.execute({ x: 3, y: 4 })).toBe(7); + expect(await arrayService.execute([1, 2, 3])).toEqual([2, 4, 6]); + }); + + it('should support async operations with delays', async () => { + const service: AsyncApplicationService = { + execute: async (input: string) => { + await new Promise(resolve => setTimeout(resolve, 10)); + return `Processed: ${input}`; + } + }; + + const start = Date.now(); + const result = await service.execute('test'); + const elapsed = Date.now() - start; + + expect(result).toBe('Processed: test'); + expect(elapsed).toBeGreaterThanOrEqual(10); + }); + + it('should support complex async operations', async () => { + interface FetchUserInput { + userId: string; + includePosts?: boolean; + } + + interface UserWithPosts { + id: string; + name: string; + email: string; + posts: Array<{ id: string; title: string; content: string }>; + } + + const userService: AsyncApplicationService = { + execute: async (input: FetchUserInput) => { + // Simulate async database fetch + await new Promise(resolve => setTimeout(resolve, 5)); + + const user: UserWithPosts = { + id: input.userId, + name: 'John Doe', + email: 'john@example.com', + posts: [] + }; + + if (input.includePosts) { + user.posts = [ + { id: 'post-1', title: 'First Post', content: 'Content 1' }, + { id: 'post-2', title: 'Second Post', content: 'Content 2' } + ]; + } + + return user; + } + }; + + const userWithPosts = await userService.execute({ + userId: 'user-123', + includePosts: true + }); + + expect(userWithPosts.id).toBe('user-123'); + expect(userWithPosts.posts).toHaveLength(2); + + const userWithoutPosts = await userService.execute({ + userId: 'user-456', + includePosts: false + }); + + expect(userWithoutPosts.id).toBe('user-456'); + expect(userWithoutPosts.posts).toHaveLength(0); + }); + }); + + describe('AsyncResultApplicationService interface', () => { + it('should have execute method returning Promise', async () => { + const service: AsyncResultApplicationService = { + execute: async (input: string) => { + if (input.length === 0) { + return Result.err('Input cannot be empty'); + } + return Result.ok(input.length); + } + }; + + const successResult = await service.execute('test'); + expect(successResult.isOk()).toBe(true); + expect(successResult.unwrap()).toBe(4); + + const errorResult = await service.execute(''); + expect(errorResult.isErr()).toBe(true); + expect(errorResult.unwrapErr()).toBe('Input cannot be empty'); + }); + + it('should support validation logic', async () => { + interface ValidationResult { + isValid: boolean; + errors: string[]; + } + + const validator: AsyncResultApplicationService = { + execute: async (input: string) => { + const errors: string[] = []; + + if (input.length < 3) { + errors.push('Must be at least 3 characters'); + } + + if (!input.match(/^[a-zA-Z]+$/)) { + errors.push('Must contain only letters'); + } + + if (errors.length > 0) { + return Result.err(errors.join(', ')); + } + + return Result.ok({ isValid: true, errors: [] }); + } + }; + + const validResult = await validator.execute('Hello'); + expect(validResult.isOk()).toBe(true); + expect(validResult.unwrap()).toEqual({ isValid: true, errors: [] }); + + const invalidResult = await validator.execute('ab'); + expect(invalidResult.isErr()).toBe(true); + expect(invalidResult.unwrapErr()).toBe('Must be at least 3 characters'); + }); + + it('should support complex business rules', async () => { + interface ProcessOrderInput { + items: Array<{ productId: string; quantity: number; price: number }>; + customerType: 'regular' | 'premium' | 'vip'; + hasCoupon: boolean; + } + + interface OrderResult { + orderId: string; + subtotal: number; + discount: number; + total: number; + status: 'processed' | 'pending' | 'failed'; + } + + const orderProcessor: AsyncResultApplicationService = { + execute: async (input: ProcessOrderInput) => { + // Calculate subtotal + const subtotal = input.items.reduce((sum, item) => sum + (item.quantity * item.price), 0); + + if (subtotal <= 0) { + return Result.err('Order must have items with positive prices'); + } + + // Calculate discount + let discount = 0; + + // Customer type discount + switch (input.customerType) { + case 'premium': + discount += subtotal * 0.1; + break; + case 'vip': + discount += subtotal * 0.2; + break; + } + + // Coupon discount + if (input.hasCoupon) { + discount += subtotal * 0.05; + } + + const total = subtotal - discount; + + const orderResult: OrderResult = { + orderId: `order-${Date.now()}`, + subtotal, + discount, + total, + status: 'processed' + }; + + return Result.ok(orderResult); + } + }; + + // Success case - VIP with coupon + const vipWithCoupon = await orderProcessor.execute({ + items: [ + { productId: 'prod-1', quantity: 2, price: 100 }, + { productId: 'prod-2', quantity: 1, price: 50 } + ], + customerType: 'vip', + hasCoupon: true + }); + + expect(vipWithCoupon.isOk()).toBe(true); + const order1 = vipWithCoupon.unwrap(); + expect(order1.subtotal).toBe(250); // 2*100 + 1*50 + expect(order1.discount).toBe(62.5); // 250 * 0.2 + 250 * 0.05 + expect(order1.total).toBe(187.5); // 250 - 62.5 + + // Success case - Regular without coupon + const regularWithoutCoupon = await orderProcessor.execute({ + items: [{ productId: 'prod-1', quantity: 1, price: 100 }], + customerType: 'regular', + hasCoupon: false + }); + + expect(regularWithoutCoupon.isOk()).toBe(true); + const order2 = regularWithoutCoupon.unwrap(); + expect(order2.subtotal).toBe(100); + expect(order2.discount).toBe(0); + expect(order2.total).toBe(100); + + // Error case - Empty order + const emptyOrder = await orderProcessor.execute({ + items: [], + customerType: 'regular', + hasCoupon: false + }); + + expect(emptyOrder.isErr()).toBe(true); + expect(emptyOrder.unwrapErr()).toBe('Order must have items with positive prices'); + }); + + it('should support async operations with delays', async () => { + interface ProcessBatchInput { + items: Array<{ id: string; data: string }>; + delayMs?: number; + } + + interface BatchResult { + processed: number; + failed: number; + results: Array<{ id: string; status: 'success' | 'failed'; message?: string }>; + } + + const batchProcessor: AsyncResultApplicationService = { + execute: async (input: ProcessBatchInput) => { + if (input.items.length === 0) { + return Result.err('Empty batch'); + } + + const delay = input.delayMs || 10; + const results: Array<{ id: string; status: 'success' | 'failed'; message?: string }> = []; + let processed = 0; + let failed = 0; + + for (const item of input.items) { + // Simulate async processing with delay + await new Promise(resolve => setTimeout(resolve, delay)); + + // Simulate some failures + if (item.id === 'fail-1' || item.id === 'fail-2') { + results.push({ id: item.id, status: 'failed', message: 'Processing failed' }); + failed++; + } else { + results.push({ id: item.id, status: 'success' }); + processed++; + } + } + + return Result.ok({ + processed, + failed, + results + }); + } + }; + + // Success case + const successResult = await batchProcessor.execute({ + items: [ + { id: 'item-1', data: 'data1' }, + { id: 'item-2', data: 'data2' }, + { id: 'item-3', data: 'data3' } + ], + delayMs: 5 + }); + + expect(successResult.isOk()).toBe(true); + const batchResult = successResult.unwrap(); + expect(batchResult.processed).toBe(3); + expect(batchResult.failed).toBe(0); + expect(batchResult.results).toHaveLength(3); + + // Mixed success/failure case + const mixedResult = await batchProcessor.execute({ + items: [ + { id: 'item-1', data: 'data1' }, + { id: 'fail-1', data: 'data2' }, + { id: 'item-3', data: 'data3' }, + { id: 'fail-2', data: 'data4' } + ], + delayMs: 5 + }); + + expect(mixedResult.isOk()).toBe(true); + const mixedBatchResult = mixedResult.unwrap(); + expect(mixedBatchResult.processed).toBe(2); + expect(mixedBatchResult.failed).toBe(2); + expect(mixedBatchResult.results).toHaveLength(4); + + // Error case - empty batch + const emptyResult = await batchProcessor.execute({ items: [] }); + expect(emptyResult.isErr()).toBe(true); + expect(emptyResult.unwrapErr()).toBe('Empty batch'); + }); + + it('should support error handling with ApplicationError', async () => { + interface ProcessPaymentInput { + amount: number; + currency: string; + } + + interface PaymentReceipt { + receiptId: string; + amount: number; + currency: string; + status: 'completed' | 'failed'; + } + + const paymentProcessor: AsyncResultApplicationService = { + execute: async (input: ProcessPaymentInput) => { + // Validate amount + if (input.amount <= 0) { + return Result.err({ + type: 'application', + context: 'payment', + kind: 'validation', + message: 'Amount must be positive' + } as ApplicationError); + } + + // Validate currency + const supportedCurrencies = ['USD', 'EUR', 'GBP']; + if (!supportedCurrencies.includes(input.currency)) { + return Result.err({ + type: 'application', + context: 'payment', + kind: 'validation', + message: `Currency ${input.currency} is not supported` + } as ApplicationError); + } + + // Simulate payment processing + const receipt: PaymentReceipt = { + receiptId: `receipt-${Date.now()}`, + amount: input.amount, + currency: input.currency, + status: 'completed' + }; + + return Result.ok(receipt); + } + }; + + // Success case + const successResult = await paymentProcessor.execute({ + amount: 100, + currency: 'USD' + }); + + expect(successResult.isOk()).toBe(true); + const receipt = successResult.unwrap(); + expect(receipt.amount).toBe(100); + expect(receipt.currency).toBe('USD'); + expect(receipt.status).toBe('completed'); + + // Error case - invalid amount + const invalidAmountResult = await paymentProcessor.execute({ + amount: -50, + currency: 'USD' + }); + + expect(invalidAmountResult.isErr()).toBe(true); + const error1 = invalidAmountResult.unwrapErr(); + expect(error1.type).toBe('application'); + expect(error1.kind).toBe('validation'); + expect(error1.message).toBe('Amount must be positive'); + + // Error case - invalid currency + const invalidCurrencyResult = await paymentProcessor.execute({ + amount: 100, + currency: 'JPY' + }); + + expect(invalidCurrencyResult.isErr()).toBe(true); + const error2 = invalidCurrencyResult.unwrapErr(); + expect(error2.type).toBe('application'); + expect(error2.kind).toBe('validation'); + expect(error2.message).toBe('Currency JPY is not supported'); + }); + }); +}); diff --git a/core/shared/application/UseCase.test.ts b/core/shared/application/UseCase.test.ts new file mode 100644 index 000000000..b9575e644 --- /dev/null +++ b/core/shared/application/UseCase.test.ts @@ -0,0 +1,324 @@ +import { describe, it, expect } from 'vitest'; +import { UseCase } from './UseCase'; +import { Result } from '../domain/Result'; +import { ApplicationErrorCode } from '../errors/ApplicationErrorCode'; + +describe('UseCase', () => { + describe('UseCase interface', () => { + it('should have execute method returning Promise', async () => { + // Concrete implementation for testing + class TestUseCase implements UseCase<{ value: number }, string, 'INVALID_VALUE'> { + async execute(input: { value: number }): Promise>> { + if (input.value < 0) { + return Result.err({ code: 'INVALID_VALUE' }); + } + return Result.ok(`Value: ${input.value}`); + } + } + + const useCase = new TestUseCase(); + + const successResult = await useCase.execute({ value: 42 }); + expect(successResult.isOk()).toBe(true); + expect(successResult.unwrap()).toBe('Value: 42'); + + const errorResult = await useCase.execute({ value: -1 }); + expect(errorResult.isErr()).toBe(true); + expect(errorResult.unwrapErr()).toEqual({ code: 'INVALID_VALUE' }); + }); + + it('should support different input types', async () => { + interface CreateUserInput { + name: string; + email: string; + } + + interface UserDTO { + id: string; + name: string; + email: string; + } + + type CreateUserErrorCode = 'INVALID_EMAIL' | 'USER_ALREADY_EXISTS'; + + class CreateUserUseCase implements UseCase { + async execute(input: CreateUserInput): Promise>> { + if (!input.email.includes('@')) { + return Result.err({ code: 'INVALID_EMAIL' }); + } + + // Simulate user creation + const user: UserDTO = { + id: `user-${Date.now()}`, + name: input.name, + email: input.email + }; + + return Result.ok(user); + } + } + + const useCase = new CreateUserUseCase(); + + const successResult = await useCase.execute({ + name: 'John Doe', + email: 'john@example.com' + }); + + expect(successResult.isOk()).toBe(true); + const user = successResult.unwrap(); + expect(user.name).toBe('John Doe'); + expect(user.email).toBe('john@example.com'); + expect(user.id).toMatch(/^user-\d+$/); + + const errorResult = await useCase.execute({ + name: 'John Doe', + email: 'invalid-email' + }); + + expect(errorResult.isErr()).toBe(true); + expect(errorResult.unwrapErr()).toEqual({ code: 'INVALID_EMAIL' }); + }); + + it('should support complex error codes', async () => { + interface ProcessPaymentInput { + amount: number; + currency: string; + paymentMethod: 'credit_card' | 'paypal' | 'bank_transfer'; + } + + interface PaymentReceipt { + receiptId: string; + amount: number; + currency: string; + status: 'completed' | 'pending' | 'failed'; + } + + type ProcessPaymentErrorCode = + | 'INSUFFICIENT_FUNDS' + | 'INVALID_CURRENCY' + | 'PAYMENT_METHOD_NOT_SUPPORTED' + | 'NETWORK_ERROR'; + + class ProcessPaymentUseCase implements UseCase { + async execute(input: ProcessPaymentInput): Promise>> { + // Validate currency + const supportedCurrencies = ['USD', 'EUR', 'GBP']; + if (!supportedCurrencies.includes(input.currency)) { + return Result.err({ code: 'INVALID_CURRENCY' }); + } + + // Validate payment method + const supportedMethods = ['credit_card', 'paypal']; + if (!supportedMethods.includes(input.paymentMethod)) { + return Result.err({ code: 'PAYMENT_METHOD_NOT_SUPPORTED' }); + } + + // Simulate payment processing + if (input.amount > 10000) { + return Result.err({ code: 'INSUFFICIENT_FUNDS' }); + } + + const receipt: PaymentReceipt = { + receiptId: `receipt-${Date.now()}`, + amount: input.amount, + currency: input.currency, + status: 'completed' + }; + + return Result.ok(receipt); + } + } + + const useCase = new ProcessPaymentUseCase(); + + // Success case + const successResult = await useCase.execute({ + amount: 100, + currency: 'USD', + paymentMethod: 'credit_card' + }); + + expect(successResult.isOk()).toBe(true); + const receipt = successResult.unwrap(); + expect(receipt.amount).toBe(100); + expect(receipt.currency).toBe('USD'); + expect(receipt.status).toBe('completed'); + + // Error case - invalid currency + const currencyError = await useCase.execute({ + amount: 100, + currency: 'JPY', + paymentMethod: 'credit_card' + }); + + expect(currencyError.isErr()).toBe(true); + expect(currencyError.unwrapErr()).toEqual({ code: 'INVALID_CURRENCY' }); + + // Error case - unsupported payment method + const methodError = await useCase.execute({ + amount: 100, + currency: 'USD', + paymentMethod: 'bank_transfer' + }); + + expect(methodError.isErr()).toBe(true); + expect(methodError.unwrapErr()).toEqual({ code: 'PAYMENT_METHOD_NOT_SUPPORTED' }); + + // Error case - insufficient funds + const fundsError = await useCase.execute({ + amount: 15000, + currency: 'USD', + paymentMethod: 'credit_card' + }); + + expect(fundsError.isErr()).toBe(true); + expect(fundsError.unwrapErr()).toEqual({ code: 'INSUFFICIENT_FUNDS' }); + }); + + it('should support void success type', async () => { + interface DeleteUserInput { + userId: string; + } + + type DeleteUserErrorCode = 'USER_NOT_FOUND' | 'INSUFFICIENT_PERMISSIONS'; + + class DeleteUserUseCase implements UseCase { + async execute(input: DeleteUserInput): Promise>> { + if (input.userId === 'not-found') { + return Result.err({ code: 'USER_NOT_FOUND' }); + } + + if (input.userId === 'no-permission') { + return Result.err({ code: 'INSUFFICIENT_PERMISSIONS' }); + } + + // Simulate deletion + return Result.ok(undefined); + } + } + + const useCase = new DeleteUserUseCase(); + + const successResult = await useCase.execute({ userId: 'user-123' }); + expect(successResult.isOk()).toBe(true); + expect(successResult.unwrap()).toBeUndefined(); + + const notFoundResult = await useCase.execute({ userId: 'not-found' }); + expect(notFoundResult.isErr()).toBe(true); + expect(notFoundResult.unwrapErr()).toEqual({ code: 'USER_NOT_FOUND' }); + + const permissionResult = await useCase.execute({ userId: 'no-permission' }); + expect(permissionResult.isErr()).toBe(true); + expect(permissionResult.unwrapErr()).toEqual({ code: 'INSUFFICIENT_PERMISSIONS' }); + }); + + it('should support complex success types', async () => { + interface SearchInput { + query: string; + filters?: { + category?: string; + priceRange?: { min: number; max: number }; + inStock?: boolean; + }; + page?: number; + limit?: number; + } + + interface SearchResult { + items: Array<{ + id: string; + name: string; + price: number; + category: string; + inStock: boolean; + }>; + total: number; + page: number; + totalPages: number; + } + + type SearchErrorCode = 'INVALID_QUERY' | 'NO_RESULTS'; + + class SearchUseCase implements UseCase { + async execute(input: SearchInput): Promise>> { + if (!input.query || input.query.length < 2) { + return Result.err({ code: 'INVALID_QUERY' }); + } + + // Simulate search results + const items = [ + { id: '1', name: 'Product A', price: 100, category: 'electronics', inStock: true }, + { id: '2', name: 'Product B', price: 200, category: 'electronics', inStock: false }, + { id: '3', name: 'Product C', price: 150, category: 'clothing', inStock: true } + ]; + + const filteredItems = items.filter(item => { + if (input.filters?.category && item.category !== input.filters.category) { + return false; + } + if (input.filters?.priceRange) { + if (item.price < input.filters.priceRange.min || item.price > input.filters.priceRange.max) { + return false; + } + } + if (input.filters?.inStock !== undefined && item.inStock !== input.filters.inStock) { + return false; + } + return true; + }); + + if (filteredItems.length === 0) { + return Result.err({ code: 'NO_RESULTS' }); + } + + const page = input.page || 1; + const limit = input.limit || 10; + const start = (page - 1) * limit; + const end = start + limit; + const paginatedItems = filteredItems.slice(start, end); + + const result: SearchResult = { + items: paginatedItems, + total: filteredItems.length, + page, + totalPages: Math.ceil(filteredItems.length / limit) + }; + + return Result.ok(result); + } + } + + const useCase = new SearchUseCase(); + + // Success case + const successResult = await useCase.execute({ + query: 'product', + filters: { category: 'electronics' }, + page: 1, + limit: 10 + }); + + expect(successResult.isOk()).toBe(true); + const searchResult = successResult.unwrap(); + expect(searchResult.items).toHaveLength(2); + expect(searchResult.total).toBe(2); + expect(searchResult.page).toBe(1); + expect(searchResult.totalPages).toBe(1); + + // Error case - invalid query + const invalidQueryResult = await useCase.execute({ query: 'a' }); + expect(invalidQueryResult.isErr()).toBe(true); + expect(invalidQueryResult.unwrapErr()).toEqual({ code: 'INVALID_QUERY' }); + + // Error case - no results + const noResultsResult = await useCase.execute({ + query: 'product', + filters: { category: 'nonexistent' } + }); + + expect(noResultsResult.isErr()).toBe(true); + expect(noResultsResult.unwrapErr()).toEqual({ code: 'NO_RESULTS' }); + }); + }); +}); diff --git a/core/shared/application/UseCaseOutputPort.test.ts b/core/shared/application/UseCaseOutputPort.test.ts new file mode 100644 index 000000000..4a580c2be --- /dev/null +++ b/core/shared/application/UseCaseOutputPort.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect } from 'vitest'; +import { UseCaseOutputPort } from './UseCaseOutputPort'; + +describe('UseCaseOutputPort', () => { + describe('UseCaseOutputPort interface', () => { + it('should have present method', () => { + const presentedData: unknown[] = []; + + const outputPort: UseCaseOutputPort = { + present: (data: string) => { + presentedData.push(data); + } + }; + + outputPort.present('test data'); + + expect(presentedData).toHaveLength(1); + expect(presentedData[0]).toBe('test data'); + }); + + it('should support different data types', () => { + const presentedData: Array<{ type: string; data: unknown }> = []; + + const outputPort: UseCaseOutputPort = { + present: (data: unknown) => { + presentedData.push({ type: typeof data, data }); + } + }; + + outputPort.present('string data'); + outputPort.present(42); + outputPort.present({ id: 1, name: 'test' }); + outputPort.present([1, 2, 3]); + + expect(presentedData).toHaveLength(4); + expect(presentedData[0]).toEqual({ type: 'string', data: 'string data' }); + expect(presentedData[1]).toEqual({ type: 'number', data: 42 }); + expect(presentedData[2]).toEqual({ type: 'object', data: { id: 1, name: 'test' } }); + expect(presentedData[3]).toEqual({ type: 'object', data: [1, 2, 3] }); + }); + + it('should support complex data structures', () => { + interface UserDTO { + id: string; + name: string; + email: string; + profile: { + avatar: string; + bio: string; + preferences: { + theme: 'light' | 'dark'; + notifications: boolean; + }; + }; + metadata: { + createdAt: Date; + updatedAt: Date; + lastLogin?: Date; + }; + } + + const presentedUsers: UserDTO[] = []; + + const outputPort: UseCaseOutputPort = { + present: (data: UserDTO) => { + presentedUsers.push(data); + } + }; + + const user: UserDTO = { + id: 'user-123', + name: 'John Doe', + email: 'john@example.com', + profile: { + avatar: 'avatar.jpg', + bio: 'Software developer', + preferences: { + theme: 'dark', + notifications: true + } + }, + metadata: { + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-02'), + lastLogin: new Date('2024-01-03') + } + }; + + outputPort.present(user); + + expect(presentedUsers).toHaveLength(1); + expect(presentedUsers[0]).toEqual(user); + }); + + it('should support array data', () => { + const presentedArrays: number[][] = []; + + const outputPort: UseCaseOutputPort = { + present: (data: number[]) => { + presentedArrays.push(data); + } + }; + + outputPort.present([1, 2, 3]); + outputPort.present([4, 5, 6]); + + expect(presentedArrays).toHaveLength(2); + expect(presentedArrays[0]).toEqual([1, 2, 3]); + expect(presentedArrays[1]).toEqual([4, 5, 6]); + }); + + it('should support null and undefined values', () => { + const presentedValues: unknown[] = []; + + const outputPort: UseCaseOutputPort = { + present: (data: unknown) => { + presentedValues.push(data); + } + }; + + outputPort.present(null); + outputPort.present(undefined); + outputPort.present('value'); + + expect(presentedValues).toHaveLength(3); + expect(presentedValues[0]).toBe(null); + expect(presentedValues[1]).toBe(undefined); + expect(presentedValues[2]).toBe('value'); + }); + }); + + describe('UseCaseOutputPort behavior', () => { + it('should support transformation before presentation', () => { + const presentedData: Array<{ transformed: string; original: string }> = []; + + const outputPort: UseCaseOutputPort = { + present: (data: string) => { + const transformed = data.toUpperCase(); + presentedData.push({ transformed, original: data }); + } + }; + + outputPort.present('hello'); + outputPort.present('world'); + + expect(presentedData).toHaveLength(2); + expect(presentedData[0]).toEqual({ transformed: 'HELLO', original: 'hello' }); + expect(presentedData[1]).toEqual({ transformed: 'WORLD', original: 'world' }); + }); + + it('should support validation before presentation', () => { + const presentedData: string[] = []; + const validationErrors: string[] = []; + + const outputPort: UseCaseOutputPort = { + present: (data: string) => { + if (data.length === 0) { + validationErrors.push('Data cannot be empty'); + return; + } + + if (data.length > 100) { + validationErrors.push('Data exceeds maximum length'); + return; + } + + presentedData.push(data); + } + }; + + outputPort.present('valid data'); + outputPort.present(''); + outputPort.present('a'.repeat(101)); + outputPort.present('another valid'); + + expect(presentedData).toHaveLength(2); + expect(presentedData[0]).toBe('valid data'); + expect(presentedData[1]).toBe('another valid'); + expect(validationErrors).toHaveLength(2); + expect(validationErrors[0]).toBe('Data cannot be empty'); + expect(validationErrors[1]).toBe('Data exceeds maximum length'); + }); + + it('should support pagination', () => { + interface PaginatedResult { + items: T[]; + total: number; + page: number; + totalPages: number; + } + + const presentedPages: PaginatedResult[] = []; + + const outputPort: UseCaseOutputPort> = { + present: (data: PaginatedResult) => { + presentedPages.push(data); + } + }; + + const page1: PaginatedResult = { + items: ['item-1', 'item-2', 'item-3'], + total: 10, + page: 1, + totalPages: 4 + }; + + const page2: PaginatedResult = { + items: ['item-4', 'item-5', 'item-6'], + total: 10, + page: 2, + totalPages: 4 + }; + + outputPort.present(page1); + outputPort.present(page2); + + expect(presentedPages).toHaveLength(2); + expect(presentedPages[0]).toEqual(page1); + expect(presentedPages[1]).toEqual(page2); + }); + + it('should support streaming presentation', () => { + const stream: string[] = []; + + const outputPort: UseCaseOutputPort = { + present: (data: string) => { + stream.push(data); + } + }; + + // Simulate streaming data + const chunks = ['chunk-1', 'chunk-2', 'chunk-3', 'chunk-4']; + chunks.forEach(chunk => outputPort.present(chunk)); + + expect(stream).toHaveLength(4); + expect(stream).toEqual(chunks); + }); + + it('should support error handling in presentation', () => { + const presentedData: string[] = []; + const presentationErrors: Error[] = []; + + const outputPort: UseCaseOutputPort = { + present: (data: string) => { + try { + // Simulate complex presentation logic that might fail + if (data === 'error') { + throw new Error('Presentation failed'); + } + presentedData.push(data); + } catch (error) { + if (error instanceof Error) { + presentationErrors.push(error); + } + } + } + }; + + outputPort.present('valid-1'); + outputPort.present('error'); + outputPort.present('valid-2'); + + expect(presentedData).toHaveLength(2); + expect(presentedData[0]).toBe('valid-1'); + expect(presentedData[1]).toBe('valid-2'); + expect(presentationErrors).toHaveLength(1); + expect(presentationErrors[0].message).toBe('Presentation failed'); + }); + }); + + describe('UseCaseOutputPort implementation patterns', () => { + it('should support console presenter', () => { + const consoleOutputs: string[] = []; + const originalConsoleLog = console.log; + + // Mock console.log + console.log = (...args: unknown[]) => consoleOutputs.push(args.join(' ')); + + const consolePresenter: UseCaseOutputPort = { + present: (data: string) => { + console.log('Presented:', data); + } + }; + + consolePresenter.present('test data'); + + // Restore console.log + console.log = originalConsoleLog; + + expect(consoleOutputs).toHaveLength(1); + expect(consoleOutputs[0]).toContain('Presented:'); + expect(consoleOutputs[0]).toContain('test data'); + }); + + it('should support HTTP response presenter', () => { + const responses: Array<{ status: number; body: unknown; headers?: Record }> = []; + + const httpResponsePresenter: UseCaseOutputPort = { + present: (data: unknown) => { + responses.push({ + status: 200, + body: data, + headers: { + 'content-type': 'application/json' + } + }); + } + }; + + httpResponsePresenter.present({ id: 1, name: 'test' }); + + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + expect(responses[0].body).toEqual({ id: 1, name: 'test' }); + expect(responses[0].headers).toEqual({ 'content-type': 'application/json' }); + }); + + it('should support WebSocket presenter', () => { + const messages: Array<{ type: string; data: unknown; timestamp: string }> = []; + + const webSocketPresenter: UseCaseOutputPort = { + present: (data: unknown) => { + messages.push({ + type: 'data', + data, + timestamp: new Date().toISOString() + }); + } + }; + + webSocketPresenter.present({ event: 'user-joined', userId: 'user-123' }); + + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe('data'); + expect(messages[0].data).toEqual({ event: 'user-joined', userId: 'user-123' }); + expect(messages[0].timestamp).toBeDefined(); + }); + + it('should support event bus presenter', () => { + const events: Array<{ topic: string; payload: unknown; metadata: unknown }> = []; + + const eventBusPresenter: UseCaseOutputPort = { + present: (data: unknown) => { + events.push({ + topic: 'user-events', + payload: data, + metadata: { + source: 'use-case', + timestamp: new Date().toISOString() + } + }); + } + }; + + eventBusPresenter.present({ userId: 'user-123', action: 'created' }); + + expect(events).toHaveLength(1); + expect(events[0].topic).toBe('user-events'); + expect(events[0].payload).toEqual({ userId: 'user-123', action: 'created' }); + expect(events[0].metadata).toMatchObject({ source: 'use-case' }); + }); + + it('should support batch presenter', () => { + const batches: Array<{ items: unknown[]; batchSize: number; processedAt: string }> = []; + let currentBatch: unknown[] = []; + const batchSize = 3; + + const batchPresenter: UseCaseOutputPort = { + present: (data: unknown) => { + currentBatch.push(data); + + if (currentBatch.length >= batchSize) { + batches.push({ + items: [...currentBatch], + batchSize: currentBatch.length, + processedAt: new Date().toISOString() + }); + currentBatch = []; + } + } + }; + + // Present 7 items + for (let i = 1; i <= 7; i++) { + batchPresenter.present({ item: i }); + } + + // Add remaining items + if (currentBatch.length > 0) { + batches.push({ + items: [...currentBatch], + batchSize: currentBatch.length, + processedAt: new Date().toISOString() + }); + } + + expect(batches).toHaveLength(3); + expect(batches[0].items).toHaveLength(3); + expect(batches[1].items).toHaveLength(3); + expect(batches[2].items).toHaveLength(1); + }); + + it('should support caching presenter', () => { + const cache = new Map(); + const cacheHits: string[] = []; + const cacheMisses: string[] = []; + + const cachingPresenter: UseCaseOutputPort<{ key: string; data: unknown }> = { + present: (data: { key: string; data: unknown }) => { + if (cache.has(data.key)) { + cacheHits.push(data.key); + } else { + cacheMisses.push(data.key); + cache.set(data.key, data.data); + } + } + }; + + cachingPresenter.present({ key: 'user-1', data: { name: 'John' } }); + cachingPresenter.present({ key: 'user-2', data: { name: 'Jane' } }); + cachingPresenter.present({ key: 'user-1', data: { name: 'John Updated' } }); + + expect(cacheHits).toHaveLength(1); + expect(cacheHits[0]).toBe('user-1'); + expect(cacheMisses).toHaveLength(2); + expect(cacheMisses[0]).toBe('user-1'); + expect(cacheMisses[1]).toBe('user-2'); + expect(cache.get('user-1')).toEqual({ name: 'John Updated' }); + }); + }); +}); diff --git a/core/shared/domain/DomainEvent.test.ts b/core/shared/domain/DomainEvent.test.ts new file mode 100644 index 000000000..bb0f5869b --- /dev/null +++ b/core/shared/domain/DomainEvent.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect } from 'vitest'; +import { DomainEvent, DomainEventPublisher, DomainEventAlias } from './DomainEvent'; + +describe('DomainEvent', () => { + describe('DomainEvent interface', () => { + it('should have required properties', () => { + const event: DomainEvent<{ userId: string }> = { + eventType: 'USER_CREATED', + aggregateId: 'user-123', + eventData: { userId: 'user-123' }, + occurredAt: new Date('2024-01-01T00:00:00Z') + }; + + expect(event.eventType).toBe('USER_CREATED'); + expect(event.aggregateId).toBe('user-123'); + expect(event.eventData).toEqual({ userId: 'user-123' }); + expect(event.occurredAt).toEqual(new Date('2024-01-01T00:00:00Z')); + }); + + it('should support different event data types', () => { + const stringEvent: DomainEvent = { + eventType: 'STRING_EVENT', + aggregateId: 'agg-1', + eventData: 'some data', + occurredAt: new Date() + }; + + const objectEvent: DomainEvent<{ id: number; name: string }> = { + eventType: 'OBJECT_EVENT', + aggregateId: 'agg-2', + eventData: { id: 1, name: 'test' }, + occurredAt: new Date() + }; + + const arrayEvent: DomainEvent = { + eventType: 'ARRAY_EVENT', + aggregateId: 'agg-3', + eventData: ['a', 'b', 'c'], + occurredAt: new Date() + }; + + expect(stringEvent.eventData).toBe('some data'); + expect(objectEvent.eventData).toEqual({ id: 1, name: 'test' }); + expect(arrayEvent.eventData).toEqual(['a', 'b', 'c']); + }); + + it('should support default unknown type', () => { + const event: DomainEvent = { + eventType: 'UNKNOWN_EVENT', + aggregateId: 'agg-1', + eventData: { any: 'data' }, + occurredAt: new Date() + }; + + expect(event.eventType).toBe('UNKNOWN_EVENT'); + expect(event.aggregateId).toBe('agg-1'); + }); + + it('should support complex event data structures', () => { + interface ComplexEventData { + userId: string; + changes: { + field: string; + oldValue: unknown; + newValue: unknown; + }[]; + metadata: { + source: string; + timestamp: string; + }; + } + + const event: DomainEvent = { + eventType: 'USER_UPDATED', + aggregateId: 'user-456', + eventData: { + userId: 'user-456', + changes: [ + { field: 'email', oldValue: 'old@example.com', newValue: 'new@example.com' } + ], + metadata: { + source: 'admin-panel', + timestamp: '2024-01-01T12:00:00Z' + } + }, + occurredAt: new Date('2024-01-01T12:00:00Z') + }; + + expect(event.eventData.userId).toBe('user-456'); + expect(event.eventData.changes).toHaveLength(1); + expect(event.eventData.metadata.source).toBe('admin-panel'); + }); + }); + + describe('DomainEventPublisher interface', () => { + it('should have publish method', async () => { + const mockPublisher: DomainEventPublisher = { + publish: async (event: DomainEvent) => { + // Mock implementation + return Promise.resolve(); + } + }; + + const event: DomainEvent<{ message: string }> = { + eventType: 'TEST_EVENT', + aggregateId: 'test-1', + eventData: { message: 'test' }, + occurredAt: new Date() + }; + + // Should not throw + await expect(mockPublisher.publish(event)).resolves.toBeUndefined(); + }); + + it('should support async publish operations', async () => { + const publishedEvents: DomainEvent[] = []; + + const mockPublisher: DomainEventPublisher = { + publish: async (event: DomainEvent) => { + publishedEvents.push(event); + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + return Promise.resolve(); + } + }; + + const event1: DomainEvent = { + eventType: 'EVENT_1', + aggregateId: 'agg-1', + eventData: { data: 'value1' }, + occurredAt: new Date() + }; + + const event2: DomainEvent = { + eventType: 'EVENT_2', + aggregateId: 'agg-2', + eventData: { data: 'value2' }, + occurredAt: new Date() + }; + + await mockPublisher.publish(event1); + await mockPublisher.publish(event2); + + expect(publishedEvents).toHaveLength(2); + expect(publishedEvents[0].eventType).toBe('EVENT_1'); + expect(publishedEvents[1].eventType).toBe('EVENT_2'); + }); + }); + + describe('DomainEvent behavior', () => { + it('should support event ordering by occurredAt', () => { + const events: DomainEvent[] = [ + { + eventType: 'EVENT_3', + aggregateId: 'agg-3', + eventData: {}, + occurredAt: new Date('2024-01-03T00:00:00Z') + }, + { + eventType: 'EVENT_1', + aggregateId: 'agg-1', + eventData: {}, + occurredAt: new Date('2024-01-01T00:00:00Z') + }, + { + eventType: 'EVENT_2', + aggregateId: 'agg-2', + eventData: {}, + occurredAt: new Date('2024-01-02T00:00:00Z') + } + ]; + + const sorted = [...events].sort((a, b) => + a.occurredAt.getTime() - b.occurredAt.getTime() + ); + + expect(sorted[0].eventType).toBe('EVENT_1'); + expect(sorted[1].eventType).toBe('EVENT_2'); + expect(sorted[2].eventType).toBe('EVENT_3'); + }); + + it('should support filtering events by aggregateId', () => { + const events: DomainEvent[] = [ + { eventType: 'EVENT_1', aggregateId: 'user-1', eventData: {}, occurredAt: new Date() }, + { eventType: 'EVENT_2', aggregateId: 'user-2', eventData: {}, occurredAt: new Date() }, + { eventType: 'EVENT_3', aggregateId: 'user-1', eventData: {}, occurredAt: new Date() } + ]; + + const user1Events = events.filter(e => e.aggregateId === 'user-1'); + expect(user1Events).toHaveLength(2); + expect(user1Events[0].eventType).toBe('EVENT_1'); + expect(user1Events[1].eventType).toBe('EVENT_3'); + }); + + it('should support event replay from event store', () => { + // Simulating event replay pattern + const eventStore: DomainEvent[] = [ + { + eventType: 'USER_CREATED', + aggregateId: 'user-123', + eventData: { userId: 'user-123', name: 'John' }, + occurredAt: new Date('2024-01-01T00:00:00Z') + }, + { + eventType: 'USER_UPDATED', + aggregateId: 'user-123', + eventData: { userId: 'user-123', email: 'john@example.com' }, + occurredAt: new Date('2024-01-02T00:00:00Z') + } + ]; + + // Replay events to build current state + let currentState: { userId: string; name?: string; email?: string } = { userId: 'user-123' }; + + for (const event of eventStore) { + if (event.eventType === 'USER_CREATED') { + const data = event.eventData as { userId: string; name: string }; + currentState.name = data.name; + } else if (event.eventType === 'USER_UPDATED') { + const data = event.eventData as { userId: string; email: string }; + currentState.email = data.email; + } + } + + expect(currentState.name).toBe('John'); + expect(currentState.email).toBe('john@example.com'); + }); + + it('should support event sourcing pattern', () => { + // Event sourcing: state is derived from events + interface AccountState { + balance: number; + transactions: number; + } + + const events: DomainEvent[] = [ + { + eventType: 'ACCOUNT_CREATED', + aggregateId: 'account-1', + eventData: { initialBalance: 100 }, + occurredAt: new Date('2024-01-01T00:00:00Z') + }, + { + eventType: 'DEPOSIT', + aggregateId: 'account-1', + eventData: { amount: 50 }, + occurredAt: new Date('2024-01-02T00:00:00Z') + }, + { + eventType: 'WITHDRAWAL', + aggregateId: 'account-1', + eventData: { amount: 30 }, + occurredAt: new Date('2024-01-03T00:00:00Z') + } + ]; + + const state: AccountState = { + balance: 0, + transactions: 0 + }; + + for (const event of events) { + switch (event.eventType) { + case 'ACCOUNT_CREATED': + state.balance = (event.eventData as { initialBalance: number }).initialBalance; + state.transactions = 1; + break; + case 'DEPOSIT': + state.balance += (event.eventData as { amount: number }).amount; + state.transactions += 1; + break; + case 'WITHDRAWAL': + state.balance -= (event.eventData as { amount: number }).amount; + state.transactions += 1; + break; + } + } + + expect(state.balance).toBe(120); // 100 + 50 - 30 + expect(state.transactions).toBe(3); + }); + }); + + describe('DomainEventAlias type', () => { + it('should be assignable to DomainEvent', () => { + const alias: DomainEventAlias<{ id: string }> = { + eventType: 'TEST', + aggregateId: 'agg-1', + eventData: { id: 'test' }, + occurredAt: new Date() + }; + + expect(alias.eventType).toBe('TEST'); + expect(alias.aggregateId).toBe('agg-1'); + }); + }); +}); diff --git a/core/shared/domain/Logger.test.ts b/core/shared/domain/Logger.test.ts new file mode 100644 index 000000000..fb5cbfc8d --- /dev/null +++ b/core/shared/domain/Logger.test.ts @@ -0,0 +1,372 @@ +import { describe, it, expect } from 'vitest'; +import { Logger } from './Logger'; + +describe('Logger', () => { + describe('Logger interface', () => { + it('should have debug method', () => { + const logs: Array<{ message: string; context?: unknown }> = []; + + const logger: Logger = { + debug: (message: string, context?: unknown) => { + logs.push({ message, context }); + }, + info: () => {}, + warn: () => {}, + error: () => {} + }; + + logger.debug('Debug message', { userId: 123 }); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('Debug message'); + expect(logs[0].context).toEqual({ userId: 123 }); + }); + + it('should have info method', () => { + const logs: Array<{ message: string; context?: unknown }> = []; + + const logger: Logger = { + debug: () => {}, + info: (message: string, context?: unknown) => { + logs.push({ message, context }); + }, + warn: () => {}, + error: () => {} + }; + + logger.info('Info message', { action: 'login' }); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('Info message'); + expect(logs[0].context).toEqual({ action: 'login' }); + }); + + it('should have warn method', () => { + const logs: Array<{ message: string; context?: unknown }> = []; + + const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: (message: string, context?: unknown) => { + logs.push({ message, context }); + }, + error: () => {} + }; + + logger.warn('Warning message', { threshold: 0.8 }); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('Warning message'); + expect(logs[0].context).toEqual({ threshold: 0.8 }); + }); + + it('should have error method', () => { + const logs: Array<{ message: string; error?: Error; context?: unknown }> = []; + + const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (message: string, error?: Error, context?: unknown) => { + logs.push({ message, error, context }); + } + }; + + const testError = new Error('Test error'); + logger.error('Error occurred', testError, { userId: 456 }); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('Error occurred'); + expect(logs[0].error).toBe(testError); + expect(logs[0].context).toEqual({ userId: 456 }); + }); + + it('should support logging without context', () => { + const logs: string[] = []; + + const logger: Logger = { + debug: (message) => logs.push(`DEBUG: ${message}`), + info: (message) => logs.push(`INFO: ${message}`), + warn: (message) => logs.push(`WARN: ${message}`), + error: (message) => logs.push(`ERROR: ${message}`) + }; + + logger.debug('Debug without context'); + logger.info('Info without context'); + logger.warn('Warn without context'); + logger.error('Error without context'); + + expect(logs).toHaveLength(4); + expect(logs[0]).toBe('DEBUG: Debug without context'); + expect(logs[1]).toBe('INFO: Info without context'); + expect(logs[2]).toBe('WARN: Warn without context'); + expect(logs[3]).toBe('ERROR: Error without context'); + }); + }); + + describe('Logger behavior', () => { + it('should support structured logging', () => { + const logs: Array<{ level: string; message: string; timestamp: string; data: unknown }> = []; + + const logger: Logger = { + debug: (message, context) => { + logs.push({ level: 'debug', message, timestamp: new Date().toISOString(), data: context }); + }, + info: (message, context) => { + logs.push({ level: 'info', message, timestamp: new Date().toISOString(), data: context }); + }, + warn: (message, context) => { + logs.push({ level: 'warn', message, timestamp: new Date().toISOString(), data: context }); + }, + error: (message, error, context) => { + const data: Record = { error }; + if (context) { + Object.assign(data, context); + } + logs.push({ level: 'error', message, timestamp: new Date().toISOString(), data }); + } + }; + + logger.info('User logged in', { userId: 'user-123', ip: '192.168.1.1' }); + + expect(logs).toHaveLength(1); + expect(logs[0].level).toBe('info'); + expect(logs[0].message).toBe('User logged in'); + expect(logs[0].data).toEqual({ userId: 'user-123', ip: '192.168.1.1' }); + }); + + it('should support log level filtering', () => { + const logs: string[] = []; + + const logger: Logger = { + debug: (message) => logs.push(`[DEBUG] ${message}`), + info: (message) => logs.push(`[INFO] ${message}`), + warn: (message) => logs.push(`[WARN] ${message}`), + error: (message) => logs.push(`[ERROR] ${message}`) + }; + + // Simulate different log levels + logger.debug('This is a debug message'); + logger.info('This is an info message'); + logger.warn('This is a warning message'); + logger.error('This is an error message'); + + expect(logs).toHaveLength(4); + expect(logs[0]).toContain('[DEBUG]'); + expect(logs[1]).toContain('[INFO]'); + expect(logs[2]).toContain('[WARN]'); + expect(logs[3]).toContain('[ERROR]'); + }); + + it('should support error logging with stack trace', () => { + const logs: Array<{ message: string; error: Error; context?: unknown }> = []; + + const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (message: string, error?: Error, context?: unknown) => { + if (error) { + logs.push({ message, error, context }); + } + } + }; + + const error = new Error('Database connection failed'); + logger.error('Failed to connect to database', error, { retryCount: 3 }); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('Failed to connect to database'); + expect(logs[0].error.message).toBe('Database connection failed'); + expect(logs[0].error.stack).toBeDefined(); + expect(logs[0].context).toEqual({ retryCount: 3 }); + }); + + it('should support logging complex objects', () => { + const logs: Array<{ message: string; context: unknown }> = []; + + const logger: Logger = { + debug: () => {}, + info: (message, context) => logs.push({ message, context }), + warn: () => {}, + error: () => {} + }; + + const complexObject = { + user: { + id: 'user-123', + profile: { + name: 'John Doe', + email: 'john@example.com', + preferences: { + theme: 'dark', + notifications: true + } + } + }, + session: { + id: 'session-456', + expiresAt: new Date('2024-12-31T23:59:59Z') + } + }; + + logger.info('User session data', complexObject); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('User session data'); + expect(logs[0].context).toEqual(complexObject); + }); + + it('should support logging arrays', () => { + const logs: Array<{ message: string; context: unknown }> = []; + + const logger: Logger = { + debug: () => {}, + info: (message, context) => logs.push({ message, context }), + warn: () => {}, + error: () => {} + }; + + const items = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' } + ]; + + logger.info('Processing items', { items, count: items.length }); + + expect(logs).toHaveLength(1); + expect(logs[0].message).toBe('Processing items'); + expect(logs[0].context).toEqual({ items, count: 3 }); + }); + + it('should support logging with null and undefined values', () => { + const logs: Array<{ message: string; context?: unknown }> = []; + + const logger: Logger = { + debug: () => {}, + info: (message, context) => logs.push({ message, context }), + warn: () => {}, + error: () => {} + }; + + logger.info('Null value', null); + logger.info('Undefined value', undefined); + logger.info('Mixed values', { a: null, b: undefined, c: 'value' }); + + expect(logs).toHaveLength(3); + expect(logs[0].context).toBe(null); + expect(logs[1].context).toBe(undefined); + expect(logs[2].context).toEqual({ a: null, b: undefined, c: 'value' }); + }); + }); + + describe('Logger implementation patterns', () => { + it('should support console logger implementation', () => { + const consoleLogs: string[] = []; + const originalConsoleDebug = console.debug; + const originalConsoleInfo = console.info; + const originalConsoleWarn = console.warn; + const originalConsoleError = console.error; + + // Mock console methods + console.debug = (...args: unknown[]) => consoleLogs.push(`DEBUG: ${args.join(' ')}`); + console.info = (...args: unknown[]) => consoleLogs.push(`INFO: ${args.join(' ')}`); + console.warn = (...args: unknown[]) => consoleLogs.push(`WARN: ${args.join(' ')}`); + console.error = (...args: unknown[]) => consoleLogs.push(`ERROR: ${args.join(' ')}`); + + const consoleLogger: Logger = { + debug: (message, context) => console.debug(message, context), + info: (message, context) => console.info(message, context), + warn: (message, context) => console.warn(message, context), + error: (message, error, context) => console.error(message, error, context) + }; + + consoleLogger.debug('Debug message', { data: 'test' }); + consoleLogger.info('Info message', { data: 'test' }); + consoleLogger.warn('Warn message', { data: 'test' }); + consoleLogger.error('Error message', new Error('Test'), { data: 'test' }); + + // Restore console methods + console.debug = originalConsoleDebug; + console.info = originalConsoleInfo; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + + expect(consoleLogs).toHaveLength(4); + expect(consoleLogs[0]).toContain('DEBUG:'); + expect(consoleLogs[1]).toContain('INFO:'); + expect(consoleLogs[2]).toContain('WARN:'); + expect(consoleLogs[3]).toContain('ERROR:'); + }); + + it('should support file logger implementation', () => { + const fileLogs: Array<{ timestamp: string; level: string; message: string; data?: unknown }> = []; + + const fileLogger: Logger = { + debug: (message, context) => { + fileLogs.push({ timestamp: new Date().toISOString(), level: 'DEBUG', message, data: context }); + }, + info: (message, context) => { + fileLogs.push({ timestamp: new Date().toISOString(), level: 'INFO', message, data: context }); + }, + warn: (message, context) => { + fileLogs.push({ timestamp: new Date().toISOString(), level: 'WARN', message, data: context }); + }, + error: (message, error, context) => { + const data: Record = { error }; + if (context) { + Object.assign(data, context); + } + fileLogs.push({ timestamp: new Date().toISOString(), level: 'ERROR', message, data }); + } + }; + + fileLogger.info('Application started', { version: '1.0.0' }); + fileLogger.warn('High memory usage', { usage: '85%' }); + fileLogger.error('Database error', new Error('Connection timeout'), { retry: 3 }); + + expect(fileLogs).toHaveLength(3); + expect(fileLogs[0].level).toBe('INFO'); + expect(fileLogs[1].level).toBe('WARN'); + expect(fileLogs[2].level).toBe('ERROR'); + expect(fileLogs[0].data).toEqual({ version: '1.0.0' }); + }); + + it('should support remote logger implementation', async () => { + const remoteLogs: Array<{ level: string; message: string; context?: unknown }> = []; + + const remoteLogger: Logger = { + debug: async (message, context) => { + remoteLogs.push({ level: 'debug', message, context }); + await new Promise(resolve => setTimeout(resolve, 1)); + }, + info: async (message, context) => { + remoteLogs.push({ level: 'info', message, context }); + await new Promise(resolve => setTimeout(resolve, 1)); + }, + warn: async (message, context) => { + remoteLogs.push({ level: 'warn', message, context }); + await new Promise(resolve => setTimeout(resolve, 1)); + }, + error: async (message, error, context) => { + const errorContext: Record = { error }; + if (context) { + Object.assign(errorContext, context); + } + remoteLogs.push({ level: 'error', message, context: errorContext }); + await new Promise(resolve => setTimeout(resolve, 1)); + } + }; + + await remoteLogger.info('User action', { action: 'click', element: 'button' }); + await remoteLogger.warn('Performance warning', { duration: '2000ms' }); + await remoteLogger.error('API failure', new Error('404 Not Found'), { endpoint: '/api/users' }); + + expect(remoteLogs).toHaveLength(3); + expect(remoteLogs[0].level).toBe('info'); + expect(remoteLogs[1].level).toBe('warn'); + expect(remoteLogs[2].level).toBe('error'); + }); + }); +}); diff --git a/core/shared/domain/Option.test.ts b/core/shared/domain/Option.test.ts new file mode 100644 index 000000000..5b6ac087b --- /dev/null +++ b/core/shared/domain/Option.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { coalesce, present } from './Option'; + +describe('Option', () => { + describe('coalesce()', () => { + it('should return the value when it is defined', () => { + expect(coalesce('defined', 'fallback')).toBe('defined'); + expect(coalesce(42, 0)).toBe(42); + expect(coalesce(true, false)).toBe(true); + }); + + it('should return the fallback when value is undefined', () => { + expect(coalesce(undefined, 'fallback')).toBe('fallback'); + expect(coalesce(undefined, 42)).toBe(42); + expect(coalesce(undefined, true)).toBe(true); + }); + + it('should return the fallback when value is null', () => { + expect(coalesce(null, 'fallback')).toBe('fallback'); + expect(coalesce(null, 42)).toBe(42); + expect(coalesce(null, true)).toBe(true); + }); + + it('should handle complex fallback values', () => { + const fallback = { id: 0, name: 'default' }; + expect(coalesce(undefined, fallback)).toEqual(fallback); + expect(coalesce(null, fallback)).toEqual(fallback); + expect(coalesce({ id: 1, name: 'actual' }, fallback)).toEqual({ id: 1, name: 'actual' }); + }); + + it('should handle array values', () => { + const fallback = [1, 2, 3]; + expect(coalesce(undefined, fallback)).toEqual([1, 2, 3]); + expect(coalesce([4, 5], fallback)).toEqual([4, 5]); + }); + + it('should handle zero and empty string as valid values', () => { + expect(coalesce(0, 999)).toBe(0); + expect(coalesce('', 'fallback')).toBe(''); + expect(coalesce(false, true)).toBe(false); + }); + }); + + describe('present()', () => { + it('should return the value when it is defined and not null', () => { + expect(present('value')).toBe('value'); + expect(present(42)).toBe(42); + expect(present(true)).toBe(true); + expect(present({})).toEqual({}); + expect(present([])).toEqual([]); + }); + + it('should return undefined when value is undefined', () => { + expect(present(undefined)).toBe(undefined); + }); + + it('should return undefined when value is null', () => { + expect(present(null)).toBe(undefined); + }); + + it('should handle zero and empty string as valid values', () => { + expect(present(0)).toBe(0); + expect(present('')).toBe(''); + expect(present(false)).toBe(false); + }); + + it('should handle complex objects', () => { + const obj = { id: 1, name: 'test', nested: { value: 'data' } }; + expect(present(obj)).toEqual(obj); + }); + + it('should handle arrays', () => { + const arr = [1, 2, 3, 'test']; + expect(present(arr)).toEqual(arr); + }); + }); + + describe('Option behavior', () => { + it('should work together for optional value handling', () => { + // Example: providing a default when value might be missing + const maybeValue: string | undefined = undefined; + const result = coalesce(maybeValue, 'default'); + expect(result).toBe('default'); + + // Example: filtering out null/undefined + const values: (string | null | undefined)[] = ['a', null, 'b', undefined, 'c']; + const filtered = values.map(present).filter((v): v is string => v !== undefined); + expect(filtered).toEqual(['a', 'b', 'c']); + }); + + it('should support conditional value assignment', () => { + const config: { timeout?: number } = {}; + const timeout = coalesce(config.timeout, 5000); + expect(timeout).toBe(5000); + + config.timeout = 3000; + const timeout2 = coalesce(config.timeout, 5000); + expect(timeout2).toBe(3000); + }); + + it('should handle nested optional properties', () => { + interface User { + profile?: { + name?: string; + email?: string; + }; + } + + const user1: User = {}; + const user2: User = { profile: {} }; + const user3: User = { profile: { name: 'John' } }; + const user4: User = { profile: { name: 'John', email: 'john@example.com' } }; + + expect(coalesce(user1.profile?.name, 'Anonymous')).toBe('Anonymous'); + expect(coalesce(user2.profile?.name, 'Anonymous')).toBe('Anonymous'); + expect(coalesce(user3.profile?.name, 'Anonymous')).toBe('John'); + expect(coalesce(user4.profile?.name, 'Anonymous')).toBe('John'); + }); + }); +}); diff --git a/core/shared/domain/Result.test.ts b/core/shared/domain/Result.test.ts new file mode 100644 index 000000000..3b63f4a75 --- /dev/null +++ b/core/shared/domain/Result.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect } from 'vitest'; +import { Result } from './Result'; + +describe('Result', () => { + describe('Result.ok()', () => { + it('should create a success result with a value', () => { + const result = Result.ok('success-value'); + + expect(result.isOk()).toBe(true); + expect(result.isErr()).toBe(false); + expect(result.unwrap()).toBe('success-value'); + }); + + it('should create a success result with undefined value', () => { + const result = Result.ok(undefined); + + expect(result.isOk()).toBe(true); + expect(result.isErr()).toBe(false); + expect(result.unwrap()).toBe(undefined); + }); + + it('should create a success result with null value', () => { + const result = Result.ok(null); + + expect(result.isOk()).toBe(true); + expect(result.isErr()).toBe(false); + expect(result.unwrap()).toBe(null); + }); + + it('should create a success result with complex object', () => { + const complexValue = { id: 123, name: 'test', nested: { data: 'value' } }; + const result = Result.ok(complexValue); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual(complexValue); + }); + + it('should create a success result with array', () => { + const arrayValue = [1, 2, 3, 'test']; + const result = Result.ok(arrayValue); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual(arrayValue); + }); + }); + + describe('Result.err()', () => { + it('should create an error result with an error', () => { + const error = new Error('test error'); + const result = Result.err(error); + + expect(result.isOk()).toBe(false); + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toBe(error); + }); + + it('should create an error result with string error', () => { + const result = Result.err('string error'); + + expect(result.isOk()).toBe(false); + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toBe('string error'); + }); + + it('should create an error result with object error', () => { + const error = { code: 'VALIDATION_ERROR', message: 'Invalid input' }; + const result = Result.err(error); + + expect(result.isOk()).toBe(false); + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual(error); + }); + + it('should create an error result with custom error type', () => { + interface CustomError { + code: string; + details: Record; + } + + const error: CustomError = { + code: 'NOT_FOUND', + details: { id: '123' } + }; + + const result = Result.err(error); + + expect(result.isOk()).toBe(false); + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual(error); + }); + }); + + describe('Result.isOk()', () => { + it('should return true for success results', () => { + const result = Result.ok('value'); + expect(result.isOk()).toBe(true); + }); + + it('should return false for error results', () => { + const result = Result.err(new Error('error')); + expect(result.isOk()).toBe(false); + }); + }); + + describe('Result.isErr()', () => { + it('should return false for success results', () => { + const result = Result.ok('value'); + expect(result.isErr()).toBe(false); + }); + + it('should return true for error results', () => { + const result = Result.err(new Error('error')); + expect(result.isErr()).toBe(true); + }); + }); + + describe('Result.unwrap()', () => { + it('should return the value for success results', () => { + const result = Result.ok('test-value'); + expect(result.unwrap()).toBe('test-value'); + }); + + it('should throw error for error results', () => { + const result = Result.err(new Error('test error')); + expect(() => result.unwrap()).toThrow('Called unwrap on an error result'); + }); + + it('should return complex values for success results', () => { + const complexValue = { id: 123, data: { nested: 'value' } }; + const result = Result.ok(complexValue); + expect(result.unwrap()).toEqual(complexValue); + }); + + it('should return arrays for success results', () => { + const arrayValue = [1, 2, 3]; + const result = Result.ok(arrayValue); + expect(result.unwrap()).toEqual(arrayValue); + }); + }); + + describe('Result.unwrapOr()', () => { + it('should return the value for success results', () => { + const result = Result.ok('actual-value'); + expect(result.unwrapOr('default-value')).toBe('actual-value'); + }); + + it('should return default value for error results', () => { + const result = Result.err(new Error('error')); + expect(result.unwrapOr('default-value')).toBe('default-value'); + }); + + it('should return default value when value is undefined', () => { + const result = Result.ok(undefined); + expect(result.unwrapOr('default-value')).toBe(undefined); + }); + + it('should return default value when value is null', () => { + const result = Result.ok(null); + expect(result.unwrapOr('default-value')).toBe(null); + }); + }); + + describe('Result.unwrapErr()', () => { + it('should return the error for error results', () => { + const error = new Error('test error'); + const result = Result.err(error); + expect(result.unwrapErr()).toBe(error); + }); + + it('should throw error for success results', () => { + const result = Result.ok('value'); + expect(() => result.unwrapErr()).toThrow('Called unwrapErr on a success result'); + }); + + it('should return string errors', () => { + const result = Result.err('string error'); + expect(result.unwrapErr()).toBe('string error'); + }); + + it('should return object errors', () => { + const error = { code: 'ERROR', message: 'Something went wrong' }; + const result = Result.err(error); + expect(result.unwrapErr()).toEqual(error); + }); + }); + + describe('Result.map()', () => { + it('should transform success values', () => { + const result = Result.ok(5); + const mapped = result.map((x) => x * 2); + + expect(mapped.isOk()).toBe(true); + expect(mapped.unwrap()).toBe(10); + }); + + it('should not transform error results', () => { + const error = new Error('test error'); + const result = Result.err(error); + const mapped = result.map((x) => x * 2); + + expect(mapped.isErr()).toBe(true); + expect(mapped.unwrapErr()).toBe(error); + }); + + it('should handle complex transformations', () => { + const result = Result.ok({ id: 1, name: 'test' }); + const mapped = result.map((obj) => ({ ...obj, name: obj.name.toUpperCase() })); + + expect(mapped.isOk()).toBe(true); + expect(mapped.unwrap()).toEqual({ id: 1, name: 'TEST' }); + }); + + it('should handle array transformations', () => { + const result = Result.ok([1, 2, 3]); + const mapped = result.map((arr) => arr.map((x) => x * 2)); + + expect(mapped.isOk()).toBe(true); + expect(mapped.unwrap()).toEqual([2, 4, 6]); + }); + }); + + describe('Result.mapErr()', () => { + it('should transform error values', () => { + const error = new Error('original error'); + const result = Result.err(error); + const mapped = result.mapErr((e) => new Error(`wrapped: ${e.message}`)); + + expect(mapped.isErr()).toBe(true); + expect(mapped.unwrapErr().message).toBe('wrapped: original error'); + }); + + it('should not transform success results', () => { + const result = Result.ok('value'); + const mapped = result.mapErr((e) => new Error(`wrapped: ${e.message}`)); + + expect(mapped.isOk()).toBe(true); + expect(mapped.unwrap()).toBe('value'); + }); + + it('should handle string error transformations', () => { + const result = Result.err('error message'); + const mapped = result.mapErr((e) => e.toUpperCase()); + + expect(mapped.isErr()).toBe(true); + expect(mapped.unwrapErr()).toBe('ERROR MESSAGE'); + }); + + it('should handle object error transformations', () => { + const error = { code: 'ERROR', message: 'Something went wrong' }; + const result = Result.err(error); + const mapped = result.mapErr((e) => ({ ...e, code: `WRAPPED_${e.code}` })); + + expect(mapped.isErr()).toBe(true); + expect(mapped.unwrapErr()).toEqual({ code: 'WRAPPED_ERROR', message: 'Something went wrong' }); + }); + }); + + describe('Result.andThen()', () => { + it('should chain success results', () => { + const result1 = Result.ok(5); + const result2 = result1.andThen((x) => Result.ok(x * 2)); + + expect(result2.isOk()).toBe(true); + expect(result2.unwrap()).toBe(10); + }); + + it('should propagate errors through chain', () => { + const error = new Error('first error'); + const result1 = Result.err(error); + const result2 = result1.andThen((x) => Result.ok(x * 2)); + + expect(result2.isErr()).toBe(true); + expect(result2.unwrapErr()).toBe(error); + }); + + it('should handle error in chained function', () => { + const result1 = Result.ok(5); + const result2 = result1.andThen((x) => Result.err(new Error(`error at ${x}`))); + + expect(result2.isErr()).toBe(true); + expect(result2.unwrapErr().message).toBe('error at 5'); + }); + + it('should support multiple chaining steps', () => { + const result = Result.ok(2) + .andThen((x) => Result.ok(x * 3)) + .andThen((x) => Result.ok(x + 1)) + .andThen((x) => Result.ok(x * 2)); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBe(14); // ((2 * 3) + 1) * 2 = 14 + }); + + it('should stop chaining on first error', () => { + const result = Result.ok(2) + .andThen((x) => Result.ok(x * 3)) + .andThen((x) => Result.err(new Error('stopped here'))) + .andThen((x) => Result.ok(x + 1)); // This should not execute + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('stopped here'); + }); + }); + + describe('Result.value getter', () => { + it('should return value for success results', () => { + const result = Result.ok('test-value'); + expect(result.value).toBe('test-value'); + }); + + it('should return undefined for error results', () => { + const result = Result.err(new Error('error')); + expect(result.value).toBe(undefined); + }); + + it('should return undefined for success results with undefined value', () => { + const result = Result.ok(undefined); + expect(result.value).toBe(undefined); + }); + }); + + describe('Result.error getter', () => { + it('should return error for error results', () => { + const error = new Error('test error'); + const result = Result.err(error); + expect(result.error).toBe(error); + }); + + it('should return undefined for success results', () => { + const result = Result.ok('value'); + expect(result.error).toBe(undefined); + }); + + it('should return string errors', () => { + const result = Result.err('string error'); + expect(result.error).toBe('string error'); + }); + }); + + describe('Result type safety', () => { + it('should work with custom error codes', () => { + type MyErrorCode = 'NOT_FOUND' | 'VALIDATION_ERROR' | 'PERMISSION_DENIED'; + + const successResult = Result.ok('data'); + const errorResult = Result.err('NOT_FOUND'); + + expect(successResult.isOk()).toBe(true); + expect(errorResult.isErr()).toBe(true); + }); + + it('should work with ApplicationErrorCode pattern', () => { + interface ApplicationErrorCode { + code: Code; + details?: Details; + } + + type MyErrorCodes = 'USER_NOT_FOUND' | 'INVALID_EMAIL'; + + const successResult = Result.ok>('user'); + const errorResult = Result.err>({ + code: 'USER_NOT_FOUND', + details: { userId: '123' } + }); + + expect(successResult.isOk()).toBe(true); + expect(errorResult.isErr()).toBe(true); + expect(errorResult.unwrapErr().code).toBe('USER_NOT_FOUND'); + }); + }); +}); diff --git a/core/shared/domain/Service.test.ts b/core/shared/domain/Service.test.ts new file mode 100644 index 000000000..0d2352bbe --- /dev/null +++ b/core/shared/domain/Service.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect } from 'vitest'; +import { + DomainService, + DomainCalculationService, + ResultDomainCalculationService, + DomainValidationService, + DomainFactoryService, + DomainServiceAlias, + DomainCalculationServiceAlias, + ResultDomainCalculationServiceAlias, + DomainValidationServiceAlias, + DomainFactoryServiceAlias +} from './Service'; +import { Result } from './Result'; + +describe('Service', () => { + describe('DomainService interface', () => { + it('should have optional serviceName property', () => { + const service: DomainService = { + serviceName: 'TestService' + }; + + expect(service.serviceName).toBe('TestService'); + }); + + it('should work without serviceName', () => { + const service: DomainService = {}; + + expect(service.serviceName).toBeUndefined(); + }); + + it('should support different service implementations', () => { + const service1: DomainService = { serviceName: 'Service1' }; + const service2: DomainService = { serviceName: 'Service2' }; + const service3: DomainService = {}; + + expect(service1.serviceName).toBe('Service1'); + expect(service2.serviceName).toBe('Service2'); + expect(service3.serviceName).toBeUndefined(); + }); + }); + + describe('DomainCalculationService interface', () => { + it('should have calculate method', () => { + const service: DomainCalculationService = { + calculate: (input: number) => input * 2 + }; + + expect(service.calculate(5)).toBe(10); + }); + + it('should support different input and output types', () => { + const stringService: DomainCalculationService = { + calculate: (input: string) => input.toUpperCase() + }; + + const objectService: DomainCalculationService<{ x: number; y: number }, number> = { + calculate: (input) => input.x + input.y + }; + + expect(stringService.calculate('hello')).toBe('HELLO'); + expect(objectService.calculate({ x: 3, y: 4 })).toBe(7); + }); + + it('should support complex calculations', () => { + interface CalculationInput { + values: number[]; + operation: 'sum' | 'average' | 'max'; + } + + const calculator: DomainCalculationService = { + calculate: (input) => { + switch (input.operation) { + case 'sum': + return input.values.reduce((a, b) => a + b, 0); + case 'average': + return input.values.reduce((a, b) => a + b, 0) / input.values.length; + case 'max': + return Math.max(...input.values); + } + } + }; + + expect(calculator.calculate({ values: [1, 2, 3], operation: 'sum' })).toBe(6); + expect(calculator.calculate({ values: [1, 2, 3], operation: 'average' })).toBe(2); + expect(calculator.calculate({ values: [1, 2, 3], operation: 'max' })).toBe(3); + }); + }); + + describe('ResultDomainCalculationService interface', () => { + it('should have calculate method returning Result', () => { + const service: ResultDomainCalculationService = { + calculate: (input: number) => { + if (input < 0) { + return Result.err('Input must be non-negative'); + } + return Result.ok(input * 2); + } + }; + + const successResult = service.calculate(5); + expect(successResult.isOk()).toBe(true); + expect(successResult.unwrap()).toBe(10); + + const errorResult = service.calculate(-1); + expect(errorResult.isErr()).toBe(true); + expect(errorResult.unwrapErr()).toBe('Input must be non-negative'); + }); + + it('should support validation logic', () => { + interface ValidationResult { + isValid: boolean; + errors: string[]; + } + + const validator: ResultDomainCalculationService = { + calculate: (input: string) => { + const errors: string[] = []; + + if (input.length < 3) { + errors.push('Must be at least 3 characters'); + } + + if (!input.match(/^[a-zA-Z]+$/)) { + errors.push('Must contain only letters'); + } + + if (errors.length > 0) { + return Result.err(errors.join(', ')); + } + + return Result.ok({ isValid: true, errors: [] }); + } + }; + + const validResult = validator.calculate('Hello'); + expect(validResult.isOk()).toBe(true); + expect(validResult.unwrap()).toEqual({ isValid: true, errors: [] }); + + const invalidResult = validator.calculate('ab'); + expect(invalidResult.isErr()).toBe(true); + expect(invalidResult.unwrapErr()).toBe('Must be at least 3 characters'); + }); + + it('should support complex business rules', () => { + interface DiscountInput { + basePrice: number; + customerType: 'regular' | 'premium' | 'vip'; + hasCoupon: boolean; + } + + const discountCalculator: ResultDomainCalculationService = { + calculate: (input) => { + let discount = 0; + + // Customer type discount + switch (input.customerType) { + case 'premium': + discount += 0.1; + break; + case 'vip': + discount += 0.2; + break; + } + + // Coupon discount + if (input.hasCoupon) { + discount += 0.05; + } + + // Validate price + if (input.basePrice <= 0) { + return Result.err('Price must be positive'); + } + + const finalPrice = input.basePrice * (1 - discount); + return Result.ok(finalPrice); + } + }; + + const vipWithCoupon = discountCalculator.calculate({ + basePrice: 100, + customerType: 'vip', + hasCoupon: true + }); + + expect(vipWithCoupon.isOk()).toBe(true); + expect(vipWithCoupon.unwrap()).toBe(75); // 100 * (1 - 0.2 - 0.05) = 75 + + const invalidPrice = discountCalculator.calculate({ + basePrice: 0, + customerType: 'regular', + hasCoupon: false + }); + + expect(invalidPrice.isErr()).toBe(true); + expect(invalidPrice.unwrapErr()).toBe('Price must be positive'); + }); + }); + + describe('DomainValidationService interface', () => { + it('should have validate method returning Result', () => { + const service: DomainValidationService = { + validate: (input: string) => { + if (input.length === 0) { + return Result.err('Input cannot be empty'); + } + return Result.ok(true); + } + }; + + const validResult = service.validate('test'); + expect(validResult.isOk()).toBe(true); + expect(validResult.unwrap()).toBe(true); + + const invalidResult = service.validate(''); + expect(invalidResult.isErr()).toBe(true); + expect(invalidResult.unwrapErr()).toBe('Input cannot be empty'); + }); + + it('should support validation of complex objects', () => { + interface UserInput { + email: string; + password: string; + age: number; + } + + const userValidator: DomainValidationService = { + validate: (input) => { + const errors: string[] = []; + + if (!input.email.includes('@')) { + errors.push('Invalid email format'); + } + + if (input.password.length < 8) { + errors.push('Password must be at least 8 characters'); + } + + if (input.age < 18) { + errors.push('Must be at least 18 years old'); + } + + if (errors.length > 0) { + return Result.err(errors.join(', ')); + } + + return Result.ok(input); + } + }; + + const validUser = userValidator.validate({ + email: 'john@example.com', + password: 'securepassword', + age: 25 + }); + + expect(validUser.isOk()).toBe(true); + expect(validUser.unwrap()).toEqual({ + email: 'john@example.com', + password: 'securepassword', + age: 25 + }); + + const invalidUser = userValidator.validate({ + email: 'invalid-email', + password: 'short', + age: 15 + }); + + expect(invalidUser.isErr()).toBe(true); + expect(invalidUser.unwrapErr()).toContain('Invalid email format'); + expect(invalidUser.unwrapErr()).toContain('Password must be at least 8 characters'); + expect(invalidUser.unwrapErr()).toContain('Must be at least 18 years old'); + }); + }); + + describe('DomainFactoryService interface', () => { + it('should have create method', () => { + const service: DomainFactoryService = { + create: (input: string) => ({ + id: input.length, + value: input.toUpperCase() + }) + }; + + const result = service.create('test'); + expect(result).toEqual({ id: 4, value: 'TEST' }); + }); + + it('should support creating complex objects', () => { + interface CreateUserInput { + name: string; + email: string; + } + + interface User { + id: string; + name: string; + email: string; + createdAt: Date; + } + + const userFactory: DomainFactoryService = { + create: (input) => ({ + id: `user-${Date.now()}`, + name: input.name, + email: input.email, + createdAt: new Date() + }) + }; + + const user = userFactory.create({ + name: 'John Doe', + email: 'john@example.com' + }); + + expect(user.id).toMatch(/^user-\d+$/); + expect(user.name).toBe('John Doe'); + expect(user.email).toBe('john@example.com'); + expect(user.createdAt).toBeInstanceOf(Date); + }); + + it('should support creating value objects', () => { + interface AddressProps { + street: string; + city: string; + zipCode: string; + } + + const addressFactory: DomainFactoryService = { + create: (input) => ({ + street: input.street.trim(), + city: input.city.trim(), + zipCode: input.zipCode.trim() + }) + }; + + const address = addressFactory.create({ + street: ' 123 Main St ', + city: ' New York ', + zipCode: ' 10001 ' + }); + + expect(address.street).toBe('123 Main St'); + expect(address.city).toBe('New York'); + expect(address.zipCode).toBe('10001'); + }); + }); + + describe('ServiceAlias types', () => { + it('should be assignable to their base interfaces', () => { + const service1: DomainServiceAlias = { serviceName: 'Test' }; + const service2: DomainCalculationServiceAlias = { + calculate: (x) => x * 2 + }; + const service3: ResultDomainCalculationServiceAlias = { + calculate: (x) => Result.ok(x * 2) + }; + const service4: DomainValidationServiceAlias = { + validate: (x) => Result.ok(x.length > 0) + }; + const service5: DomainFactoryServiceAlias = { + create: (x) => x.toUpperCase() + }; + + expect(service1.serviceName).toBe('Test'); + expect(service2.calculate(5)).toBe(10); + expect(service3.calculate(5).isOk()).toBe(true); + expect(service4.validate('test').isOk()).toBe(true); + expect(service5.create('test')).toBe('TEST'); + }); + }); +}); diff --git a/core/shared/errors/ApplicationError.test.ts b/core/shared/errors/ApplicationError.test.ts new file mode 100644 index 000000000..f582f1cc6 --- /dev/null +++ b/core/shared/errors/ApplicationError.test.ts @@ -0,0 +1,471 @@ +import { describe, it, expect } from 'vitest'; +import { ApplicationError, CommonApplicationErrorKind } from './ApplicationError'; + +describe('ApplicationError', () => { + describe('ApplicationError interface', () => { + it('should have required properties', () => { + const error: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'user-service', + kind: 'not_found', + message: 'User not found' + }; + + expect(error.type).toBe('application'); + expect(error.context).toBe('user-service'); + expect(error.kind).toBe('not_found'); + expect(error.message).toBe('User not found'); + }); + + it('should support optional details', () => { + const error: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'payment-service', + kind: 'validation', + message: 'Invalid payment amount', + details: { amount: -100, minAmount: 0 } + }; + + expect(error.details).toEqual({ amount: -100, minAmount: 0 }); + }); + + it('should support different error kinds', () => { + const notFoundError: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'user-service', + kind: 'not_found', + message: 'User not found' + }; + + const forbiddenError: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'admin-service', + kind: 'forbidden', + message: 'Access denied' + }; + + const conflictError: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'order-service', + kind: 'conflict', + message: 'Order already exists' + }; + + const validationError: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'form-service', + kind: 'validation', + message: 'Invalid input' + }; + + const unknownError: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'unknown-service', + kind: 'unknown', + message: 'Unknown error' + }; + + expect(notFoundError.kind).toBe('not_found'); + expect(forbiddenError.kind).toBe('forbidden'); + expect(conflictError.kind).toBe('conflict'); + expect(validationError.kind).toBe('validation'); + expect(unknownError.kind).toBe('unknown'); + }); + + it('should support custom error kinds', () => { + const customError: ApplicationError<'RATE_LIMIT_EXCEEDED'> = { + name: 'ApplicationError', + type: 'application', + context: 'api-gateway', + kind: 'RATE_LIMIT_EXCEEDED', + message: 'Rate limit exceeded', + details: { retryAfter: 60 } + }; + + expect(customError.kind).toBe('RATE_LIMIT_EXCEEDED'); + expect(customError.details).toEqual({ retryAfter: 60 }); + }); + }); + + describe('CommonApplicationErrorKind type', () => { + it('should include standard error kinds', () => { + const kinds: CommonApplicationErrorKind[] = [ + 'not_found', + 'forbidden', + 'conflict', + 'validation', + 'unknown' + ]; + + kinds.forEach(kind => { + const error: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'test', + kind, + message: `Test ${kind} error` + }; + + expect(error.kind).toBe(kind); + }); + }); + + it('should support string extension for custom kinds', () => { + const customKinds: CommonApplicationErrorKind[] = [ + 'CUSTOM_ERROR_1', + 'CUSTOM_ERROR_2', + 'BUSINESS_RULE_VIOLATION' + ]; + + customKinds.forEach(kind => { + const error: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'test', + kind, + message: `Test ${kind} error` + }; + + expect(error.kind).toBe(kind); + }); + }); + }); + + describe('ApplicationError behavior', () => { + it('should be assignable to Error interface', () => { + const error: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'test-service', + kind: 'validation', + message: 'Validation failed' + }; + + // ApplicationError extends Error + expect(error.type).toBe('application'); + expect(error.message).toBe('Validation failed'); + }); + + it('should support error inheritance pattern', () => { + class CustomApplicationError extends Error implements ApplicationError { + readonly type: 'application' = 'application'; + readonly context: string; + readonly kind: string; + readonly details?: unknown; + + constructor(context: string, kind: string, message: string, details?: unknown) { + super(message); + this.context = context; + this.kind = kind; + this.details = details; + this.name = 'CustomApplicationError'; + } + } + + const error = new CustomApplicationError( + 'user-service', + 'USER_NOT_FOUND', + 'User with ID 123 not found', + { userId: '123' } + ); + + expect(error.type).toBe('application'); + expect(error.context).toBe('user-service'); + expect(error.kind).toBe('USER_NOT_FOUND'); + expect(error.message).toBe('User with ID 123 not found'); + expect(error.details).toEqual({ userId: '123' }); + expect(error.name).toBe('CustomApplicationError'); + expect(error.stack).toBeDefined(); + }); + + it('should support error serialization', () => { + const error: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'payment-service', + kind: 'INSUFFICIENT_FUNDS', + message: 'Insufficient funds for transaction', + details: { + balance: 50, + required: 100, + currency: 'USD' + } + }; + + const serialized = JSON.stringify(error); + const parsed = JSON.parse(serialized); + + expect(parsed.type).toBe('application'); + expect(parsed.context).toBe('payment-service'); + expect(parsed.kind).toBe('INSUFFICIENT_FUNDS'); + expect(parsed.message).toBe('Insufficient funds for transaction'); + expect(parsed.details).toEqual({ + balance: 50, + required: 100, + currency: 'USD' + }); + }); + + it('should support error deserialization', () => { + const serialized = JSON.stringify({ + type: 'application', + context: 'auth-service', + kind: 'INVALID_CREDENTIALS', + message: 'Invalid username or password', + details: { attempt: 3 } + }); + + const parsed: ApplicationError = JSON.parse(serialized); + + expect(parsed.type).toBe('application'); + expect(parsed.context).toBe('auth-service'); + expect(parsed.kind).toBe('INVALID_CREDENTIALS'); + expect(parsed.message).toBe('Invalid username or password'); + expect(parsed.details).toEqual({ attempt: 3 }); + }); + + it('should support error comparison', () => { + const error1: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'user-service', + kind: 'not_found', + message: 'User not found' + }; + + const error2: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'user-service', + kind: 'not_found', + message: 'User not found' + }; + + const error3: ApplicationError = { + name: 'ApplicationError', + type: 'application', + context: 'order-service', + kind: 'not_found', + message: 'Order not found' + }; + + // Same kind and context + expect(error1.kind).toBe(error2.kind); + expect(error1.context).toBe(error2.context); + + // Different context + expect(error1.context).not.toBe(error3.context); + }); + + it('should support error categorization', () => { + const errors: ApplicationError[] = [ + { + name: 'ApplicationError', + type: 'application', + context: 'user-service', + kind: 'not_found', + message: 'User not found' + }, + { + name: 'ApplicationError', + type: 'application', + context: 'admin-service', + kind: 'forbidden', + message: 'Access denied' + }, + { + name: 'ApplicationError', + type: 'application', + context: 'order-service', + kind: 'conflict', + message: 'Order already exists' + }, + { + name: 'ApplicationError', + type: 'application', + context: 'form-service', + kind: 'validation', + message: 'Invalid input' + } + ]; + + const notFoundErrors = errors.filter(e => e.kind === 'not_found'); + const forbiddenErrors = errors.filter(e => e.kind === 'forbidden'); + const conflictErrors = errors.filter(e => e.kind === 'conflict'); + const validationErrors = errors.filter(e => e.kind === 'validation'); + + expect(notFoundErrors).toHaveLength(1); + expect(forbiddenErrors).toHaveLength(1); + expect(conflictErrors).toHaveLength(1); + expect(validationErrors).toHaveLength(1); + }); + + it('should support error aggregation by context', () => { + const errors: ApplicationError[] = [ + { + name: 'ApplicationError', + type: 'application', + context: 'user-service', + kind: 'not_found', + message: 'User not found' + }, + { + name: 'ApplicationError', + type: 'application', + context: 'user-service', + kind: 'validation', + message: 'Invalid user data' + }, + { + name: 'ApplicationError', + type: 'application', + context: 'order-service', + kind: 'not_found', + message: 'Order not found' + } + ]; + + const userErrors = errors.filter(e => e.context === 'user-service'); + const orderErrors = errors.filter(e => e.context === 'order-service'); + + expect(userErrors).toHaveLength(2); + expect(orderErrors).toHaveLength(1); + }); + }); + + describe('ApplicationError implementation patterns', () => { + it('should support error factory pattern', () => { + function createApplicationError( + context: string, + kind: K, + message: string, + details?: unknown + ): ApplicationError { + return { + name: 'ApplicationError', + type: 'application', + context, + kind, + message, + details + }; + } + + const notFoundError = createApplicationError( + 'user-service', + 'USER_NOT_FOUND', + 'User not found', + { userId: '123' } + ); + + const validationError = createApplicationError( + 'form-service', + 'VALIDATION_ERROR', + 'Invalid form data', + { field: 'email', value: 'invalid' } + ); + + expect(notFoundError.kind).toBe('USER_NOT_FOUND'); + expect(notFoundError.details).toEqual({ userId: '123' }); + expect(validationError.kind).toBe('VALIDATION_ERROR'); + expect(validationError.details).toEqual({ field: 'email', value: 'invalid' }); + }); + + it('should support error builder pattern', () => { + class ApplicationErrorBuilder { + private context: string = ''; + private kind: K = '' as K; + private message: string = ''; + private details?: unknown; + + withContext(context: string): this { + this.context = context; + return this; + } + + withKind(kind: K): this { + this.kind = kind; + return this; + } + + withMessage(message: string): this { + this.message = message; + return this; + } + + withDetails(details: unknown): this { + this.details = details; + return this; + } + + build(): ApplicationError { + return { + name: 'ApplicationError', + type: 'application', + context: this.context, + kind: this.kind, + message: this.message, + details: this.details + }; + } + } + + const error = new ApplicationErrorBuilder<'USER_NOT_FOUND'>() + .withContext('user-service') + .withKind('USER_NOT_FOUND') + .withMessage('User not found') + .withDetails({ userId: '123' }) + .build(); + + expect(error.type).toBe('application'); + expect(error.context).toBe('user-service'); + expect(error.kind).toBe('USER_NOT_FOUND'); + expect(error.message).toBe('User not found'); + expect(error.details).toEqual({ userId: '123' }); + }); + + it('should support error categorization by severity', () => { + const errors: ApplicationError[] = [ + { + name: 'ApplicationError', + type: 'application', + context: 'auth-service', + kind: 'INVALID_CREDENTIALS', + message: 'Invalid credentials', + details: { severity: 'high' } + }, + { + name: 'ApplicationError', + type: 'application', + context: 'user-service', + kind: 'USER_NOT_FOUND', + message: 'User not found', + details: { severity: 'medium' } + }, + { + name: 'ApplicationError', + type: 'application', + context: 'cache-service', + kind: 'CACHE_MISS', + message: 'Cache miss', + details: { severity: 'low' } + } + ]; + + const highSeverity = errors.filter(e => (e.details as { severity: string })?.severity === 'high'); + const mediumSeverity = errors.filter(e => (e.details as { severity: string })?.severity === 'medium'); + const lowSeverity = errors.filter(e => (e.details as { severity: string })?.severity === 'low'); + + expect(highSeverity).toHaveLength(1); + expect(mediumSeverity).toHaveLength(1); + expect(lowSeverity).toHaveLength(1); + }); + }); +}); diff --git a/core/shared/errors/ApplicationErrorCode.test.ts b/core/shared/errors/ApplicationErrorCode.test.ts new file mode 100644 index 000000000..e87ffc359 --- /dev/null +++ b/core/shared/errors/ApplicationErrorCode.test.ts @@ -0,0 +1,335 @@ +import { describe, it, expect } from 'vitest'; +import { ApplicationErrorCode } from './ApplicationErrorCode'; + +describe('ApplicationErrorCode', () => { + describe('ApplicationErrorCode type', () => { + it('should create error code with code only', () => { + const errorCode: ApplicationErrorCode<'USER_NOT_FOUND'> = { + code: 'USER_NOT_FOUND' + }; + + expect(errorCode.code).toBe('USER_NOT_FOUND'); + }); + + it('should create error code with code and details', () => { + const errorCode: ApplicationErrorCode<'INSUFFICIENT_FUNDS', { balance: number; required: number }> = { + code: 'INSUFFICIENT_FUNDS', + details: { balance: 50, required: 100 } + }; + + expect(errorCode.code).toBe('INSUFFICIENT_FUNDS'); + expect(errorCode.details).toEqual({ balance: 50, required: 100 }); + }); + + it('should support different error code types', () => { + const notFoundCode: ApplicationErrorCode<'USER_NOT_FOUND'> = { + code: 'USER_NOT_FOUND' + }; + + const validationCode: ApplicationErrorCode<'VALIDATION_ERROR', { field: string }> = { + code: 'VALIDATION_ERROR', + details: { field: 'email' } + }; + + const permissionCode: ApplicationErrorCode<'PERMISSION_DENIED', { resource: string }> = { + code: 'PERMISSION_DENIED', + details: { resource: 'admin-panel' } + }; + + expect(notFoundCode.code).toBe('USER_NOT_FOUND'); + expect(validationCode.code).toBe('VALIDATION_ERROR'); + expect(validationCode.details).toEqual({ field: 'email' }); + expect(permissionCode.code).toBe('PERMISSION_DENIED'); + expect(permissionCode.details).toEqual({ resource: 'admin-panel' }); + }); + + it('should support complex details types', () => { + interface PaymentErrorDetails { + amount: number; + currency: string; + retryAfter?: number; + attempts: number; + } + + const paymentErrorCode: ApplicationErrorCode<'PAYMENT_FAILED', PaymentErrorDetails> = { + code: 'PAYMENT_FAILED', + details: { + amount: 100, + currency: 'USD', + retryAfter: 60, + attempts: 3 + } + }; + + expect(paymentErrorCode.code).toBe('PAYMENT_FAILED'); + expect(paymentErrorCode.details).toEqual({ + amount: 100, + currency: 'USD', + retryAfter: 60, + attempts: 3 + }); + }); + + it('should support optional details', () => { + const errorCodeWithDetails: ApplicationErrorCode<'ERROR', { message: string }> = { + code: 'ERROR', + details: { message: 'Something went wrong' } + }; + + const errorCodeWithoutDetails: ApplicationErrorCode<'ERROR', undefined> = { + code: 'ERROR' + }; + + expect(errorCodeWithDetails.code).toBe('ERROR'); + expect(errorCodeWithDetails.details).toEqual({ message: 'Something went wrong' }); + expect(errorCodeWithoutDetails.code).toBe('ERROR'); + }); + }); + + describe('ApplicationErrorCode behavior', () => { + it('should be assignable to Result error type', () => { + // ApplicationErrorCode is designed to be used with Result type + // This test verifies the type compatibility + type MyErrorCodes = 'USER_NOT_FOUND' | 'VALIDATION_ERROR' | 'PERMISSION_DENIED'; + + const userNotFound: ApplicationErrorCode<'USER_NOT_FOUND'> = { + code: 'USER_NOT_FOUND' + }; + + const validationError: ApplicationErrorCode<'VALIDATION_ERROR', { field: string }> = { + code: 'VALIDATION_ERROR', + details: { field: 'email' } + }; + + const permissionError: ApplicationErrorCode<'PERMISSION_DENIED', { resource: string }> = { + code: 'PERMISSION_DENIED', + details: { resource: 'admin-panel' } + }; + + expect(userNotFound.code).toBe('USER_NOT_FOUND'); + expect(validationError.code).toBe('VALIDATION_ERROR'); + expect(validationError.details).toEqual({ field: 'email' }); + expect(permissionError.code).toBe('PERMISSION_DENIED'); + expect(permissionError.details).toEqual({ resource: 'admin-panel' }); + }); + + it('should support error code patterns', () => { + // Common error code patterns + const notFoundPattern: ApplicationErrorCode<'NOT_FOUND', { resource: string; id?: string }> = { + code: 'NOT_FOUND', + details: { resource: 'user', id: '123' } + }; + + const conflictPattern: ApplicationErrorCode<'CONFLICT', { resource: string; existingId: string }> = { + code: 'CONFLICT', + details: { resource: 'order', existingId: '456' } + }; + + const validationPattern: ApplicationErrorCode<'VALIDATION_ERROR', { field: string; value: unknown; reason: string }> = { + code: 'VALIDATION_ERROR', + details: { field: 'email', value: 'invalid', reason: 'must contain @' } + }; + + expect(notFoundPattern.code).toBe('NOT_FOUND'); + expect(notFoundPattern.details).toEqual({ resource: 'user', id: '123' }); + expect(conflictPattern.code).toBe('CONFLICT'); + expect(conflictPattern.details).toEqual({ resource: 'order', existingId: '456' }); + expect(validationPattern.code).toBe('VALIDATION_ERROR'); + expect(validationPattern.details).toEqual({ field: 'email', value: 'invalid', reason: 'must contain @' }); + }); + + it('should support error code with metadata', () => { + interface ErrorMetadata { + timestamp: string; + requestId?: string; + userId?: string; + sessionId?: string; + } + + const errorCode: ApplicationErrorCode<'AUTH_ERROR', ErrorMetadata> = { + code: 'AUTH_ERROR', + details: { + timestamp: new Date().toISOString(), + requestId: 'req-123', + userId: 'user-456', + sessionId: 'session-789' + } + }; + + expect(errorCode.code).toBe('AUTH_ERROR'); + expect(errorCode.details).toBeDefined(); + expect(errorCode.details?.timestamp).toBeDefined(); + expect(errorCode.details?.requestId).toBe('req-123'); + }); + + it('should support error code with retry information', () => { + interface RetryInfo { + retryAfter: number; + maxRetries: number; + currentAttempt: number; + } + + const retryableError: ApplicationErrorCode<'RATE_LIMIT_EXCEEDED', RetryInfo> = { + code: 'RATE_LIMIT_EXCEEDED', + details: { + retryAfter: 60, + maxRetries: 3, + currentAttempt: 1 + } + }; + + expect(retryableError.code).toBe('RATE_LIMIT_EXCEEDED'); + expect(retryableError.details).toEqual({ + retryAfter: 60, + maxRetries: 3, + currentAttempt: 1 + }); + }); + + it('should support error code with validation details', () => { + interface ValidationErrorDetails { + field: string; + value: unknown; + constraints: string[]; + message: string; + } + + const validationError: ApplicationErrorCode<'VALIDATION_ERROR', ValidationErrorDetails> = { + code: 'VALIDATION_ERROR', + details: { + field: 'email', + value: 'invalid-email', + constraints: ['must be a valid email', 'must not be empty'], + message: 'Email validation failed' + } + }; + + expect(validationError.code).toBe('VALIDATION_ERROR'); + expect(validationError.details).toEqual({ + field: 'email', + value: 'invalid-email', + constraints: ['must be a valid email', 'must not be empty'], + message: 'Email validation failed' + }); + }); + }); + + describe('ApplicationErrorCode implementation patterns', () => { + it('should support error code factory pattern', () => { + function createErrorCode( + code: Code, + details?: Details + ): ApplicationErrorCode { + return details ? { code, details } : { code }; + } + + const notFound = createErrorCode('USER_NOT_FOUND'); + const validation = createErrorCode('VALIDATION_ERROR', { field: 'email' }); + const permission = createErrorCode('PERMISSION_DENIED', { resource: 'admin' }); + + expect(notFound.code).toBe('USER_NOT_FOUND'); + expect(validation.code).toBe('VALIDATION_ERROR'); + expect(validation.details).toEqual({ field: 'email' }); + expect(permission.code).toBe('PERMISSION_DENIED'); + expect(permission.details).toEqual({ resource: 'admin' }); + }); + + it('should support error code builder pattern', () => { + class ErrorCodeBuilder { + private code: Code = '' as Code; + private details?: Details; + + withCode(code: Code): this { + this.code = code; + return this; + } + + withDetails(details: Details): this { + this.details = details; + return this; + } + + build(): ApplicationErrorCode { + return this.details ? { code: this.code, details: this.details } : { code: this.code }; + } + } + + const errorCode = new ErrorCodeBuilder<'USER_NOT_FOUND'>() + .withCode('USER_NOT_FOUND') + .build(); + + const errorCodeWithDetails = new ErrorCodeBuilder<'VALIDATION_ERROR', { field: string }>() + .withCode('VALIDATION_ERROR') + .withDetails({ field: 'email' }) + .build(); + + expect(errorCode.code).toBe('USER_NOT_FOUND'); + expect(errorCodeWithDetails.code).toBe('VALIDATION_ERROR'); + expect(errorCodeWithDetails.details).toEqual({ field: 'email' }); + }); + + it('should support error code categorization', () => { + const errorCodes: ApplicationErrorCode[] = [ + { code: 'USER_NOT_FOUND' }, + { code: 'VALIDATION_ERROR', details: { field: 'email' } }, + { code: 'PERMISSION_DENIED', details: { resource: 'admin' } }, + { code: 'NETWORK_ERROR' } + ]; + + const notFoundCodes = errorCodes.filter(e => e.code === 'USER_NOT_FOUND'); + const validationCodes = errorCodes.filter(e => e.code === 'VALIDATION_ERROR'); + const permissionCodes = errorCodes.filter(e => e.code === 'PERMISSION_DENIED'); + const networkCodes = errorCodes.filter(e => e.code === 'NETWORK_ERROR'); + + expect(notFoundCodes).toHaveLength(1); + expect(validationCodes).toHaveLength(1); + expect(permissionCodes).toHaveLength(1); + expect(networkCodes).toHaveLength(1); + }); + + it('should support error code with complex details', () => { + interface ComplexErrorDetails { + error: { + code: string; + message: string; + stack?: string; + }; + context: { + service: string; + operation: string; + timestamp: string; + }; + metadata: { + retryCount: number; + timeout: number; + }; + } + + const complexError: ApplicationErrorCode<'SYSTEM_ERROR', ComplexErrorDetails> = { + code: 'SYSTEM_ERROR', + details: { + error: { + code: 'E001', + message: 'System failure', + stack: 'Error stack trace...' + }, + context: { + service: 'payment-service', + operation: 'processPayment', + timestamp: new Date().toISOString() + }, + metadata: { + retryCount: 3, + timeout: 5000 + } + } + }; + + expect(complexError.code).toBe('SYSTEM_ERROR'); + expect(complexError.details).toBeDefined(); + expect(complexError.details?.error.code).toBe('E001'); + expect(complexError.details?.context.service).toBe('payment-service'); + expect(complexError.details?.metadata.retryCount).toBe(3); + }); + }); +}); diff --git a/core/shared/errors/DomainError.test.ts b/core/shared/errors/DomainError.test.ts new file mode 100644 index 000000000..31c78e265 --- /dev/null +++ b/core/shared/errors/DomainError.test.ts @@ -0,0 +1,508 @@ +import { describe, it, expect } from 'vitest'; +import { DomainError, CommonDomainErrorKind, IDomainError, DomainErrorAlias } from './DomainError'; + +describe('DomainError', () => { + describe('DomainError interface', () => { + it('should have required properties', () => { + const error: DomainError = { + name: 'DomainError', + type: 'domain', + context: 'user-service', + kind: 'validation', + message: 'Invalid user data' + }; + + expect(error.type).toBe('domain'); + expect(error.context).toBe('user-service'); + expect(error.kind).toBe('validation'); + expect(error.message).toBe('Invalid user data'); + }); + + it('should support different error kinds', () => { + const validationError: DomainError = { + name: 'DomainError', + type: 'domain', + context: 'form-service', + kind: 'validation', + message: 'Invalid input' + }; + + const invariantError: DomainError = { + name: 'DomainError', + type: 'domain', + context: 'order-service', + kind: 'invariant', + message: 'Order total must be positive' + }; + + const customError: DomainError = { + name: 'DomainError', + type: 'domain', + context: 'payment-service', + kind: 'BUSINESS_RULE_VIOLATION', + message: 'Payment exceeds limit' + }; + + expect(validationError.kind).toBe('validation'); + expect(invariantError.kind).toBe('invariant'); + expect(customError.kind).toBe('BUSINESS_RULE_VIOLATION'); + }); + + it('should support custom error kinds', () => { + const customError: DomainError<'INVALID_STATE'> = { + name: 'DomainError', + type: 'domain', + context: 'state-machine', + kind: 'INVALID_STATE', + message: 'Cannot transition from current state' + }; + + expect(customError.kind).toBe('INVALID_STATE'); + }); + }); + + describe('CommonDomainErrorKind type', () => { + it('should include standard error kinds', () => { + const kinds: CommonDomainErrorKind[] = ['validation', 'invariant']; + + kinds.forEach(kind => { + const error: DomainError = { + name: 'DomainError', + type: 'domain', + context: 'test', + kind, + message: `Test ${kind} error` + }; + + expect(error.kind).toBe(kind); + }); + }); + + it('should support string extension for custom kinds', () => { + const customKinds: CommonDomainErrorKind[] = [ + 'BUSINESS_RULE_VIOLATION', + 'DOMAIN_CONSTRAINT_VIOLATION', + 'AGGREGATE_ROOT_VIOLATION' + ]; + + customKinds.forEach(kind => { + const error: DomainError = { + name: 'DomainError', + type: 'domain', + context: 'test', + kind, + message: `Test ${kind} error` + }; + + expect(error.kind).toBe(kind); + }); + }); + }); + + describe('DomainError behavior', () => { + it('should be assignable to Error interface', () => { + const error: DomainError = { + type: 'domain', + context: 'user-service', + kind: 'validation', + message: 'Validation failed' + }; + + // DomainError extends Error + expect(error.type).toBe('domain'); + expect(error.message).toBe('Validation failed'); + }); + + it('should support error inheritance pattern', () => { + class CustomDomainError extends Error implements DomainError { + readonly type: 'domain' = 'domain'; + readonly context: string; + readonly kind: string; + + constructor(context: string, kind: string, message: string) { + super(message); + this.context = context; + this.kind = kind; + this.name = 'CustomDomainError'; + } + } + + const error = new CustomDomainError( + 'user-service', + 'VALIDATION_ERROR', + 'User email is invalid' + ); + + expect(error.type).toBe('domain'); + expect(error.context).toBe('user-service'); + expect(error.kind).toBe('VALIDATION_ERROR'); + expect(error.message).toBe('User email is invalid'); + expect(error.name).toBe('CustomDomainError'); + expect(error.stack).toBeDefined(); + }); + + it('should support error serialization', () => { + const error: DomainError = { + type: 'domain', + context: 'order-service', + kind: 'invariant', + message: 'Order total must be positive', + details: { total: -100 } + }; + + const serialized = JSON.stringify(error); + const parsed = JSON.parse(serialized); + + expect(parsed.type).toBe('domain'); + expect(parsed.context).toBe('order-service'); + expect(parsed.kind).toBe('invariant'); + expect(parsed.message).toBe('Order total must be positive'); + expect(parsed.details).toEqual({ total: -100 }); + }); + + it('should support error deserialization', () => { + const serialized = JSON.stringify({ + type: 'domain', + context: 'payment-service', + kind: 'BUSINESS_RULE_VIOLATION', + message: 'Payment exceeds limit', + details: { limit: 1000, amount: 1500 } + }); + + const parsed: DomainError = JSON.parse(serialized); + + expect(parsed.type).toBe('domain'); + expect(parsed.context).toBe('payment-service'); + expect(parsed.kind).toBe('BUSINESS_RULE_VIOLATION'); + expect(parsed.message).toBe('Payment exceeds limit'); + expect(parsed.details).toEqual({ limit: 1000, amount: 1500 }); + }); + + it('should support error comparison', () => { + const error1: DomainError = { + type: 'domain', + context: 'user-service', + kind: 'validation', + message: 'Invalid email' + }; + + const error2: DomainError = { + type: 'domain', + context: 'user-service', + kind: 'validation', + message: 'Invalid email' + }; + + const error3: DomainError = { + type: 'domain', + context: 'order-service', + kind: 'invariant', + message: 'Order total must be positive' + }; + + // Same kind and context + expect(error1.kind).toBe(error2.kind); + expect(error1.context).toBe(error2.context); + + // Different context + expect(error1.context).not.toBe(error3.context); + }); + + it('should support error categorization', () => { + const errors: DomainError[] = [ + { + type: 'domain', + context: 'user-service', + kind: 'validation', + message: 'Invalid email' + }, + { + type: 'domain', + context: 'order-service', + kind: 'invariant', + message: 'Order total must be positive' + }, + { + type: 'domain', + context: 'payment-service', + kind: 'BUSINESS_RULE_VIOLATION', + message: 'Payment exceeds limit' + } + ]; + + const validationErrors = errors.filter(e => e.kind === 'validation'); + const invariantErrors = errors.filter(e => e.kind === 'invariant'); + const businessRuleErrors = errors.filter(e => e.kind === 'BUSINESS_RULE_VIOLATION'); + + expect(validationErrors).toHaveLength(1); + expect(invariantErrors).toHaveLength(1); + expect(businessRuleErrors).toHaveLength(1); + }); + + it('should support error aggregation by context', () => { + const errors: DomainError[] = [ + { + type: 'domain', + context: 'user-service', + kind: 'validation', + message: 'Invalid email' + }, + { + type: 'domain', + context: 'user-service', + kind: 'invariant', + message: 'User must have at least one role' + }, + { + type: 'domain', + context: 'order-service', + kind: 'invariant', + message: 'Order total must be positive' + } + ]; + + const userErrors = errors.filter(e => e.context === 'user-service'); + const orderErrors = errors.filter(e => e.context === 'order-service'); + + expect(userErrors).toHaveLength(2); + expect(orderErrors).toHaveLength(1); + }); + }); + + describe('IDomainError interface', () => { + it('should be assignable to DomainError', () => { + const error: IDomainError = { + type: 'domain', + context: 'user-service', + kind: 'validation', + message: 'Invalid email' + }; + + expect(error.type).toBe('domain'); + expect(error.context).toBe('user-service'); + expect(error.kind).toBe('validation'); + expect(error.message).toBe('Invalid email'); + }); + + it('should support different error kinds', () => { + const validationError: IDomainError = { + type: 'domain', + context: 'form-service', + kind: 'validation', + message: 'Invalid input' + }; + + const invariantError: IDomainError = { + type: 'domain', + context: 'order-service', + kind: 'invariant', + message: 'Order total must be positive' + }; + + expect(validationError.kind).toBe('validation'); + expect(invariantError.kind).toBe('invariant'); + }); + }); + + describe('DomainErrorAlias type', () => { + it('should be assignable to DomainError', () => { + const alias: DomainErrorAlias = { + type: 'domain', + context: 'user-service', + kind: 'validation', + message: 'Invalid email' + }; + + expect(alias.type).toBe('domain'); + expect(alias.context).toBe('user-service'); + expect(alias.kind).toBe('validation'); + expect(alias.message).toBe('Invalid email'); + }); + + it('should support different error kinds', () => { + const validationError: DomainErrorAlias = { + type: 'domain', + context: 'form-service', + kind: 'validation', + message: 'Invalid input' + }; + + const invariantError: DomainErrorAlias = { + type: 'domain', + context: 'order-service', + kind: 'invariant', + message: 'Order total must be positive' + }; + + expect(validationError.kind).toBe('validation'); + expect(invariantError.kind).toBe('invariant'); + }); + }); + + describe('DomainError implementation patterns', () => { + it('should support error factory pattern', () => { + function createDomainError( + context: string, + kind: K, + message: string, + details?: unknown + ): DomainError { + return { + type: 'domain', + context, + kind, + message, + details + }; + } + + const validationError = createDomainError( + 'user-service', + 'VALIDATION_ERROR', + 'Invalid email', + { field: 'email', value: 'invalid' } + ); + + const invariantError = createDomainError( + 'order-service', + 'INVARIANT_VIOLATION', + 'Order total must be positive' + ); + + expect(validationError.kind).toBe('VALIDATION_ERROR'); + expect(validationError.details).toEqual({ field: 'email', value: 'invalid' }); + expect(invariantError.kind).toBe('INVARIANT_VIOLATION'); + expect(invariantError.details).toBeUndefined(); + }); + + it('should support error builder pattern', () => { + class DomainErrorBuilder { + private context: string = ''; + private kind: K = '' as K; + private message: string = ''; + private details?: unknown; + + withContext(context: string): this { + this.context = context; + return this; + } + + withKind(kind: K): this { + this.kind = kind; + return this; + } + + withMessage(message: string): this { + this.message = message; + return this; + } + + withDetails(details: unknown): this { + this.details = details; + return this; + } + + build(): DomainError { + return { + type: 'domain', + context: this.context, + kind: this.kind, + message: this.message, + details: this.details + }; + } + } + + const error = new DomainErrorBuilder<'VALIDATION_ERROR'>() + .withContext('user-service') + .withKind('VALIDATION_ERROR') + .withMessage('Invalid email') + .withDetails({ field: 'email', value: 'invalid' }) + .build(); + + expect(error.type).toBe('domain'); + expect(error.context).toBe('user-service'); + expect(error.kind).toBe('VALIDATION_ERROR'); + expect(error.message).toBe('Invalid email'); + expect(error.details).toEqual({ field: 'email', value: 'invalid' }); + }); + + it('should support error categorization by severity', () => { + const errors: DomainError[] = [ + { + type: 'domain', + context: 'user-service', + kind: 'VALIDATION_ERROR', + message: 'Invalid email', + details: { severity: 'low' } + }, + { + type: 'domain', + context: 'order-service', + kind: 'INVARIANT_VIOLATION', + message: 'Order total must be positive', + details: { severity: 'high' } + }, + { + type: 'domain', + context: 'payment-service', + kind: 'BUSINESS_RULE_VIOLATION', + message: 'Payment exceeds limit', + details: { severity: 'critical' } + } + ]; + + const lowSeverity = errors.filter(e => (e.details as { severity: string })?.severity === 'low'); + const highSeverity = errors.filter(e => (e.details as { severity: string })?.severity === 'high'); + const criticalSeverity = errors.filter(e => (e.details as { severity: string })?.severity === 'critical'); + + expect(lowSeverity).toHaveLength(1); + expect(highSeverity).toHaveLength(1); + expect(criticalSeverity).toHaveLength(1); + }); + + it('should support domain invariant violations', () => { + const invariantError: DomainError<'INVARIANT_VIOLATION'> = { + type: 'domain', + context: 'order-service', + kind: 'INVARIANT_VIOLATION', + message: 'Order total must be positive', + details: { + invariant: 'total > 0', + actualValue: -100, + expectedValue: '> 0' + } + }; + + expect(invariantError.kind).toBe('INVARIANT_VIOLATION'); + expect(invariantError.details).toEqual({ + invariant: 'total > 0', + actualValue: -100, + expectedValue: '> 0' + }); + }); + + it('should support business rule violations', () => { + const businessRuleError: DomainError<'BUSINESS_RULE_VIOLATION'> = { + type: 'domain', + context: 'payment-service', + kind: 'BUSINESS_RULE_VIOLATION', + message: 'Payment exceeds limit', + details: { + rule: 'payment_limit', + limit: 1000, + attempted: 1500, + currency: 'USD' + } + }; + + expect(businessRuleError.kind).toBe('BUSINESS_RULE_VIOLATION'); + expect(businessRuleError.details).toEqual({ + rule: 'payment_limit', + limit: 1000, + attempted: 1500, + currency: 'USD' + }); + }); + }); +});