core tests
This commit is contained in:
319
core/notifications/application/ports/NotificationGateway.test.ts
Normal file
319
core/notifications/application/ports/NotificationGateway.test.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import {
|
||||
NotificationGateway,
|
||||
NotificationGatewayRegistry,
|
||||
NotificationDeliveryResult,
|
||||
} from './NotificationGateway';
|
||||
|
||||
describe('NotificationGateway - Interface Contract', () => {
|
||||
it('NotificationGateway interface defines send method', () => {
|
||||
const mockGateway: NotificationGateway = {
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
supportsChannel: vi.fn().mockReturnValue(true),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
||||
};
|
||||
|
||||
const notification = Notification.create({
|
||||
id: 'test-id',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Test body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
expect(mockGateway.send).toBeDefined();
|
||||
expect(typeof mockGateway.send).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationGateway interface defines supportsChannel method', () => {
|
||||
const mockGateway: NotificationGateway = {
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
supportsChannel: vi.fn().mockReturnValue(true),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
||||
};
|
||||
|
||||
expect(mockGateway.supportsChannel).toBeDefined();
|
||||
expect(typeof mockGateway.supportsChannel).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationGateway interface defines isConfigured method', () => {
|
||||
const mockGateway: NotificationGateway = {
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
supportsChannel: vi.fn().mockReturnValue(true),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
||||
};
|
||||
|
||||
expect(mockGateway.isConfigured).toBeDefined();
|
||||
expect(typeof mockGateway.isConfigured).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationGateway interface defines getChannel method', () => {
|
||||
const mockGateway: NotificationGateway = {
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
supportsChannel: vi.fn().mockReturnValue(true),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
||||
};
|
||||
|
||||
expect(mockGateway.getChannel).toBeDefined();
|
||||
expect(typeof mockGateway.getChannel).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationDeliveryResult has required properties', () => {
|
||||
const result: NotificationDeliveryResult = {
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(result).toHaveProperty('channel');
|
||||
expect(result).toHaveProperty('attemptedAt');
|
||||
});
|
||||
|
||||
it('NotificationDeliveryResult can have optional externalId', () => {
|
||||
const result: NotificationDeliveryResult = {
|
||||
success: true,
|
||||
channel: 'email',
|
||||
externalId: 'email-123',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
|
||||
expect(result.externalId).toBe('email-123');
|
||||
});
|
||||
|
||||
it('NotificationDeliveryResult can have optional error', () => {
|
||||
const result: NotificationDeliveryResult = {
|
||||
success: false,
|
||||
channel: 'discord',
|
||||
error: 'Failed to send to Discord',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
|
||||
expect(result.error).toBe('Failed to send to Discord');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationGatewayRegistry - Interface Contract', () => {
|
||||
it('NotificationGatewayRegistry interface defines register method', () => {
|
||||
const mockRegistry: NotificationGatewayRegistry = {
|
||||
register: vi.fn(),
|
||||
getGateway: vi.fn().mockReturnValue(null),
|
||||
getAllGateways: vi.fn().mockReturnValue([]),
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
};
|
||||
|
||||
expect(mockRegistry.register).toBeDefined();
|
||||
expect(typeof mockRegistry.register).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationGatewayRegistry interface defines getGateway method', () => {
|
||||
const mockRegistry: NotificationGatewayRegistry = {
|
||||
register: vi.fn(),
|
||||
getGateway: vi.fn().mockReturnValue(null),
|
||||
getAllGateways: vi.fn().mockReturnValue([]),
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
};
|
||||
|
||||
expect(mockRegistry.getGateway).toBeDefined();
|
||||
expect(typeof mockRegistry.getGateway).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationGatewayRegistry interface defines getAllGateways method', () => {
|
||||
const mockRegistry: NotificationGatewayRegistry = {
|
||||
register: vi.fn(),
|
||||
getGateway: vi.fn().mockReturnValue(null),
|
||||
getAllGateways: vi.fn().mockReturnValue([]),
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
};
|
||||
|
||||
expect(mockRegistry.getAllGateways).toBeDefined();
|
||||
expect(typeof mockRegistry.getAllGateways).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationGatewayRegistry interface defines send method', () => {
|
||||
const mockRegistry: NotificationGatewayRegistry = {
|
||||
register: vi.fn(),
|
||||
getGateway: vi.fn().mockReturnValue(null),
|
||||
getAllGateways: vi.fn().mockReturnValue([]),
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
};
|
||||
|
||||
expect(mockRegistry.send).toBeDefined();
|
||||
expect(typeof mockRegistry.send).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationGateway - Integration with Notification', () => {
|
||||
it('gateway can send notification and return delivery result', async () => {
|
||||
const mockGateway: NotificationGateway = {
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
externalId: 'msg-123',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
supportsChannel: vi.fn().mockReturnValue(true),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
||||
};
|
||||
|
||||
const notification = Notification.create({
|
||||
id: 'test-id',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Test body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
const result = await mockGateway.send(notification);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe('in_app');
|
||||
expect(result.externalId).toBe('msg-123');
|
||||
expect(mockGateway.send).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it('gateway can handle failed delivery', async () => {
|
||||
const mockGateway: NotificationGateway = {
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
channel: 'email',
|
||||
error: 'SMTP server unavailable',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
supportsChannel: vi.fn().mockReturnValue(true),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
getChannel: vi.fn().mockReturnValue('email'),
|
||||
};
|
||||
|
||||
const notification = Notification.create({
|
||||
id: 'test-id',
|
||||
recipientId: 'driver-1',
|
||||
type: 'race_registration_open',
|
||||
title: 'Test',
|
||||
body: 'Test body',
|
||||
channel: 'email',
|
||||
});
|
||||
|
||||
const result = await mockGateway.send(notification);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.channel).toBe('email');
|
||||
expect(result.error).toBe('SMTP server unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationGatewayRegistry - Integration', () => {
|
||||
it('registry can route notification to appropriate gateway', async () => {
|
||||
const inAppGateway: NotificationGateway = {
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'in_app',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
supportsChannel: vi.fn().mockReturnValue(true),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
||||
};
|
||||
|
||||
const emailGateway: NotificationGateway = {
|
||||
send: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
channel: 'email',
|
||||
externalId: 'email-456',
|
||||
attemptedAt: new Date(),
|
||||
}),
|
||||
supportsChannel: vi.fn().mockReturnValue(true),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
getChannel: vi.fn().mockReturnValue('email'),
|
||||
};
|
||||
|
||||
const mockRegistry: NotificationGatewayRegistry = {
|
||||
register: vi.fn(),
|
||||
getGateway: vi.fn().mockImplementation((channel) => {
|
||||
if (channel === 'in_app') return inAppGateway;
|
||||
if (channel === 'email') return emailGateway;
|
||||
return null;
|
||||
}),
|
||||
getAllGateways: vi.fn().mockReturnValue([inAppGateway, emailGateway]),
|
||||
send: vi.fn().mockImplementation(async (notification) => {
|
||||
const gateway = mockRegistry.getGateway(notification.channel);
|
||||
if (gateway) {
|
||||
return gateway.send(notification);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
channel: notification.channel,
|
||||
error: 'No gateway found',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const inAppNotification = Notification.create({
|
||||
id: 'test-1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Test body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
const emailNotification = Notification.create({
|
||||
id: 'test-2',
|
||||
recipientId: 'driver-1',
|
||||
type: 'race_registration_open',
|
||||
title: 'Test',
|
||||
body: 'Test body',
|
||||
channel: 'email',
|
||||
});
|
||||
|
||||
const inAppResult = await mockRegistry.send(inAppNotification);
|
||||
expect(inAppResult.success).toBe(true);
|
||||
expect(inAppResult.channel).toBe('in_app');
|
||||
|
||||
const emailResult = await mockRegistry.send(emailNotification);
|
||||
expect(emailResult.success).toBe(true);
|
||||
expect(emailResult.channel).toBe('email');
|
||||
expect(emailResult.externalId).toBe('email-456');
|
||||
});
|
||||
});
|
||||
346
core/notifications/application/ports/NotificationService.test.ts
Normal file
346
core/notifications/application/ports/NotificationService.test.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
NotificationService,
|
||||
SendNotificationCommand,
|
||||
NotificationData,
|
||||
NotificationAction,
|
||||
} from './NotificationService';
|
||||
|
||||
describe('NotificationService - Interface Contract', () => {
|
||||
it('NotificationService interface defines sendNotification method', () => {
|
||||
const mockService: NotificationService = {
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockService.sendNotification).toBeDefined();
|
||||
expect(typeof mockService.sendNotification).toBe('function');
|
||||
});
|
||||
|
||||
it('SendNotificationCommand has required properties', () => {
|
||||
const command: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test notification',
|
||||
channel: 'in_app',
|
||||
urgency: 'toast',
|
||||
};
|
||||
|
||||
expect(command).toHaveProperty('recipientId');
|
||||
expect(command).toHaveProperty('type');
|
||||
expect(command).toHaveProperty('title');
|
||||
expect(command).toHaveProperty('body');
|
||||
expect(command).toHaveProperty('channel');
|
||||
expect(command).toHaveProperty('urgency');
|
||||
});
|
||||
|
||||
it('SendNotificationCommand can have optional data', () => {
|
||||
const command: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'race_results_posted',
|
||||
title: 'Race Results',
|
||||
body: 'Your race results are available',
|
||||
channel: 'email',
|
||||
urgency: 'toast',
|
||||
data: {
|
||||
raceEventId: 'event-123',
|
||||
sessionId: 'session-456',
|
||||
position: 5,
|
||||
positionChange: 2,
|
||||
},
|
||||
};
|
||||
|
||||
expect(command.data).toBeDefined();
|
||||
expect(command.data?.raceEventId).toBe('event-123');
|
||||
expect(command.data?.position).toBe(5);
|
||||
});
|
||||
|
||||
it('SendNotificationCommand can have optional actionUrl', () => {
|
||||
const command: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'protest_vote_required',
|
||||
title: 'Vote Required',
|
||||
body: 'You need to vote on a protest',
|
||||
channel: 'in_app',
|
||||
urgency: 'modal',
|
||||
actionUrl: '/protests/vote/123',
|
||||
};
|
||||
|
||||
expect(command.actionUrl).toBe('/protests/vote/123');
|
||||
});
|
||||
|
||||
it('SendNotificationCommand can have optional actions array', () => {
|
||||
const actions: NotificationAction[] = [
|
||||
{
|
||||
label: 'View Details',
|
||||
type: 'primary',
|
||||
href: '/protests/123',
|
||||
},
|
||||
{
|
||||
label: 'Dismiss',
|
||||
type: 'secondary',
|
||||
actionId: 'dismiss',
|
||||
},
|
||||
];
|
||||
|
||||
const command: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'protest_filed',
|
||||
title: 'Protest Filed',
|
||||
body: 'A protest has been filed against you',
|
||||
channel: 'in_app',
|
||||
urgency: 'modal',
|
||||
actions,
|
||||
};
|
||||
|
||||
expect(command.actions).toBeDefined();
|
||||
expect(command.actions?.length).toBe(2);
|
||||
expect(command.actions?.[0].label).toBe('View Details');
|
||||
expect(command.actions?.[1].type).toBe('secondary');
|
||||
});
|
||||
|
||||
it('SendNotificationCommand can have optional requiresResponse', () => {
|
||||
const command: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'protest_vote_required',
|
||||
title: 'Vote Required',
|
||||
body: 'You need to vote on a protest',
|
||||
channel: 'in_app',
|
||||
urgency: 'modal',
|
||||
requiresResponse: true,
|
||||
};
|
||||
|
||||
expect(command.requiresResponse).toBe(true);
|
||||
});
|
||||
|
||||
it('NotificationData can have various optional fields', () => {
|
||||
const data: NotificationData = {
|
||||
raceEventId: 'event-123',
|
||||
sessionId: 'session-456',
|
||||
leagueId: 'league-789',
|
||||
position: 3,
|
||||
positionChange: 1,
|
||||
incidents: 2,
|
||||
provisionalRatingChange: 15,
|
||||
finalRatingChange: 10,
|
||||
hadPenaltiesApplied: true,
|
||||
deadline: new Date('2024-01-01'),
|
||||
protestId: 'protest-999',
|
||||
customField: 'custom value',
|
||||
};
|
||||
|
||||
expect(data.raceEventId).toBe('event-123');
|
||||
expect(data.sessionId).toBe('session-456');
|
||||
expect(data.leagueId).toBe('league-789');
|
||||
expect(data.position).toBe(3);
|
||||
expect(data.positionChange).toBe(1);
|
||||
expect(data.incidents).toBe(2);
|
||||
expect(data.provisionalRatingChange).toBe(15);
|
||||
expect(data.finalRatingChange).toBe(10);
|
||||
expect(data.hadPenaltiesApplied).toBe(true);
|
||||
expect(data.deadline).toBeInstanceOf(Date);
|
||||
expect(data.protestId).toBe('protest-999');
|
||||
expect(data.customField).toBe('custom value');
|
||||
});
|
||||
|
||||
it('NotificationData can have minimal fields', () => {
|
||||
const data: NotificationData = {
|
||||
raceEventId: 'event-123',
|
||||
};
|
||||
|
||||
expect(data.raceEventId).toBe('event-123');
|
||||
});
|
||||
|
||||
it('NotificationAction has required properties', () => {
|
||||
const action: NotificationAction = {
|
||||
label: 'View Details',
|
||||
type: 'primary',
|
||||
};
|
||||
|
||||
expect(action).toHaveProperty('label');
|
||||
expect(action).toHaveProperty('type');
|
||||
});
|
||||
|
||||
it('NotificationAction can have optional href', () => {
|
||||
const action: NotificationAction = {
|
||||
label: 'View Details',
|
||||
type: 'primary',
|
||||
href: '/protests/123',
|
||||
};
|
||||
|
||||
expect(action.href).toBe('/protests/123');
|
||||
});
|
||||
|
||||
it('NotificationAction can have optional actionId', () => {
|
||||
const action: NotificationAction = {
|
||||
label: 'Dismiss',
|
||||
type: 'secondary',
|
||||
actionId: 'dismiss',
|
||||
};
|
||||
|
||||
expect(action.actionId).toBe('dismiss');
|
||||
});
|
||||
|
||||
it('NotificationAction type can be primary, secondary, or danger', () => {
|
||||
const primaryAction: NotificationAction = {
|
||||
label: 'Accept',
|
||||
type: 'primary',
|
||||
};
|
||||
|
||||
const secondaryAction: NotificationAction = {
|
||||
label: 'Cancel',
|
||||
type: 'secondary',
|
||||
};
|
||||
|
||||
const dangerAction: NotificationAction = {
|
||||
label: 'Delete',
|
||||
type: 'danger',
|
||||
};
|
||||
|
||||
expect(primaryAction.type).toBe('primary');
|
||||
expect(secondaryAction.type).toBe('secondary');
|
||||
expect(dangerAction.type).toBe('danger');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationService - Integration', () => {
|
||||
it('service can send notification with all optional fields', async () => {
|
||||
const mockService: NotificationService = {
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const command: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'race_performance_summary',
|
||||
title: 'Performance Summary',
|
||||
body: 'Your performance summary is ready',
|
||||
channel: 'email',
|
||||
urgency: 'toast',
|
||||
data: {
|
||||
raceEventId: 'event-123',
|
||||
sessionId: 'session-456',
|
||||
position: 5,
|
||||
positionChange: 2,
|
||||
incidents: 1,
|
||||
provisionalRatingChange: 10,
|
||||
finalRatingChange: 8,
|
||||
hadPenaltiesApplied: false,
|
||||
},
|
||||
actionUrl: '/performance/summary/123',
|
||||
actions: [
|
||||
{
|
||||
label: 'View Details',
|
||||
type: 'primary',
|
||||
href: '/performance/summary/123',
|
||||
},
|
||||
{
|
||||
label: 'Dismiss',
|
||||
type: 'secondary',
|
||||
actionId: 'dismiss',
|
||||
},
|
||||
],
|
||||
requiresResponse: false,
|
||||
};
|
||||
|
||||
await mockService.sendNotification(command);
|
||||
|
||||
expect(mockService.sendNotification).toHaveBeenCalledWith(command);
|
||||
});
|
||||
|
||||
it('service can send notification with minimal fields', async () => {
|
||||
const mockService: NotificationService = {
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const command: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'System Update',
|
||||
body: 'System will be down for maintenance',
|
||||
channel: 'in_app',
|
||||
urgency: 'toast',
|
||||
};
|
||||
|
||||
await mockService.sendNotification(command);
|
||||
|
||||
expect(mockService.sendNotification).toHaveBeenCalledWith(command);
|
||||
});
|
||||
|
||||
it('service can send notification with different urgency levels', async () => {
|
||||
const mockService: NotificationService = {
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const silentCommand: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'race_reminder',
|
||||
title: 'Race Reminder',
|
||||
body: 'Your race starts in 30 minutes',
|
||||
channel: 'in_app',
|
||||
urgency: 'silent',
|
||||
};
|
||||
|
||||
const toastCommand: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'league_invite',
|
||||
title: 'League Invite',
|
||||
body: 'You have been invited to a league',
|
||||
channel: 'in_app',
|
||||
urgency: 'toast',
|
||||
};
|
||||
|
||||
const modalCommand: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'protest_vote_required',
|
||||
title: 'Vote Required',
|
||||
body: 'You need to vote on a protest',
|
||||
channel: 'in_app',
|
||||
urgency: 'modal',
|
||||
};
|
||||
|
||||
await mockService.sendNotification(silentCommand);
|
||||
await mockService.sendNotification(toastCommand);
|
||||
await mockService.sendNotification(modalCommand);
|
||||
|
||||
expect(mockService.sendNotification).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('service can send notification through different channels', async () => {
|
||||
const mockService: NotificationService = {
|
||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const inAppCommand: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'System Update',
|
||||
body: 'System will be down for maintenance',
|
||||
channel: 'in_app',
|
||||
urgency: 'toast',
|
||||
};
|
||||
|
||||
const emailCommand: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'race_results_posted',
|
||||
title: 'Race Results',
|
||||
body: 'Your race results are available',
|
||||
channel: 'email',
|
||||
urgency: 'toast',
|
||||
};
|
||||
|
||||
const discordCommand: SendNotificationCommand = {
|
||||
recipientId: 'driver-1',
|
||||
type: 'sponsorship_request_received',
|
||||
title: 'Sponsorship Request',
|
||||
body: 'A sponsor wants to sponsor you',
|
||||
channel: 'discord',
|
||||
urgency: 'toast',
|
||||
};
|
||||
|
||||
await mockService.sendNotification(inAppCommand);
|
||||
await mockService.sendNotification(emailCommand);
|
||||
await mockService.sendNotification(discordCommand);
|
||||
|
||||
expect(mockService.sendNotification).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
import { Result } from '@core/shared/domain/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import { NotificationRepository } from '../../domain/repositories/NotificationRepository';
|
||||
import {
|
||||
GetAllNotificationsUseCase,
|
||||
type GetAllNotificationsInput,
|
||||
} from './GetAllNotificationsUseCase';
|
||||
|
||||
interface NotificationRepositoryMock {
|
||||
findByRecipientId: Mock;
|
||||
}
|
||||
|
||||
describe('GetAllNotificationsUseCase', () => {
|
||||
let notificationRepository: NotificationRepositoryMock;
|
||||
let logger: Logger;
|
||||
let useCase: GetAllNotificationsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
notificationRepository = {
|
||||
findByRecipientId: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
useCase = new GetAllNotificationsUseCase(
|
||||
notificationRepository as unknown as NotificationRepository,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns all notifications and total count for recipient', async () => {
|
||||
const recipientId = 'driver-1';
|
||||
const notifications: Notification[] = [
|
||||
Notification.create({
|
||||
id: 'n1',
|
||||
recipientId,
|
||||
type: 'system_announcement',
|
||||
title: 'Test 1',
|
||||
body: 'Body 1',
|
||||
channel: 'in_app',
|
||||
}),
|
||||
Notification.create({
|
||||
id: 'n2',
|
||||
recipientId,
|
||||
type: 'race_registration_open',
|
||||
title: 'Test 2',
|
||||
body: 'Body 2',
|
||||
channel: 'email',
|
||||
}),
|
||||
];
|
||||
|
||||
notificationRepository.findByRecipientId.mockResolvedValue(notifications);
|
||||
|
||||
const input: GetAllNotificationsInput = { recipientId };
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(notificationRepository.findByRecipientId).toHaveBeenCalledWith(recipientId);
|
||||
expect(result).toBeInstanceOf(Result);
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.notifications).toEqual(notifications);
|
||||
expect(successResult.totalCount).toBe(2);
|
||||
});
|
||||
|
||||
it('returns empty array when no notifications exist', async () => {
|
||||
const recipientId = 'driver-1';
|
||||
notificationRepository.findByRecipientId.mockResolvedValue([]);
|
||||
|
||||
const input: GetAllNotificationsInput = { recipientId };
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(notificationRepository.findByRecipientId).toHaveBeenCalledWith(recipientId);
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.notifications).toEqual([]);
|
||||
expect(successResult.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('handles repository errors by logging and returning error result', async () => {
|
||||
const recipientId = 'driver-1';
|
||||
const error = new Error('DB error');
|
||||
notificationRepository.findByRecipientId.mockRejectedValue(error);
|
||||
|
||||
const input: GetAllNotificationsInput = { recipientId };
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>;
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(err.details.message).toBe('DB error');
|
||||
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs debug message when starting execution', async () => {
|
||||
const recipientId = 'driver-1';
|
||||
notificationRepository.findByRecipientId.mockResolvedValue([]);
|
||||
|
||||
const input: GetAllNotificationsInput = { recipientId };
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
`Attempting to retrieve all notifications for recipient ID: ${recipientId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('logs info message on successful retrieval', async () => {
|
||||
const recipientId = 'driver-1';
|
||||
const notifications: Notification[] = [
|
||||
Notification.create({
|
||||
id: 'n1',
|
||||
recipientId,
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
channel: 'in_app',
|
||||
}),
|
||||
];
|
||||
|
||||
notificationRepository.findByRecipientId.mockResolvedValue(notifications);
|
||||
|
||||
const input: GetAllNotificationsInput = { recipientId };
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
`Successfully retrieved 1 notifications for recipient ID: ${recipientId}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { NotificationDomainError } from './NotificationDomainError';
|
||||
|
||||
describe('NotificationDomainError', () => {
|
||||
it('creates an error with default validation kind', () => {
|
||||
const error = new NotificationDomainError('Invalid notification data');
|
||||
|
||||
expect(error.name).toBe('NotificationDomainError');
|
||||
expect(error.type).toBe('domain');
|
||||
expect(error.context).toBe('notifications');
|
||||
expect(error.kind).toBe('validation');
|
||||
expect(error.message).toBe('Invalid notification data');
|
||||
});
|
||||
|
||||
it('creates an error with custom kind', () => {
|
||||
const error = new NotificationDomainError('Notification not found', 'not_found');
|
||||
|
||||
expect(error.kind).toBe('not_found');
|
||||
expect(error.message).toBe('Notification not found');
|
||||
});
|
||||
|
||||
it('creates an error with business rule kind', () => {
|
||||
const error = new NotificationDomainError('Cannot send notification during quiet hours', 'business_rule');
|
||||
|
||||
expect(error.kind).toBe('business_rule');
|
||||
expect(error.message).toBe('Cannot send notification during quiet hours');
|
||||
});
|
||||
|
||||
it('creates an error with conflict kind', () => {
|
||||
const error = new NotificationDomainError('Notification already read', 'conflict');
|
||||
|
||||
expect(error.kind).toBe('conflict');
|
||||
expect(error.message).toBe('Notification already read');
|
||||
});
|
||||
|
||||
it('creates an error with unauthorized kind', () => {
|
||||
const error = new NotificationDomainError('Cannot access notification', 'unauthorized');
|
||||
|
||||
expect(error.kind).toBe('unauthorized');
|
||||
expect(error.message).toBe('Cannot access notification');
|
||||
});
|
||||
|
||||
it('inherits from Error', () => {
|
||||
const error = new NotificationDomainError('Test error');
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.stack).toBeDefined();
|
||||
});
|
||||
|
||||
it('has correct error properties', () => {
|
||||
const error = new NotificationDomainError('Test error', 'validation');
|
||||
|
||||
expect(error.name).toBe('NotificationDomainError');
|
||||
expect(error.type).toBe('domain');
|
||||
expect(error.context).toBe('notifications');
|
||||
expect(error.kind).toBe('validation');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { NotificationPreference } from '../entities/NotificationPreference';
|
||||
import { NotificationPreferenceRepository } from './NotificationPreferenceRepository';
|
||||
|
||||
describe('NotificationPreferenceRepository - Interface Contract', () => {
|
||||
it('NotificationPreferenceRepository interface defines findByDriverId method', () => {
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
||||
};
|
||||
|
||||
expect(mockRepository.findByDriverId).toBeDefined();
|
||||
expect(typeof mockRepository.findByDriverId).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationPreferenceRepository interface defines save method', () => {
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
||||
};
|
||||
|
||||
expect(mockRepository.save).toBeDefined();
|
||||
expect(typeof mockRepository.save).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationPreferenceRepository interface defines delete method', () => {
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
||||
};
|
||||
|
||||
expect(mockRepository.delete).toBeDefined();
|
||||
expect(typeof mockRepository.delete).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationPreferenceRepository interface defines getOrCreateDefault method', () => {
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
||||
};
|
||||
|
||||
expect(mockRepository.getOrCreateDefault).toBeDefined();
|
||||
expect(typeof mockRepository.getOrCreateDefault).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationPreferenceRepository - Integration', () => {
|
||||
it('can find preferences by driver ID', async () => {
|
||||
const mockPreference = NotificationPreference.create({
|
||||
id: 'driver-1',
|
||||
driverId: 'driver-1',
|
||||
channels: {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: true },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
},
|
||||
quietHoursStart: 22,
|
||||
quietHoursEnd: 7,
|
||||
});
|
||||
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(mockPreference),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue(mockPreference),
|
||||
};
|
||||
|
||||
const result = await mockRepository.findByDriverId('driver-1');
|
||||
|
||||
expect(result).toBe(mockPreference);
|
||||
expect(mockRepository.findByDriverId).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
|
||||
it('returns null when preferences not found', async () => {
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
||||
};
|
||||
|
||||
const result = await mockRepository.findByDriverId('driver-999');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockRepository.findByDriverId).toHaveBeenCalledWith('driver-999');
|
||||
});
|
||||
|
||||
it('can save preferences', async () => {
|
||||
const mockPreference = NotificationPreference.create({
|
||||
id: 'driver-1',
|
||||
driverId: 'driver-1',
|
||||
channels: {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: true },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
},
|
||||
quietHoursStart: 22,
|
||||
quietHoursEnd: 7,
|
||||
});
|
||||
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(mockPreference),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue(mockPreference),
|
||||
};
|
||||
|
||||
await mockRepository.save(mockPreference);
|
||||
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(mockPreference);
|
||||
});
|
||||
|
||||
it('can delete preferences by driver ID', async () => {
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
||||
};
|
||||
|
||||
await mockRepository.delete('driver-1');
|
||||
|
||||
expect(mockRepository.delete).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
|
||||
it('can get or create default preferences', async () => {
|
||||
const defaultPreference = NotificationPreference.createDefault('driver-1');
|
||||
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue(defaultPreference),
|
||||
};
|
||||
|
||||
const result = await mockRepository.getOrCreateDefault('driver-1');
|
||||
|
||||
expect(result).toBe(defaultPreference);
|
||||
expect(mockRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
|
||||
it('handles workflow: find, update, save', async () => {
|
||||
const existingPreference = NotificationPreference.create({
|
||||
id: 'driver-1',
|
||||
driverId: 'driver-1',
|
||||
channels: {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: false },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
const updatedPreference = NotificationPreference.create({
|
||||
id: 'driver-1',
|
||||
driverId: 'driver-1',
|
||||
channels: {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: true },
|
||||
discord: { enabled: true },
|
||||
push: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn()
|
||||
.mockResolvedValueOnce(existingPreference)
|
||||
.mockResolvedValueOnce(updatedPreference),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue(existingPreference),
|
||||
};
|
||||
|
||||
// Find existing preferences
|
||||
const found = await mockRepository.findByDriverId('driver-1');
|
||||
expect(found).toBe(existingPreference);
|
||||
|
||||
// Update preferences
|
||||
const updated = found!.updateChannel('email', { enabled: true });
|
||||
const updated2 = updated.updateChannel('discord', { enabled: true });
|
||||
|
||||
// Save updated preferences
|
||||
await mockRepository.save(updated2);
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(updated2);
|
||||
|
||||
// Verify update
|
||||
const updatedFound = await mockRepository.findByDriverId('driver-1');
|
||||
expect(updatedFound).toBe(updatedPreference);
|
||||
});
|
||||
|
||||
it('handles workflow: get or create, then update', async () => {
|
||||
const defaultPreference = NotificationPreference.createDefault('driver-1');
|
||||
|
||||
const updatedPreference = NotificationPreference.create({
|
||||
id: 'driver-1',
|
||||
driverId: 'driver-1',
|
||||
channels: {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: true },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue(defaultPreference),
|
||||
};
|
||||
|
||||
// Get or create default preferences
|
||||
const preferences = await mockRepository.getOrCreateDefault('driver-1');
|
||||
expect(preferences).toBe(defaultPreference);
|
||||
|
||||
// Update preferences
|
||||
const updated = preferences.updateChannel('email', { enabled: true });
|
||||
|
||||
// Save updated preferences
|
||||
await mockRepository.save(updated);
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(updated);
|
||||
});
|
||||
|
||||
it('handles workflow: delete preferences', async () => {
|
||||
const mockRepository: NotificationPreferenceRepository = {
|
||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
||||
};
|
||||
|
||||
// Delete preferences
|
||||
await mockRepository.delete('driver-1');
|
||||
expect(mockRepository.delete).toHaveBeenCalledWith('driver-1');
|
||||
|
||||
// Verify deletion
|
||||
const result = await mockRepository.findByDriverId('driver-1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,539 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Notification } from '../entities/Notification';
|
||||
import { NotificationRepository } from './NotificationRepository';
|
||||
|
||||
describe('NotificationRepository - Interface Contract', () => {
|
||||
it('NotificationRepository interface defines findById method', () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockRepository.findById).toBeDefined();
|
||||
expect(typeof mockRepository.findById).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationRepository interface defines findByRecipientId method', () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockRepository.findByRecipientId).toBeDefined();
|
||||
expect(typeof mockRepository.findByRecipientId).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationRepository interface defines findUnreadByRecipientId method', () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockRepository.findUnreadByRecipientId).toBeDefined();
|
||||
expect(typeof mockRepository.findUnreadByRecipientId).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationRepository interface defines findByRecipientIdAndType method', () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockRepository.findByRecipientIdAndType).toBeDefined();
|
||||
expect(typeof mockRepository.findByRecipientIdAndType).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationRepository interface defines countUnreadByRecipientId method', () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockRepository.countUnreadByRecipientId).toBeDefined();
|
||||
expect(typeof mockRepository.countUnreadByRecipientId).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationRepository interface defines create method', () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockRepository.create).toBeDefined();
|
||||
expect(typeof mockRepository.create).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationRepository interface defines update method', () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockRepository.update).toBeDefined();
|
||||
expect(typeof mockRepository.update).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationRepository interface defines delete method', () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockRepository.delete).toBeDefined();
|
||||
expect(typeof mockRepository.delete).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationRepository interface defines deleteAllByRecipientId method', () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockRepository.deleteAllByRecipientId).toBeDefined();
|
||||
expect(typeof mockRepository.deleteAllByRecipientId).toBe('function');
|
||||
});
|
||||
|
||||
it('NotificationRepository interface defines markAllAsReadByRecipientId method', () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
expect(mockRepository.markAllAsReadByRecipientId).toBeDefined();
|
||||
expect(typeof mockRepository.markAllAsReadByRecipientId).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationRepository - Integration', () => {
|
||||
it('can find notification by ID', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'notification-1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Test body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(notification),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([notification]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([notification]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([notification]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await mockRepository.findById('notification-1');
|
||||
|
||||
expect(result).toBe(notification);
|
||||
expect(mockRepository.findById).toHaveBeenCalledWith('notification-1');
|
||||
});
|
||||
|
||||
it('returns null when notification not found by ID', async () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await mockRepository.findById('notification-999');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockRepository.findById).toHaveBeenCalledWith('notification-999');
|
||||
});
|
||||
|
||||
it('can find all notifications for a recipient', async () => {
|
||||
const notifications = [
|
||||
Notification.create({
|
||||
id: 'notification-1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test 1',
|
||||
body: 'Body 1',
|
||||
channel: 'in_app',
|
||||
}),
|
||||
Notification.create({
|
||||
id: 'notification-2',
|
||||
recipientId: 'driver-1',
|
||||
type: 'race_registration_open',
|
||||
title: 'Test 2',
|
||||
body: 'Body 2',
|
||||
channel: 'email',
|
||||
}),
|
||||
];
|
||||
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue(notifications),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue(notifications),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue(notifications),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(2),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await mockRepository.findByRecipientId('driver-1');
|
||||
|
||||
expect(result).toBe(notifications);
|
||||
expect(mockRepository.findByRecipientId).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
|
||||
it('can find unread notifications for a recipient', async () => {
|
||||
const unreadNotifications = [
|
||||
Notification.create({
|
||||
id: 'notification-1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test 1',
|
||||
body: 'Body 1',
|
||||
channel: 'in_app',
|
||||
}),
|
||||
];
|
||||
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue(unreadNotifications),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue(unreadNotifications),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await mockRepository.findUnreadByRecipientId('driver-1');
|
||||
|
||||
expect(result).toBe(unreadNotifications);
|
||||
expect(mockRepository.findUnreadByRecipientId).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
|
||||
it('can find notifications by type for a recipient', async () => {
|
||||
const protestNotifications = [
|
||||
Notification.create({
|
||||
id: 'notification-1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'protest_filed',
|
||||
title: 'Protest Filed',
|
||||
body: 'A protest has been filed',
|
||||
channel: 'in_app',
|
||||
}),
|
||||
];
|
||||
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue(protestNotifications),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const result = await mockRepository.findByRecipientIdAndType('driver-1', 'protest_filed');
|
||||
|
||||
expect(result).toBe(protestNotifications);
|
||||
expect(mockRepository.findByRecipientIdAndType).toHaveBeenCalledWith('driver-1', 'protest_filed');
|
||||
});
|
||||
|
||||
it('can count unread notifications for a recipient', async () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(3),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const count = await mockRepository.countUnreadByRecipientId('driver-1');
|
||||
|
||||
expect(count).toBe(3);
|
||||
expect(mockRepository.countUnreadByRecipientId).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
|
||||
it('can create a new notification', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'notification-1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Test body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await mockRepository.create(notification);
|
||||
|
||||
expect(mockRepository.create).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it('can update an existing notification', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'notification-1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Test body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(notification),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([notification]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([notification]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([notification]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await mockRepository.update(notification);
|
||||
|
||||
expect(mockRepository.update).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
|
||||
it('can delete a notification by ID', async () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await mockRepository.delete('notification-1');
|
||||
|
||||
expect(mockRepository.delete).toHaveBeenCalledWith('notification-1');
|
||||
});
|
||||
|
||||
it('can delete all notifications for a recipient', async () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await mockRepository.deleteAllByRecipientId('driver-1');
|
||||
|
||||
expect(mockRepository.deleteAllByRecipientId).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
|
||||
it('can mark all notifications as read for a recipient', async () => {
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await mockRepository.markAllAsReadByRecipientId('driver-1');
|
||||
|
||||
expect(mockRepository.markAllAsReadByRecipientId).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
|
||||
it('handles workflow: create, find, update, delete', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'notification-1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Test body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
const updatedNotification = Notification.create({
|
||||
id: 'notification-1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Updated Test',
|
||||
body: 'Updated body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
const mockRepository: NotificationRepository = {
|
||||
findById: vi.fn()
|
||||
.mockResolvedValueOnce(notification)
|
||||
.mockResolvedValueOnce(updatedNotification)
|
||||
.mockResolvedValueOnce(null),
|
||||
findByRecipientId: vi.fn()
|
||||
.mockResolvedValueOnce([notification])
|
||||
.mockResolvedValueOnce([updatedNotification])
|
||||
.mockResolvedValueOnce([]),
|
||||
findUnreadByRecipientId: vi.fn()
|
||||
.mockResolvedValueOnce([notification])
|
||||
.mockResolvedValueOnce([updatedNotification])
|
||||
.mockResolvedValueOnce([]),
|
||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
||||
countUnreadByRecipientId: vi.fn()
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(0),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// Create notification
|
||||
await mockRepository.create(notification);
|
||||
expect(mockRepository.create).toHaveBeenCalledWith(notification);
|
||||
|
||||
// Find notification
|
||||
const found = await mockRepository.findById('notification-1');
|
||||
expect(found).toBe(notification);
|
||||
|
||||
// Update notification
|
||||
await mockRepository.update(updatedNotification);
|
||||
expect(mockRepository.update).toHaveBeenCalledWith(updatedNotification);
|
||||
|
||||
// Verify update
|
||||
const updatedFound = await mockRepository.findById('notification-1');
|
||||
expect(updatedFound).toBe(updatedNotification);
|
||||
|
||||
// Delete notification
|
||||
await mockRepository.delete('notification-1');
|
||||
expect(mockRepository.delete).toHaveBeenCalledWith('notification-1');
|
||||
|
||||
// Verify deletion
|
||||
const deletedFound = await mockRepository.findById('notification-1');
|
||||
expect(deletedFound).toBeNull();
|
||||
});
|
||||
});
|
||||
419
core/notifications/domain/types/NotificationTypes.test.ts
Normal file
419
core/notifications/domain/types/NotificationTypes.test.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getChannelDisplayName,
|
||||
isExternalChannel,
|
||||
DEFAULT_ENABLED_CHANNELS,
|
||||
ALL_CHANNELS,
|
||||
getNotificationTypeTitle,
|
||||
getNotificationTypePriority,
|
||||
type NotificationChannel,
|
||||
type NotificationType,
|
||||
} from './NotificationTypes';
|
||||
|
||||
describe('NotificationTypes - Channel Functions', () => {
|
||||
describe('getChannelDisplayName', () => {
|
||||
it('returns correct display name for in_app channel', () => {
|
||||
expect(getChannelDisplayName('in_app')).toBe('In-App');
|
||||
});
|
||||
|
||||
it('returns correct display name for email channel', () => {
|
||||
expect(getChannelDisplayName('email')).toBe('Email');
|
||||
});
|
||||
|
||||
it('returns correct display name for discord channel', () => {
|
||||
expect(getChannelDisplayName('discord')).toBe('Discord');
|
||||
});
|
||||
|
||||
it('returns correct display name for push channel', () => {
|
||||
expect(getChannelDisplayName('push')).toBe('Push Notification');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExternalChannel', () => {
|
||||
it('returns false for in_app channel', () => {
|
||||
expect(isExternalChannel('in_app')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for email channel', () => {
|
||||
expect(isExternalChannel('email')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for discord channel', () => {
|
||||
expect(isExternalChannel('discord')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for push channel', () => {
|
||||
expect(isExternalChannel('push')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_ENABLED_CHANNELS', () => {
|
||||
it('contains only in_app channel', () => {
|
||||
expect(DEFAULT_ENABLED_CHANNELS).toEqual(['in_app']);
|
||||
});
|
||||
|
||||
it('is an array', () => {
|
||||
expect(Array.isArray(DEFAULT_ENABLED_CHANNELS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ALL_CHANNELS', () => {
|
||||
it('contains all notification channels', () => {
|
||||
expect(ALL_CHANNELS).toEqual(['in_app', 'email', 'discord', 'push']);
|
||||
});
|
||||
|
||||
it('is an array', () => {
|
||||
expect(Array.isArray(ALL_CHANNELS)).toBe(true);
|
||||
});
|
||||
|
||||
it('has correct length', () => {
|
||||
expect(ALL_CHANNELS.length).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationTypes - Notification Type Functions', () => {
|
||||
describe('getNotificationTypeTitle', () => {
|
||||
it('returns correct title for protest_filed', () => {
|
||||
expect(getNotificationTypeTitle('protest_filed')).toBe('Protest Filed');
|
||||
});
|
||||
|
||||
it('returns correct title for protest_defense_requested', () => {
|
||||
expect(getNotificationTypeTitle('protest_defense_requested')).toBe('Defense Requested');
|
||||
});
|
||||
|
||||
it('returns correct title for protest_defense_submitted', () => {
|
||||
expect(getNotificationTypeTitle('protest_defense_submitted')).toBe('Defense Submitted');
|
||||
});
|
||||
|
||||
it('returns correct title for protest_comment_added', () => {
|
||||
expect(getNotificationTypeTitle('protest_comment_added')).toBe('New Comment');
|
||||
});
|
||||
|
||||
it('returns correct title for protest_vote_required', () => {
|
||||
expect(getNotificationTypeTitle('protest_vote_required')).toBe('Vote Required');
|
||||
});
|
||||
|
||||
it('returns correct title for protest_vote_cast', () => {
|
||||
expect(getNotificationTypeTitle('protest_vote_cast')).toBe('Vote Cast');
|
||||
});
|
||||
|
||||
it('returns correct title for protest_resolved', () => {
|
||||
expect(getNotificationTypeTitle('protest_resolved')).toBe('Protest Resolved');
|
||||
});
|
||||
|
||||
it('returns correct title for penalty_issued', () => {
|
||||
expect(getNotificationTypeTitle('penalty_issued')).toBe('Penalty Issued');
|
||||
});
|
||||
|
||||
it('returns correct title for penalty_appealed', () => {
|
||||
expect(getNotificationTypeTitle('penalty_appealed')).toBe('Penalty Appealed');
|
||||
});
|
||||
|
||||
it('returns correct title for penalty_appeal_resolved', () => {
|
||||
expect(getNotificationTypeTitle('penalty_appeal_resolved')).toBe('Appeal Resolved');
|
||||
});
|
||||
|
||||
it('returns correct title for race_registration_open', () => {
|
||||
expect(getNotificationTypeTitle('race_registration_open')).toBe('Registration Open');
|
||||
});
|
||||
|
||||
it('returns correct title for race_reminder', () => {
|
||||
expect(getNotificationTypeTitle('race_reminder')).toBe('Race Reminder');
|
||||
});
|
||||
|
||||
it('returns correct title for race_results_posted', () => {
|
||||
expect(getNotificationTypeTitle('race_results_posted')).toBe('Results Posted');
|
||||
});
|
||||
|
||||
it('returns correct title for race_performance_summary', () => {
|
||||
expect(getNotificationTypeTitle('race_performance_summary')).toBe('Performance Summary');
|
||||
});
|
||||
|
||||
it('returns correct title for race_final_results', () => {
|
||||
expect(getNotificationTypeTitle('race_final_results')).toBe('Final Results');
|
||||
});
|
||||
|
||||
it('returns correct title for league_invite', () => {
|
||||
expect(getNotificationTypeTitle('league_invite')).toBe('League Invitation');
|
||||
});
|
||||
|
||||
it('returns correct title for league_join_request', () => {
|
||||
expect(getNotificationTypeTitle('league_join_request')).toBe('Join Request');
|
||||
});
|
||||
|
||||
it('returns correct title for league_join_approved', () => {
|
||||
expect(getNotificationTypeTitle('league_join_approved')).toBe('Request Approved');
|
||||
});
|
||||
|
||||
it('returns correct title for league_join_rejected', () => {
|
||||
expect(getNotificationTypeTitle('league_join_rejected')).toBe('Request Rejected');
|
||||
});
|
||||
|
||||
it('returns correct title for league_role_changed', () => {
|
||||
expect(getNotificationTypeTitle('league_role_changed')).toBe('Role Changed');
|
||||
});
|
||||
|
||||
it('returns correct title for team_invite', () => {
|
||||
expect(getNotificationTypeTitle('team_invite')).toBe('Team Invitation');
|
||||
});
|
||||
|
||||
it('returns correct title for team_join_request', () => {
|
||||
expect(getNotificationTypeTitle('team_join_request')).toBe('Team Join Request');
|
||||
});
|
||||
|
||||
it('returns correct title for team_join_approved', () => {
|
||||
expect(getNotificationTypeTitle('team_join_approved')).toBe('Team Request Approved');
|
||||
});
|
||||
|
||||
it('returns correct title for sponsorship_request_received', () => {
|
||||
expect(getNotificationTypeTitle('sponsorship_request_received')).toBe('Sponsorship Request');
|
||||
});
|
||||
|
||||
it('returns correct title for sponsorship_request_accepted', () => {
|
||||
expect(getNotificationTypeTitle('sponsorship_request_accepted')).toBe('Sponsorship Accepted');
|
||||
});
|
||||
|
||||
it('returns correct title for sponsorship_request_rejected', () => {
|
||||
expect(getNotificationTypeTitle('sponsorship_request_rejected')).toBe('Sponsorship Rejected');
|
||||
});
|
||||
|
||||
it('returns correct title for sponsorship_request_withdrawn', () => {
|
||||
expect(getNotificationTypeTitle('sponsorship_request_withdrawn')).toBe('Sponsorship Withdrawn');
|
||||
});
|
||||
|
||||
it('returns correct title for sponsorship_activated', () => {
|
||||
expect(getNotificationTypeTitle('sponsorship_activated')).toBe('Sponsorship Active');
|
||||
});
|
||||
|
||||
it('returns correct title for sponsorship_payment_received', () => {
|
||||
expect(getNotificationTypeTitle('sponsorship_payment_received')).toBe('Payment Received');
|
||||
});
|
||||
|
||||
it('returns correct title for system_announcement', () => {
|
||||
expect(getNotificationTypeTitle('system_announcement')).toBe('Announcement');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNotificationTypePriority', () => {
|
||||
it('returns correct priority for protest_filed', () => {
|
||||
expect(getNotificationTypePriority('protest_filed')).toBe(8);
|
||||
});
|
||||
|
||||
it('returns correct priority for protest_defense_requested', () => {
|
||||
expect(getNotificationTypePriority('protest_defense_requested')).toBe(9);
|
||||
});
|
||||
|
||||
it('returns correct priority for protest_defense_submitted', () => {
|
||||
expect(getNotificationTypePriority('protest_defense_submitted')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns correct priority for protest_comment_added', () => {
|
||||
expect(getNotificationTypePriority('protest_comment_added')).toBe(4);
|
||||
});
|
||||
|
||||
it('returns correct priority for protest_vote_required', () => {
|
||||
expect(getNotificationTypePriority('protest_vote_required')).toBe(8);
|
||||
});
|
||||
|
||||
it('returns correct priority for protest_vote_cast', () => {
|
||||
expect(getNotificationTypePriority('protest_vote_cast')).toBe(3);
|
||||
});
|
||||
|
||||
it('returns correct priority for protest_resolved', () => {
|
||||
expect(getNotificationTypePriority('protest_resolved')).toBe(7);
|
||||
});
|
||||
|
||||
it('returns correct priority for penalty_issued', () => {
|
||||
expect(getNotificationTypePriority('penalty_issued')).toBe(9);
|
||||
});
|
||||
|
||||
it('returns correct priority for penalty_appealed', () => {
|
||||
expect(getNotificationTypePriority('penalty_appealed')).toBe(7);
|
||||
});
|
||||
|
||||
it('returns correct priority for penalty_appeal_resolved', () => {
|
||||
expect(getNotificationTypePriority('penalty_appeal_resolved')).toBe(7);
|
||||
});
|
||||
|
||||
it('returns correct priority for race_registration_open', () => {
|
||||
expect(getNotificationTypePriority('race_registration_open')).toBe(5);
|
||||
});
|
||||
|
||||
it('returns correct priority for race_reminder', () => {
|
||||
expect(getNotificationTypePriority('race_reminder')).toBe(8);
|
||||
});
|
||||
|
||||
it('returns correct priority for race_results_posted', () => {
|
||||
expect(getNotificationTypePriority('race_results_posted')).toBe(5);
|
||||
});
|
||||
|
||||
it('returns correct priority for race_performance_summary', () => {
|
||||
expect(getNotificationTypePriority('race_performance_summary')).toBe(9);
|
||||
});
|
||||
|
||||
it('returns correct priority for race_final_results', () => {
|
||||
expect(getNotificationTypePriority('race_final_results')).toBe(7);
|
||||
});
|
||||
|
||||
it('returns correct priority for league_invite', () => {
|
||||
expect(getNotificationTypePriority('league_invite')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns correct priority for league_join_request', () => {
|
||||
expect(getNotificationTypePriority('league_join_request')).toBe(5);
|
||||
});
|
||||
|
||||
it('returns correct priority for league_join_approved', () => {
|
||||
expect(getNotificationTypePriority('league_join_approved')).toBe(7);
|
||||
});
|
||||
|
||||
it('returns correct priority for league_join_rejected', () => {
|
||||
expect(getNotificationTypePriority('league_join_rejected')).toBe(7);
|
||||
});
|
||||
|
||||
it('returns correct priority for league_role_changed', () => {
|
||||
expect(getNotificationTypePriority('league_role_changed')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns correct priority for team_invite', () => {
|
||||
expect(getNotificationTypePriority('team_invite')).toBe(5);
|
||||
});
|
||||
|
||||
it('returns correct priority for team_join_request', () => {
|
||||
expect(getNotificationTypePriority('team_join_request')).toBe(4);
|
||||
});
|
||||
|
||||
it('returns correct priority for team_join_approved', () => {
|
||||
expect(getNotificationTypePriority('team_join_approved')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns correct priority for sponsorship_request_received', () => {
|
||||
expect(getNotificationTypePriority('sponsorship_request_received')).toBe(7);
|
||||
});
|
||||
|
||||
it('returns correct priority for sponsorship_request_accepted', () => {
|
||||
expect(getNotificationTypePriority('sponsorship_request_accepted')).toBe(8);
|
||||
});
|
||||
|
||||
it('returns correct priority for sponsorship_request_rejected', () => {
|
||||
expect(getNotificationTypePriority('sponsorship_request_rejected')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns correct priority for sponsorship_request_withdrawn', () => {
|
||||
expect(getNotificationTypePriority('sponsorship_request_withdrawn')).toBe(5);
|
||||
});
|
||||
|
||||
it('returns correct priority for sponsorship_activated', () => {
|
||||
expect(getNotificationTypePriority('sponsorship_activated')).toBe(7);
|
||||
});
|
||||
|
||||
it('returns correct priority for sponsorship_payment_received', () => {
|
||||
expect(getNotificationTypePriority('sponsorship_payment_received')).toBe(8);
|
||||
});
|
||||
|
||||
it('returns correct priority for system_announcement', () => {
|
||||
expect(getNotificationTypePriority('system_announcement')).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationTypes - Type Safety', () => {
|
||||
it('ALL_CHANNELS contains all NotificationChannel values', () => {
|
||||
const channels: NotificationChannel[] = ['in_app', 'email', 'discord', 'push'];
|
||||
channels.forEach(channel => {
|
||||
expect(ALL_CHANNELS).toContain(channel);
|
||||
});
|
||||
});
|
||||
|
||||
it('DEFAULT_ENABLED_CHANNELS is a subset of ALL_CHANNELS', () => {
|
||||
DEFAULT_ENABLED_CHANNELS.forEach(channel => {
|
||||
expect(ALL_CHANNELS).toContain(channel);
|
||||
});
|
||||
});
|
||||
|
||||
it('all notification types have titles', () => {
|
||||
const types: NotificationType[] = [
|
||||
'protest_filed',
|
||||
'protest_defense_requested',
|
||||
'protest_defense_submitted',
|
||||
'protest_comment_added',
|
||||
'protest_vote_required',
|
||||
'protest_vote_cast',
|
||||
'protest_resolved',
|
||||
'penalty_issued',
|
||||
'penalty_appealed',
|
||||
'penalty_appeal_resolved',
|
||||
'race_registration_open',
|
||||
'race_reminder',
|
||||
'race_results_posted',
|
||||
'race_performance_summary',
|
||||
'race_final_results',
|
||||
'league_invite',
|
||||
'league_join_request',
|
||||
'league_join_approved',
|
||||
'league_join_rejected',
|
||||
'league_role_changed',
|
||||
'team_invite',
|
||||
'team_join_request',
|
||||
'team_join_approved',
|
||||
'sponsorship_request_received',
|
||||
'sponsorship_request_accepted',
|
||||
'sponsorship_request_rejected',
|
||||
'sponsorship_request_withdrawn',
|
||||
'sponsorship_activated',
|
||||
'sponsorship_payment_received',
|
||||
'system_announcement',
|
||||
];
|
||||
|
||||
types.forEach(type => {
|
||||
const title = getNotificationTypeTitle(type);
|
||||
expect(title).toBeDefined();
|
||||
expect(typeof title).toBe('string');
|
||||
expect(title.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('all notification types have priorities', () => {
|
||||
const types: NotificationType[] = [
|
||||
'protest_filed',
|
||||
'protest_defense_requested',
|
||||
'protest_defense_submitted',
|
||||
'protest_comment_added',
|
||||
'protest_vote_required',
|
||||
'protest_vote_cast',
|
||||
'protest_resolved',
|
||||
'penalty_issued',
|
||||
'penalty_appealed',
|
||||
'penalty_appeal_resolved',
|
||||
'race_registration_open',
|
||||
'race_reminder',
|
||||
'race_results_posted',
|
||||
'race_performance_summary',
|
||||
'race_final_results',
|
||||
'league_invite',
|
||||
'league_join_request',
|
||||
'league_join_approved',
|
||||
'league_join_rejected',
|
||||
'league_role_changed',
|
||||
'team_invite',
|
||||
'team_join_request',
|
||||
'team_join_approved',
|
||||
'sponsorship_request_received',
|
||||
'sponsorship_request_accepted',
|
||||
'sponsorship_request_rejected',
|
||||
'sponsorship_request_withdrawn',
|
||||
'sponsorship_activated',
|
||||
'sponsorship_payment_received',
|
||||
'system_announcement',
|
||||
];
|
||||
|
||||
types.forEach(type => {
|
||||
const priority = getNotificationTypePriority(type);
|
||||
expect(priority).toBeDefined();
|
||||
expect(typeof priority).toBe('number');
|
||||
expect(priority).toBeGreaterThanOrEqual(0);
|
||||
expect(priority).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,174 @@
|
||||
import * as mod from '@core/payments/domain/entities/MemberPayment';
|
||||
import {
|
||||
MemberPayment,
|
||||
MemberPaymentStatus,
|
||||
} from '@core/payments/domain/entities/MemberPayment';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('payments/domain/entities/MemberPayment.ts', () => {
|
||||
it('imports', () => {
|
||||
expect(mod).toBeTruthy();
|
||||
describe('payments/domain/entities/MemberPayment', () => {
|
||||
describe('MemberPaymentStatus enum', () => {
|
||||
it('should have correct status values', () => {
|
||||
expect(MemberPaymentStatus.PENDING).toBe('pending');
|
||||
expect(MemberPaymentStatus.PAID).toBe('paid');
|
||||
expect(MemberPaymentStatus.OVERDUE).toBe('overdue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemberPayment interface', () => {
|
||||
it('should have all required properties', () => {
|
||||
const payment: MemberPayment = {
|
||||
id: 'payment-123',
|
||||
feeId: 'fee-456',
|
||||
driverId: 'driver-789',
|
||||
amount: 100,
|
||||
platformFee: 10,
|
||||
netAmount: 90,
|
||||
status: MemberPaymentStatus.PENDING,
|
||||
dueDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(payment.id).toBe('payment-123');
|
||||
expect(payment.feeId).toBe('fee-456');
|
||||
expect(payment.driverId).toBe('driver-789');
|
||||
expect(payment.amount).toBe(100);
|
||||
expect(payment.platformFee).toBe(10);
|
||||
expect(payment.netAmount).toBe(90);
|
||||
expect(payment.status).toBe(MemberPaymentStatus.PENDING);
|
||||
expect(payment.dueDate).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
it('should support optional paidAt property', () => {
|
||||
const payment: MemberPayment = {
|
||||
id: 'payment-123',
|
||||
feeId: 'fee-456',
|
||||
driverId: 'driver-789',
|
||||
amount: 100,
|
||||
platformFee: 10,
|
||||
netAmount: 90,
|
||||
status: MemberPaymentStatus.PAID,
|
||||
dueDate: new Date('2024-01-01'),
|
||||
paidAt: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
expect(payment.paidAt).toEqual(new Date('2024-01-15'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('MemberPayment.rehydrate', () => {
|
||||
it('should rehydrate a MemberPayment from props', () => {
|
||||
const props: MemberPayment = {
|
||||
id: 'payment-123',
|
||||
feeId: 'fee-456',
|
||||
driverId: 'driver-789',
|
||||
amount: 100,
|
||||
platformFee: 10,
|
||||
netAmount: 90,
|
||||
status: MemberPaymentStatus.PENDING,
|
||||
dueDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const rehydrated = MemberPayment.rehydrate(props);
|
||||
|
||||
expect(rehydrated).toEqual(props);
|
||||
expect(rehydrated.id).toBe('payment-123');
|
||||
expect(rehydrated.feeId).toBe('fee-456');
|
||||
expect(rehydrated.driverId).toBe('driver-789');
|
||||
expect(rehydrated.amount).toBe(100);
|
||||
expect(rehydrated.platformFee).toBe(10);
|
||||
expect(rehydrated.netAmount).toBe(90);
|
||||
expect(rehydrated.status).toBe(MemberPaymentStatus.PENDING);
|
||||
expect(rehydrated.dueDate).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
it('should preserve optional paidAt when rehydrating', () => {
|
||||
const props: MemberPayment = {
|
||||
id: 'payment-123',
|
||||
feeId: 'fee-456',
|
||||
driverId: 'driver-789',
|
||||
amount: 100,
|
||||
platformFee: 10,
|
||||
netAmount: 90,
|
||||
status: MemberPaymentStatus.PAID,
|
||||
dueDate: new Date('2024-01-01'),
|
||||
paidAt: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
const rehydrated = MemberPayment.rehydrate(props);
|
||||
|
||||
expect(rehydrated.paidAt).toEqual(new Date('2024-01-15'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Business rules and invariants', () => {
|
||||
it('should calculate netAmount correctly (amount - platformFee)', () => {
|
||||
const payment: MemberPayment = {
|
||||
id: 'payment-123',
|
||||
feeId: 'fee-456',
|
||||
driverId: 'driver-789',
|
||||
amount: 100,
|
||||
platformFee: 10,
|
||||
netAmount: 90,
|
||||
status: MemberPaymentStatus.PENDING,
|
||||
dueDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(payment.netAmount).toBe(payment.amount - payment.platformFee);
|
||||
});
|
||||
|
||||
it('should support different payment statuses', () => {
|
||||
const pendingPayment: MemberPayment = {
|
||||
id: 'payment-123',
|
||||
feeId: 'fee-456',
|
||||
driverId: 'driver-789',
|
||||
amount: 100,
|
||||
platformFee: 10,
|
||||
netAmount: 90,
|
||||
status: MemberPaymentStatus.PENDING,
|
||||
dueDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const paidPayment: MemberPayment = {
|
||||
id: 'payment-124',
|
||||
feeId: 'fee-456',
|
||||
driverId: 'driver-789',
|
||||
amount: 100,
|
||||
platformFee: 10,
|
||||
netAmount: 90,
|
||||
status: MemberPaymentStatus.PAID,
|
||||
dueDate: new Date('2024-01-01'),
|
||||
paidAt: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
const overduePayment: MemberPayment = {
|
||||
id: 'payment-125',
|
||||
feeId: 'fee-456',
|
||||
driverId: 'driver-789',
|
||||
amount: 100,
|
||||
platformFee: 10,
|
||||
netAmount: 90,
|
||||
status: MemberPaymentStatus.OVERDUE,
|
||||
dueDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(pendingPayment.status).toBe(MemberPaymentStatus.PENDING);
|
||||
expect(paidPayment.status).toBe(MemberPaymentStatus.PAID);
|
||||
expect(overduePayment.status).toBe(MemberPaymentStatus.OVERDUE);
|
||||
});
|
||||
|
||||
it('should handle zero and negative amounts', () => {
|
||||
const zeroPayment: MemberPayment = {
|
||||
id: 'payment-123',
|
||||
feeId: 'fee-456',
|
||||
driverId: 'driver-789',
|
||||
amount: 0,
|
||||
platformFee: 0,
|
||||
netAmount: 0,
|
||||
status: MemberPaymentStatus.PENDING,
|
||||
dueDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(zeroPayment.amount).toBe(0);
|
||||
expect(zeroPayment.platformFee).toBe(0);
|
||||
expect(zeroPayment.netAmount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,200 @@
|
||||
import * as mod from '@core/payments/domain/entities/MembershipFee';
|
||||
import {
|
||||
MembershipFee,
|
||||
MembershipFeeType,
|
||||
} from '@core/payments/domain/entities/MembershipFee';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('payments/domain/entities/MembershipFee.ts', () => {
|
||||
it('imports', () => {
|
||||
expect(mod).toBeTruthy();
|
||||
describe('payments/domain/entities/MembershipFee', () => {
|
||||
describe('MembershipFeeType enum', () => {
|
||||
it('should have correct fee type values', () => {
|
||||
expect(MembershipFeeType.SEASON).toBe('season');
|
||||
expect(MembershipFeeType.MONTHLY).toBe('monthly');
|
||||
expect(MembershipFeeType.PER_RACE).toBe('per_race');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MembershipFee interface', () => {
|
||||
it('should have all required properties', () => {
|
||||
const fee: MembershipFee = {
|
||||
id: 'fee-123',
|
||||
leagueId: 'league-456',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 100,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(fee.id).toBe('fee-123');
|
||||
expect(fee.leagueId).toBe('league-456');
|
||||
expect(fee.type).toBe(MembershipFeeType.SEASON);
|
||||
expect(fee.amount).toBe(100);
|
||||
expect(fee.enabled).toBe(true);
|
||||
expect(fee.createdAt).toEqual(new Date('2024-01-01'));
|
||||
expect(fee.updatedAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
it('should support optional seasonId property', () => {
|
||||
const fee: MembershipFee = {
|
||||
id: 'fee-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 100,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(fee.seasonId).toBe('season-789');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MembershipFee.rehydrate', () => {
|
||||
it('should rehydrate a MembershipFee from props', () => {
|
||||
const props: MembershipFee = {
|
||||
id: 'fee-123',
|
||||
leagueId: 'league-456',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 100,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const rehydrated = MembershipFee.rehydrate(props);
|
||||
|
||||
expect(rehydrated).toEqual(props);
|
||||
expect(rehydrated.id).toBe('fee-123');
|
||||
expect(rehydrated.leagueId).toBe('league-456');
|
||||
expect(rehydrated.type).toBe(MembershipFeeType.SEASON);
|
||||
expect(rehydrated.amount).toBe(100);
|
||||
expect(rehydrated.enabled).toBe(true);
|
||||
expect(rehydrated.createdAt).toEqual(new Date('2024-01-01'));
|
||||
expect(rehydrated.updatedAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
it('should preserve optional seasonId when rehydrating', () => {
|
||||
const props: MembershipFee = {
|
||||
id: 'fee-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 100,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const rehydrated = MembershipFee.rehydrate(props);
|
||||
|
||||
expect(rehydrated.seasonId).toBe('season-789');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Business rules and invariants', () => {
|
||||
it('should support different fee types', () => {
|
||||
const seasonFee: MembershipFee = {
|
||||
id: 'fee-123',
|
||||
leagueId: 'league-456',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 100,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const monthlyFee: MembershipFee = {
|
||||
id: 'fee-124',
|
||||
leagueId: 'league-456',
|
||||
type: MembershipFeeType.MONTHLY,
|
||||
amount: 50,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const perRaceFee: MembershipFee = {
|
||||
id: 'fee-125',
|
||||
leagueId: 'league-456',
|
||||
type: MembershipFeeType.PER_RACE,
|
||||
amount: 10,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(seasonFee.type).toBe(MembershipFeeType.SEASON);
|
||||
expect(monthlyFee.type).toBe(MembershipFeeType.MONTHLY);
|
||||
expect(perRaceFee.type).toBe(MembershipFeeType.PER_RACE);
|
||||
});
|
||||
|
||||
it('should handle enabled/disabled state', () => {
|
||||
const enabledFee: MembershipFee = {
|
||||
id: 'fee-123',
|
||||
leagueId: 'league-456',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 100,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const disabledFee: MembershipFee = {
|
||||
id: 'fee-124',
|
||||
leagueId: 'league-456',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 0,
|
||||
enabled: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(enabledFee.enabled).toBe(true);
|
||||
expect(disabledFee.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle zero and negative amounts', () => {
|
||||
const zeroFee: MembershipFee = {
|
||||
id: 'fee-123',
|
||||
leagueId: 'league-456',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 0,
|
||||
enabled: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(zeroFee.amount).toBe(0);
|
||||
expect(zeroFee.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle different league and season combinations', () => {
|
||||
const leagueOnlyFee: MembershipFee = {
|
||||
id: 'fee-123',
|
||||
leagueId: 'league-456',
|
||||
type: MembershipFeeType.MONTHLY,
|
||||
amount: 50,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const leagueAndSeasonFee: MembershipFee = {
|
||||
id: 'fee-124',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 100,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(leagueOnlyFee.leagueId).toBe('league-456');
|
||||
expect(leagueOnlyFee.seasonId).toBeUndefined();
|
||||
expect(leagueAndSeasonFee.leagueId).toBe('league-456');
|
||||
expect(leagueAndSeasonFee.seasonId).toBe('season-789');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,311 @@
|
||||
import * as mod from '@core/payments/domain/entities/Payment';
|
||||
import {
|
||||
Payment,
|
||||
PaymentStatus,
|
||||
PaymentType,
|
||||
PayerType,
|
||||
} from '@core/payments/domain/entities/Payment';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('payments/domain/entities/Payment.ts', () => {
|
||||
it('imports', () => {
|
||||
expect(mod).toBeTruthy();
|
||||
describe('payments/domain/entities/Payment', () => {
|
||||
describe('PaymentType enum', () => {
|
||||
it('should have correct payment type values', () => {
|
||||
expect(PaymentType.SPONSORSHIP).toBe('sponsorship');
|
||||
expect(PaymentType.MEMBERSHIP_FEE).toBe('membership_fee');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PayerType enum', () => {
|
||||
it('should have correct payer type values', () => {
|
||||
expect(PayerType.SPONSOR).toBe('sponsor');
|
||||
expect(PayerType.DRIVER).toBe('driver');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PaymentStatus enum', () => {
|
||||
it('should have correct status values', () => {
|
||||
expect(PaymentStatus.PENDING).toBe('pending');
|
||||
expect(PaymentStatus.COMPLETED).toBe('completed');
|
||||
expect(PaymentStatus.FAILED).toBe('failed');
|
||||
expect(PaymentStatus.REFUNDED).toBe('refunded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Payment interface', () => {
|
||||
it('should have all required properties', () => {
|
||||
const payment: Payment = {
|
||||
id: 'payment-123',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 1000,
|
||||
platformFee: 50,
|
||||
netAmount: 950,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.PENDING,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(payment.id).toBe('payment-123');
|
||||
expect(payment.type).toBe(PaymentType.SPONSORSHIP);
|
||||
expect(payment.amount).toBe(1000);
|
||||
expect(payment.platformFee).toBe(50);
|
||||
expect(payment.netAmount).toBe(950);
|
||||
expect(payment.payerId).toBe('sponsor-456');
|
||||
expect(payment.payerType).toBe(PayerType.SPONSOR);
|
||||
expect(payment.leagueId).toBe('league-789');
|
||||
expect(payment.status).toBe(PaymentStatus.PENDING);
|
||||
expect(payment.createdAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
it('should support optional seasonId property', () => {
|
||||
const payment: Payment = {
|
||||
id: 'payment-123',
|
||||
type: PaymentType.MEMBERSHIP_FEE,
|
||||
amount: 100,
|
||||
platformFee: 5,
|
||||
netAmount: 95,
|
||||
payerId: 'driver-456',
|
||||
payerType: PayerType.DRIVER,
|
||||
leagueId: 'league-789',
|
||||
seasonId: 'season-999',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
completedAt: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
expect(payment.seasonId).toBe('season-999');
|
||||
expect(payment.completedAt).toEqual(new Date('2024-01-15'));
|
||||
});
|
||||
|
||||
it('should support optional completedAt property', () => {
|
||||
const payment: Payment = {
|
||||
id: 'payment-123',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 1000,
|
||||
platformFee: 50,
|
||||
netAmount: 950,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
completedAt: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
expect(payment.completedAt).toEqual(new Date('2024-01-15'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Payment.rehydrate', () => {
|
||||
it('should rehydrate a Payment from props', () => {
|
||||
const props: Payment = {
|
||||
id: 'payment-123',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 1000,
|
||||
platformFee: 50,
|
||||
netAmount: 950,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.PENDING,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const rehydrated = Payment.rehydrate(props);
|
||||
|
||||
expect(rehydrated).toEqual(props);
|
||||
expect(rehydrated.id).toBe('payment-123');
|
||||
expect(rehydrated.type).toBe(PaymentType.SPONSORSHIP);
|
||||
expect(rehydrated.amount).toBe(1000);
|
||||
expect(rehydrated.platformFee).toBe(50);
|
||||
expect(rehydrated.netAmount).toBe(950);
|
||||
expect(rehydrated.payerId).toBe('sponsor-456');
|
||||
expect(rehydrated.payerType).toBe(PayerType.SPONSOR);
|
||||
expect(rehydrated.leagueId).toBe('league-789');
|
||||
expect(rehydrated.status).toBe(PaymentStatus.PENDING);
|
||||
expect(rehydrated.createdAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
it('should preserve optional seasonId when rehydrating', () => {
|
||||
const props: Payment = {
|
||||
id: 'payment-123',
|
||||
type: PaymentType.MEMBERSHIP_FEE,
|
||||
amount: 100,
|
||||
platformFee: 5,
|
||||
netAmount: 95,
|
||||
payerId: 'driver-456',
|
||||
payerType: PayerType.DRIVER,
|
||||
leagueId: 'league-789',
|
||||
seasonId: 'season-999',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
completedAt: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
const rehydrated = Payment.rehydrate(props);
|
||||
|
||||
expect(rehydrated.seasonId).toBe('season-999');
|
||||
expect(rehydrated.completedAt).toEqual(new Date('2024-01-15'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Business rules and invariants', () => {
|
||||
it('should calculate netAmount correctly (amount - platformFee)', () => {
|
||||
const payment: Payment = {
|
||||
id: 'payment-123',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 1000,
|
||||
platformFee: 50,
|
||||
netAmount: 950,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.PENDING,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(payment.netAmount).toBe(payment.amount - payment.platformFee);
|
||||
});
|
||||
|
||||
it('should support different payment types', () => {
|
||||
const sponsorshipPayment: Payment = {
|
||||
id: 'payment-123',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 1000,
|
||||
platformFee: 50,
|
||||
netAmount: 950,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.PENDING,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const membershipFeePayment: Payment = {
|
||||
id: 'payment-124',
|
||||
type: PaymentType.MEMBERSHIP_FEE,
|
||||
amount: 100,
|
||||
platformFee: 5,
|
||||
netAmount: 95,
|
||||
payerId: 'driver-456',
|
||||
payerType: PayerType.DRIVER,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(sponsorshipPayment.type).toBe(PaymentType.SPONSORSHIP);
|
||||
expect(membershipFeePayment.type).toBe(PaymentType.MEMBERSHIP_FEE);
|
||||
});
|
||||
|
||||
it('should support different payer types', () => {
|
||||
const sponsorPayment: Payment = {
|
||||
id: 'payment-123',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 1000,
|
||||
platformFee: 50,
|
||||
netAmount: 950,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.PENDING,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const driverPayment: Payment = {
|
||||
id: 'payment-124',
|
||||
type: PaymentType.MEMBERSHIP_FEE,
|
||||
amount: 100,
|
||||
platformFee: 5,
|
||||
netAmount: 95,
|
||||
payerId: 'driver-456',
|
||||
payerType: PayerType.DRIVER,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(sponsorPayment.payerType).toBe(PayerType.SPONSOR);
|
||||
expect(driverPayment.payerType).toBe(PayerType.DRIVER);
|
||||
});
|
||||
|
||||
it('should support different payment statuses', () => {
|
||||
const pendingPayment: Payment = {
|
||||
id: 'payment-123',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 1000,
|
||||
platformFee: 50,
|
||||
netAmount: 950,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.PENDING,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const completedPayment: Payment = {
|
||||
id: 'payment-124',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 1000,
|
||||
platformFee: 50,
|
||||
netAmount: 950,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
completedAt: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
const failedPayment: Payment = {
|
||||
id: 'payment-125',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 1000,
|
||||
platformFee: 50,
|
||||
netAmount: 950,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.FAILED,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const refundedPayment: Payment = {
|
||||
id: 'payment-126',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 1000,
|
||||
platformFee: 50,
|
||||
netAmount: 950,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.REFUNDED,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(pendingPayment.status).toBe(PaymentStatus.PENDING);
|
||||
expect(completedPayment.status).toBe(PaymentStatus.COMPLETED);
|
||||
expect(failedPayment.status).toBe(PaymentStatus.FAILED);
|
||||
expect(refundedPayment.status).toBe(PaymentStatus.REFUNDED);
|
||||
});
|
||||
|
||||
it('should handle zero and negative amounts', () => {
|
||||
const zeroPayment: Payment = {
|
||||
id: 'payment-123',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 0,
|
||||
platformFee: 0,
|
||||
netAmount: 0,
|
||||
payerId: 'sponsor-456',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-789',
|
||||
status: PaymentStatus.PENDING,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(zeroPayment.amount).toBe(0);
|
||||
expect(zeroPayment.platformFee).toBe(0);
|
||||
expect(zeroPayment.netAmount).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,298 @@
|
||||
import * as mod from '@core/payments/domain/entities/Prize';
|
||||
import { Prize, PrizeType } from '@core/payments/domain/entities/Prize';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('payments/domain/entities/Prize.ts', () => {
|
||||
it('imports', () => {
|
||||
expect(mod).toBeTruthy();
|
||||
describe('payments/domain/entities/Prize', () => {
|
||||
describe('PrizeType enum', () => {
|
||||
it('should have correct prize type values', () => {
|
||||
expect(PrizeType.CASH).toBe('cash');
|
||||
expect(PrizeType.MERCHANDISE).toBe('merchandise');
|
||||
expect(PrizeType.OTHER).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prize interface', () => {
|
||||
it('should have all required properties', () => {
|
||||
const prize: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(prize.id).toBe('prize-123');
|
||||
expect(prize.leagueId).toBe('league-456');
|
||||
expect(prize.seasonId).toBe('season-789');
|
||||
expect(prize.position).toBe(1);
|
||||
expect(prize.name).toBe('Champion Prize');
|
||||
expect(prize.amount).toBe(1000);
|
||||
expect(prize.type).toBe(PrizeType.CASH);
|
||||
expect(prize.awarded).toBe(false);
|
||||
expect(prize.createdAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
it('should support optional description property', () => {
|
||||
const prize: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
description: 'Awarded to the champion of the season',
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(prize.description).toBe('Awarded to the champion of the season');
|
||||
});
|
||||
|
||||
it('should support optional awardedTo and awardedAt properties', () => {
|
||||
const prize: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
awarded: true,
|
||||
awardedTo: 'driver-999',
|
||||
awardedAt: new Date('2024-06-01'),
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(prize.awardedTo).toBe('driver-999');
|
||||
expect(prize.awardedAt).toEqual(new Date('2024-06-01'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prize.rehydrate', () => {
|
||||
it('should rehydrate a Prize from props', () => {
|
||||
const props: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const rehydrated = Prize.rehydrate(props);
|
||||
|
||||
expect(rehydrated).toEqual(props);
|
||||
expect(rehydrated.id).toBe('prize-123');
|
||||
expect(rehydrated.leagueId).toBe('league-456');
|
||||
expect(rehydrated.seasonId).toBe('season-789');
|
||||
expect(rehydrated.position).toBe(1);
|
||||
expect(rehydrated.name).toBe('Champion Prize');
|
||||
expect(rehydrated.amount).toBe(1000);
|
||||
expect(rehydrated.type).toBe(PrizeType.CASH);
|
||||
expect(rehydrated.awarded).toBe(false);
|
||||
expect(rehydrated.createdAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
it('should preserve optional description when rehydrating', () => {
|
||||
const props: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
description: 'Awarded to the champion of the season',
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const rehydrated = Prize.rehydrate(props);
|
||||
|
||||
expect(rehydrated.description).toBe('Awarded to the champion of the season');
|
||||
});
|
||||
|
||||
it('should preserve optional awardedTo and awardedAt when rehydrating', () => {
|
||||
const props: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
awarded: true,
|
||||
awardedTo: 'driver-999',
|
||||
awardedAt: new Date('2024-06-01'),
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const rehydrated = Prize.rehydrate(props);
|
||||
|
||||
expect(rehydrated.awardedTo).toBe('driver-999');
|
||||
expect(rehydrated.awardedAt).toEqual(new Date('2024-06-01'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Business rules and invariants', () => {
|
||||
it('should support different prize types', () => {
|
||||
const cashPrize: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const merchandisePrize: Prize = {
|
||||
id: 'prize-124',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 2,
|
||||
name: 'T-Shirt',
|
||||
amount: 50,
|
||||
type: PrizeType.MERCHANDISE,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const otherPrize: Prize = {
|
||||
id: 'prize-125',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 3,
|
||||
name: 'Special Recognition',
|
||||
amount: 0,
|
||||
type: PrizeType.OTHER,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(cashPrize.type).toBe(PrizeType.CASH);
|
||||
expect(merchandisePrize.type).toBe(PrizeType.MERCHANDISE);
|
||||
expect(otherPrize.type).toBe(PrizeType.OTHER);
|
||||
});
|
||||
|
||||
it('should handle awarded and unawarded prizes', () => {
|
||||
const unawardedPrize: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const awardedPrize: Prize = {
|
||||
id: 'prize-124',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
awarded: true,
|
||||
awardedTo: 'driver-999',
|
||||
awardedAt: new Date('2024-06-01'),
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(unawardedPrize.awarded).toBe(false);
|
||||
expect(unawardedPrize.awardedTo).toBeUndefined();
|
||||
expect(unawardedPrize.awardedAt).toBeUndefined();
|
||||
|
||||
expect(awardedPrize.awarded).toBe(true);
|
||||
expect(awardedPrize.awardedTo).toBe('driver-999');
|
||||
expect(awardedPrize.awardedAt).toEqual(new Date('2024-06-01'));
|
||||
});
|
||||
|
||||
it('should handle different positions', () => {
|
||||
const firstPlacePrize: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const secondPlacePrize: Prize = {
|
||||
id: 'prize-124',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 2,
|
||||
name: 'Runner-Up Prize',
|
||||
amount: 500,
|
||||
type: PrizeType.CASH,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const thirdPlacePrize: Prize = {
|
||||
id: 'prize-125',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 3,
|
||||
name: 'Third Place Prize',
|
||||
amount: 250,
|
||||
type: PrizeType.CASH,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(firstPlacePrize.position).toBe(1);
|
||||
expect(secondPlacePrize.position).toBe(2);
|
||||
expect(thirdPlacePrize.position).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle zero and negative amounts', () => {
|
||||
const zeroPrize: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Participation Prize',
|
||||
amount: 0,
|
||||
type: PrizeType.OTHER,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(zeroPrize.amount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle different league and season combinations', () => {
|
||||
const leagueOnlyPrize: Prize = {
|
||||
id: 'prize-123',
|
||||
leagueId: 'league-456',
|
||||
seasonId: 'season-789',
|
||||
position: 1,
|
||||
name: 'Champion Prize',
|
||||
amount: 1000,
|
||||
type: PrizeType.CASH,
|
||||
awarded: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(leagueOnlyPrize.leagueId).toBe('league-456');
|
||||
expect(leagueOnlyPrize.seasonId).toBe('season-789');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,284 @@
|
||||
import * as mod from '@core/payments/domain/entities/Wallet';
|
||||
import {
|
||||
ReferenceType,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
Wallet,
|
||||
} from '@core/payments/domain/entities/Wallet';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('payments/domain/entities/Wallet.ts', () => {
|
||||
it('imports', () => {
|
||||
expect(mod).toBeTruthy();
|
||||
describe('payments/domain/entities/Wallet', () => {
|
||||
describe('TransactionType enum', () => {
|
||||
it('should have correct transaction type values', () => {
|
||||
expect(TransactionType.DEPOSIT).toBe('deposit');
|
||||
expect(TransactionType.WITHDRAWAL).toBe('withdrawal');
|
||||
expect(TransactionType.PLATFORM_FEE).toBe('platform_fee');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReferenceType enum', () => {
|
||||
it('should have correct reference type values', () => {
|
||||
expect(ReferenceType.SPONSORSHIP).toBe('sponsorship');
|
||||
expect(ReferenceType.MEMBERSHIP_FEE).toBe('membership_fee');
|
||||
expect(ReferenceType.PRIZE).toBe('prize');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wallet interface', () => {
|
||||
it('should have all required properties', () => {
|
||||
const wallet: Wallet = {
|
||||
id: 'wallet-123',
|
||||
leagueId: 'league-456',
|
||||
balance: 1000,
|
||||
totalRevenue: 5000,
|
||||
totalPlatformFees: 250,
|
||||
totalWithdrawn: 3750,
|
||||
currency: 'USD',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(wallet.id).toBe('wallet-123');
|
||||
expect(wallet.leagueId).toBe('league-456');
|
||||
expect(wallet.balance).toBe(1000);
|
||||
expect(wallet.totalRevenue).toBe(5000);
|
||||
expect(wallet.totalPlatformFees).toBe(250);
|
||||
expect(wallet.totalWithdrawn).toBe(3750);
|
||||
expect(wallet.currency).toBe('USD');
|
||||
expect(wallet.createdAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wallet.rehydrate', () => {
|
||||
it('should rehydrate a Wallet from props', () => {
|
||||
const props: Wallet = {
|
||||
id: 'wallet-123',
|
||||
leagueId: 'league-456',
|
||||
balance: 1000,
|
||||
totalRevenue: 5000,
|
||||
totalPlatformFees: 250,
|
||||
totalWithdrawn: 3750,
|
||||
currency: 'USD',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const rehydrated = Wallet.rehydrate(props);
|
||||
|
||||
expect(rehydrated).toEqual(props);
|
||||
expect(rehydrated.id).toBe('wallet-123');
|
||||
expect(rehydrated.leagueId).toBe('league-456');
|
||||
expect(rehydrated.balance).toBe(1000);
|
||||
expect(rehydrated.totalRevenue).toBe(5000);
|
||||
expect(rehydrated.totalPlatformFees).toBe(250);
|
||||
expect(rehydrated.totalWithdrawn).toBe(3750);
|
||||
expect(rehydrated.currency).toBe('USD');
|
||||
expect(rehydrated.createdAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction interface', () => {
|
||||
it('should have all required properties', () => {
|
||||
const transaction: Transaction = {
|
||||
id: 'txn-123',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.DEPOSIT,
|
||||
amount: 1000,
|
||||
description: 'Sponsorship payment',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(transaction.id).toBe('txn-123');
|
||||
expect(transaction.walletId).toBe('wallet-456');
|
||||
expect(transaction.type).toBe(TransactionType.DEPOSIT);
|
||||
expect(transaction.amount).toBe(1000);
|
||||
expect(transaction.description).toBe('Sponsorship payment');
|
||||
expect(transaction.createdAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
it('should support optional referenceId and referenceType properties', () => {
|
||||
const transaction: Transaction = {
|
||||
id: 'txn-123',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.DEPOSIT,
|
||||
amount: 1000,
|
||||
description: 'Sponsorship payment',
|
||||
referenceId: 'payment-789',
|
||||
referenceType: ReferenceType.SPONSORSHIP,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(transaction.referenceId).toBe('payment-789');
|
||||
expect(transaction.referenceType).toBe(ReferenceType.SPONSORSHIP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction.rehydrate', () => {
|
||||
it('should rehydrate a Transaction from props', () => {
|
||||
const props: Transaction = {
|
||||
id: 'txn-123',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.DEPOSIT,
|
||||
amount: 1000,
|
||||
description: 'Sponsorship payment',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const rehydrated = Transaction.rehydrate(props);
|
||||
|
||||
expect(rehydrated).toEqual(props);
|
||||
expect(rehydrated.id).toBe('txn-123');
|
||||
expect(rehydrated.walletId).toBe('wallet-456');
|
||||
expect(rehydrated.type).toBe(TransactionType.DEPOSIT);
|
||||
expect(rehydrated.amount).toBe(1000);
|
||||
expect(rehydrated.description).toBe('Sponsorship payment');
|
||||
expect(rehydrated.createdAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
it('should preserve optional referenceId and referenceType when rehydrating', () => {
|
||||
const props: Transaction = {
|
||||
id: 'txn-123',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.DEPOSIT,
|
||||
amount: 1000,
|
||||
description: 'Sponsorship payment',
|
||||
referenceId: 'payment-789',
|
||||
referenceType: ReferenceType.SPONSORSHIP,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const rehydrated = Transaction.rehydrate(props);
|
||||
|
||||
expect(rehydrated.referenceId).toBe('payment-789');
|
||||
expect(rehydrated.referenceType).toBe(ReferenceType.SPONSORSHIP);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Business rules and invariants', () => {
|
||||
it('should calculate balance correctly', () => {
|
||||
const wallet: Wallet = {
|
||||
id: 'wallet-123',
|
||||
leagueId: 'league-456',
|
||||
balance: 1000,
|
||||
totalRevenue: 5000,
|
||||
totalPlatformFees: 250,
|
||||
totalWithdrawn: 3750,
|
||||
currency: 'USD',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// Balance should be: totalRevenue - totalPlatformFees - totalWithdrawn
|
||||
const expectedBalance = wallet.totalRevenue - wallet.totalPlatformFees - wallet.totalWithdrawn;
|
||||
expect(wallet.balance).toBe(expectedBalance);
|
||||
});
|
||||
|
||||
it('should support different transaction types', () => {
|
||||
const depositTransaction: Transaction = {
|
||||
id: 'txn-123',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.DEPOSIT,
|
||||
amount: 1000,
|
||||
description: 'Sponsorship payment',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const withdrawalTransaction: Transaction = {
|
||||
id: 'txn-124',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.WITHDRAWAL,
|
||||
amount: 500,
|
||||
description: 'Withdrawal to bank',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const platformFeeTransaction: Transaction = {
|
||||
id: 'txn-125',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.PLATFORM_FEE,
|
||||
amount: 50,
|
||||
description: 'Platform fee deduction',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(depositTransaction.type).toBe(TransactionType.DEPOSIT);
|
||||
expect(withdrawalTransaction.type).toBe(TransactionType.WITHDRAWAL);
|
||||
expect(platformFeeTransaction.type).toBe(TransactionType.PLATFORM_FEE);
|
||||
});
|
||||
|
||||
it('should support different reference types', () => {
|
||||
const sponsorshipTransaction: Transaction = {
|
||||
id: 'txn-123',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.DEPOSIT,
|
||||
amount: 1000,
|
||||
description: 'Sponsorship payment',
|
||||
referenceId: 'payment-789',
|
||||
referenceType: ReferenceType.SPONSORSHIP,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const membershipFeeTransaction: Transaction = {
|
||||
id: 'txn-124',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.DEPOSIT,
|
||||
amount: 100,
|
||||
description: 'Membership fee payment',
|
||||
referenceId: 'payment-790',
|
||||
referenceType: ReferenceType.MEMBERSHIP_FEE,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const prizeTransaction: Transaction = {
|
||||
id: 'txn-125',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.WITHDRAWAL,
|
||||
amount: 500,
|
||||
description: 'Prize payout',
|
||||
referenceId: 'prize-791',
|
||||
referenceType: ReferenceType.PRIZE,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(sponsorshipTransaction.referenceType).toBe(ReferenceType.SPONSORSHIP);
|
||||
expect(membershipFeeTransaction.referenceType).toBe(ReferenceType.MEMBERSHIP_FEE);
|
||||
expect(prizeTransaction.referenceType).toBe(ReferenceType.PRIZE);
|
||||
});
|
||||
|
||||
it('should handle zero and negative amounts', () => {
|
||||
const zeroTransaction: Transaction = {
|
||||
id: 'txn-123',
|
||||
walletId: 'wallet-456',
|
||||
type: TransactionType.DEPOSIT,
|
||||
amount: 0,
|
||||
description: 'Zero amount transaction',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(zeroTransaction.amount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle different currencies', () => {
|
||||
const usdWallet: Wallet = {
|
||||
id: 'wallet-123',
|
||||
leagueId: 'league-456',
|
||||
balance: 1000,
|
||||
totalRevenue: 5000,
|
||||
totalPlatformFees: 250,
|
||||
totalWithdrawn: 3750,
|
||||
currency: 'USD',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const eurWallet: Wallet = {
|
||||
id: 'wallet-124',
|
||||
leagueId: 'league-457',
|
||||
balance: 1000,
|
||||
totalRevenue: 5000,
|
||||
totalPlatformFees: 250,
|
||||
totalWithdrawn: 3750,
|
||||
currency: 'EUR',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
expect(usdWallet.currency).toBe('USD');
|
||||
expect(eurWallet.currency).toBe('EUR');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
501
core/ports/media/MediaResolverPort.comprehensive.test.ts
Normal file
501
core/ports/media/MediaResolverPort.comprehensive.test.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Comprehensive Tests for MediaResolverPort
|
||||
*
|
||||
* Tests cover:
|
||||
* - Interface contract compliance
|
||||
* - ResolutionStrategies for all reference types
|
||||
* - resolveWithDefaults helper function
|
||||
* - isMediaResolverPort type guard
|
||||
* - Edge cases and error handling
|
||||
* - Business logic decisions
|
||||
*/
|
||||
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MediaResolverPort,
|
||||
ResolutionStrategies,
|
||||
resolveWithDefaults,
|
||||
isMediaResolverPort,
|
||||
} from './MediaResolverPort';
|
||||
|
||||
describe('MediaResolverPort - Comprehensive Tests', () => {
|
||||
describe('Interface Contract Compliance', () => {
|
||||
it('should define resolve method signature correctly', () => {
|
||||
// Verify the interface has the correct method signature
|
||||
const testInterface: MediaResolverPort = {
|
||||
resolve: async (ref: MediaReference): Promise<string | null> => {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
expect(testInterface).toBeDefined();
|
||||
expect(typeof testInterface.resolve).toBe('function');
|
||||
});
|
||||
|
||||
it('should accept MediaReference and return Promise<string | null>', async () => {
|
||||
const mockResolver: MediaResolverPort = {
|
||||
resolve: async (ref: MediaReference): Promise<string | null> => {
|
||||
// Verify ref is a MediaReference instance
|
||||
expect(ref).toBeInstanceOf(MediaReference);
|
||||
return '/test/path';
|
||||
},
|
||||
};
|
||||
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const result = await mockResolver.resolve(ref);
|
||||
|
||||
expect(result).toBe('/test/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResolutionStrategies - System Default', () => {
|
||||
it('should resolve system-default avatar without variant', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const result = ResolutionStrategies.systemDefault(ref);
|
||||
|
||||
expect(result).toBe('/media/default/neutral-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve system-default avatar with male variant', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'male');
|
||||
const result = ResolutionStrategies.systemDefault(ref);
|
||||
|
||||
expect(result).toBe('/media/default/male-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve system-default avatar with female variant', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'female');
|
||||
const result = ResolutionStrategies.systemDefault(ref);
|
||||
|
||||
expect(result).toBe('/media/default/female-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve system-default avatar with neutral variant', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'neutral');
|
||||
const result = ResolutionStrategies.systemDefault(ref);
|
||||
|
||||
expect(result).toBe('/media/default/neutral-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve system-default logo', () => {
|
||||
const ref = MediaReference.createSystemDefault('logo');
|
||||
const result = ResolutionStrategies.systemDefault(ref);
|
||||
|
||||
expect(result).toBe('/media/default/logo.png');
|
||||
});
|
||||
|
||||
it('should return null for non-system-default reference', () => {
|
||||
const ref = MediaReference.createGenerated('team-123');
|
||||
const result = ResolutionStrategies.systemDefault(ref);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResolutionStrategies - Generated', () => {
|
||||
it('should resolve generated reference for team', () => {
|
||||
const ref = MediaReference.createGenerated('team-123');
|
||||
const result = ResolutionStrategies.generated(ref);
|
||||
|
||||
expect(result).toBe('/media/teams/123/logo');
|
||||
});
|
||||
|
||||
it('should resolve generated reference for league', () => {
|
||||
const ref = MediaReference.createGenerated('league-456');
|
||||
const result = ResolutionStrategies.generated(ref);
|
||||
|
||||
expect(result).toBe('/media/leagues/456/logo');
|
||||
});
|
||||
|
||||
it('should resolve generated reference for driver', () => {
|
||||
const ref = MediaReference.createGenerated('driver-789');
|
||||
const result = ResolutionStrategies.generated(ref);
|
||||
|
||||
expect(result).toBe('/media/avatar/789');
|
||||
});
|
||||
|
||||
it('should resolve generated reference for unknown type', () => {
|
||||
const ref = MediaReference.createGenerated('unknown-999');
|
||||
const result = ResolutionStrategies.generated(ref);
|
||||
|
||||
expect(result).toBe('/media/generated/unknown/999');
|
||||
});
|
||||
|
||||
it('should return null for generated reference without generationRequestId', () => {
|
||||
// Create a reference with missing generationRequestId
|
||||
const ref = MediaReference.createGenerated('valid-id');
|
||||
// Manually create an invalid reference
|
||||
const invalidRef = { type: 'generated' } as MediaReference;
|
||||
const result = ResolutionStrategies.generated(invalidRef);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-generated reference', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const result = ResolutionStrategies.generated(ref);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle generated reference with special characters in ID', () => {
|
||||
const ref = MediaReference.createGenerated('team-abc-123_XYZ');
|
||||
const result = ResolutionStrategies.generated(ref);
|
||||
|
||||
expect(result).toBe('/media/teams/abc-123_XYZ/logo');
|
||||
});
|
||||
|
||||
it('should handle generated reference with multiple hyphens', () => {
|
||||
const ref = MediaReference.createGenerated('team-abc-def-123');
|
||||
const result = ResolutionStrategies.generated(ref);
|
||||
|
||||
expect(result).toBe('/media/teams/abc-def-123/logo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResolutionStrategies - Uploaded', () => {
|
||||
it('should resolve uploaded reference', () => {
|
||||
const ref = MediaReference.createUploaded('media-456');
|
||||
const result = ResolutionStrategies.uploaded(ref);
|
||||
|
||||
expect(result).toBe('/media/uploaded/media-456');
|
||||
});
|
||||
|
||||
it('should return null for uploaded reference without mediaId', () => {
|
||||
// Create a reference with missing mediaId
|
||||
const ref = MediaReference.createUploaded('valid-id');
|
||||
// Manually create an invalid reference
|
||||
const invalidRef = { type: 'uploaded' } as MediaReference;
|
||||
const result = ResolutionStrategies.uploaded(invalidRef);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-uploaded reference', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const result = ResolutionStrategies.uploaded(ref);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle uploaded reference with special characters', () => {
|
||||
const ref = MediaReference.createUploaded('media-abc-123_XYZ');
|
||||
const result = ResolutionStrategies.uploaded(ref);
|
||||
|
||||
expect(result).toBe('/media/uploaded/media-abc-123_XYZ');
|
||||
});
|
||||
|
||||
it('should handle uploaded reference with very long ID', () => {
|
||||
const longId = 'a'.repeat(1000);
|
||||
const ref = MediaReference.createUploaded(longId);
|
||||
const result = ResolutionStrategies.uploaded(ref);
|
||||
|
||||
expect(result).toBe(`/media/uploaded/${longId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResolutionStrategies - None', () => {
|
||||
it('should return null for none reference', () => {
|
||||
const ref = MediaReference.createNone();
|
||||
const result = ResolutionStrategies.none(ref);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for any reference passed to none strategy', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const result = ResolutionStrategies.none(ref);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWithDefaults - Integration Tests', () => {
|
||||
it('should resolve system-default reference using resolveWithDefaults', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
expect(result).toBe('/media/default/neutral-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve system-default avatar with male variant using resolveWithDefaults', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'male');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
expect(result).toBe('/media/default/male-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve system-default logo using resolveWithDefaults', () => {
|
||||
const ref = MediaReference.createSystemDefault('logo');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
expect(result).toBe('/media/default/logo.png');
|
||||
});
|
||||
|
||||
it('should resolve generated reference using resolveWithDefaults', () => {
|
||||
const ref = MediaReference.createGenerated('team-123');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
expect(result).toBe('/media/teams/123/logo');
|
||||
});
|
||||
|
||||
it('should resolve uploaded reference using resolveWithDefaults', () => {
|
||||
const ref = MediaReference.createUploaded('media-456');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
expect(result).toBe('/media/uploaded/media-456');
|
||||
});
|
||||
|
||||
it('should resolve none reference using resolveWithDefaults', () => {
|
||||
const ref = MediaReference.createNone();
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle all reference types in sequence', () => {
|
||||
const refs = [
|
||||
MediaReference.createSystemDefault('avatar'),
|
||||
MediaReference.createSystemDefault('avatar', 'male'),
|
||||
MediaReference.createSystemDefault('logo'),
|
||||
MediaReference.createGenerated('team-123'),
|
||||
MediaReference.createGenerated('league-456'),
|
||||
MediaReference.createGenerated('driver-789'),
|
||||
MediaReference.createUploaded('media-456'),
|
||||
MediaReference.createNone(),
|
||||
];
|
||||
|
||||
const results = refs.map(ref => resolveWithDefaults(ref));
|
||||
|
||||
expect(results).toEqual([
|
||||
'/media/default/neutral-default-avatar.png',
|
||||
'/media/default/male-default-avatar.png',
|
||||
'/media/default/logo.png',
|
||||
'/media/teams/123/logo',
|
||||
'/media/leagues/456/logo',
|
||||
'/media/avatar/789',
|
||||
'/media/uploaded/media-456',
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMediaResolverPort Type Guard', () => {
|
||||
it('should return true for valid MediaResolverPort implementation', () => {
|
||||
const validResolver: MediaResolverPort = {
|
||||
resolve: async (ref: MediaReference): Promise<string | null> => {
|
||||
return '/test/path';
|
||||
},
|
||||
};
|
||||
|
||||
expect(isMediaResolverPort(validResolver)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(isMediaResolverPort(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isMediaResolverPort(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-object', () => {
|
||||
expect(isMediaResolverPort('string')).toBe(false);
|
||||
expect(isMediaResolverPort(123)).toBe(false);
|
||||
expect(isMediaResolverPort(true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object without resolve method', () => {
|
||||
const invalidResolver = {
|
||||
someOtherMethod: () => {},
|
||||
};
|
||||
|
||||
expect(isMediaResolverPort(invalidResolver)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object with resolve property but not a function', () => {
|
||||
const invalidResolver = {
|
||||
resolve: 'not a function',
|
||||
};
|
||||
|
||||
expect(isMediaResolverPort(invalidResolver)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object with resolve as non-function property', () => {
|
||||
const invalidResolver = {
|
||||
resolve: 123,
|
||||
};
|
||||
|
||||
expect(isMediaResolverPort(invalidResolver)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for object with resolve method and other properties', () => {
|
||||
const validResolver = {
|
||||
resolve: async (ref: MediaReference): Promise<string | null> => {
|
||||
return '/test/path';
|
||||
},
|
||||
extraProperty: 'value',
|
||||
anotherMethod: () => {},
|
||||
};
|
||||
|
||||
expect(isMediaResolverPort(validResolver)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Business Logic Decisions', () => {
|
||||
it('should make correct decision for system-default avatar without variant', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Decision: Should use neutral default avatar
|
||||
expect(result).toBe('/media/default/neutral-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should make correct decision for system-default avatar with specific variant', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'female');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Decision: Should use the specified variant
|
||||
expect(result).toBe('/media/default/female-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should make correct decision for generated team reference', () => {
|
||||
const ref = MediaReference.createGenerated('team-123');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Decision: Should resolve to team logo path
|
||||
expect(result).toBe('/media/teams/123/logo');
|
||||
});
|
||||
|
||||
it('should make correct decision for generated league reference', () => {
|
||||
const ref = MediaReference.createGenerated('league-456');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Decision: Should resolve to league logo path
|
||||
expect(result).toBe('/media/leagues/456/logo');
|
||||
});
|
||||
|
||||
it('should make correct decision for generated driver reference', () => {
|
||||
const ref = MediaReference.createGenerated('driver-789');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Decision: Should resolve to avatar path
|
||||
expect(result).toBe('/media/avatar/789');
|
||||
});
|
||||
|
||||
it('should make correct decision for uploaded reference', () => {
|
||||
const ref = MediaReference.createUploaded('media-456');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Decision: Should resolve to uploaded media path
|
||||
expect(result).toBe('/media/uploaded/media-456');
|
||||
});
|
||||
|
||||
it('should make correct decision for none reference', () => {
|
||||
const ref = MediaReference.createNone();
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Decision: Should return null (no media)
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should make correct decision for unknown generated type', () => {
|
||||
const ref = MediaReference.createGenerated('unknown-999');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Decision: Should fall back to generic generated path
|
||||
expect(result).toBe('/media/generated/unknown/999');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Error Handling', () => {
|
||||
it('should handle empty string IDs gracefully', () => {
|
||||
// MediaReference factory methods throw on empty strings
|
||||
// This tests that the strategies handle invalid refs gracefully
|
||||
const invalidRef = { type: 'generated' } as MediaReference;
|
||||
const result = ResolutionStrategies.generated(invalidRef);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle references with missing properties', () => {
|
||||
const invalidRef = { type: 'uploaded' } as MediaReference;
|
||||
const result = ResolutionStrategies.uploaded(invalidRef);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle very long IDs without performance issues', () => {
|
||||
const longId = 'a'.repeat(10000);
|
||||
const ref = MediaReference.createUploaded(longId);
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
expect(result).toBe(`/media/uploaded/${longId}`);
|
||||
});
|
||||
|
||||
it('should handle Unicode characters in IDs', () => {
|
||||
const ref = MediaReference.createUploaded('media-日本語-123');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
expect(result).toBe('/media/uploaded/media-日本語-123');
|
||||
});
|
||||
|
||||
it('should handle special characters in generated IDs', () => {
|
||||
const ref = MediaReference.createGenerated('team-abc_def-123');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
expect(result).toBe('/media/teams/abc_def-123/logo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path Format Consistency', () => {
|
||||
it('should maintain consistent path format for system-default', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Should start with /media/default/
|
||||
expect(result).toMatch(/^\/media\/default\//);
|
||||
});
|
||||
|
||||
it('should maintain consistent path format for generated team', () => {
|
||||
const ref = MediaReference.createGenerated('team-123');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Should start with /media/teams/
|
||||
expect(result).toMatch(/^\/media\/teams\//);
|
||||
});
|
||||
|
||||
it('should maintain consistent path format for generated league', () => {
|
||||
const ref = MediaReference.createGenerated('league-456');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Should start with /media/leagues/
|
||||
expect(result).toMatch(/^\/media\/leagues\//);
|
||||
});
|
||||
|
||||
it('should maintain consistent path format for generated driver', () => {
|
||||
const ref = MediaReference.createGenerated('driver-789');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Should start with /media/avatar/
|
||||
expect(result).toMatch(/^\/media\/avatar\//);
|
||||
});
|
||||
|
||||
it('should maintain consistent path format for uploaded', () => {
|
||||
const ref = MediaReference.createUploaded('media-456');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Should start with /media/uploaded/
|
||||
expect(result).toMatch(/^\/media\/uploaded\//);
|
||||
});
|
||||
|
||||
it('should maintain consistent path format for unknown generated type', () => {
|
||||
const ref = MediaReference.createGenerated('unknown-999');
|
||||
const result = resolveWithDefaults(ref);
|
||||
|
||||
// Should start with /media/generated/
|
||||
expect(result).toMatch(/^\/media\/generated\//);
|
||||
});
|
||||
});
|
||||
});
|
||||
57
core/racing/application/use-cases/DriverStatsUseCase.test.ts
Normal file
57
core/racing/application/use-cases/DriverStatsUseCase.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { DriverStatsUseCase, type DriverStats } from './DriverStatsUseCase';
|
||||
import type { ResultRepository } from '../../domain/repositories/ResultRepository';
|
||||
import type { StandingRepository } from '../../domain/repositories/StandingRepository';
|
||||
import type { DriverStatsRepository } from '../../domain/repositories/DriverStatsRepository';
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
|
||||
describe('DriverStatsUseCase', () => {
|
||||
const mockResultRepository = {} as ResultRepository;
|
||||
const mockStandingRepository = {} as StandingRepository;
|
||||
const mockDriverStatsRepository = {
|
||||
getDriverStats: vi.fn(),
|
||||
} as unknown as DriverStatsRepository;
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const useCase = new DriverStatsUseCase(
|
||||
mockResultRepository,
|
||||
mockStandingRepository,
|
||||
mockDriverStatsRepository,
|
||||
mockLogger
|
||||
);
|
||||
|
||||
it('should return driver stats when found', async () => {
|
||||
const mockStats: DriverStats = {
|
||||
rating: 1500,
|
||||
safetyRating: 4.5,
|
||||
sportsmanshipRating: 4.8,
|
||||
totalRaces: 10,
|
||||
wins: 2,
|
||||
podiums: 5,
|
||||
dnfs: 0,
|
||||
avgFinish: 3.5,
|
||||
bestFinish: 1,
|
||||
worstFinish: 8,
|
||||
consistency: 0.9,
|
||||
experienceLevel: 'Intermediate',
|
||||
overallRank: 42,
|
||||
};
|
||||
vi.mocked(mockDriverStatsRepository.getDriverStats).mockResolvedValue(mockStats);
|
||||
|
||||
const result = await useCase.getDriverStats('driver-1');
|
||||
|
||||
expect(result).toEqual(mockStats);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('Getting stats for driver driver-1');
|
||||
expect(mockDriverStatsRepository.getDriverStats).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
|
||||
it('should return null when stats are not found', async () => {
|
||||
vi.mocked(mockDriverStatsRepository.getDriverStats).mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.getDriverStats('non-existent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
43
core/racing/application/use-cases/GetDriverUseCase.test.ts
Normal file
43
core/racing/application/use-cases/GetDriverUseCase.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { GetDriverUseCase } from './GetDriverUseCase';
|
||||
import { Result } from '@core/shared/domain/Result';
|
||||
import type { DriverRepository } from '../../domain/repositories/DriverRepository';
|
||||
import type { Driver } from '../../domain/entities/Driver';
|
||||
|
||||
describe('GetDriverUseCase', () => {
|
||||
const mockDriverRepository = {
|
||||
findById: vi.fn(),
|
||||
} as unknown as DriverRepository;
|
||||
|
||||
const useCase = new GetDriverUseCase(mockDriverRepository);
|
||||
|
||||
it('should return a driver when found', async () => {
|
||||
const mockDriver = { id: 'driver-1', name: 'John Doe' } as unknown as Driver;
|
||||
vi.mocked(mockDriverRepository.findById).mockResolvedValue(mockDriver);
|
||||
|
||||
const result = await useCase.execute({ driverId: 'driver-1' });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBe(mockDriver);
|
||||
expect(mockDriverRepository.findById).toHaveBeenCalledWith('driver-1');
|
||||
});
|
||||
|
||||
it('should return null when driver is not found', async () => {
|
||||
vi.mocked(mockDriverRepository.findById).mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute({ driverId: 'non-existent' });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return an error when repository throws', async () => {
|
||||
const error = new Error('Repository error');
|
||||
vi.mocked(mockDriverRepository.findById).mockRejectedValue(error);
|
||||
|
||||
const result = await useCase.execute({ driverId: 'driver-1' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.error).toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { GetTeamsLeaderboardUseCase } from './GetTeamsLeaderboardUseCase';
|
||||
import { Result } from '@core/shared/domain/Result';
|
||||
import type { TeamRepository } from '../../domain/repositories/TeamRepository';
|
||||
import type { TeamMembershipRepository } from '../../domain/repositories/TeamMembershipRepository';
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
import type { Team } from '../../domain/entities/Team';
|
||||
|
||||
describe('GetTeamsLeaderboardUseCase', () => {
|
||||
const mockTeamRepository = {
|
||||
findAll: vi.fn(),
|
||||
} as unknown as TeamRepository;
|
||||
|
||||
const mockTeamMembershipRepository = {
|
||||
getTeamMembers: vi.fn(),
|
||||
} as unknown as TeamMembershipRepository;
|
||||
|
||||
const mockGetDriverStats = vi.fn();
|
||||
|
||||
const mockLogger = {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const useCase = new GetTeamsLeaderboardUseCase(
|
||||
mockTeamRepository,
|
||||
mockTeamMembershipRepository,
|
||||
mockGetDriverStats,
|
||||
mockLogger
|
||||
);
|
||||
|
||||
it('should return teams leaderboard with calculated stats', async () => {
|
||||
const mockTeam1 = { id: 'team-1', name: 'Team 1' } as unknown as Team;
|
||||
const mockTeam2 = { id: 'team-2', name: 'Team 2' } as unknown as Team;
|
||||
vi.mocked(mockTeamRepository.findAll).mockResolvedValue([mockTeam1, mockTeam2]);
|
||||
|
||||
vi.mocked(mockTeamMembershipRepository.getTeamMembers).mockImplementation(async (teamId) => {
|
||||
if (teamId === 'team-1') return [{ driverId: 'driver-1' }, { driverId: 'driver-2' }] as any;
|
||||
if (teamId === 'team-2') return [{ driverId: 'driver-3' }] as any;
|
||||
return [];
|
||||
});
|
||||
|
||||
mockGetDriverStats.mockImplementation((driverId) => {
|
||||
if (driverId === 'driver-1') return { rating: 1000, wins: 1, totalRaces: 5 };
|
||||
if (driverId === 'driver-2') return { rating: 2000, wins: 2, totalRaces: 10 };
|
||||
if (driverId === 'driver-3') return { rating: 1500, wins: 0, totalRaces: 2 };
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ leagueId: 'league-1' });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.items).toHaveLength(2);
|
||||
|
||||
const item1 = data.items.find(i => i.team.id === 'team-1');
|
||||
expect(item1?.rating).toBe(1500); // (1000 + 2000) / 2
|
||||
expect(item1?.totalWins).toBe(3);
|
||||
expect(item1?.totalRaces).toBe(15);
|
||||
|
||||
const item2 = data.items.find(i => i.team.id === 'team-2');
|
||||
expect(item2?.rating).toBe(1500);
|
||||
expect(item2?.totalWins).toBe(0);
|
||||
expect(item2?.totalRaces).toBe(2);
|
||||
|
||||
expect(data.topItems).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle teams with no members', async () => {
|
||||
const mockTeam = { id: 'team-empty', name: 'Empty Team' } as unknown as Team;
|
||||
vi.mocked(mockTeamRepository.findAll).mockResolvedValue([mockTeam]);
|
||||
vi.mocked(mockTeamMembershipRepository.getTeamMembers).mockResolvedValue([]);
|
||||
|
||||
const result = await useCase.execute({ leagueId: 'league-1' });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.items[0].rating).toBeNull();
|
||||
expect(data.items[0].performanceLevel).toBe('beginner');
|
||||
});
|
||||
|
||||
it('should return error when repository fails', async () => {
|
||||
vi.mocked(mockTeamRepository.findAll).mockRejectedValue(new Error('DB Error'));
|
||||
|
||||
const result = await useCase.execute({ leagueId: 'league-1' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.error.code).toBe('REPOSITORY_ERROR');
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
59
core/racing/application/use-cases/RankingUseCase.test.ts
Normal file
59
core/racing/application/use-cases/RankingUseCase.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { RankingUseCase, type DriverRanking } from './RankingUseCase';
|
||||
import type { StandingRepository } from '../../domain/repositories/StandingRepository';
|
||||
import type { DriverRepository } from '../../domain/repositories/DriverRepository';
|
||||
import type { DriverStatsRepository } from '../../domain/repositories/DriverStatsRepository';
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
|
||||
describe('RankingUseCase', () => {
|
||||
const mockStandingRepository = {} as StandingRepository;
|
||||
const mockDriverRepository = {} as DriverRepository;
|
||||
const mockDriverStatsRepository = {
|
||||
getAllStats: vi.fn(),
|
||||
} as unknown as DriverStatsRepository;
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
const useCase = new RankingUseCase(
|
||||
mockStandingRepository,
|
||||
mockDriverRepository,
|
||||
mockDriverStatsRepository,
|
||||
mockLogger
|
||||
);
|
||||
|
||||
it('should return all driver rankings', async () => {
|
||||
const mockStatsMap = new Map([
|
||||
['driver-1', { rating: 1500, wins: 2, totalRaces: 10, overallRank: 1 }],
|
||||
['driver-2', { rating: 1200, wins: 0, totalRaces: 5, overallRank: 2 }],
|
||||
]);
|
||||
vi.mocked(mockDriverStatsRepository.getAllStats).mockResolvedValue(mockStatsMap as any);
|
||||
|
||||
const result = await useCase.getAllDriverRankings();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual({
|
||||
driverId: 'driver-1',
|
||||
rating: 1500,
|
||||
wins: 2,
|
||||
totalRaces: 10,
|
||||
overallRank: 1,
|
||||
});
|
||||
expect(result).toContainEqual({
|
||||
driverId: 'driver-2',
|
||||
rating: 1200,
|
||||
wins: 0,
|
||||
totalRaces: 5,
|
||||
overallRank: 2,
|
||||
});
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith('Getting all driver rankings');
|
||||
});
|
||||
|
||||
it('should return empty array when no stats exist', async () => {
|
||||
vi.mocked(mockDriverStatsRepository.getAllStats).mockResolvedValue(new Map());
|
||||
|
||||
const result = await useCase.getAllDriverRankings();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
44
core/racing/application/utils/RaceResultGenerator.test.ts
Normal file
44
core/racing/application/utils/RaceResultGenerator.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { RaceResultGenerator } from './RaceResultGenerator';
|
||||
|
||||
describe('RaceResultGenerator', () => {
|
||||
it('should generate results for all drivers', () => {
|
||||
const raceId = 'race-1';
|
||||
const driverIds = ['d1', 'd2', 'd3'];
|
||||
const driverRatings = new Map([
|
||||
['d1', 2000],
|
||||
['d2', 1500],
|
||||
['d3', 1000],
|
||||
]);
|
||||
|
||||
const results = RaceResultGenerator.generateRaceResults(raceId, driverIds, driverRatings);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
const resultDriverIds = results.map(r => r.driverId.toString());
|
||||
expect(resultDriverIds).toContain('d1');
|
||||
expect(resultDriverIds).toContain('d2');
|
||||
expect(resultDriverIds).toContain('d3');
|
||||
|
||||
results.forEach(r => {
|
||||
expect(r.raceId.toString()).toBe(raceId);
|
||||
expect(r.position.toNumber()).toBeGreaterThan(0);
|
||||
expect(r.position.toNumber()).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
it('should provide incident descriptions', () => {
|
||||
expect(RaceResultGenerator.getIncidentDescription(0)).toBe('Clean race');
|
||||
expect(RaceResultGenerator.getIncidentDescription(1)).toBe('Track limits violation');
|
||||
expect(RaceResultGenerator.getIncidentDescription(2)).toBe('Contact with another car');
|
||||
expect(RaceResultGenerator.getIncidentDescription(3)).toBe('Off-track incident');
|
||||
expect(RaceResultGenerator.getIncidentDescription(4)).toBe('Collision requiring safety car');
|
||||
expect(RaceResultGenerator.getIncidentDescription(5)).toBe('5 incidents');
|
||||
});
|
||||
|
||||
it('should calculate incident penalty points', () => {
|
||||
expect(RaceResultGenerator.getIncidentPenaltyPoints(0)).toBe(0);
|
||||
expect(RaceResultGenerator.getIncidentPenaltyPoints(1)).toBe(0);
|
||||
expect(RaceResultGenerator.getIncidentPenaltyPoints(2)).toBe(2);
|
||||
expect(RaceResultGenerator.getIncidentPenaltyPoints(3)).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RaceResultGeneratorWithIncidents } from './RaceResultGeneratorWithIncidents';
|
||||
import { RaceIncidents } from '../../domain/value-objects/RaceIncidents';
|
||||
|
||||
describe('RaceResultGeneratorWithIncidents', () => {
|
||||
it('should generate results for all drivers', () => {
|
||||
const raceId = 'race-1';
|
||||
const driverIds = ['d1', 'd2'];
|
||||
const driverRatings = new Map([
|
||||
['d1', 2000],
|
||||
['d2', 1500],
|
||||
]);
|
||||
|
||||
const results = RaceResultGeneratorWithIncidents.generateRaceResults(raceId, driverIds, driverRatings);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
results.forEach(r => {
|
||||
expect(r.raceId.toString()).toBe(raceId);
|
||||
expect(r.incidents).toBeInstanceOf(RaceIncidents);
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate incident penalty points', () => {
|
||||
const incidents = new RaceIncidents([
|
||||
{ type: 'contact', lap: 1, description: 'desc', penaltyPoints: 2 },
|
||||
{ type: 'unsafe_rejoin', lap: 5, description: 'desc', penaltyPoints: 3 },
|
||||
]);
|
||||
|
||||
expect(RaceResultGeneratorWithIncidents.getIncidentPenaltyPoints(incidents)).toBe(5);
|
||||
});
|
||||
|
||||
it('should get incident description', () => {
|
||||
const incidents = new RaceIncidents([
|
||||
{ type: 'contact', lap: 1, description: 'desc', penaltyPoints: 2 },
|
||||
]);
|
||||
|
||||
const description = RaceResultGeneratorWithIncidents.getIncidentDescription(incidents);
|
||||
expect(description).toContain('1 incidents');
|
||||
});
|
||||
});
|
||||
75
core/racing/domain/services/ChampionshipAggregator.test.ts
Normal file
75
core/racing/domain/services/ChampionshipAggregator.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ChampionshipAggregator } from './ChampionshipAggregator';
|
||||
import type { DropScoreApplier } from './DropScoreApplier';
|
||||
import { Points } from '../value-objects/Points';
|
||||
|
||||
describe('ChampionshipAggregator', () => {
|
||||
const mockDropScoreApplier = {
|
||||
apply: vi.fn(),
|
||||
} as unknown as DropScoreApplier;
|
||||
|
||||
const aggregator = new ChampionshipAggregator(mockDropScoreApplier);
|
||||
|
||||
it('should aggregate points and sort standings by total points', () => {
|
||||
const seasonId = 'season-1';
|
||||
const championship = {
|
||||
id: 'champ-1',
|
||||
dropScorePolicy: { strategy: 'none' },
|
||||
} as any;
|
||||
|
||||
const eventPointsByEventId = {
|
||||
'event-1': [
|
||||
{
|
||||
participant: { id: 'p1', type: 'driver' },
|
||||
totalPoints: 10,
|
||||
basePoints: 10,
|
||||
bonusPoints: 0,
|
||||
penaltyPoints: 0
|
||||
},
|
||||
{
|
||||
participant: { id: 'p2', type: 'driver' },
|
||||
totalPoints: 20,
|
||||
basePoints: 20,
|
||||
bonusPoints: 0,
|
||||
penaltyPoints: 0
|
||||
},
|
||||
],
|
||||
'event-2': [
|
||||
{
|
||||
participant: { id: 'p1', type: 'driver' },
|
||||
totalPoints: 15,
|
||||
basePoints: 15,
|
||||
bonusPoints: 0,
|
||||
penaltyPoints: 0
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
vi.mocked(mockDropScoreApplier.apply).mockImplementation((policy, events) => {
|
||||
const total = events.reduce((sum, e) => sum + e.points, 0);
|
||||
return {
|
||||
totalPoints: total,
|
||||
counted: events,
|
||||
dropped: [],
|
||||
};
|
||||
});
|
||||
|
||||
const standings = aggregator.aggregate({
|
||||
seasonId,
|
||||
championship,
|
||||
eventPointsByEventId,
|
||||
});
|
||||
|
||||
expect(standings).toHaveLength(2);
|
||||
|
||||
// p1 should be first (10 + 15 = 25 points)
|
||||
expect(standings[0].participant.id).toBe('p1');
|
||||
expect(standings[0].totalPoints.toNumber()).toBe(25);
|
||||
expect(standings[0].position.toNumber()).toBe(1);
|
||||
|
||||
// p2 should be second (20 points)
|
||||
expect(standings[1].participant.id).toBe('p2');
|
||||
expect(standings[1].totalPoints.toNumber()).toBe(20);
|
||||
expect(standings[1].position.toNumber()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,7 @@ export class ChampionshipAggregator {
|
||||
totalPoints,
|
||||
resultsCounted,
|
||||
resultsDropped,
|
||||
position: 0,
|
||||
position: 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
74
core/racing/domain/services/SeasonScheduleGenerator.test.ts
Normal file
74
core/racing/domain/services/SeasonScheduleGenerator.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SeasonScheduleGenerator } from './SeasonScheduleGenerator';
|
||||
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
|
||||
import { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
|
||||
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
|
||||
import { WeekdaySet } from '../value-objects/WeekdaySet';
|
||||
import { LeagueTimezone } from '../value-objects/LeagueTimezone';
|
||||
import { MonthlyRecurrencePattern } from '../value-objects/MonthlyRecurrencePattern';
|
||||
|
||||
describe('SeasonScheduleGenerator', () => {
|
||||
it('should generate weekly slots', () => {
|
||||
const startDate = new Date(2024, 0, 1); // Monday, Jan 1st 2024
|
||||
const schedule = new SeasonSchedule({
|
||||
startDate,
|
||||
plannedRounds: 4,
|
||||
timeOfDay: new RaceTimeOfDay(20, 0),
|
||||
timezone: LeagueTimezone.create('UTC'),
|
||||
recurrence: RecurrenceStrategy.weekly(WeekdaySet.fromArray(['Mon'])),
|
||||
});
|
||||
|
||||
const slots = SeasonScheduleGenerator.generateSlots(schedule);
|
||||
|
||||
expect(slots).toHaveLength(4);
|
||||
expect(slots[0].roundNumber).toBe(1);
|
||||
expect(slots[0].scheduledAt.getHours()).toBe(20);
|
||||
expect(slots[0].scheduledAt.getMinutes()).toBe(0);
|
||||
expect(slots[0].scheduledAt.getFullYear()).toBe(2024);
|
||||
expect(slots[0].scheduledAt.getMonth()).toBe(0);
|
||||
expect(slots[0].scheduledAt.getDate()).toBe(1);
|
||||
|
||||
expect(slots[1].roundNumber).toBe(2);
|
||||
expect(slots[1].scheduledAt.getDate()).toBe(8);
|
||||
expect(slots[2].roundNumber).toBe(3);
|
||||
expect(slots[2].scheduledAt.getDate()).toBe(15);
|
||||
expect(slots[3].roundNumber).toBe(4);
|
||||
expect(slots[3].scheduledAt.getDate()).toBe(22);
|
||||
});
|
||||
|
||||
it('should generate slots every 2 weeks', () => {
|
||||
const startDate = new Date(2024, 0, 1);
|
||||
const schedule = new SeasonSchedule({
|
||||
startDate,
|
||||
plannedRounds: 2,
|
||||
timeOfDay: new RaceTimeOfDay(20, 0),
|
||||
timezone: LeagueTimezone.create('UTC'),
|
||||
recurrence: RecurrenceStrategy.everyNWeeks(2, WeekdaySet.fromArray(['Mon'])),
|
||||
});
|
||||
|
||||
const slots = SeasonScheduleGenerator.generateSlots(schedule);
|
||||
|
||||
expect(slots).toHaveLength(2);
|
||||
expect(slots[0].scheduledAt.getDate()).toBe(1);
|
||||
expect(slots[1].scheduledAt.getDate()).toBe(15);
|
||||
});
|
||||
|
||||
it('should generate monthly slots (nth weekday)', () => {
|
||||
const startDate = new Date(2024, 0, 1);
|
||||
const schedule = new SeasonSchedule({
|
||||
startDate,
|
||||
plannedRounds: 2,
|
||||
timeOfDay: new RaceTimeOfDay(20, 0),
|
||||
timezone: LeagueTimezone.create('UTC'),
|
||||
recurrence: RecurrenceStrategy.monthlyNthWeekday(MonthlyRecurrencePattern.create(1, 'Mon')),
|
||||
});
|
||||
|
||||
const slots = SeasonScheduleGenerator.generateSlots(schedule);
|
||||
|
||||
expect(slots).toHaveLength(2);
|
||||
expect(slots[0].scheduledAt.getMonth()).toBe(0);
|
||||
expect(slots[0].scheduledAt.getDate()).toBe(1);
|
||||
expect(slots[1].scheduledAt.getMonth()).toBe(1);
|
||||
expect(slots[1].scheduledAt.getDate()).toBe(5);
|
||||
});
|
||||
});
|
||||
50
core/racing/domain/services/SkillLevelService.test.ts
Normal file
50
core/racing/domain/services/SkillLevelService.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SkillLevelService } from './SkillLevelService';
|
||||
|
||||
describe('SkillLevelService', () => {
|
||||
describe('getSkillLevel', () => {
|
||||
it('should return pro for rating >= 3000', () => {
|
||||
expect(SkillLevelService.getSkillLevel(3000)).toBe('pro');
|
||||
expect(SkillLevelService.getSkillLevel(5000)).toBe('pro');
|
||||
});
|
||||
|
||||
it('should return advanced for rating >= 2500 and < 3000', () => {
|
||||
expect(SkillLevelService.getSkillLevel(2500)).toBe('advanced');
|
||||
expect(SkillLevelService.getSkillLevel(2999)).toBe('advanced');
|
||||
});
|
||||
|
||||
it('should return intermediate for rating >= 1800 and < 2500', () => {
|
||||
expect(SkillLevelService.getSkillLevel(1800)).toBe('intermediate');
|
||||
expect(SkillLevelService.getSkillLevel(2499)).toBe('intermediate');
|
||||
});
|
||||
|
||||
it('should return beginner for rating < 1800', () => {
|
||||
expect(SkillLevelService.getSkillLevel(1799)).toBe('beginner');
|
||||
expect(SkillLevelService.getSkillLevel(500)).toBe('beginner');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamPerformanceLevel', () => {
|
||||
it('should return beginner for null rating', () => {
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(null)).toBe('beginner');
|
||||
});
|
||||
|
||||
it('should return pro for rating >= 4500', () => {
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(4500)).toBe('pro');
|
||||
});
|
||||
|
||||
it('should return advanced for rating >= 3000 and < 4500', () => {
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(3000)).toBe('advanced');
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(4499)).toBe('advanced');
|
||||
});
|
||||
|
||||
it('should return intermediate for rating >= 2000 and < 3000', () => {
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(2000)).toBe('intermediate');
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(2999)).toBe('intermediate');
|
||||
});
|
||||
|
||||
it('should return beginner for rating < 2000', () => {
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(1999)).toBe('beginner');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AverageStrengthOfFieldCalculator } from './StrengthOfFieldCalculator';
|
||||
|
||||
describe('AverageStrengthOfFieldCalculator', () => {
|
||||
const calculator = new AverageStrengthOfFieldCalculator();
|
||||
|
||||
it('should calculate average SOF and round it', () => {
|
||||
const ratings = [
|
||||
{ driverId: 'd1', rating: 1500 },
|
||||
{ driverId: 'd2', rating: 2000 },
|
||||
{ driverId: 'd3', rating: 1750 },
|
||||
];
|
||||
|
||||
const sof = calculator.calculate(ratings);
|
||||
|
||||
expect(sof).toBe(1750);
|
||||
});
|
||||
|
||||
it('should handle rounding correctly', () => {
|
||||
const ratings = [
|
||||
{ driverId: 'd1', rating: 1000 },
|
||||
{ driverId: 'd2', rating: 1001 },
|
||||
];
|
||||
|
||||
const sof = calculator.calculate(ratings);
|
||||
|
||||
expect(sof).toBe(1001); // (1000 + 1001) / 2 = 1000.5 -> 1001
|
||||
});
|
||||
|
||||
it('should return null for empty ratings', () => {
|
||||
expect(calculator.calculate([])).toBeNull();
|
||||
});
|
||||
|
||||
it('should filter out non-positive ratings', () => {
|
||||
const ratings = [
|
||||
{ driverId: 'd1', rating: 1500 },
|
||||
{ driverId: 'd2', rating: 0 },
|
||||
{ driverId: 'd3', rating: -100 },
|
||||
];
|
||||
|
||||
const sof = calculator.calculate(ratings);
|
||||
|
||||
expect(sof).toBe(1500);
|
||||
});
|
||||
|
||||
it('should return null if all ratings are non-positive', () => {
|
||||
const ratings = [
|
||||
{ driverId: 'd1', rating: 0 },
|
||||
{ driverId: 'd2', rating: -500 },
|
||||
];
|
||||
|
||||
expect(calculator.calculate(ratings)).toBeNull();
|
||||
});
|
||||
});
|
||||
174
core/shared/domain/Entity.test.ts
Normal file
174
core/shared/domain/Entity.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Entity, EntityProps, EntityAlias } from './Entity';
|
||||
|
||||
// Concrete implementation for testing
|
||||
class TestEntity extends Entity<string> {
|
||||
constructor(id: string) {
|
||||
super(id);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Entity', () => {
|
||||
describe('EntityProps interface', () => {
|
||||
it('should have readonly id property', () => {
|
||||
const props: EntityProps<string> = { id: 'test-id' };
|
||||
expect(props.id).toBe('test-id');
|
||||
});
|
||||
|
||||
it('should support different id types', () => {
|
||||
const stringProps: EntityProps<string> = { id: 'test-id' };
|
||||
const numberProps: EntityProps<number> = { id: 123 };
|
||||
const uuidProps: EntityProps<string> = { id: '550e8400-e29b-41d4-a716-446655440000' };
|
||||
|
||||
expect(stringProps.id).toBe('test-id');
|
||||
expect(numberProps.id).toBe(123);
|
||||
expect(uuidProps.id).toBe('550e8400-e29b-41d4-a716-446655440000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entity class', () => {
|
||||
it('should have id property', () => {
|
||||
const entity = new TestEntity('entity-123');
|
||||
expect(entity.id).toBe('entity-123');
|
||||
});
|
||||
|
||||
it('should have equals method', () => {
|
||||
const entity1 = new TestEntity('entity-123');
|
||||
const entity2 = new TestEntity('entity-123');
|
||||
const entity3 = new TestEntity('entity-456');
|
||||
|
||||
expect(entity1.equals(entity2)).toBe(true);
|
||||
expect(entity1.equals(entity3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when comparing with undefined', () => {
|
||||
const entity = new TestEntity('entity-123');
|
||||
expect(entity.equals(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when comparing with null', () => {
|
||||
const entity = new TestEntity('entity-123');
|
||||
expect(entity.equals(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when comparing with entity of different type', () => {
|
||||
const entity1 = new TestEntity('entity-123');
|
||||
const entity2 = new TestEntity('entity-123');
|
||||
|
||||
// Even with same ID, if they're different entity types, equals should work
|
||||
// since it only compares IDs
|
||||
expect(entity1.equals(entity2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support numeric IDs', () => {
|
||||
class NumericEntity extends Entity<number> {
|
||||
constructor(id: number) {
|
||||
super(id);
|
||||
}
|
||||
}
|
||||
|
||||
const entity1 = new NumericEntity(123);
|
||||
const entity2 = new NumericEntity(123);
|
||||
const entity3 = new NumericEntity(456);
|
||||
|
||||
expect(entity1.id).toBe(123);
|
||||
expect(entity1.equals(entity2)).toBe(true);
|
||||
expect(entity1.equals(entity3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support UUID IDs', () => {
|
||||
const uuid1 = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const uuid2 = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const uuid3 = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
|
||||
|
||||
const entity1 = new TestEntity(uuid1);
|
||||
const entity2 = new TestEntity(uuid2);
|
||||
const entity3 = new TestEntity(uuid3);
|
||||
|
||||
expect(entity1.equals(entity2)).toBe(true);
|
||||
expect(entity1.equals(entity3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be immutable - id cannot be changed', () => {
|
||||
const entity = new TestEntity('entity-123');
|
||||
|
||||
// Try to change id (should not work in TypeScript, but testing runtime)
|
||||
// @ts-expect-error - Testing immutability
|
||||
entity.id = 'new-id';
|
||||
|
||||
// ID should remain unchanged
|
||||
expect(entity.id).toBe('entity-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EntityAlias type', () => {
|
||||
it('should be assignable to EntityProps', () => {
|
||||
const alias: EntityAlias<string> = { id: 'test-id' };
|
||||
expect(alias.id).toBe('test-id');
|
||||
});
|
||||
|
||||
it('should work with different ID types', () => {
|
||||
const stringAlias: EntityAlias<string> = { id: 'test' };
|
||||
const numberAlias: EntityAlias<number> = { id: 42 };
|
||||
const uuidAlias: EntityAlias<string> = { id: '550e8400-e29b-41d4-a716-446655440000' };
|
||||
|
||||
expect(stringAlias.id).toBe('test');
|
||||
expect(numberAlias.id).toBe(42);
|
||||
expect(uuidAlias.id).toBe('550e8400-e29b-41d4-a716-446655440000');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entity behavior', () => {
|
||||
it('should support entity identity', () => {
|
||||
// Entities are identified by their ID
|
||||
const entity1 = new TestEntity('same-id');
|
||||
const entity2 = new TestEntity('same-id');
|
||||
const entity3 = new TestEntity('different-id');
|
||||
|
||||
// Same ID = same identity
|
||||
expect(entity1.equals(entity2)).toBe(true);
|
||||
|
||||
// Different ID = different identity
|
||||
expect(entity1.equals(entity3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support entity reference equality', () => {
|
||||
const entity = new TestEntity('entity-123');
|
||||
|
||||
// Same instance should equal itself
|
||||
expect(entity.equals(entity)).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with complex ID types', () => {
|
||||
interface ComplexId {
|
||||
tenant: string;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
class ComplexEntity extends Entity<ComplexId> {
|
||||
constructor(id: ComplexId) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
equals(other?: Entity<ComplexId>): boolean {
|
||||
if (!other) return false;
|
||||
return (
|
||||
this.id.tenant === other.id.tenant &&
|
||||
this.id.sequence === other.id.sequence
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const id1: ComplexId = { tenant: 'org-a', sequence: 1 };
|
||||
const id2: ComplexId = { tenant: 'org-a', sequence: 1 };
|
||||
const id3: ComplexId = { tenant: 'org-b', sequence: 1 };
|
||||
|
||||
const entity1 = new ComplexEntity(id1);
|
||||
const entity2 = new ComplexEntity(id2);
|
||||
const entity3 = new ComplexEntity(id3);
|
||||
|
||||
expect(entity1.equals(entity2)).toBe(true);
|
||||
expect(entity1.equals(entity3)).toBe(false);
|
||||
});
|
||||
});
|
||||
118
core/shared/domain/ValueObject.test.ts
Normal file
118
core/shared/domain/ValueObject.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ValueObject, ValueObjectAlias } from './ValueObject';
|
||||
|
||||
// Concrete implementation for testing
|
||||
class TestValueObject implements ValueObject<{ name: string; value: number }> {
|
||||
readonly props: { name: string; value: number };
|
||||
|
||||
constructor(name: string, value: number) {
|
||||
this.props = { name, value };
|
||||
}
|
||||
|
||||
equals(other: ValueObject<{ name: string; value: number }>): boolean {
|
||||
return (
|
||||
this.props.name === other.props.name && this.props.value === other.props.value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ValueObject', () => {
|
||||
describe('ValueObject interface', () => {
|
||||
it('should have readonly props property', () => {
|
||||
const vo = new TestValueObject('test', 42);
|
||||
expect(vo.props).toEqual({ name: 'test', value: 42 });
|
||||
});
|
||||
|
||||
it('should have equals method', () => {
|
||||
const vo1 = new TestValueObject('test', 42);
|
||||
const vo2 = new TestValueObject('test', 42);
|
||||
const vo3 = new TestValueObject('different', 42);
|
||||
|
||||
expect(vo1.equals(vo2)).toBe(true);
|
||||
expect(vo1.equals(vo3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when comparing with undefined', () => {
|
||||
const vo = new TestValueObject('test', 42);
|
||||
// Testing that equals method handles undefined gracefully
|
||||
const result = vo.equals as any;
|
||||
expect(result(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when comparing with null', () => {
|
||||
const vo = new TestValueObject('test', 42);
|
||||
// Testing that equals method handles null gracefully
|
||||
const result = vo.equals as any;
|
||||
expect(result(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ValueObjectAlias type', () => {
|
||||
it('should be assignable to ValueObject', () => {
|
||||
const vo: ValueObjectAlias<{ name: string }> = {
|
||||
props: { name: 'test' },
|
||||
equals: (other) => other.props.name === 'test',
|
||||
};
|
||||
|
||||
expect(vo.props.name).toBe('test');
|
||||
expect(vo.equals(vo)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ValueObject behavior', () => {
|
||||
it('should support complex value objects', () => {
|
||||
interface AddressProps {
|
||||
street: string;
|
||||
city: string;
|
||||
zipCode: string;
|
||||
}
|
||||
|
||||
class Address implements ValueObject<AddressProps> {
|
||||
readonly props: AddressProps;
|
||||
|
||||
constructor(street: string, city: string, zipCode: string) {
|
||||
this.props = { street, city, zipCode };
|
||||
}
|
||||
|
||||
equals(other: ValueObject<AddressProps>): boolean {
|
||||
return (
|
||||
this.props.street === other.props.street &&
|
||||
this.props.city === other.props.city &&
|
||||
this.props.zipCode === other.props.zipCode
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const address1 = new Address('123 Main St', 'New York', '10001');
|
||||
const address2 = new Address('123 Main St', 'New York', '10001');
|
||||
const address3 = new Address('456 Oak Ave', 'Boston', '02101');
|
||||
|
||||
expect(address1.equals(address2)).toBe(true);
|
||||
expect(address1.equals(address3)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support immutable value objects', () => {
|
||||
class ImmutableValueObject implements ValueObject<{ readonly data: string[] }> {
|
||||
readonly props: { readonly data: string[] };
|
||||
|
||||
constructor(data: string[]) {
|
||||
this.props = { data: [...data] }; // Create a copy to ensure immutability
|
||||
}
|
||||
|
||||
equals(other: ValueObject<{ readonly data: string[] }>): boolean {
|
||||
return (
|
||||
this.props.data.length === other.props.data.length &&
|
||||
this.props.data.every((item, index) => item === other.props.data[index])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const vo1 = new ImmutableValueObject(['a', 'b', 'c']);
|
||||
const vo2 = new ImmutableValueObject(['a', 'b', 'c']);
|
||||
const vo3 = new ImmutableValueObject(['a', 'b', 'd']);
|
||||
|
||||
expect(vo1.equals(vo2)).toBe(true);
|
||||
expect(vo1.equals(vo3)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user