452 lines
14 KiB
TypeScript
452 lines
14 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|