Files
gridpilot.gg/core/shared/application/Service.test.ts
Marc Mintel 093eece3d7
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m47s
Contract Testing / contract-snapshot (pull_request) Has been skipped
core tests
2026-01-22 18:20:33 +01:00

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