core tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m47s
Contract Testing / contract-snapshot (pull_request) Has been skipped

This commit is contained in:
2026-01-22 18:20:33 +01:00
parent 35cc7cf12b
commit 093eece3d7
13 changed files with 4831 additions and 0 deletions

View File

@@ -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<Result>', async () => {
// Concrete implementation for testing
class TestAsyncUseCase implements AsyncUseCase<{ id: string }, { data: string }, 'NOT_FOUND'> {
async execute(input: { id: string }): Promise<Result<{ data: string }, ApplicationErrorCode<'NOT_FOUND'>>> {
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<GetUserInput, UserDTO, GetUserErrorCode> {
async execute(input: GetUserInput): Promise<Result<UserDTO, ApplicationErrorCode<GetUserErrorCode>>> {
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<SearchOrdersInput, OrdersResult, SearchOrdersErrorCode> {
async execute(input: SearchOrdersInput): Promise<Result<OrdersResult, ApplicationErrorCode<SearchOrdersErrorCode>>> {
// 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<ProcessBatchInput, ProcessBatchResult, ProcessBatchErrorCode> {
async execute(input: ProcessBatchInput): Promise<Result<ProcessBatchResult, ApplicationErrorCode<ProcessBatchErrorCode>>> {
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<StreamInput, StreamResult, StreamErrorCode> {
async execute(input: StreamInput): Promise<Result<StreamResult, ApplicationErrorCode<StreamErrorCode>>> {
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' });
});
});
});

View File

@@ -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<string, number> = {};
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<string, unknown> = {
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<string>();
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);
});
});
});

View File

@@ -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<Output>', async () => {
const service: AsyncApplicationService<string, number> = {
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<string, string> = {
execute: async (input: string) => input.toUpperCase()
};
const objectService: AsyncApplicationService<{ x: number; y: number }, number> = {
execute: async (input) => input.x + input.y
};
const arrayService: AsyncApplicationService<number[], number[]> = {
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<string, string> = {
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<FetchUserInput, UserWithPosts> = {
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<Result>', async () => {
const service: AsyncResultApplicationService<string, number, string> = {
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<string, ValidationResult, string> = {
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<ProcessOrderInput, OrderResult, string> = {
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<ProcessBatchInput, BatchResult, string> = {
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<ProcessPaymentInput, PaymentReceipt, ApplicationError> = {
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');
});
});
});

View File

@@ -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<Result>', async () => {
// Concrete implementation for testing
class TestUseCase implements UseCase<{ value: number }, string, 'INVALID_VALUE'> {
async execute(input: { value: number }): Promise<Result<string, ApplicationErrorCode<'INVALID_VALUE'>>> {
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<CreateUserInput, UserDTO, CreateUserErrorCode> {
async execute(input: CreateUserInput): Promise<Result<UserDTO, ApplicationErrorCode<CreateUserErrorCode>>> {
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<ProcessPaymentInput, PaymentReceipt, ProcessPaymentErrorCode> {
async execute(input: ProcessPaymentInput): Promise<Result<PaymentReceipt, ApplicationErrorCode<ProcessPaymentErrorCode>>> {
// 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<DeleteUserInput, void, DeleteUserErrorCode> {
async execute(input: DeleteUserInput): Promise<Result<void, ApplicationErrorCode<DeleteUserErrorCode>>> {
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<SearchInput, SearchResult, SearchErrorCode> {
async execute(input: SearchInput): Promise<Result<SearchResult, ApplicationErrorCode<SearchErrorCode>>> {
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' });
});
});
});

View File

@@ -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<string> = {
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<unknown> = {
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<UserDTO> = {
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<number[]> = {
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<unknown> = {
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<string> = {
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<string> = {
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<T> {
items: T[];
total: number;
page: number;
totalPages: number;
}
const presentedPages: PaginatedResult<string>[] = [];
const outputPort: UseCaseOutputPort<PaginatedResult<string>> = {
present: (data: PaginatedResult<string>) => {
presentedPages.push(data);
}
};
const page1: PaginatedResult<string> = {
items: ['item-1', 'item-2', 'item-3'],
total: 10,
page: 1,
totalPages: 4
};
const page2: PaginatedResult<string> = {
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<string> = {
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<string> = {
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<string> = {
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<string, string> }> = [];
const httpResponsePresenter: UseCaseOutputPort<unknown> = {
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<unknown> = {
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<unknown> = {
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<unknown> = {
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<string, unknown>();
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' });
});
});
});

View File

@@ -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<string> = {
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<string[]> = {
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<ComplexEventData> = {
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');
});
});
});

View File

@@ -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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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');
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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<string, unknown>;
}
const error: CustomError = {
code: 'NOT_FOUND',
details: { id: '123' }
};
const result = Result.err<unknown, CustomError>(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<number, Error>(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<string, Error>(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<number, Error>(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<string, MyErrorCode>('data');
const errorResult = Result.err<string, MyErrorCode>('NOT_FOUND');
expect(successResult.isOk()).toBe(true);
expect(errorResult.isErr()).toBe(true);
});
it('should work with ApplicationErrorCode pattern', () => {
interface ApplicationErrorCode<Code extends string, Details = unknown> {
code: Code;
details?: Details;
}
type MyErrorCodes = 'USER_NOT_FOUND' | 'INVALID_EMAIL';
const successResult = Result.ok<string, ApplicationErrorCode<MyErrorCodes>>('user');
const errorResult = Result.err<string, ApplicationErrorCode<MyErrorCodes>>({
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');
});
});
});

View File

@@ -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<number, number> = {
calculate: (input: number) => input * 2
};
expect(service.calculate(5)).toBe(10);
});
it('should support different input and output types', () => {
const stringService: DomainCalculationService<string, string> = {
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<CalculationInput, number> = {
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<number, number, string> = {
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<string, ValidationResult, string> = {
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<DiscountInput, number, string> = {
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<string, boolean, string> = {
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<UserInput, UserInput, string> = {
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<string, { id: number; value: string }> = {
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<CreateUserInput, User> = {
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<AddressProps, AddressProps> = {
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<number, number> = {
calculate: (x) => x * 2
};
const service3: ResultDomainCalculationServiceAlias<number, number, string> = {
calculate: (x) => Result.ok(x * 2)
};
const service4: DomainValidationServiceAlias<string, boolean, string> = {
validate: (x) => Result.ok(x.length > 0)
};
const service5: DomainFactoryServiceAlias<string, string> = {
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');
});
});
});

View File

@@ -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<K extends string>(
context: string,
kind: K,
message: string,
details?: unknown
): ApplicationError<K> {
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<K extends string> {
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<K> {
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);
});
});
});

View File

@@ -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 extends string, Details = unknown>(
code: Code,
details?: Details
): ApplicationErrorCode<Code, Details> {
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<Code extends string, Details = unknown> {
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<Code, Details> {
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<string, unknown>[] = [
{ 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);
});
});
});

View File

@@ -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<K extends string>(
context: string,
kind: K,
message: string,
details?: unknown
): DomainError<K> {
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<K extends string> {
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<K> {
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'
});
});
});
});