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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user