core tests
This commit is contained in:
412
core/shared/application/AsyncUseCase.test.ts
Normal file
412
core/shared/application/AsyncUseCase.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
366
core/shared/application/ErrorReporter.test.ts
Normal file
366
core/shared/application/ErrorReporter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
451
core/shared/application/Service.test.ts
Normal file
451
core/shared/application/Service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
324
core/shared/application/UseCase.test.ts
Normal file
324
core/shared/application/UseCase.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
431
core/shared/application/UseCaseOutputPort.test.ts
Normal file
431
core/shared/application/UseCaseOutputPort.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user