413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
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(6);
|
|
expect(defaultStream.totalSize).toBe(57);
|
|
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(57);
|
|
|
|
// 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' });
|
|
});
|
|
});
|
|
});
|