11 Commits

Author SHA1 Message Date
838f1602de adapter tests
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m51s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-24 21:39:59 +01:00
1e821c4a5c core tests 2026-01-24 19:19:16 +01:00
5da14b1b21 core tests 2026-01-24 12:18:31 +01:00
3bef15f3bd core tests 2026-01-24 02:05:43 +01:00
78c9c1ec75 core tests 2026-01-24 01:53:04 +01:00
5e12570442 Merge pull request 'core tests' (#1) from tests/core into main
Some checks failed
CI / lint-typecheck (push) Failing after 4m50s
CI / tests (push) Has been skipped
CI / contract-tests (push) Has been skipped
CI / e2e-tests (push) Has been skipped
CI / comment-pr (push) Has been skipped
CI / commit-types (push) Has been skipped
Reviewed-on: #1
2026-01-24 00:40:23 +00:00
648dce2193 core tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m43s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 18:52:22 +01:00
280d6fc199 core tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m51s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 18:44:01 +01:00
093eece3d7 core tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m47s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 18:20:33 +01:00
35cc7cf12b core tests 2026-01-22 18:05:30 +01:00
0a37454171 view data tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m51s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 17:28:09 +01:00
125 changed files with 24214 additions and 316 deletions

View File

@@ -0,0 +1,287 @@
import { TypeOrmPersistenceSchemaAdapter } from './TypeOrmPersistenceSchemaAdapterError';
describe('TypeOrmPersistenceSchemaAdapter', () => {
describe('constructor', () => {
// Given: valid parameters with all required fields
// When: TypeOrmPersistenceSchemaAdapter is instantiated
// Then: it should create an error with correct properties
it('should create an error with all required properties', () => {
// Given
const params = {
entityName: 'Achievement',
fieldName: 'name',
reason: 'not_string',
};
// When
const error = new TypeOrmPersistenceSchemaAdapter(params);
// Then
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaAdapter);
expect(error.name).toBe('TypeOrmPersistenceSchemaAdapter');
expect(error.entityName).toBe('Achievement');
expect(error.fieldName).toBe('name');
expect(error.reason).toBe('not_string');
expect(error.message).toBe('Schema validation failed for Achievement.name: not_string');
});
// Given: valid parameters with custom message
// When: TypeOrmPersistenceSchemaAdapter is instantiated
// Then: it should use the custom message
it('should use custom message when provided', () => {
// Given
const params = {
entityName: 'Achievement',
fieldName: 'name',
reason: 'not_string',
message: 'Custom error message',
};
// When
const error = new TypeOrmPersistenceSchemaAdapter(params);
// Then
expect(error.message).toBe('Custom error message');
});
// Given: parameters with empty string entityName
// When: TypeOrmPersistenceSchemaAdapter is instantiated
// Then: it should still create an error with the provided entityName
it('should handle empty string entityName', () => {
// Given
const params = {
entityName: '',
fieldName: 'name',
reason: 'not_string',
};
// When
const error = new TypeOrmPersistenceSchemaAdapter(params);
// Then
expect(error.entityName).toBe('');
expect(error.message).toBe('Schema validation failed for .name: not_string');
});
// Given: parameters with empty string fieldName
// When: TypeOrmPersistenceSchemaAdapter is instantiated
// Then: it should still create an error with the provided fieldName
it('should handle empty string fieldName', () => {
// Given
const params = {
entityName: 'Achievement',
fieldName: '',
reason: 'not_string',
};
// When
const error = new TypeOrmPersistenceSchemaAdapter(params);
// Then
expect(error.fieldName).toBe('');
expect(error.message).toBe('Schema validation failed for Achievement.: not_string');
});
// Given: parameters with empty string reason
// When: TypeOrmPersistenceSchemaAdapter is instantiated
// Then: it should still create an error with the provided reason
it('should handle empty string reason', () => {
// Given
const params = {
entityName: 'Achievement',
fieldName: 'name',
reason: '',
};
// When
const error = new TypeOrmPersistenceSchemaAdapter(params);
// Then
expect(error.reason).toBe('');
expect(error.message).toBe('Schema validation failed for Achievement.name: ');
});
});
describe('error details shape', () => {
// Given: an error instance
// When: checking the error structure
// Then: it should have the correct shape with entityName, fieldName, and reason
it('should have correct error details shape', () => {
// Given
const error = new TypeOrmPersistenceSchemaAdapter({
entityName: 'UserAchievement',
fieldName: 'userId',
reason: 'empty_string',
});
// When & Then
expect(error).toHaveProperty('entityName');
expect(error).toHaveProperty('fieldName');
expect(error).toHaveProperty('reason');
expect(error).toHaveProperty('message');
expect(error).toHaveProperty('name');
});
// Given: an error instance
// When: checking the error is an instance of Error
// Then: it should be an instance of Error
it('should be an instance of Error', () => {
// Given
const error = new TypeOrmPersistenceSchemaAdapter({
entityName: 'Achievement',
fieldName: 'points',
reason: 'not_integer',
});
// When & Then
expect(error).toBeInstanceOf(Error);
});
// Given: an error instance
// When: checking the error name
// Then: it should be 'TypeOrmPersistenceSchemaAdapter'
it('should have correct error name', () => {
// Given
const error = new TypeOrmPersistenceSchemaAdapter({
entityName: 'Achievement',
fieldName: 'category',
reason: 'invalid_enum_value',
});
// When & Then
expect(error.name).toBe('TypeOrmPersistenceSchemaAdapter');
});
});
describe('error message format', () => {
// Given: an error with standard parameters
// When: checking the error message
// Then: it should follow the standard format
it('should follow standard message format', () => {
// Given
const error = new TypeOrmPersistenceSchemaAdapter({
entityName: 'Achievement',
fieldName: 'requirements[0].type',
reason: 'not_string',
});
// When & Then
expect(error.message).toBe('Schema validation failed for Achievement.requirements[0].type: not_string');
});
// Given: an error with nested field name
// When: checking the error message
// Then: it should include the nested field path
it('should include nested field path in message', () => {
// Given
const error = new TypeOrmPersistenceSchemaAdapter({
entityName: 'Achievement',
fieldName: 'requirements[0].operator',
reason: 'invalid_enum_value',
});
// When & Then
expect(error.message).toBe('Schema validation failed for Achievement.requirements[0].operator: invalid_enum_value');
});
// Given: an error with custom message
// When: checking the error message
// Then: it should use the custom message
it('should use custom message when provided', () => {
// Given
const error = new TypeOrmPersistenceSchemaAdapter({
entityName: 'UserAchievement',
fieldName: 'earnedAt',
reason: 'invalid_date',
message: 'The earnedAt field must be a valid date',
});
// When & Then
expect(error.message).toBe('The earnedAt field must be a valid date');
});
});
describe('error property immutability', () => {
// Given: an error instance
// When: checking the properties
// Then: properties should be defined and accessible
it('should have defined properties', () => {
// Given
const error = new TypeOrmPersistenceSchemaAdapter({
entityName: 'Achievement',
fieldName: 'name',
reason: 'not_string',
});
// When & Then
expect(error.entityName).toBe('Achievement');
expect(error.fieldName).toBe('name');
expect(error.reason).toBe('not_string');
});
// Given: an error instance
// When: trying to modify properties
// Then: properties can be modified (TypeScript readonly doesn't enforce runtime immutability)
it('should allow property modification (TypeScript readonly is compile-time only)', () => {
// Given
const error = new TypeOrmPersistenceSchemaAdapter({
entityName: 'Achievement',
fieldName: 'name',
reason: 'not_string',
});
// When
(error as any).entityName = 'NewEntity';
(error as any).fieldName = 'newField';
(error as any).reason = 'new_reason';
// Then
expect(error.entityName).toBe('NewEntity');
expect(error.fieldName).toBe('newField');
expect(error.reason).toBe('new_reason');
});
});
describe('error serialization', () => {
// Given: an error instance
// When: converting to string
// Then: it should include the error message
it('should serialize to string with message', () => {
// Given
const error = new TypeOrmPersistenceSchemaAdapter({
entityName: 'Achievement',
fieldName: 'name',
reason: 'not_string',
});
// When
const stringRepresentation = error.toString();
// Then
expect(stringRepresentation).toContain('TypeOrmPersistenceSchemaAdapter');
expect(stringRepresentation).toContain('Schema validation failed for Achievement.name: not_string');
});
// Given: an error instance
// When: converting to JSON
// Then: it should include all error properties
it('should serialize to JSON with all properties', () => {
// Given
const error = new TypeOrmPersistenceSchemaAdapter({
entityName: 'Achievement',
fieldName: 'name',
reason: 'not_string',
});
// When
const jsonRepresentation = JSON.parse(JSON.stringify(error));
// Then
expect(jsonRepresentation).toHaveProperty('entityName', 'Achievement');
expect(jsonRepresentation).toHaveProperty('fieldName', 'name');
expect(jsonRepresentation).toHaveProperty('reason', 'not_string');
expect(jsonRepresentation).toHaveProperty('message', 'Schema validation failed for Achievement.name: not_string');
expect(jsonRepresentation).toHaveProperty('name', 'TypeOrmPersistenceSchemaAdapter');
});
});
});

View File

@@ -0,0 +1,639 @@
import { Achievement, AchievementCategory, AchievementRequirement } from '@core/identity/domain/entities/Achievement';
import { UserAchievement } from '@core/identity/domain/entities/UserAchievement';
import { AchievementOrmEntity } from '../entities/AchievementOrmEntity';
import { UserAchievementOrmEntity } from '../entities/UserAchievementOrmEntity';
import { TypeOrmPersistenceSchemaAdapter } from '../errors/TypeOrmPersistenceSchemaAdapterError';
import { AchievementOrmMapper } from './AchievementOrmMapper';
describe('AchievementOrmMapper', () => {
let mapper: AchievementOrmMapper;
beforeEach(() => {
mapper = new AchievementOrmMapper();
});
describe('toOrmEntity', () => {
// Given: a valid Achievement domain entity
// When: toOrmEntity is called
// Then: it should return a properly mapped AchievementOrmEntity
it('should map Achievement domain entity to ORM entity', () => {
// Given
const achievement = Achievement.create({
id: 'ach-123',
name: 'First Race',
description: 'Complete your first race',
category: 'driver' as AchievementCategory,
rarity: 'common',
points: 10,
requirements: [
{ type: 'races_completed', value: 1, operator: '>=' } as AchievementRequirement,
],
isSecret: false,
});
// When
const result = mapper.toOrmEntity(achievement);
// Then
expect(result).toBeInstanceOf(AchievementOrmEntity);
expect(result.id).toBe('ach-123');
expect(result.name).toBe('First Race');
expect(result.description).toBe('Complete your first race');
expect(result.category).toBe('driver');
expect(result.rarity).toBe('common');
expect(result.points).toBe(10);
expect(result.requirements).toEqual([
{ type: 'races_completed', value: 1, operator: '>=' },
]);
expect(result.isSecret).toBe(false);
expect(result.createdAt).toBeInstanceOf(Date);
});
// Given: an Achievement with optional iconUrl
// When: toOrmEntity is called
// Then: it should map iconUrl correctly (or null if not provided)
it('should map Achievement with iconUrl to ORM entity', () => {
// Given
const achievement = Achievement.create({
id: 'ach-456',
name: 'Champion',
description: 'Win a championship',
category: 'driver' as AchievementCategory,
rarity: 'legendary',
points: 100,
requirements: [
{ type: 'championships_won', value: 1, operator: '>=' } as AchievementRequirement,
],
isSecret: false,
iconUrl: 'https://example.com/icon.png',
});
// When
const result = mapper.toOrmEntity(achievement);
// Then
expect(result.iconUrl).toBe('https://example.com/icon.png');
});
// Given: an Achievement without iconUrl
// When: toOrmEntity is called
// Then: it should map iconUrl to null
it('should map Achievement without iconUrl to null in ORM entity', () => {
// Given
const achievement = Achievement.create({
id: 'ach-789',
name: 'Clean Race',
description: 'Complete a race without incidents',
category: 'driver' as AchievementCategory,
rarity: 'uncommon',
points: 25,
requirements: [
{ type: 'clean_races', value: 1, operator: '>=' } as AchievementRequirement,
],
isSecret: false,
});
// When
const result = mapper.toOrmEntity(achievement);
// Then
expect(result.iconUrl).toBeNull();
});
});
describe('toDomain', () => {
// Given: a valid AchievementOrmEntity
// When: toDomain is called
// Then: it should return a properly mapped Achievement domain entity
it('should map AchievementOrmEntity to domain entity', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-123';
entity.name = 'First Race';
entity.description = 'Complete your first race';
entity.category = 'driver';
entity.rarity = 'common';
entity.points = 10;
entity.requirements = [
{ type: 'races_completed', value: 1, operator: '>=' },
];
entity.isSecret = false;
entity.createdAt = new Date('2024-01-01');
// When
const result = mapper.toDomain(entity);
// Then
expect(result).toBeInstanceOf(Achievement);
expect(result.id).toBe('ach-123');
expect(result.name).toBe('First Race');
expect(result.description).toBe('Complete your first race');
expect(result.category).toBe('driver');
expect(result.rarity).toBe('common');
expect(result.points).toBe(10);
expect(result.requirements).toEqual([
{ type: 'races_completed', value: 1, operator: '>=' },
]);
expect(result.isSecret).toBe(false);
expect(result.createdAt).toEqual(new Date('2024-01-01'));
});
// Given: an AchievementOrmEntity with iconUrl
// When: toDomain is called
// Then: it should map iconUrl correctly
it('should map AchievementOrmEntity with iconUrl to domain entity', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-456';
entity.name = 'Champion';
entity.description = 'Win a championship';
entity.category = 'driver';
entity.rarity = 'legendary';
entity.points = 100;
entity.requirements = [
{ type: 'championships_won', value: 1, operator: '>=' },
];
entity.isSecret = false;
entity.iconUrl = 'https://example.com/icon.png';
entity.createdAt = new Date('2024-01-01');
// When
const result = mapper.toDomain(entity);
// Then
expect(result.iconUrl).toBe('https://example.com/icon.png');
});
// Given: an AchievementOrmEntity with null iconUrl
// When: toDomain is called
// Then: it should map iconUrl to empty string
it('should map AchievementOrmEntity with null iconUrl to empty string in domain entity', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-789';
entity.name = 'Clean Race';
entity.description = 'Complete a race without incidents';
entity.category = 'driver';
entity.rarity = 'uncommon';
entity.points = 25;
entity.requirements = [
{ type: 'clean_races', value: 1, operator: '>=' },
];
entity.isSecret = false;
entity.iconUrl = null;
entity.createdAt = new Date('2024-01-01');
// When
const result = mapper.toDomain(entity);
// Then
expect(result.iconUrl).toBe('');
});
// Given: an AchievementOrmEntity with invalid id (empty string)
// When: toDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when id is empty string', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = '';
entity.name = 'First Race';
entity.description = 'Complete your first race';
entity.category = 'driver';
entity.rarity = 'common';
entity.points = 10;
entity.requirements = [
{ type: 'races_completed', value: 1, operator: '>=' },
];
entity.isSecret = false;
entity.createdAt = new Date('2024-01-01');
// When & Then
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'Achievement',
fieldName: 'id',
reason: 'empty_string',
})
);
});
// Given: an AchievementOrmEntity with invalid name (not a string)
// When: toDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when name is not a string', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-123';
entity.name = 123 as any;
entity.description = 'Complete your first race';
entity.category = 'driver';
entity.rarity = 'common';
entity.points = 10;
entity.requirements = [
{ type: 'races_completed', value: 1, operator: '>=' },
];
entity.isSecret = false;
entity.createdAt = new Date('2024-01-01');
// When & Then
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'Achievement',
fieldName: 'name',
reason: 'not_string',
})
);
});
// Given: an AchievementOrmEntity with invalid category (not in valid categories)
// When: toDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when category is invalid', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-123';
entity.name = 'First Race';
entity.description = 'Complete your first race';
entity.category = 'invalid_category' as any;
entity.rarity = 'common';
entity.points = 10;
entity.requirements = [
{ type: 'races_completed', value: 1, operator: '>=' },
];
entity.isSecret = false;
entity.createdAt = new Date('2024-01-01');
// When & Then
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'Achievement',
fieldName: 'category',
reason: 'invalid_enum_value',
})
);
});
// Given: an AchievementOrmEntity with invalid points (not an integer)
// When: toDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when points is not an integer', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-123';
entity.name = 'First Race';
entity.description = 'Complete your first race';
entity.category = 'driver';
entity.rarity = 'common';
entity.points = 10.5;
entity.requirements = [
{ type: 'races_completed', value: 1, operator: '>=' },
];
entity.isSecret = false;
entity.createdAt = new Date('2024-01-01');
// When & Then
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'Achievement',
fieldName: 'points',
reason: 'not_integer',
})
);
});
// Given: an AchievementOrmEntity with invalid requirements (not an array)
// When: toDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when requirements is not an array', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-123';
entity.name = 'First Race';
entity.description = 'Complete your first race';
entity.category = 'driver';
entity.rarity = 'common';
entity.points = 10;
entity.requirements = 'not_an_array' as any;
entity.isSecret = false;
entity.createdAt = new Date('2024-01-01');
// When & Then
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'Achievement',
fieldName: 'requirements',
reason: 'not_array',
})
);
});
// Given: an AchievementOrmEntity with invalid requirement object (null)
// When: toDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when requirement is null', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-123';
entity.name = 'First Race';
entity.description = 'Complete your first race';
entity.category = 'driver';
entity.rarity = 'common';
entity.points = 10;
entity.requirements = [null as any];
entity.isSecret = false;
entity.createdAt = new Date('2024-01-01');
// When & Then
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'Achievement',
fieldName: 'requirements[0]',
reason: 'invalid_requirement_object',
})
);
});
// Given: an AchievementOrmEntity with invalid requirement type (not a string)
// When: toDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when requirement type is not a string', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-123';
entity.name = 'First Race';
entity.description = 'Complete your first race';
entity.category = 'driver';
entity.rarity = 'common';
entity.points = 10;
entity.requirements = [{ type: 123, value: 1, operator: '>=' } as any];
entity.isSecret = false;
entity.createdAt = new Date('2024-01-01');
// When & Then
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'Achievement',
fieldName: 'requirements[0].type',
reason: 'not_string',
})
);
});
// Given: an AchievementOrmEntity with invalid requirement operator (not in valid operators)
// When: toDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when requirement operator is invalid', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-123';
entity.name = 'First Race';
entity.description = 'Complete your first race';
entity.category = 'driver';
entity.rarity = 'common';
entity.points = 10;
entity.requirements = [{ type: 'races_completed', value: 1, operator: 'invalid' } as any];
entity.isSecret = false;
entity.createdAt = new Date('2024-01-01');
// When & Then
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'Achievement',
fieldName: 'requirements[0].operator',
reason: 'invalid_enum_value',
})
);
});
// Given: an AchievementOrmEntity with invalid createdAt (not a Date)
// When: toDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when createdAt is not a Date', () => {
// Given
const entity = new AchievementOrmEntity();
entity.id = 'ach-123';
entity.name = 'First Race';
entity.description = 'Complete your first race';
entity.category = 'driver';
entity.rarity = 'common';
entity.points = 10;
entity.requirements = [
{ type: 'races_completed', value: 1, operator: '>=' },
];
entity.isSecret = false;
entity.createdAt = 'not_a_date' as any;
// When & Then
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'Achievement',
fieldName: 'createdAt',
reason: 'not_date',
})
);
});
});
describe('toUserAchievementOrmEntity', () => {
// Given: a valid UserAchievement domain entity
// When: toUserAchievementOrmEntity is called
// Then: it should return a properly mapped UserAchievementOrmEntity
it('should map UserAchievement domain entity to ORM entity', () => {
// Given
const userAchievement = UserAchievement.create({
id: 'ua-123',
userId: 'user-456',
achievementId: 'ach-789',
earnedAt: new Date('2024-01-01'),
progress: 50,
});
// When
const result = mapper.toUserAchievementOrmEntity(userAchievement);
// Then
expect(result).toBeInstanceOf(UserAchievementOrmEntity);
expect(result.id).toBe('ua-123');
expect(result.userId).toBe('user-456');
expect(result.achievementId).toBe('ach-789');
expect(result.earnedAt).toEqual(new Date('2024-01-01'));
expect(result.progress).toBe(50);
expect(result.notifiedAt).toBeNull();
});
// Given: a UserAchievement with notifiedAt
// When: toUserAchievementOrmEntity is called
// Then: it should map notifiedAt correctly
it('should map UserAchievement with notifiedAt to ORM entity', () => {
// Given
const userAchievement = UserAchievement.create({
id: 'ua-123',
userId: 'user-456',
achievementId: 'ach-789',
earnedAt: new Date('2024-01-01'),
progress: 100,
notifiedAt: new Date('2024-01-02'),
});
// When
const result = mapper.toUserAchievementOrmEntity(userAchievement);
// Then
expect(result.notifiedAt).toEqual(new Date('2024-01-02'));
});
});
describe('toUserAchievementDomain', () => {
// Given: a valid UserAchievementOrmEntity
// When: toUserAchievementDomain is called
// Then: it should return a properly mapped UserAchievement domain entity
it('should map UserAchievementOrmEntity to domain entity', () => {
// Given
const entity = new UserAchievementOrmEntity();
entity.id = 'ua-123';
entity.userId = 'user-456';
entity.achievementId = 'ach-789';
entity.earnedAt = new Date('2024-01-01');
entity.progress = 50;
entity.notifiedAt = null;
// When
const result = mapper.toUserAchievementDomain(entity);
// Then
expect(result).toBeInstanceOf(UserAchievement);
expect(result.id).toBe('ua-123');
expect(result.userId).toBe('user-456');
expect(result.achievementId).toBe('ach-789');
expect(result.earnedAt).toEqual(new Date('2024-01-01'));
expect(result.progress).toBe(50);
expect(result.notifiedAt).toBeUndefined();
});
// Given: a UserAchievementOrmEntity with notifiedAt
// When: toUserAchievementDomain is called
// Then: it should map notifiedAt correctly
it('should map UserAchievementOrmEntity with notifiedAt to domain entity', () => {
// Given
const entity = new UserAchievementOrmEntity();
entity.id = 'ua-123';
entity.userId = 'user-456';
entity.achievementId = 'ach-789';
entity.earnedAt = new Date('2024-01-01');
entity.progress = 100;
entity.notifiedAt = new Date('2024-01-02');
// When
const result = mapper.toUserAchievementDomain(entity);
// Then
expect(result.notifiedAt).toEqual(new Date('2024-01-02'));
});
// Given: a UserAchievementOrmEntity with invalid id (empty string)
// When: toUserAchievementDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when id is empty string', () => {
// Given
const entity = new UserAchievementOrmEntity();
entity.id = '';
entity.userId = 'user-456';
entity.achievementId = 'ach-789';
entity.earnedAt = new Date('2024-01-01');
entity.progress = 50;
entity.notifiedAt = null;
// When & Then
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'UserAchievement',
fieldName: 'id',
reason: 'empty_string',
})
);
});
// Given: a UserAchievementOrmEntity with invalid userId (not a string)
// When: toUserAchievementDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when userId is not a string', () => {
// Given
const entity = new UserAchievementOrmEntity();
entity.id = 'ua-123';
entity.userId = 123 as any;
entity.achievementId = 'ach-789';
entity.earnedAt = new Date('2024-01-01');
entity.progress = 50;
entity.notifiedAt = null;
// When & Then
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'UserAchievement',
fieldName: 'userId',
reason: 'not_string',
})
);
});
// Given: a UserAchievementOrmEntity with invalid progress (not an integer)
// When: toUserAchievementDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when progress is not an integer', () => {
// Given
const entity = new UserAchievementOrmEntity();
entity.id = 'ua-123';
entity.userId = 'user-456';
entity.achievementId = 'ach-789';
entity.earnedAt = new Date('2024-01-01');
entity.progress = 50.5;
entity.notifiedAt = null;
// When & Then
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'UserAchievement',
fieldName: 'progress',
reason: 'not_integer',
})
);
});
// Given: a UserAchievementOrmEntity with invalid earnedAt (not a Date)
// When: toUserAchievementDomain is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
it('should throw TypeOrmPersistenceSchemaAdapter when earnedAt is not a Date', () => {
// Given
const entity = new UserAchievementOrmEntity();
entity.id = 'ua-123';
entity.userId = 'user-456';
entity.achievementId = 'ach-789';
entity.earnedAt = 'not_a_date' as any;
entity.progress = 50;
entity.notifiedAt = null;
// When & Then
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
expect.objectContaining({
entityName: 'UserAchievement',
fieldName: 'earnedAt',
reason: 'not_date',
})
);
});
});
});

View File

@@ -111,7 +111,11 @@ export class AchievementOrmMapper {
assertNonEmptyString(entityName, 'achievementId', entity.achievementId); assertNonEmptyString(entityName, 'achievementId', entity.achievementId);
assertInteger(entityName, 'progress', entity.progress); assertInteger(entityName, 'progress', entity.progress);
assertDate(entityName, 'earnedAt', entity.earnedAt); assertDate(entityName, 'earnedAt', entity.earnedAt);
assertOptionalStringOrNull(entityName, 'notifiedAt', entity.notifiedAt);
// Validate notifiedAt (Date | null)
if (entity.notifiedAt !== null) {
assertDate(entityName, 'notifiedAt', entity.notifiedAt);
}
try { try {
return UserAchievement.create({ return UserAchievement.create({

View File

@@ -0,0 +1,808 @@
import { vi } from 'vitest';
import { DataSource, Repository } from 'typeorm';
import { Achievement } from '@core/identity/domain/entities/Achievement';
import { UserAchievement } from '@core/identity/domain/entities/UserAchievement';
import { AchievementOrmEntity } from '../entities/AchievementOrmEntity';
import { UserAchievementOrmEntity } from '../entities/UserAchievementOrmEntity';
import { AchievementOrmMapper } from '../mappers/AchievementOrmMapper';
import { TypeOrmAchievementRepository } from './TypeOrmAchievementRepository';
describe('TypeOrmAchievementRepository', () => {
let mockDataSource: { getRepository: ReturnType<typeof vi.fn> };
let mockAchievementRepo: { findOne: ReturnType<typeof vi.fn>; find: ReturnType<typeof vi.fn>; save: ReturnType<typeof vi.fn> };
let mockUserAchievementRepo: { findOne: ReturnType<typeof vi.fn>; find: ReturnType<typeof vi.fn>; save: ReturnType<typeof vi.fn> };
let mockMapper: { toOrmEntity: ReturnType<typeof vi.fn>; toDomain: ReturnType<typeof vi.fn>; toUserAchievementOrmEntity: ReturnType<typeof vi.fn>; toUserAchievementDomain: ReturnType<typeof vi.fn> };
let repository: TypeOrmAchievementRepository;
beforeEach(() => {
// Given: mocked TypeORM DataSource and repositories
mockAchievementRepo = {
findOne: vi.fn(),
find: vi.fn(),
save: vi.fn(),
};
mockUserAchievementRepo = {
findOne: vi.fn(),
find: vi.fn(),
save: vi.fn(),
};
mockDataSource = {
getRepository: vi.fn((entityClass) => {
if (entityClass === AchievementOrmEntity) {
return mockAchievementRepo;
}
if (entityClass === UserAchievementOrmEntity) {
return mockUserAchievementRepo;
}
throw new Error('Unknown entity class');
}),
};
mockMapper = {
toOrmEntity: vi.fn(),
toDomain: vi.fn(),
toUserAchievementOrmEntity: vi.fn(),
toUserAchievementDomain: vi.fn(),
};
// When: repository is instantiated with mocked dependencies
repository = new TypeOrmAchievementRepository(mockDataSource as any, mockMapper as any);
});
describe('DI Boundary - Constructor', () => {
// Given: both dependencies provided
// When: repository is instantiated
// Then: it should create repository successfully
it('should create repository with valid dependencies', () => {
// Given & When & Then
expect(repository).toBeInstanceOf(TypeOrmAchievementRepository);
});
// Given: repository instance
// When: checking repository properties
// Then: it should have injected dependencies
it('should have injected dependencies', () => {
// Given & When & Then
expect((repository as any).dataSource).toBe(mockDataSource);
expect((repository as any).mapper).toBe(mockMapper);
});
// Given: repository instance
// When: checking repository methods
// Then: it should have all required methods
it('should have all required repository methods', () => {
// Given & When & Then
expect(repository.findAchievementById).toBeDefined();
expect(repository.findAllAchievements).toBeDefined();
expect(repository.findAchievementsByCategory).toBeDefined();
expect(repository.createAchievement).toBeDefined();
expect(repository.findUserAchievementById).toBeDefined();
expect(repository.findUserAchievementsByUserId).toBeDefined();
expect(repository.findUserAchievementByUserAndAchievement).toBeDefined();
expect(repository.hasUserEarnedAchievement).toBeDefined();
expect(repository.createUserAchievement).toBeDefined();
expect(repository.updateUserAchievement).toBeDefined();
expect(repository.getAchievementLeaderboard).toBeDefined();
expect(repository.getUserAchievementStats).toBeDefined();
});
});
describe('findAchievementById', () => {
// Given: an achievement exists in the database
// When: findAchievementById is called
// Then: it should return the achievement domain entity
it('should return achievement when found', async () => {
// Given
const achievementId = 'ach-123';
const ormEntity = new AchievementOrmEntity();
ormEntity.id = achievementId;
ormEntity.name = 'First Race';
ormEntity.description = 'Complete your first race';
ormEntity.category = 'driver';
ormEntity.rarity = 'common';
ormEntity.points = 10;
ormEntity.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
ormEntity.isSecret = false;
ormEntity.createdAt = new Date('2024-01-01');
const domainEntity = Achievement.create({
id: achievementId,
name: 'First Race',
description: 'Complete your first race',
category: 'driver',
rarity: 'common',
points: 10,
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
isSecret: false,
});
mockAchievementRepo.findOne.mockResolvedValue(ormEntity);
mockMapper.toDomain.mockReturnValue(domainEntity);
// When
const result = await repository.findAchievementById(achievementId);
// Then
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: achievementId } });
expect(mockMapper.toDomain).toHaveBeenCalledWith(ormEntity);
expect(result).toBe(domainEntity);
});
// Given: no achievement exists with the given ID
// When: findAchievementById is called
// Then: it should return null
it('should return null when achievement not found', async () => {
// Given
const achievementId = 'ach-999';
mockAchievementRepo.findOne.mockResolvedValue(null);
// When
const result = await repository.findAchievementById(achievementId);
// Then
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: achievementId } });
expect(mockMapper.toDomain).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('findAllAchievements', () => {
// Given: multiple achievements exist in the database
// When: findAllAchievements is called
// Then: it should return all achievement domain entities
it('should return all achievements', async () => {
// Given
const ormEntity1 = new AchievementOrmEntity();
ormEntity1.id = 'ach-1';
ormEntity1.name = 'First Race';
ormEntity1.description = 'Complete your first race';
ormEntity1.category = 'driver';
ormEntity1.rarity = 'common';
ormEntity1.points = 10;
ormEntity1.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
ormEntity1.isSecret = false;
ormEntity1.createdAt = new Date('2024-01-01');
const ormEntity2 = new AchievementOrmEntity();
ormEntity2.id = 'ach-2';
ormEntity2.name = 'Champion';
ormEntity2.description = 'Win a championship';
ormEntity2.category = 'driver';
ormEntity2.rarity = 'legendary';
ormEntity2.points = 100;
ormEntity2.requirements = [{ type: 'championships_won', value: 1, operator: '>=' }];
ormEntity2.isSecret = false;
ormEntity2.createdAt = new Date('2024-01-02');
const domainEntity1 = Achievement.create({
id: 'ach-1',
name: 'First Race',
description: 'Complete your first race',
category: 'driver',
rarity: 'common',
points: 10,
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
isSecret: false,
});
const domainEntity2 = Achievement.create({
id: 'ach-2',
name: 'Champion',
description: 'Win a championship',
category: 'driver',
rarity: 'legendary',
points: 100,
requirements: [{ type: 'championships_won', value: 1, operator: '>=' }],
isSecret: false,
});
mockAchievementRepo.find.mockResolvedValue([ormEntity1, ormEntity2]);
mockMapper.toDomain
.mockReturnValueOnce(domainEntity1)
.mockReturnValueOnce(domainEntity2);
// When
const result = await repository.findAllAchievements();
// Then
expect(mockAchievementRepo.find).toHaveBeenCalledWith();
expect(mockMapper.toDomain).toHaveBeenCalledTimes(2);
expect(result).toEqual([domainEntity1, domainEntity2]);
});
// Given: no achievements exist in the database
// When: findAllAchievements is called
// Then: it should return an empty array
it('should return empty array when no achievements exist', async () => {
// Given
mockAchievementRepo.find.mockResolvedValue([]);
// When
const result = await repository.findAllAchievements();
// Then
expect(mockAchievementRepo.find).toHaveBeenCalledWith();
expect(mockMapper.toDomain).not.toHaveBeenCalled();
expect(result).toEqual([]);
});
});
describe('findAchievementsByCategory', () => {
// Given: achievements exist in a specific category
// When: findAchievementsByCategory is called
// Then: it should return achievements from that category
it('should return achievements by category', async () => {
// Given
const category = 'driver';
const ormEntity = new AchievementOrmEntity();
ormEntity.id = 'ach-1';
ormEntity.name = 'First Race';
ormEntity.description = 'Complete your first race';
ormEntity.category = 'driver';
ormEntity.rarity = 'common';
ormEntity.points = 10;
ormEntity.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
ormEntity.isSecret = false;
ormEntity.createdAt = new Date('2024-01-01');
const domainEntity = Achievement.create({
id: 'ach-1',
name: 'First Race',
description: 'Complete your first race',
category: 'driver',
rarity: 'common',
points: 10,
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
isSecret: false,
});
mockAchievementRepo.find.mockResolvedValue([ormEntity]);
mockMapper.toDomain.mockReturnValue(domainEntity);
// When
const result = await repository.findAchievementsByCategory(category);
// Then
expect(mockAchievementRepo.find).toHaveBeenCalledWith({ where: { category } });
expect(mockMapper.toDomain).toHaveBeenCalledWith(ormEntity);
expect(result).toEqual([domainEntity]);
});
});
describe('createAchievement', () => {
// Given: a valid achievement domain entity
// When: createAchievement is called
// Then: it should save the achievement and return it
it('should create and save achievement', async () => {
// Given
const achievement = Achievement.create({
id: 'ach-123',
name: 'First Race',
description: 'Complete your first race',
category: 'driver',
rarity: 'common',
points: 10,
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
isSecret: false,
});
const ormEntity = new AchievementOrmEntity();
ormEntity.id = 'ach-123';
ormEntity.name = 'First Race';
ormEntity.description = 'Complete your first race';
ormEntity.category = 'driver';
ormEntity.rarity = 'common';
ormEntity.points = 10;
ormEntity.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
ormEntity.isSecret = false;
ormEntity.createdAt = new Date('2024-01-01');
mockMapper.toOrmEntity.mockReturnValue(ormEntity);
mockAchievementRepo.save.mockResolvedValue(ormEntity);
// When
const result = await repository.createAchievement(achievement);
// Then
expect(mockMapper.toOrmEntity).toHaveBeenCalledWith(achievement);
expect(mockAchievementRepo.save).toHaveBeenCalledWith(ormEntity);
expect(result).toBe(achievement);
});
});
describe('findUserAchievementById', () => {
// Given: a user achievement exists in the database
// When: findUserAchievementById is called
// Then: it should return the user achievement domain entity
it('should return user achievement when found', async () => {
// Given
const userAchievementId = 'ua-123';
const ormEntity = new UserAchievementOrmEntity();
ormEntity.id = userAchievementId;
ormEntity.userId = 'user-456';
ormEntity.achievementId = 'ach-789';
ormEntity.earnedAt = new Date('2024-01-01');
ormEntity.progress = 50;
ormEntity.notifiedAt = null;
const domainEntity = UserAchievement.create({
id: userAchievementId,
userId: 'user-456',
achievementId: 'ach-789',
earnedAt: new Date('2024-01-01'),
progress: 50,
});
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
// When
const result = await repository.findUserAchievementById(userAchievementId);
// Then
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: userAchievementId } });
expect(mockMapper.toUserAchievementDomain).toHaveBeenCalledWith(ormEntity);
expect(result).toBe(domainEntity);
});
// Given: no user achievement exists with the given ID
// When: findUserAchievementById is called
// Then: it should return null
it('should return null when user achievement not found', async () => {
// Given
const userAchievementId = 'ua-999';
mockUserAchievementRepo.findOne.mockResolvedValue(null);
// When
const result = await repository.findUserAchievementById(userAchievementId);
// Then
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: userAchievementId } });
expect(mockMapper.toUserAchievementDomain).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('findUserAchievementsByUserId', () => {
// Given: user achievements exist for a specific user
// When: findUserAchievementsByUserId is called
// Then: it should return user achievements for that user
it('should return user achievements by user ID', async () => {
// Given
const userId = 'user-456';
const ormEntity = new UserAchievementOrmEntity();
ormEntity.id = 'ua-123';
ormEntity.userId = userId;
ormEntity.achievementId = 'ach-789';
ormEntity.earnedAt = new Date('2024-01-01');
ormEntity.progress = 50;
ormEntity.notifiedAt = null;
const domainEntity = UserAchievement.create({
id: 'ua-123',
userId: userId,
achievementId: 'ach-789',
earnedAt: new Date('2024-01-01'),
progress: 50,
});
mockUserAchievementRepo.find.mockResolvedValue([ormEntity]);
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
// When
const result = await repository.findUserAchievementsByUserId(userId);
// Then
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId } });
expect(mockMapper.toUserAchievementDomain).toHaveBeenCalledWith(ormEntity);
expect(result).toEqual([domainEntity]);
});
});
describe('findUserAchievementByUserAndAchievement', () => {
// Given: a user achievement exists for a specific user and achievement
// When: findUserAchievementByUserAndAchievement is called
// Then: it should return the user achievement
it('should return user achievement by user and achievement IDs', async () => {
// Given
const userId = 'user-456';
const achievementId = 'ach-789';
const ormEntity = new UserAchievementOrmEntity();
ormEntity.id = 'ua-123';
ormEntity.userId = userId;
ormEntity.achievementId = achievementId;
ormEntity.earnedAt = new Date('2024-01-01');
ormEntity.progress = 50;
ormEntity.notifiedAt = null;
const domainEntity = UserAchievement.create({
id: 'ua-123',
userId: userId,
achievementId: achievementId,
earnedAt: new Date('2024-01-01'),
progress: 50,
});
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
// When
const result = await repository.findUserAchievementByUserAndAchievement(userId, achievementId);
// Then
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
expect(mockMapper.toUserAchievementDomain).toHaveBeenCalledWith(ormEntity);
expect(result).toBe(domainEntity);
});
// Given: no user achievement exists for the given user and achievement
// When: findUserAchievementByUserAndAchievement is called
// Then: it should return null
it('should return null when user achievement not found', async () => {
// Given
const userId = 'user-456';
const achievementId = 'ach-999';
mockUserAchievementRepo.findOne.mockResolvedValue(null);
// When
const result = await repository.findUserAchievementByUserAndAchievement(userId, achievementId);
// Then
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
expect(mockMapper.toUserAchievementDomain).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
describe('hasUserEarnedAchievement', () => {
// Given: a user has earned an achievement (progress = 100)
// When: hasUserEarnedAchievement is called
// Then: it should return true
it('should return true when user has earned achievement', async () => {
// Given
const userId = 'user-456';
const achievementId = 'ach-789';
const ormEntity = new UserAchievementOrmEntity();
ormEntity.id = 'ua-123';
ormEntity.userId = userId;
ormEntity.achievementId = achievementId;
ormEntity.earnedAt = new Date('2024-01-01');
ormEntity.progress = 100;
ormEntity.notifiedAt = null;
const domainEntity = UserAchievement.create({
id: 'ua-123',
userId: userId,
achievementId: achievementId,
earnedAt: new Date('2024-01-01'),
progress: 100,
});
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
// When
const result = await repository.hasUserEarnedAchievement(userId, achievementId);
// Then
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
expect(result).toBe(true);
});
// Given: a user has not earned an achievement (progress < 100)
// When: hasUserEarnedAchievement is called
// Then: it should return false
it('should return false when user has not earned achievement', async () => {
// Given
const userId = 'user-456';
const achievementId = 'ach-789';
const ormEntity = new UserAchievementOrmEntity();
ormEntity.id = 'ua-123';
ormEntity.userId = userId;
ormEntity.achievementId = achievementId;
ormEntity.earnedAt = new Date('2024-01-01');
ormEntity.progress = 50;
ormEntity.notifiedAt = null;
const domainEntity = UserAchievement.create({
id: 'ua-123',
userId: userId,
achievementId: achievementId,
earnedAt: new Date('2024-01-01'),
progress: 50,
});
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
// When
const result = await repository.hasUserEarnedAchievement(userId, achievementId);
// Then
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
expect(result).toBe(false);
});
// Given: no user achievement exists
// When: hasUserEarnedAchievement is called
// Then: it should return false
it('should return false when user achievement not found', async () => {
// Given
const userId = 'user-456';
const achievementId = 'ach-999';
mockUserAchievementRepo.findOne.mockResolvedValue(null);
// When
const result = await repository.hasUserEarnedAchievement(userId, achievementId);
// Then
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
expect(result).toBe(false);
});
});
describe('createUserAchievement', () => {
// Given: a valid user achievement domain entity
// When: createUserAchievement is called
// Then: it should save the user achievement and return it
it('should create and save user achievement', async () => {
// Given
const userAchievement = UserAchievement.create({
id: 'ua-123',
userId: 'user-456',
achievementId: 'ach-789',
earnedAt: new Date('2024-01-01'),
progress: 50,
});
const ormEntity = new UserAchievementOrmEntity();
ormEntity.id = 'ua-123';
ormEntity.userId = 'user-456';
ormEntity.achievementId = 'ach-789';
ormEntity.earnedAt = new Date('2024-01-01');
ormEntity.progress = 50;
ormEntity.notifiedAt = null;
mockMapper.toUserAchievementOrmEntity.mockReturnValue(ormEntity);
mockUserAchievementRepo.save.mockResolvedValue(ormEntity);
// When
const result = await repository.createUserAchievement(userAchievement);
// Then
expect(mockMapper.toUserAchievementOrmEntity).toHaveBeenCalledWith(userAchievement);
expect(mockUserAchievementRepo.save).toHaveBeenCalledWith(ormEntity);
expect(result).toBe(userAchievement);
});
});
describe('updateUserAchievement', () => {
// Given: an existing user achievement to update
// When: updateUserAchievement is called
// Then: it should update the user achievement and return it
it('should update and save user achievement', async () => {
// Given
const userAchievement = UserAchievement.create({
id: 'ua-123',
userId: 'user-456',
achievementId: 'ach-789',
earnedAt: new Date('2024-01-01'),
progress: 75,
});
const ormEntity = new UserAchievementOrmEntity();
ormEntity.id = 'ua-123';
ormEntity.userId = 'user-456';
ormEntity.achievementId = 'ach-789';
ormEntity.earnedAt = new Date('2024-01-01');
ormEntity.progress = 75;
ormEntity.notifiedAt = null;
mockMapper.toUserAchievementOrmEntity.mockReturnValue(ormEntity);
mockUserAchievementRepo.save.mockResolvedValue(ormEntity);
// When
const result = await repository.updateUserAchievement(userAchievement);
// Then
expect(mockMapper.toUserAchievementOrmEntity).toHaveBeenCalledWith(userAchievement);
expect(mockUserAchievementRepo.save).toHaveBeenCalledWith(ormEntity);
expect(result).toBe(userAchievement);
});
});
describe('getAchievementLeaderboard', () => {
// Given: multiple users have completed achievements
// When: getAchievementLeaderboard is called
// Then: it should return sorted leaderboard
it('should return achievement leaderboard', async () => {
// Given
const userAchievement1 = new UserAchievementOrmEntity();
userAchievement1.id = 'ua-1';
userAchievement1.userId = 'user-1';
userAchievement1.achievementId = 'ach-1';
userAchievement1.progress = 100;
const userAchievement2 = new UserAchievementOrmEntity();
userAchievement2.id = 'ua-2';
userAchievement2.userId = 'user-2';
userAchievement2.achievementId = 'ach-2';
userAchievement2.progress = 100;
const achievement1 = new AchievementOrmEntity();
achievement1.id = 'ach-1';
achievement1.points = 10;
const achievement2 = new AchievementOrmEntity();
achievement2.id = 'ach-2';
achievement2.points = 20;
mockUserAchievementRepo.find.mockResolvedValue([userAchievement1, userAchievement2]);
mockAchievementRepo.findOne
.mockResolvedValueOnce(achievement1)
.mockResolvedValueOnce(achievement2);
// When
const result = await repository.getAchievementLeaderboard(10);
// Then
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { progress: 100 } });
expect(mockAchievementRepo.findOne).toHaveBeenCalledTimes(2);
expect(result).toEqual([
{ userId: 'user-2', points: 20, count: 1 },
{ userId: 'user-1', points: 10, count: 1 },
]);
});
// Given: no completed user achievements exist
// When: getAchievementLeaderboard is called
// Then: it should return empty array
it('should return empty array when no completed achievements', async () => {
// Given
mockUserAchievementRepo.find.mockResolvedValue([]);
// When
const result = await repository.getAchievementLeaderboard(10);
// Then
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { progress: 100 } });
expect(result).toEqual([]);
});
// Given: user achievements exist but achievement not found
// When: getAchievementLeaderboard is called
// Then: it should skip those achievements
it('should skip achievements that cannot be found', async () => {
// Given
const userAchievement = new UserAchievementOrmEntity();
userAchievement.id = 'ua-1';
userAchievement.userId = 'user-1';
userAchievement.achievementId = 'ach-999';
userAchievement.progress = 100;
mockUserAchievementRepo.find.mockResolvedValue([userAchievement]);
mockAchievementRepo.findOne.mockResolvedValue(null);
// When
const result = await repository.getAchievementLeaderboard(10);
// Then
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { progress: 100 } });
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: 'ach-999' } });
expect(result).toEqual([]);
});
});
describe('getUserAchievementStats', () => {
// Given: a user has completed achievements
// When: getUserAchievementStats is called
// Then: it should return user statistics
it('should return user achievement statistics', async () => {
// Given
const userId = 'user-1';
const userAchievement1 = new UserAchievementOrmEntity();
userAchievement1.id = 'ua-1';
userAchievement1.userId = userId;
userAchievement1.achievementId = 'ach-1';
userAchievement1.progress = 100;
const userAchievement2 = new UserAchievementOrmEntity();
userAchievement2.id = 'ua-2';
userAchievement2.userId = userId;
userAchievement2.achievementId = 'ach-2';
userAchievement2.progress = 100;
const achievement1 = new AchievementOrmEntity();
achievement1.id = 'ach-1';
achievement1.category = 'driver';
achievement1.points = 10;
const achievement2 = new AchievementOrmEntity();
achievement2.id = 'ach-2';
achievement2.category = 'steward';
achievement2.points = 20;
mockUserAchievementRepo.find.mockResolvedValue([userAchievement1, userAchievement2]);
mockAchievementRepo.findOne
.mockResolvedValueOnce(achievement1)
.mockResolvedValueOnce(achievement2);
// When
const result = await repository.getUserAchievementStats(userId);
// Then
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId, progress: 100 } });
expect(mockAchievementRepo.findOne).toHaveBeenCalledTimes(2);
expect(result).toEqual({
total: 2,
points: 30,
byCategory: {
driver: 1,
steward: 1,
admin: 0,
community: 0,
},
});
});
// Given: a user has no completed achievements
// When: getUserAchievementStats is called
// Then: it should return zero statistics
it('should return zero statistics when no completed achievements', async () => {
// Given
const userId = 'user-1';
mockUserAchievementRepo.find.mockResolvedValue([]);
// When
const result = await repository.getUserAchievementStats(userId);
// Then
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId, progress: 100 } });
expect(result).toEqual({
total: 0,
points: 0,
byCategory: {
driver: 0,
steward: 0,
admin: 0,
community: 0,
},
});
});
// Given: a user has completed achievements but achievement not found
// When: getUserAchievementStats is called
// Then: it should skip those achievements
it('should skip achievements that cannot be found', async () => {
// Given
const userId = 'user-1';
const userAchievement = new UserAchievementOrmEntity();
userAchievement.id = 'ua-1';
userAchievement.userId = userId;
userAchievement.achievementId = 'ach-999';
userAchievement.progress = 100;
mockUserAchievementRepo.find.mockResolvedValue([userAchievement]);
mockAchievementRepo.findOne.mockResolvedValue(null);
// When
const result = await repository.getUserAchievementStats(userId);
// Then
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId, progress: 100 } });
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: 'ach-999' } });
expect(result).toEqual({
total: 1,
points: 0,
byCategory: {
driver: 0,
steward: 0,
admin: 0,
community: 0,
},
});
});
});
});

View File

@@ -0,0 +1,550 @@
import { TypeOrmPersistenceSchemaAdapter } from '../errors/TypeOrmPersistenceSchemaAdapterError';
import {
assertNonEmptyString,
assertDate,
assertEnumValue,
assertArray,
assertNumber,
assertInteger,
assertBoolean,
assertOptionalStringOrNull,
assertRecord,
} from './AchievementSchemaGuard';
describe('AchievementSchemaGuard', () => {
describe('assertNonEmptyString', () => {
// Given: a valid non-empty string
// When: assertNonEmptyString is called
// Then: it should not throw an error
it('should accept a valid non-empty string', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 'valid string';
// When & Then
expect(() => assertNonEmptyString(entityName, fieldName, value)).not.toThrow();
});
// Given: a value that is not a string
// When: assertNonEmptyString is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_string'
it('should reject a non-string value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 123;
// When & Then
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_string',
})
);
});
// Given: an empty string
// When: assertNonEmptyString is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'empty_string'
it('should reject an empty string', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = '';
// When & Then
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'empty_string',
})
);
});
// Given: a string with only whitespace
// When: assertNonEmptyString is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'empty_string'
it('should reject a string with only whitespace', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = ' ';
// When & Then
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'empty_string',
})
);
});
});
describe('assertDate', () => {
// Given: a valid Date object
// When: assertDate is called
// Then: it should not throw an error
it('should accept a valid Date object', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = new Date();
// When & Then
expect(() => assertDate(entityName, fieldName, value)).not.toThrow();
});
// Given: a value that is not a Date
// When: assertDate is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_date'
it('should reject a non-Date value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = '2024-01-01';
// When & Then
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertDate(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_date',
})
);
});
// Given: an invalid Date object (NaN)
// When: assertDate is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'invalid_date'
it('should reject an invalid Date object', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = new Date('invalid');
// When & Then
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertDate(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'invalid_date',
})
);
});
});
describe('assertEnumValue', () => {
const VALID_VALUES = ['option1', 'option2', 'option3'] as const;
// Given: a valid enum value
// When: assertEnumValue is called
// Then: it should not throw an error
it('should accept a valid enum value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 'option1';
// When & Then
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).not.toThrow();
});
// Given: a value that is not a string
// When: assertEnumValue is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_string'
it('should reject a non-string value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 123;
// When & Then
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_string',
})
);
});
// Given: an invalid enum value
// When: assertEnumValue is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'invalid_enum_value'
it('should reject an invalid enum value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 'invalid_option';
// When & Then
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'invalid_enum_value',
})
);
});
});
describe('assertArray', () => {
// Given: a valid array
// When: assertArray is called
// Then: it should not throw an error
it('should accept a valid array', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = [1, 2, 3];
// When & Then
expect(() => assertArray(entityName, fieldName, value)).not.toThrow();
});
// Given: a value that is not an array
// When: assertArray is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_array'
it('should reject a non-array value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = { key: 'value' };
// When & Then
expect(() => assertArray(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertArray(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_array',
})
);
});
// Given: null value
// When: assertArray is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_array'
it('should reject null value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = null;
// When & Then
expect(() => assertArray(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertArray(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_array',
})
);
});
});
describe('assertNumber', () => {
// Given: a valid number
// When: assertNumber is called
// Then: it should not throw an error
it('should accept a valid number', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 42;
// When & Then
expect(() => assertNumber(entityName, fieldName, value)).not.toThrow();
});
// Given: a value that is not a number
// When: assertNumber is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_number'
it('should reject a non-number value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = '42';
// When & Then
expect(() => assertNumber(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertNumber(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_number',
})
);
});
// Given: NaN value
// When: assertNumber is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_number'
it('should reject NaN value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = NaN;
// When & Then
expect(() => assertNumber(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertNumber(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_number',
})
);
});
});
describe('assertInteger', () => {
// Given: a valid integer
// When: assertInteger is called
// Then: it should not throw an error
it('should accept a valid integer', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 42;
// When & Then
expect(() => assertInteger(entityName, fieldName, value)).not.toThrow();
});
// Given: a value that is not an integer (float)
// When: assertInteger is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_integer'
it('should reject a float value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 42.5;
// When & Then
expect(() => assertInteger(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertInteger(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_integer',
})
);
});
// Given: a value that is not a number
// When: assertInteger is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_integer'
it('should reject a non-number value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = '42';
// When & Then
expect(() => assertInteger(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertInteger(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_integer',
})
);
});
});
describe('assertBoolean', () => {
// Given: a valid boolean (true)
// When: assertBoolean is called
// Then: it should not throw an error
it('should accept true', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = true;
// When & Then
expect(() => assertBoolean(entityName, fieldName, value)).not.toThrow();
});
// Given: a valid boolean (false)
// When: assertBoolean is called
// Then: it should not throw an error
it('should accept false', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = false;
// When & Then
expect(() => assertBoolean(entityName, fieldName, value)).not.toThrow();
});
// Given: a value that is not a boolean
// When: assertBoolean is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_boolean'
it('should reject a non-boolean value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 'true';
// When & Then
expect(() => assertBoolean(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertBoolean(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_boolean',
})
);
});
});
describe('assertOptionalStringOrNull', () => {
// Given: a valid string
// When: assertOptionalStringOrNull is called
// Then: it should not throw an error
it('should accept a valid string', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 'valid string';
// When & Then
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).not.toThrow();
});
// Given: null value
// When: assertOptionalStringOrNull is called
// Then: it should not throw an error
it('should accept null value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = null;
// When & Then
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).not.toThrow();
});
// Given: undefined value
// When: assertOptionalStringOrNull is called
// Then: it should not throw an error
it('should accept undefined value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = undefined;
// When & Then
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).not.toThrow();
});
// Given: a value that is not a string, null, or undefined
// When: assertOptionalStringOrNull is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_string'
it('should reject a non-string value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 123;
// When & Then
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_string',
})
);
});
});
describe('assertRecord', () => {
// Given: a valid record (object)
// When: assertRecord is called
// Then: it should not throw an error
it('should accept a valid record', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = { key: 'value' };
// When & Then
expect(() => assertRecord(entityName, fieldName, value)).not.toThrow();
});
// Given: a value that is not an object (null)
// When: assertRecord is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_object'
it('should reject null value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = null;
// When & Then
expect(() => assertRecord(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertRecord(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_object',
})
);
});
// Given: a value that is an array
// When: assertRecord is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_object'
it('should reject array value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = [1, 2, 3];
// When & Then
expect(() => assertRecord(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertRecord(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_object',
})
);
});
// Given: a value that is a primitive (string)
// When: assertRecord is called
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_object'
it('should reject string value', () => {
// Given
const entityName = 'TestEntity';
const fieldName = 'testField';
const value = 'not an object';
// When & Then
expect(() => assertRecord(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
expect(() => assertRecord(entityName, fieldName, value)).toThrow(
expect.objectContaining({
entityName,
fieldName,
reason: 'not_object',
})
);
});
});
});

View File

@@ -0,0 +1,100 @@
import { InMemoryActivityRepository } from './InMemoryActivityRepository';
import { DriverData } from '../../../../core/dashboard/application/ports/DashboardRepository';
describe('InMemoryActivityRepository', () => {
let repository: InMemoryActivityRepository;
beforeEach(() => {
repository = new InMemoryActivityRepository();
});
describe('findDriverById', () => {
it('should return null when driver does not exist', async () => {
// Given
const driverId = 'non-existent';
// When
const result = await repository.findDriverById(driverId);
// Then
expect(result).toBeNull();
});
it('should return driver when it exists', async () => {
// Given
const driver: DriverData = {
id: 'driver-1',
name: 'John Doe',
rating: 1500,
rank: 10,
starts: 100,
wins: 10,
podiums: 30,
leagues: 5,
};
repository.addDriver(driver);
// When
const result = await repository.findDriverById(driver.id);
// Then
expect(result).toEqual(driver);
});
it('should overwrite driver with same id (idempotency/uniqueness)', async () => {
// Given
const driverId = 'driver-1';
const driver1: DriverData = {
id: driverId,
name: 'John Doe',
rating: 1500,
rank: 10,
starts: 100,
wins: 10,
podiums: 30,
leagues: 5,
};
const driver2: DriverData = {
id: driverId,
name: 'John Updated',
rating: 1600,
rank: 5,
starts: 101,
wins: 11,
podiums: 31,
leagues: 5,
};
// When
repository.addDriver(driver1);
repository.addDriver(driver2);
const result = await repository.findDriverById(driverId);
// Then
expect(result).toEqual(driver2);
});
});
describe('upcomingRaces', () => {
it('should return empty array when no races for driver', async () => {
// When
const result = await repository.getUpcomingRaces('driver-1');
// Then
expect(result).toEqual([]);
});
it('should return races when they exist', async () => {
// Given
const driverId = 'driver-1';
const races = [{ id: 'race-1', name: 'Grand Prix', date: new Date().toISOString() }];
repository.addUpcomingRaces(driverId, races);
// When
const result = await repository.getUpcomingRaces(driverId);
// Then
expect(result).toEqual(races);
});
});
});

View File

@@ -1,6 +1,6 @@
import { AdminUser } from '../../domain/entities/AdminUser'; import { AdminUser } from '@core/admin/domain/entities/AdminUser';
import { UserRole } from '../../domain/value-objects/UserRole'; import { UserRole } from '@core/admin/domain/value-objects/UserRole';
import { UserStatus } from '../../domain/value-objects/UserStatus'; import { UserStatus } from '@core/admin/domain/value-objects/UserStatus';
import { InMemoryAdminUserRepository } from './InMemoryAdminUserRepository'; import { InMemoryAdminUserRepository } from './InMemoryAdminUserRepository';
describe('InMemoryAdminUserRepository', () => { describe('InMemoryAdminUserRepository', () => {

View File

@@ -1,7 +1,7 @@
import { AdminUser } from '../../domain/entities/AdminUser'; import { AdminUser } from '@core/admin/domain/entities/AdminUser';
import { AdminUserRepository, StoredAdminUser, UserFilter, UserListQuery, UserListResult } from '../../domain/repositories/AdminUserRepository'; import { AdminUserRepository, StoredAdminUser, UserFilter, UserListQuery, UserListResult } from '@core/admin/domain/repositories/AdminUserRepository';
import { Email } from '../../domain/value-objects/Email'; import { Email } from '@core/admin/domain/value-objects/Email';
import { UserId } from '../../domain/value-objects/UserId'; import { UserId } from '@core/admin/domain/value-objects/UserId';
/** /**
* In-memory implementation of AdminUserRepository for testing and development * In-memory implementation of AdminUserRepository for testing and development

View File

@@ -0,0 +1,610 @@
import { describe, expect, it } from 'vitest';
import { AdminUserOrmEntity } from './AdminUserOrmEntity';
describe('AdminUserOrmEntity', () => {
describe('TDD - Test First', () => {
describe('entity properties', () => {
it('should have id property', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity).toHaveProperty('id');
});
it('should have email property', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity).toHaveProperty('email');
});
it('should have displayName property', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity).toHaveProperty('displayName');
});
it('should have roles property', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity).toHaveProperty('roles');
});
it('should have status property', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity).toHaveProperty('status');
});
it('should have primaryDriverId property', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity).toHaveProperty('primaryDriverId');
});
it('should have lastLoginAt property', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity).toHaveProperty('lastLoginAt');
});
it('should have createdAt property', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity).toHaveProperty('createdAt');
});
it('should have updatedAt property', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity).toHaveProperty('updatedAt');
});
});
describe('property types', () => {
it('should have id as string', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'test-id';
// Act & Assert
expect(typeof entity.id).toBe('string');
expect(entity.id).toBe('test-id');
});
it('should have email as string', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.email = 'test@example.com';
// Act & Assert
expect(typeof entity.email).toBe('string');
expect(entity.email).toBe('test@example.com');
});
it('should have displayName as string', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.displayName = 'Test User';
// Act & Assert
expect(typeof entity.displayName).toBe('string');
expect(entity.displayName).toBe('Test User');
});
it('should have roles as string array', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.roles = ['admin', 'user'];
// Act & Assert
expect(Array.isArray(entity.roles)).toBe(true);
expect(entity.roles).toEqual(['admin', 'user']);
});
it('should have status as string', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.status = 'active';
// Act & Assert
expect(typeof entity.status).toBe('string');
expect(entity.status).toBe('active');
});
it('should have primaryDriverId as optional string', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity.primaryDriverId).toBeUndefined();
entity.primaryDriverId = 'driver-123';
expect(typeof entity.primaryDriverId).toBe('string');
expect(entity.primaryDriverId).toBe('driver-123');
});
it('should have lastLoginAt as optional Date', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
expect(entity.lastLoginAt).toBeUndefined();
const now = new Date();
entity.lastLoginAt = now;
expect(entity.lastLoginAt).toBeInstanceOf(Date);
expect(entity.lastLoginAt).toBe(now);
});
it('should have createdAt as Date', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
entity.createdAt = now;
// Act & Assert
expect(entity.createdAt).toBeInstanceOf(Date);
expect(entity.createdAt).toBe(now);
});
it('should have updatedAt as Date', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
entity.updatedAt = now;
// Act & Assert
expect(entity.updatedAt).toBeInstanceOf(Date);
expect(entity.updatedAt).toBe(now);
});
});
describe('property values', () => {
it('should handle valid UUID for id', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const uuid = '123e4567-e89b-12d3-a456-426614174000';
// Act
entity.id = uuid;
// Assert
expect(entity.id).toBe(uuid);
});
it('should handle email with special characters', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const email = 'user+tag@example-domain.com';
// Act
entity.email = email;
// Assert
expect(entity.email).toBe(email);
});
it('should handle display name with spaces', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const displayName = 'John Doe Smith';
// Act
entity.displayName = displayName;
// Assert
expect(entity.displayName).toBe(displayName);
});
it('should handle roles with multiple entries', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const roles = ['owner', 'admin', 'user', 'moderator'];
// Act
entity.roles = roles;
// Assert
expect(entity.roles).toEqual(roles);
expect(entity.roles).toHaveLength(4);
});
it('should handle status with different values', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act & Assert
entity.status = 'active';
expect(entity.status).toBe('active');
entity.status = 'suspended';
expect(entity.status).toBe('suspended');
entity.status = 'deleted';
expect(entity.status).toBe('deleted');
});
it('should handle primaryDriverId with valid driver ID', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const driverId = 'driver-abc123';
// Act
entity.primaryDriverId = driverId;
// Assert
expect(entity.primaryDriverId).toBe(driverId);
});
it('should handle lastLoginAt with current date', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
// Act
entity.lastLoginAt = now;
// Assert
expect(entity.lastLoginAt).toBe(now);
});
it('should handle createdAt with specific date', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const specificDate = new Date('2024-01-01T00:00:00.000Z');
// Act
entity.createdAt = specificDate;
// Assert
expect(entity.createdAt).toBe(specificDate);
});
it('should handle updatedAt with specific date', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const specificDate = new Date('2024-01-02T00:00:00.000Z');
// Act
entity.updatedAt = specificDate;
// Assert
expect(entity.updatedAt).toBe(specificDate);
});
});
describe('property assignments', () => {
it('should allow setting all properties', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
// Act
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['admin'];
entity.status = 'active';
entity.primaryDriverId = 'driver-456';
entity.lastLoginAt = now;
entity.createdAt = now;
entity.updatedAt = now;
// Assert
expect(entity.id).toBe('user-123');
expect(entity.email).toBe('test@example.com');
expect(entity.displayName).toBe('Test User');
expect(entity.roles).toEqual(['admin']);
expect(entity.status).toBe('active');
expect(entity.primaryDriverId).toBe('driver-456');
expect(entity.lastLoginAt).toBe(now);
expect(entity.createdAt).toBe(now);
expect(entity.updatedAt).toBe(now);
});
it('should allow updating properties', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
const later = new Date(now.getTime() + 1000);
// Act
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['user'];
entity.status = 'active';
entity.primaryDriverId = 'driver-456';
entity.lastLoginAt = now;
entity.createdAt = now;
entity.updatedAt = now;
// Update
entity.displayName = 'Updated Name';
entity.roles = ['admin', 'user'];
entity.status = 'suspended';
entity.lastLoginAt = later;
entity.updatedAt = later;
// Assert
expect(entity.displayName).toBe('Updated Name');
expect(entity.roles).toEqual(['admin', 'user']);
expect(entity.status).toBe('suspended');
expect(entity.lastLoginAt).toBe(later);
expect(entity.updatedAt).toBe(later);
});
it('should allow clearing optional properties', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
// Act
entity.primaryDriverId = 'driver-123';
entity.lastLoginAt = now;
// Clear
entity.primaryDriverId = undefined;
entity.lastLoginAt = undefined;
// Assert
expect(entity.primaryDriverId).toBeUndefined();
expect(entity.lastLoginAt).toBeUndefined();
});
});
describe('empty entity', () => {
it('should create entity with undefined properties', () => {
// Arrange & Act
const entity = new AdminUserOrmEntity();
// Assert
expect(entity.id).toBeUndefined();
expect(entity.email).toBeUndefined();
expect(entity.displayName).toBeUndefined();
expect(entity.roles).toBeUndefined();
expect(entity.status).toBeUndefined();
expect(entity.primaryDriverId).toBeUndefined();
expect(entity.lastLoginAt).toBeUndefined();
expect(entity.createdAt).toBeUndefined();
expect(entity.updatedAt).toBeUndefined();
});
it('should allow partial initialization', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act
entity.id = 'user-123';
entity.email = 'test@example.com';
// Assert
expect(entity.id).toBe('user-123');
expect(entity.email).toBe('test@example.com');
expect(entity.displayName).toBeUndefined();
expect(entity.roles).toBeUndefined();
});
});
describe('real-world scenarios', () => {
it('should handle complete user entity', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
// Act
entity.id = '123e4567-e89b-12d3-a456-426614174000';
entity.email = 'admin@example.com';
entity.displayName = 'Administrator';
entity.roles = ['owner', 'admin'];
entity.status = 'active';
entity.primaryDriverId = 'driver-789';
entity.lastLoginAt = now;
entity.createdAt = now;
entity.updatedAt = now;
// Assert
expect(entity.id).toBe('123e4567-e89b-12d3-a456-426614174000');
expect(entity.email).toBe('admin@example.com');
expect(entity.displayName).toBe('Administrator');
expect(entity.roles).toEqual(['owner', 'admin']);
expect(entity.status).toBe('active');
expect(entity.primaryDriverId).toBe('driver-789');
expect(entity.lastLoginAt).toBe(now);
expect(entity.createdAt).toBe(now);
expect(entity.updatedAt).toBe(now);
});
it('should handle user without primary driver', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
// Act
entity.id = 'user-456';
entity.email = 'user@example.com';
entity.displayName = 'Regular User';
entity.roles = ['user'];
entity.status = 'active';
entity.createdAt = now;
entity.updatedAt = now;
// Assert
expect(entity.primaryDriverId).toBeUndefined();
expect(entity.lastLoginAt).toBeUndefined();
});
it('should handle suspended user', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
// Act
entity.id = 'user-789';
entity.email = 'suspended@example.com';
entity.displayName = 'Suspended User';
entity.roles = ['user'];
entity.status = 'suspended';
entity.createdAt = now;
entity.updatedAt = now;
// Assert
expect(entity.status).toBe('suspended');
});
it('should handle user with many roles', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
// Act
entity.id = 'user-999';
entity.email = 'multi@example.com';
entity.displayName = 'Multi Role User';
entity.roles = ['owner', 'admin', 'user', 'moderator', 'viewer'];
entity.status = 'active';
entity.createdAt = now;
entity.updatedAt = now;
// Assert
expect(entity.roles).toHaveLength(5);
expect(entity.roles).toContain('owner');
expect(entity.roles).toContain('admin');
expect(entity.roles).toContain('user');
expect(entity.roles).toContain('moderator');
expect(entity.roles).toContain('viewer');
});
it('should handle user with recent login', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
const recentLogin = new Date(now.getTime() - 60000); // 1 minute ago
// Act
entity.id = 'user-111';
entity.email = 'active@example.com';
entity.displayName = 'Active User';
entity.roles = ['user'];
entity.status = 'active';
entity.primaryDriverId = 'driver-222';
entity.lastLoginAt = recentLogin;
entity.createdAt = now;
entity.updatedAt = now;
// Assert
expect(entity.lastLoginAt).toBe(recentLogin);
expect(entity.lastLoginAt!.getTime()).toBeLessThan(now.getTime());
});
it('should handle user with old login', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const now = new Date();
const oldLogin = new Date(now.getTime() - 86400000); // 1 day ago
// Act
entity.id = 'user-333';
entity.email = 'old@example.com';
entity.displayName = 'Old Login User';
entity.roles = ['user'];
entity.status = 'active';
entity.lastLoginAt = oldLogin;
entity.createdAt = now;
entity.updatedAt = now;
// Assert
expect(entity.lastLoginAt).toBe(oldLogin);
expect(entity.lastLoginAt!.getTime()).toBeLessThan(now.getTime());
});
});
describe('edge cases', () => {
it('should handle empty string values', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act
entity.id = '';
entity.email = '';
entity.displayName = '';
entity.status = '';
// Assert
expect(entity.id).toBe('');
expect(entity.email).toBe('');
expect(entity.displayName).toBe('');
expect(entity.status).toBe('');
});
it('should handle empty roles array', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act
entity.roles = [];
// Assert
expect(entity.roles).toEqual([]);
expect(entity.roles).toHaveLength(0);
});
it('should handle null values for optional properties', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act
entity.primaryDriverId = null as any;
entity.lastLoginAt = null as any;
// Assert
expect(entity.primaryDriverId).toBeNull();
expect(entity.lastLoginAt).toBeNull();
});
it('should handle very long strings', () => {
// Arrange
const entity = new AdminUserOrmEntity();
const longString = 'a'.repeat(1000);
// Act
entity.email = `${longString}@example.com`;
entity.displayName = longString;
// Assert
expect(entity.email).toBe(`${longString}@example.com`);
expect(entity.displayName).toBe(longString);
});
it('should handle unicode characters', () => {
// Arrange
const entity = new AdminUserOrmEntity();
// Act
entity.email = '用户@例子.测试';
entity.displayName = '用户 例子';
// Assert
expect(entity.email).toBe('用户@例子.测试');
expect(entity.displayName).toBe('用户 例子');
});
});
});
});

View File

@@ -0,0 +1,521 @@
import { describe, expect, it } from 'vitest';
import { TypeOrmAdminSchemaError } from './TypeOrmAdminSchemaError';
describe('TypeOrmAdminSchemaError', () => {
describe('TDD - Test First', () => {
describe('constructor', () => {
it('should create an error with all required details', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid format',
message: 'Email must be a valid email address',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.name).toBe('TypeOrmAdminSchemaError');
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Invalid format - Email must be a valid email address');
});
it('should create an error with minimal details', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'id',
reason: 'Missing',
message: 'ID field is required',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.id: Missing - ID field is required');
});
it('should create an error with complex entity name', () => {
// Arrange
const details = {
entityName: 'AdminUserOrmEntity',
fieldName: 'roles',
reason: 'Type mismatch',
message: 'Expected simple-json but got text',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUserOrmEntity.roles: Type mismatch - Expected simple-json but got text');
});
it('should create an error with long field name', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'veryLongFieldNameThatExceedsNormalLength',
reason: 'Constraint violation',
message: 'Field length exceeds maximum allowed',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.veryLongFieldNameThatExceedsNormalLength: Constraint violation - Field length exceeds maximum allowed');
});
it('should create an error with special characters in message', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Validation failed',
message: 'Email "test@example.com" contains invalid characters: @, ., com',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Validation failed - Email "test@example.com" contains invalid characters: @, ., com');
});
it('should create an error with empty reason', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: '',
message: 'Email is required',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: - Email is required');
});
it('should create an error with empty message', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid',
message: '',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Invalid - ');
});
it('should create an error with empty reason and message', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: '',
message: '',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: - ');
});
});
describe('error properties', () => {
it('should have correct error name', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid',
message: 'Test error',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.name).toBe('TypeOrmAdminSchemaError');
});
it('should be instance of Error', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid',
message: 'Test error',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error instanceof Error).toBe(true);
expect(error instanceof TypeOrmAdminSchemaError).toBe(true);
});
it('should have a stack trace', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid',
message: 'Test error',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.stack).toBeDefined();
expect(typeof error.stack).toBe('string');
expect(error.stack).toContain('TypeOrmAdminSchemaError');
});
it('should preserve details object reference', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid',
message: 'Test error',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toBe(details);
});
it('should allow modification of details after creation', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid',
message: 'Test error',
};
const error = new TypeOrmAdminSchemaError(details);
// Act
error.details.reason = 'Updated reason';
// Assert
expect(error.details.reason).toBe('Updated reason');
expect(error.message).toContain('Updated reason');
});
});
describe('message formatting', () => {
it('should format message with all parts', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Validation failed',
message: 'Email must be a valid email address',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Validation failed - Email must be a valid email address');
});
it('should handle multiple words in entity name', () => {
// Arrange
const details = {
entityName: 'Admin User Entity',
fieldName: 'email',
reason: 'Invalid',
message: 'Test',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.message).toBe('[TypeOrmAdminSchemaError] Admin User Entity.email: Invalid - Test');
});
it('should handle multiple words in field name', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email address',
reason: 'Invalid',
message: 'Test',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email address: Invalid - Test');
});
it('should handle multiple words in reason', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Validation failed completely',
message: 'Test',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Validation failed completely - Test');
});
it('should handle multiple words in message', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid',
message: 'This is a very long error message that contains many words',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Invalid - This is a very long error message that contains many words');
});
it('should handle special characters in all parts', () => {
// Arrange
const details = {
entityName: 'Admin_User-Entity',
fieldName: 'email@address',
reason: 'Validation failed: @, ., com',
message: 'Email "test@example.com" is invalid',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.message).toBe('[TypeOrmAdminSchemaError] Admin_User-Entity.email@address: Validation failed: @, ., com - Email "test@example.com" is invalid');
});
});
describe('error inheritance', () => {
it('should be instance of Error', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid',
message: 'Test error',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error instanceof Error).toBe(true);
});
it('should be instance of TypeOrmAdminSchemaError', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid',
message: 'Test error',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error instanceof TypeOrmAdminSchemaError).toBe(true);
});
it('should not be instance of other error types', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Invalid',
message: 'Test error',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error instanceof TypeError).toBe(false);
expect(error instanceof RangeError).toBe(false);
expect(error instanceof ReferenceError).toBe(false);
});
});
describe('real-world scenarios', () => {
it('should handle missing column error', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'primaryDriverId',
reason: 'Column not found',
message: 'Column "primary_driver_id" does not exist in table "admin_users"',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.primaryDriverId: Column not found - Column "primary_driver_id" does not exist in table "admin_users"');
});
it('should handle type mismatch error', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'roles',
reason: 'Type mismatch',
message: 'Expected type "simple-json" but got "text" for column "roles"',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.roles: Type mismatch - Expected type "simple-json" but got "text" for column "roles"');
});
it('should handle constraint violation error', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Constraint violation',
message: 'UNIQUE constraint failed: admin_users.email',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Constraint violation - UNIQUE constraint failed: admin_users.email');
});
it('should handle nullable constraint error', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'displayName',
reason: 'Constraint violation',
message: 'NOT NULL constraint failed: admin_users.display_name',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.displayName: Constraint violation - NOT NULL constraint failed: admin_users.display_name');
});
it('should handle foreign key constraint error', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'primaryDriverId',
reason: 'Constraint violation',
message: 'FOREIGN KEY constraint failed: admin_users.primary_driver_id references drivers.id',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.primaryDriverId: Constraint violation - FOREIGN KEY constraint failed: admin_users.primary_driver_id references drivers.id');
});
it('should handle index creation error', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'email',
reason: 'Index creation failed',
message: 'Failed to create unique index on column "email"',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Index creation failed - Failed to create unique index on column "email"');
});
it('should handle default value error', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'status',
reason: 'Default value error',
message: 'Default value "active" is not valid for column "status"',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.status: Default value error - Default value "active" is not valid for column "status"');
});
it('should handle timestamp column error', () => {
// Arrange
const details = {
entityName: 'AdminUser',
fieldName: 'createdAt',
reason: 'Type error',
message: 'Column "created_at" has invalid type "datetime" for PostgreSQL',
};
// Act
const error = new TypeOrmAdminSchemaError(details);
// Assert
expect(error.details).toEqual(details);
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.createdAt: Type error - Column "created_at" has invalid type "datetime" for PostgreSQL');
});
});
});
});

View File

@@ -0,0 +1,408 @@
import { describe, expect, it } from 'vitest';
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
import { AdminUserOrmEntity } from '../entities/AdminUserOrmEntity';
import { AdminUserOrmMapper } from './AdminUserOrmMapper';
import { TypeOrmAdminSchemaError } from '../errors/TypeOrmAdminSchemaError';
describe('AdminUserOrmMapper', () => {
describe('TDD - Test First', () => {
describe('toDomain', () => {
it('should map valid ORM entity to domain entity', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['owner'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
const mapper = new AdminUserOrmMapper();
// Act
const domain = mapper.toDomain(entity);
// Assert
expect(domain.id.value).toBe('user-123');
expect(domain.email.value).toBe('test@example.com');
expect(domain.displayName).toBe('Test User');
expect(domain.roles).toHaveLength(1);
expect(domain.roles[0]!.value).toBe('owner');
expect(domain.status.value).toBe('active');
expect(domain.createdAt).toEqual(new Date('2024-01-01'));
expect(domain.updatedAt).toEqual(new Date('2024-01-02'));
});
it('should map entity with optional fields', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['user'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
entity.primaryDriverId = 'driver-456';
entity.lastLoginAt = new Date('2024-01-03');
const mapper = new AdminUserOrmMapper();
// Act
const domain = mapper.toDomain(entity);
// Assert
expect(domain.primaryDriverId).toBe('driver-456');
expect(domain.lastLoginAt).toEqual(new Date('2024-01-03'));
});
it('should handle null optional fields', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['user'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
entity.primaryDriverId = null;
entity.lastLoginAt = null;
const mapper = new AdminUserOrmMapper();
// Act
const domain = mapper.toDomain(entity);
// Assert
expect(domain.primaryDriverId).toBeUndefined();
expect(domain.lastLoginAt).toBeUndefined();
});
it('should throw error for missing id', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = '';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['user'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
const mapper = new AdminUserOrmMapper();
// Act & Assert
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
expect(() => mapper.toDomain(entity)).toThrow('Field id must be a non-empty string');
});
it('should throw error for missing email', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = '';
entity.displayName = 'Test User';
entity.roles = ['user'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
const mapper = new AdminUserOrmMapper();
// Act & Assert
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
expect(() => mapper.toDomain(entity)).toThrow('Field email must be a non-empty string');
});
it('should throw error for missing displayName', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = '';
entity.roles = ['user'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
const mapper = new AdminUserOrmMapper();
// Act & Assert
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
expect(() => mapper.toDomain(entity)).toThrow('Field displayName must be a non-empty string');
});
it('should throw error for invalid roles array', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = null as unknown as string[];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
const mapper = new AdminUserOrmMapper();
// Act & Assert
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
expect(() => mapper.toDomain(entity)).toThrow('Field roles must be an array of strings');
});
it('should throw error for invalid roles array items', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['user', 123 as unknown as string];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
const mapper = new AdminUserOrmMapper();
// Act & Assert
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
expect(() => mapper.toDomain(entity)).toThrow('Field roles must be an array of strings');
});
it('should throw error for missing status', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['user'];
entity.status = '';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
const mapper = new AdminUserOrmMapper();
// Act & Assert
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
expect(() => mapper.toDomain(entity)).toThrow('Field status must be a non-empty string');
});
it('should throw error for invalid createdAt', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['user'];
entity.status = 'active';
entity.createdAt = new Date('invalid') as unknown as Date;
entity.updatedAt = new Date('2024-01-02');
const mapper = new AdminUserOrmMapper();
// Act & Assert
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
expect(() => mapper.toDomain(entity)).toThrow('Field createdAt must be a valid Date');
});
it('should throw error for invalid updatedAt', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['user'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('invalid') as unknown as Date;
const mapper = new AdminUserOrmMapper();
// Act & Assert
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
expect(() => mapper.toDomain(entity)).toThrow('Field updatedAt must be a valid Date');
});
it('should throw error for invalid primaryDriverId type', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['user'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
entity.primaryDriverId = 123 as unknown as string;
const mapper = new AdminUserOrmMapper();
// Act & Assert
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
expect(() => mapper.toDomain(entity)).toThrow('Field primaryDriverId must be a string or undefined');
});
it('should throw error for invalid lastLoginAt type', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['user'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
entity.lastLoginAt = 'invalid' as unknown as Date;
const mapper = new AdminUserOrmMapper();
// Act & Assert
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
expect(() => mapper.toDomain(entity)).toThrow('Field lastLoginAt must be a valid Date');
});
it('should handle multiple roles', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['owner', 'admin'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
const mapper = new AdminUserOrmMapper();
// Act
const domain = mapper.toDomain(entity);
// Assert
expect(domain.roles).toHaveLength(2);
expect(domain.roles.map(r => r.value)).toContain('owner');
expect(domain.roles.map(r => r.value)).toContain('admin');
});
});
describe('toOrmEntity', () => {
it('should map domain entity to ORM entity', () => {
// Arrange
const domain = AdminUser.create({
id: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
roles: ['owner'],
status: 'active',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
});
const mapper = new AdminUserOrmMapper();
// Act
const entity = mapper.toOrmEntity(domain);
// Assert
expect(entity.id).toBe('user-123');
expect(entity.email).toBe('test@example.com');
expect(entity.displayName).toBe('Test User');
expect(entity.roles).toEqual(['owner']);
expect(entity.status).toBe('active');
expect(entity.createdAt).toEqual(new Date('2024-01-01'));
expect(entity.updatedAt).toEqual(new Date('2024-01-02'));
});
it('should map domain entity with optional fields', () => {
// Arrange
const domain = AdminUser.create({
id: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
roles: ['user'],
status: 'active',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
primaryDriverId: 'driver-456',
lastLoginAt: new Date('2024-01-03'),
});
const mapper = new AdminUserOrmMapper();
// Act
const entity = mapper.toOrmEntity(domain);
// Assert
expect(entity.primaryDriverId).toBe('driver-456');
expect(entity.lastLoginAt).toEqual(new Date('2024-01-03'));
});
it('should handle domain entity without optional fields', () => {
// Arrange
const domain = AdminUser.create({
id: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
roles: ['user'],
status: 'active',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
});
const mapper = new AdminUserOrmMapper();
// Act
const entity = mapper.toOrmEntity(domain);
// Assert
expect(entity.primaryDriverId).toBeUndefined();
expect(entity.lastLoginAt).toBeUndefined();
});
it('should map domain entity with multiple roles', () => {
// Arrange
const domain = AdminUser.create({
id: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
roles: ['owner', 'admin'],
status: 'active',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
});
const mapper = new AdminUserOrmMapper();
// Act
const entity = mapper.toOrmEntity(domain);
// Assert
expect(entity.roles).toEqual(['owner', 'admin']);
});
});
describe('toStored', () => {
it('should call toDomain for stored entity', () => {
// Arrange
const entity = new AdminUserOrmEntity();
entity.id = 'user-123';
entity.email = 'test@example.com';
entity.displayName = 'Test User';
entity.roles = ['owner'];
entity.status = 'active';
entity.createdAt = new Date('2024-01-01');
entity.updatedAt = new Date('2024-01-02');
const mapper = new AdminUserOrmMapper();
// Act
const domain = mapper.toStored(entity);
// Assert
expect(domain.id.value).toBe('user-123');
expect(domain.email.value).toBe('test@example.com');
expect(domain.displayName).toBe('Test User');
});
});
});
});

View File

@@ -0,0 +1,365 @@
import { describe, expect, it } from 'vitest';
import { TypeOrmAdminSchemaError } from '../errors/TypeOrmAdminSchemaError';
import {
assertNonEmptyString,
assertStringArray,
assertDate,
assertOptionalDate,
assertOptionalString,
} from './TypeOrmAdminSchemaGuards';
describe('TypeOrmAdminSchemaGuards', () => {
describe('TDD - Test First', () => {
describe('assertNonEmptyString', () => {
it('should pass for valid non-empty string', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'email';
const value = 'test@example.com';
// Act & Assert
expect(() => assertNonEmptyString(entityName, fieldName, value)).not.toThrow();
});
it('should throw error for empty string', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'email';
const value = '';
// Act & Assert
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
});
it('should throw error for string with only spaces', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'email';
const value = ' ';
// Act & Assert
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
});
it('should throw error for non-string value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'email';
const value = 123;
// Act & Assert
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
});
it('should throw error for null value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'email';
const value = null;
// Act & Assert
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
});
it('should throw error for undefined value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'email';
const value = undefined;
// Act & Assert
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
});
});
describe('assertStringArray', () => {
it('should pass for valid string array', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'roles';
const value = ['admin', 'user'];
// Act & Assert
expect(() => assertStringArray(entityName, fieldName, value)).not.toThrow();
});
it('should pass for empty array', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'roles';
const value = [];
// Act & Assert
expect(() => assertStringArray(entityName, fieldName, value)).not.toThrow();
});
it('should throw error for non-array value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'roles';
const value = 'admin';
// Act & Assert
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
});
it('should throw error for array with non-string items', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'roles';
const value = ['admin', 123];
// Act & Assert
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
});
it('should throw error for null value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'roles';
const value = null;
// Act & Assert
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
});
it('should throw error for undefined value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'roles';
const value = undefined;
// Act & Assert
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
});
});
describe('assertDate', () => {
it('should pass for valid Date', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'createdAt';
const value = new Date();
// Act & Assert
expect(() => assertDate(entityName, fieldName, value)).not.toThrow();
});
it('should pass for specific date', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'createdAt';
const value = new Date('2024-01-01');
// Act & Assert
expect(() => assertDate(entityName, fieldName, value)).not.toThrow();
});
it('should throw error for invalid date', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'createdAt';
const value = new Date('invalid');
// Act & Assert
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
});
it('should throw error for non-Date value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'createdAt';
const value = '2024-01-01';
// Act & Assert
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
});
it('should throw error for null value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'createdAt';
const value = null;
// Act & Assert
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
});
it('should throw error for undefined value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'createdAt';
const value = undefined;
// Act & Assert
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
});
});
describe('assertOptionalDate', () => {
it('should pass for valid Date', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'lastLoginAt';
const value = new Date();
// Act & Assert
expect(() => assertOptionalDate(entityName, fieldName, value)).not.toThrow();
});
it('should pass for null value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'lastLoginAt';
const value = null;
// Act & Assert
expect(() => assertOptionalDate(entityName, fieldName, value)).not.toThrow();
});
it('should pass for undefined value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'lastLoginAt';
const value = undefined;
// Act & Assert
expect(() => assertOptionalDate(entityName, fieldName, value)).not.toThrow();
});
it('should throw error for invalid date', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'lastLoginAt';
const value = new Date('invalid');
// Act & Assert
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow('Field lastLoginAt must be a valid Date');
});
it('should throw error for non-Date value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'lastLoginAt';
const value = '2024-01-01';
// Act & Assert
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow('Field lastLoginAt must be a valid Date');
});
});
describe('assertOptionalString', () => {
it('should pass for valid string', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'primaryDriverId';
const value = 'driver-123';
// Act & Assert
expect(() => assertOptionalString(entityName, fieldName, value)).not.toThrow();
});
it('should pass for null value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'primaryDriverId';
const value = null;
// Act & Assert
expect(() => assertOptionalString(entityName, fieldName, value)).not.toThrow();
});
it('should pass for undefined value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'primaryDriverId';
const value = undefined;
// Act & Assert
expect(() => assertOptionalString(entityName, fieldName, value)).not.toThrow();
});
it('should throw error for non-string value', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'primaryDriverId';
const value = 123;
// Act & Assert
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow('Field primaryDriverId must be a string or undefined');
});
it('should throw error for empty string', () => {
// Arrange
const entityName = 'AdminUser';
const fieldName = 'primaryDriverId';
const value = '';
// Act & Assert
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow('Field primaryDriverId must be a string or undefined');
});
});
describe('real-world scenarios', () => {
it('should validate complete admin user entity', () => {
// Arrange
const entityName = 'AdminUser';
const id = 'user-123';
const email = 'admin@example.com';
const displayName = 'Admin User';
const roles = ['owner', 'admin'];
const status = 'active';
const createdAt = new Date();
const updatedAt = new Date();
// Act & Assert
expect(() => assertNonEmptyString(entityName, 'id', id)).not.toThrow();
expect(() => assertNonEmptyString(entityName, 'email', email)).not.toThrow();
expect(() => assertNonEmptyString(entityName, 'displayName', displayName)).not.toThrow();
expect(() => assertStringArray(entityName, 'roles', roles)).not.toThrow();
expect(() => assertNonEmptyString(entityName, 'status', status)).not.toThrow();
expect(() => assertDate(entityName, 'createdAt', createdAt)).not.toThrow();
expect(() => assertDate(entityName, 'updatedAt', updatedAt)).not.toThrow();
});
it('should validate admin user with optional fields', () => {
// Arrange
const entityName = 'AdminUser';
const primaryDriverId = 'driver-456';
const lastLoginAt = new Date();
// Act & Assert
expect(() => assertOptionalString(entityName, 'primaryDriverId', primaryDriverId)).not.toThrow();
expect(() => assertOptionalDate(entityName, 'lastLoginAt', lastLoginAt)).not.toThrow();
});
it('should validate admin user without optional fields', () => {
// Arrange
const entityName = 'AdminUser';
const primaryDriverId = undefined;
const lastLoginAt = null;
// Act & Assert
expect(() => assertOptionalString(entityName, 'primaryDriverId', primaryDriverId)).not.toThrow();
expect(() => assertOptionalDate(entityName, 'lastLoginAt', lastLoginAt)).not.toThrow();
});
});
});
});

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { TypeOrmAnalyticsSchemaError } from './TypeOrmAnalyticsSchemaError';
describe('TypeOrmAnalyticsSchemaError', () => {
it('contains entity, field, and reason', () => {
// Given
const params = {
entityName: 'AnalyticsSnapshot',
fieldName: 'metrics.pageViews',
reason: 'not_number' as const,
message: 'Custom message',
};
// When
const error = new TypeOrmAnalyticsSchemaError(params);
// Then
expect(error.name).toBe('TypeOrmAnalyticsSchemaError');
expect(error.entityName).toBe(params.entityName);
expect(error.fieldName).toBe(params.fieldName);
expect(error.reason).toBe(params.reason);
expect(error.message).toBe(params.message);
});
it('works without optional message', () => {
// Given
const params = {
entityName: 'EngagementEvent',
fieldName: 'id',
reason: 'missing' as const,
};
// When
const error = new TypeOrmAnalyticsSchemaError(params);
// Then
expect(error.message).toBe('');
expect(error.entityName).toBe(params.entityName);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { AnalyticsSnapshotOrmMapper } from './AnalyticsSnapshotOrmMapper';
describe('AnalyticsSnapshotOrmMapper', () => {
const mapper = new AnalyticsSnapshotOrmMapper();
it('maps domain -> orm -> domain (round-trip)', () => {
// Given
const domain = AnalyticsSnapshot.create({
id: 'snap_1',
entityType: 'league',
entityId: 'league-1',
period: 'daily',
startDate: new Date('2025-01-01T00:00:00.000Z'),
endDate: new Date('2025-01-01T23:59:59.999Z'),
metrics: {
pageViews: 100,
uniqueVisitors: 50,
avgSessionDuration: 120,
bounceRate: 0.4,
engagementScore: 75,
sponsorClicks: 10,
sponsorUrlClicks: 5,
socialShares: 2,
leagueJoins: 1,
raceRegistrations: 3,
exposureValue: 150.5,
},
createdAt: new Date('2025-01-02T00:00:00.000Z'),
});
// When
const orm = mapper.toOrmEntity(domain);
const rehydrated = mapper.toDomain(orm);
// Then
expect(orm).toBeInstanceOf(AnalyticsSnapshotOrmEntity);
expect(orm.id).toBe(domain.id);
expect(rehydrated.id).toBe(domain.id);
expect(rehydrated.entityType).toBe(domain.entityType);
expect(rehydrated.entityId).toBe(domain.entityId);
expect(rehydrated.period).toBe(domain.period);
expect(rehydrated.startDate.toISOString()).toBe(domain.startDate.toISOString());
expect(rehydrated.endDate.toISOString()).toBe(domain.endDate.toISOString());
expect(rehydrated.metrics).toEqual(domain.metrics);
expect(rehydrated.createdAt.toISOString()).toBe(domain.createdAt.toISOString());
});
it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => {
// Given
const orm = new AnalyticsSnapshotOrmEntity();
orm.id = ''; // Invalid: empty
orm.entityType = 'league' as any;
orm.entityId = 'league-1';
orm.period = 'daily' as any;
orm.startDate = new Date();
orm.endDate = new Date();
orm.metrics = {} as any; // Invalid: missing fields
orm.createdAt = new Date();
// When / Then
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
});
it('throws TypeOrmAnalyticsSchemaError when metrics are missing required fields', () => {
// Given
const orm = new AnalyticsSnapshotOrmEntity();
orm.id = 'snap_1';
orm.entityType = 'league' as any;
orm.entityId = 'league-1';
orm.period = 'daily' as any;
orm.startDate = new Date();
orm.endDate = new Date();
orm.metrics = { pageViews: 100 } as any; // Missing other metrics
orm.createdAt = new Date();
// When / Then
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
try {
mapper.toDomain(orm);
} catch (e: any) {
expect(e.fieldName).toContain('metrics.');
}
});
});

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { EngagementEventOrmMapper } from './EngagementEventOrmMapper';
describe('EngagementEventOrmMapper', () => {
const mapper = new EngagementEventOrmMapper();
it('maps domain -> orm -> domain (round-trip)', () => {
// Given
const domain = EngagementEvent.create({
id: 'eng_1',
action: 'click_sponsor_logo',
entityType: 'sponsor',
entityId: 'sponsor-1',
actorType: 'driver',
actorId: 'driver-1',
sessionId: 'sess-1',
metadata: { key: 'value', num: 123, bool: true },
timestamp: new Date('2025-01-01T10:00:00.000Z'),
});
// When
const orm = mapper.toOrmEntity(domain);
const rehydrated = mapper.toDomain(orm);
// Then
expect(orm).toBeInstanceOf(EngagementEventOrmEntity);
expect(orm.id).toBe(domain.id);
expect(rehydrated.id).toBe(domain.id);
expect(rehydrated.action).toBe(domain.action);
expect(rehydrated.entityType).toBe(domain.entityType);
expect(rehydrated.entityId).toBe(domain.entityId);
expect(rehydrated.actorType).toBe(domain.actorType);
expect(rehydrated.actorId).toBe(domain.actorId);
expect(rehydrated.sessionId).toBe(domain.sessionId);
expect(rehydrated.metadata).toEqual(domain.metadata);
expect(rehydrated.timestamp.toISOString()).toBe(domain.timestamp.toISOString());
});
it('maps domain -> orm -> domain with nulls', () => {
// Given
const domain = EngagementEvent.create({
id: 'eng_2',
action: 'view_standings',
entityType: 'league',
entityId: 'league-1',
actorType: 'anonymous',
sessionId: 'sess-2',
timestamp: new Date('2025-01-01T11:00:00.000Z'),
});
// When
const orm = mapper.toOrmEntity(domain);
const rehydrated = mapper.toDomain(orm);
// Then
expect(orm.actorId).toBeNull();
expect(orm.metadata).toBeNull();
expect(rehydrated.actorId).toBeUndefined();
expect(rehydrated.metadata).toBeUndefined();
});
it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => {
// Given
const orm = new EngagementEventOrmEntity();
orm.id = ''; // Invalid
orm.action = 'invalid_action' as any;
orm.entityType = 'league' as any;
orm.entityId = 'league-1';
orm.actorType = 'anonymous' as any;
orm.sessionId = 'sess-1';
orm.timestamp = new Date();
// When / Then
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
});
it('throws TypeOrmAnalyticsSchemaError for invalid metadata values', () => {
// Given
const orm = new EngagementEventOrmEntity();
orm.id = 'eng_1';
orm.action = 'click_sponsor_logo' as any;
orm.entityType = 'sponsor' as any;
orm.entityId = 'sponsor-1';
orm.actorType = 'driver' as any;
orm.sessionId = 'sess-1';
orm.timestamp = new Date();
orm.metadata = { invalid: { nested: 'object' } } as any;
// When / Then
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
try {
mapper.toDomain(orm);
} catch (e: any) {
expect(e.reason).toBe('invalid_shape');
expect(e.fieldName).toBe('metadata');
}
});
});

View File

@@ -0,0 +1,102 @@
import type { Repository } from 'typeorm';
import { describe, expect, it, vi } from 'vitest';
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
import { AnalyticsSnapshotOrmMapper } from '../mappers/AnalyticsSnapshotOrmMapper';
import { TypeOrmAnalyticsSnapshotRepository } from './TypeOrmAnalyticsSnapshotRepository';
describe('TypeOrmAnalyticsSnapshotRepository', () => {
it('saves mapped entities via injected mapper', async () => {
// Given
const orm = new AnalyticsSnapshotOrmEntity();
orm.id = 'snap_1';
const mapper: AnalyticsSnapshotOrmMapper = {
toOrmEntity: vi.fn().mockReturnValue(orm),
toDomain: vi.fn(),
} as unknown as AnalyticsSnapshotOrmMapper;
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
save: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<AnalyticsSnapshotOrmEntity>;
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
const domain = AnalyticsSnapshot.create({
id: 'snap_1',
entityType: 'league',
entityId: 'league-1',
period: 'daily',
startDate: new Date(),
endDate: new Date(),
metrics: {} as any,
createdAt: new Date(),
});
// When
await sut.save(domain);
// Then
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domain);
expect(repo.save).toHaveBeenCalledWith(orm);
});
it('findById maps entity -> domain', async () => {
// Given
const orm = new AnalyticsSnapshotOrmEntity();
orm.id = 'snap_1';
const domain = AnalyticsSnapshot.create({
id: 'snap_1',
entityType: 'league',
entityId: 'league-1',
period: 'daily',
startDate: new Date(),
endDate: new Date(),
metrics: {} as any,
createdAt: new Date(),
});
const mapper: AnalyticsSnapshotOrmMapper = {
toOrmEntity: vi.fn(),
toDomain: vi.fn().mockReturnValue(domain),
} as unknown as AnalyticsSnapshotOrmMapper;
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
findOneBy: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<AnalyticsSnapshotOrmEntity>;
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
// When
const result = await sut.findById('snap_1');
// Then
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'snap_1' });
expect(mapper.toDomain).toHaveBeenCalledWith(orm);
expect(result?.id).toBe('snap_1');
});
it('findLatest uses correct query options', async () => {
// Given
const orm = new AnalyticsSnapshotOrmEntity();
const mapper: AnalyticsSnapshotOrmMapper = {
toDomain: vi.fn().mockReturnValue({ id: 'snap_1' } as any),
} as any;
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
findOne: vi.fn().mockResolvedValue(orm),
} as any;
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
// When
await sut.findLatest('league', 'league-1', 'daily');
// Then
expect(repo.findOne).toHaveBeenCalledWith({
where: { entityType: 'league', entityId: 'league-1', period: 'daily' },
order: { endDate: 'DESC' },
});
});
});

View File

@@ -0,0 +1,100 @@
import type { Repository } from 'typeorm';
import { describe, expect, it, vi } from 'vitest';
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
import { EngagementEventOrmMapper } from '../mappers/EngagementEventOrmMapper';
import { TypeOrmEngagementRepository } from './TypeOrmEngagementRepository';
describe('TypeOrmEngagementRepository', () => {
it('saves mapped entities via injected mapper', async () => {
// Given
const orm = new EngagementEventOrmEntity();
orm.id = 'eng_1';
const mapper: EngagementEventOrmMapper = {
toOrmEntity: vi.fn().mockReturnValue(orm),
toDomain: vi.fn(),
} as unknown as EngagementEventOrmMapper;
const repo: Repository<EngagementEventOrmEntity> = {
save: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<EngagementEventOrmEntity>;
const sut = new TypeOrmEngagementRepository(repo, mapper);
const domain = EngagementEvent.create({
id: 'eng_1',
action: 'click_sponsor_logo',
entityType: 'sponsor',
entityId: 'sponsor-1',
actorType: 'anonymous',
sessionId: 'sess-1',
timestamp: new Date(),
});
// When
await sut.save(domain);
// Then
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domain);
expect(repo.save).toHaveBeenCalledWith(orm);
});
it('findById maps entity -> domain', async () => {
// Given
const orm = new EngagementEventOrmEntity();
orm.id = 'eng_1';
const domain = EngagementEvent.create({
id: 'eng_1',
action: 'click_sponsor_logo',
entityType: 'sponsor',
entityId: 'sponsor-1',
actorType: 'anonymous',
sessionId: 'sess-1',
timestamp: new Date(),
});
const mapper: EngagementEventOrmMapper = {
toOrmEntity: vi.fn(),
toDomain: vi.fn().mockReturnValue(domain),
} as unknown as EngagementEventOrmMapper;
const repo: Repository<EngagementEventOrmEntity> = {
findOneBy: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<EngagementEventOrmEntity>;
const sut = new TypeOrmEngagementRepository(repo, mapper);
// When
const result = await sut.findById('eng_1');
// Then
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'eng_1' });
expect(mapper.toDomain).toHaveBeenCalledWith(orm);
expect(result?.id).toBe('eng_1');
});
it('countByAction uses correct where clause', async () => {
// Given
const repo: Repository<EngagementEventOrmEntity> = {
count: vi.fn().mockResolvedValue(5),
} as any;
const sut = new TypeOrmEngagementRepository(repo, {} as any);
const since = new Date();
// When
await sut.countByAction('click_sponsor_logo', 'sponsor-1', since);
// Then
expect(repo.count).toHaveBeenCalledWith({
where: expect.objectContaining({
action: 'click_sponsor_logo',
entityId: 'sponsor-1',
timestamp: expect.anything(),
}),
});
});
});

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import {
assertBoolean,
assertDate,
assertEnumValue,
assertInteger,
assertNonEmptyString,
assertNumber,
assertOptionalIntegerOrNull,
assertOptionalNumberOrNull,
assertOptionalStringOrNull,
assertRecord,
} from './TypeOrmAnalyticsSchemaGuards';
describe('TypeOrmAnalyticsSchemaGuards', () => {
const entity = 'TestEntity';
describe('assertNonEmptyString', () => {
it('accepts valid string', () => {
expect(() => assertNonEmptyString(entity, 'field', 'valid')).not.toThrow();
});
it('rejects null/undefined', () => {
expect(() => assertNonEmptyString(entity, 'field', null)).toThrow(TypeOrmAnalyticsSchemaError);
expect(() => assertNonEmptyString(entity, 'field', undefined)).toThrow(TypeOrmAnalyticsSchemaError);
});
it('rejects empty/whitespace string', () => {
expect(() => assertNonEmptyString(entity, 'field', '')).toThrow(TypeOrmAnalyticsSchemaError);
expect(() => assertNonEmptyString(entity, 'field', ' ')).toThrow(TypeOrmAnalyticsSchemaError);
});
it('rejects non-string', () => {
expect(() => assertNonEmptyString(entity, 'field', 123)).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertOptionalStringOrNull', () => {
it('accepts valid string, null, or undefined', () => {
expect(() => assertOptionalStringOrNull(entity, 'field', 'valid')).not.toThrow();
expect(() => assertOptionalStringOrNull(entity, 'field', null)).not.toThrow();
expect(() => assertOptionalStringOrNull(entity, 'field', undefined)).not.toThrow();
});
it('rejects non-string', () => {
expect(() => assertOptionalStringOrNull(entity, 'field', 123)).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertNumber', () => {
it('accepts valid number', () => {
expect(() => assertNumber(entity, 'field', 123.45)).not.toThrow();
expect(() => assertNumber(entity, 'field', 0)).not.toThrow();
});
it('rejects NaN', () => {
expect(() => assertNumber(entity, 'field', NaN)).toThrow(TypeOrmAnalyticsSchemaError);
});
it('rejects non-number', () => {
expect(() => assertNumber(entity, 'field', '123')).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertOptionalNumberOrNull', () => {
it('accepts valid number, null, or undefined', () => {
expect(() => assertOptionalNumberOrNull(entity, 'field', 123)).not.toThrow();
expect(() => assertOptionalNumberOrNull(entity, 'field', null)).not.toThrow();
expect(() => assertOptionalNumberOrNull(entity, 'field', undefined)).not.toThrow();
});
});
describe('assertInteger', () => {
it('accepts valid integer', () => {
expect(() => assertInteger(entity, 'field', 123)).not.toThrow();
});
it('rejects float', () => {
expect(() => assertInteger(entity, 'field', 123.45)).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertOptionalIntegerOrNull', () => {
it('accepts valid integer, null, or undefined', () => {
expect(() => assertOptionalIntegerOrNull(entity, 'field', 123)).not.toThrow();
expect(() => assertOptionalIntegerOrNull(entity, 'field', null)).not.toThrow();
});
});
describe('assertBoolean', () => {
it('accepts boolean', () => {
expect(() => assertBoolean(entity, 'field', true)).not.toThrow();
expect(() => assertBoolean(entity, 'field', false)).not.toThrow();
});
it('rejects non-boolean', () => {
expect(() => assertBoolean(entity, 'field', 'true')).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertDate', () => {
it('accepts valid Date', () => {
expect(() => assertDate(entity, 'field', new Date())).not.toThrow();
});
it('rejects invalid Date', () => {
expect(() => assertDate(entity, 'field', new Date('invalid'))).toThrow(TypeOrmAnalyticsSchemaError);
});
it('rejects non-Date', () => {
expect(() => assertDate(entity, 'field', '2025-01-01')).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertEnumValue', () => {
const allowed = ['a', 'b'] as const;
it('accepts allowed value', () => {
expect(() => assertEnumValue(entity, 'field', 'a', allowed)).not.toThrow();
});
it('rejects disallowed value', () => {
expect(() => assertEnumValue(entity, 'field', 'c', allowed)).toThrow(TypeOrmAnalyticsSchemaError);
});
});
describe('assertRecord', () => {
it('accepts object', () => {
expect(() => assertRecord(entity, 'field', { a: 1 })).not.toThrow();
});
it('rejects array', () => {
expect(() => assertRecord(entity, 'field', [])).toThrow(TypeOrmAnalyticsSchemaError);
});
it('rejects null', () => {
expect(() => assertRecord(entity, 'field', null)).toThrow(TypeOrmAnalyticsSchemaError);
});
});
});

View File

@@ -0,0 +1,77 @@
import { InMemoryDriverRepository } from './InMemoryDriverRepository';
import { DriverData } from '../../../../core/dashboard/application/ports/DashboardRepository';
describe('InMemoryDriverRepository', () => {
let repository: InMemoryDriverRepository;
beforeEach(() => {
repository = new InMemoryDriverRepository();
});
describe('findDriverById', () => {
it('should return null when driver does not exist', async () => {
// Given
const driverId = 'non-existent';
// When
const result = await repository.findDriverById(driverId);
// Then
expect(result).toBeNull();
});
it('should return driver when it exists', async () => {
// Given
const driver: DriverData = {
id: 'driver-1',
name: 'John Doe',
rating: 1500,
rank: 10,
starts: 100,
wins: 10,
podiums: 30,
leagues: 5,
};
repository.addDriver(driver);
// When
const result = await repository.findDriverById(driver.id);
// Then
expect(result).toEqual(driver);
});
it('should overwrite driver with same id (idempotency)', async () => {
// Given
const driverId = 'driver-1';
const driver1: DriverData = {
id: driverId,
name: 'John Doe',
rating: 1500,
rank: 10,
starts: 100,
wins: 10,
podiums: 30,
leagues: 5,
};
const driver2: DriverData = {
id: driverId,
name: 'John Updated',
rating: 1600,
rank: 5,
starts: 101,
wins: 11,
podiums: 31,
leagues: 5,
};
// When
repository.addDriver(driver1);
repository.addDriver(driver2);
const result = await repository.findDriverById(driverId);
// Then
expect(result).toEqual(driver2);
});
});
});

View File

@@ -0,0 +1,77 @@
import { InMemoryEventPublisher } from './InMemoryEventPublisher';
import { DashboardAccessedEvent } from '../../core/dashboard/application/ports/DashboardEventPublisher';
import { LeagueCreatedEvent } from '../../core/leagues/application/ports/LeagueEventPublisher';
describe('InMemoryEventPublisher', () => {
let publisher: InMemoryEventPublisher;
beforeEach(() => {
publisher = new InMemoryEventPublisher();
});
describe('Dashboard Events', () => {
it('should publish and track dashboard accessed events', async () => {
// Given
const event: DashboardAccessedEvent = { userId: 'user-1', timestamp: new Date() };
// When
await publisher.publishDashboardAccessed(event);
// Then
expect(publisher.getDashboardAccessedEventCount()).toBe(1);
});
it('should throw error when configured to fail', async () => {
// Given
publisher.setShouldFail(true);
const event: DashboardAccessedEvent = { userId: 'user-1', timestamp: new Date() };
// When & Then
await expect(publisher.publishDashboardAccessed(event)).rejects.toThrow('Event publisher failed');
});
});
describe('League Events', () => {
it('should publish and track league created events', async () => {
// Given
const event: LeagueCreatedEvent = { leagueId: 'league-1', name: 'Test League', timestamp: new Date() };
// When
await publisher.emitLeagueCreated(event);
// Then
expect(publisher.getLeagueCreatedEventCount()).toBe(1);
expect(publisher.getLeagueCreatedEvents()).toContainEqual(event);
});
});
describe('Generic Domain Events', () => {
it('should publish and track generic domain events', async () => {
// Given
const event = { type: 'TestEvent', timestamp: new Date() };
// When
await publisher.publish(event);
// Then
expect(publisher.getEvents()).toContainEqual(event);
});
});
describe('Maintenance', () => {
it('should clear all events', async () => {
// Given
await publisher.publishDashboardAccessed({ userId: 'u1', timestamp: new Date() });
await publisher.emitLeagueCreated({ leagueId: 'l1', name: 'L1', timestamp: new Date() });
await publisher.publish({ type: 'Generic', timestamp: new Date() });
// When
publisher.clear();
// Then
expect(publisher.getDashboardAccessedEventCount()).toBe(0);
expect(publisher.getLeagueCreatedEventCount()).toBe(0);
expect(publisher.getEvents().length).toBe(0);
});
});
});

View File

@@ -0,0 +1,103 @@
import { InMemoryHealthEventPublisher } from './InMemoryHealthEventPublisher';
describe('InMemoryHealthEventPublisher', () => {
let publisher: InMemoryHealthEventPublisher;
beforeEach(() => {
publisher = new InMemoryHealthEventPublisher();
});
describe('Health Check Events', () => {
it('should publish and track health check completed events', async () => {
// Given
const event = {
healthy: true,
responseTime: 100,
timestamp: new Date(),
endpoint: 'http://api.test/health',
};
// When
await publisher.publishHealthCheckCompleted(event);
// Then
expect(publisher.getEventCount()).toBe(1);
expect(publisher.getEventCountByType('HealthCheckCompleted')).toBe(1);
const events = publisher.getEventsByType('HealthCheckCompleted');
expect(events[0]).toMatchObject({
type: 'HealthCheckCompleted',
...event,
});
});
it('should publish and track health check failed events', async () => {
// Given
const event = {
error: 'Connection refused',
timestamp: new Date(),
endpoint: 'http://api.test/health',
};
// When
await publisher.publishHealthCheckFailed(event);
// Then
expect(publisher.getEventCountByType('HealthCheckFailed')).toBe(1);
});
});
describe('Connection Status Events', () => {
it('should publish and track connected events', async () => {
// Given
const event = {
timestamp: new Date(),
responseTime: 50,
};
// When
await publisher.publishConnected(event);
// Then
expect(publisher.getEventCountByType('Connected')).toBe(1);
});
it('should publish and track disconnected events', async () => {
// Given
const event = {
timestamp: new Date(),
consecutiveFailures: 3,
};
// When
await publisher.publishDisconnected(event);
// Then
expect(publisher.getEventCountByType('Disconnected')).toBe(1);
});
});
describe('Error Handling', () => {
it('should throw error when configured to fail', async () => {
// Given
publisher.setShouldFail(true);
const event = { timestamp: new Date() };
// When & Then
await expect(publisher.publishChecking(event)).rejects.toThrow('Event publisher failed');
});
});
describe('Maintenance', () => {
it('should clear all events', async () => {
// Given
await publisher.publishChecking({ timestamp: new Date() });
await publisher.publishConnected({ timestamp: new Date(), responseTime: 10 });
// When
publisher.clear();
// Then
expect(publisher.getEventCount()).toBe(0);
});
});
});

View File

@@ -0,0 +1,123 @@
import { InMemoryHealthCheckAdapter } from './InMemoryHealthCheckAdapter';
describe('InMemoryHealthCheckAdapter', () => {
let adapter: InMemoryHealthCheckAdapter;
beforeEach(() => {
adapter = new InMemoryHealthCheckAdapter();
adapter.setResponseTime(0); // Speed up tests
});
describe('Health Checks', () => {
it('should return healthy by default', async () => {
// When
const result = await adapter.performHealthCheck();
// Then
expect(result.healthy).toBe(true);
expect(adapter.getStatus()).toBe('connected');
});
it('should return unhealthy when configured to fail', async () => {
// Given
adapter.setShouldFail(true, 'Custom error');
// When
const result = await adapter.performHealthCheck();
// Then
expect(result.healthy).toBe(false);
expect(result.error).toBe('Custom error');
});
});
describe('Status Transitions', () => {
it('should transition to disconnected after 3 consecutive failures', async () => {
// Given
adapter.setShouldFail(true);
// When
await adapter.performHealthCheck(); // 1
expect(adapter.getStatus()).toBe('checking'); // Initial state is disconnected, first failure keeps it checking/disconnected
await adapter.performHealthCheck(); // 2
await adapter.performHealthCheck(); // 3
// Then
expect(adapter.getStatus()).toBe('disconnected');
});
it('should transition to degraded if reliability is low', async () => {
// Given
// We need 5 requests total, and reliability < 0.7
// 1 success, 4 failures (not consecutive)
await adapter.performHealthCheck(); // Success 1
adapter.setShouldFail(true);
await adapter.performHealthCheck(); // Failure 1
adapter.setShouldFail(false);
await adapter.performHealthCheck(); // Success 2 (resets consecutive)
adapter.setShouldFail(true);
await adapter.performHealthCheck(); // Failure 2
await adapter.performHealthCheck(); // Failure 3
adapter.setShouldFail(false);
await adapter.performHealthCheck(); // Success 3 (resets consecutive)
adapter.setShouldFail(true);
await adapter.performHealthCheck(); // Failure 4
await adapter.performHealthCheck(); // Failure 5
// Then
expect(adapter.getStatus()).toBe('degraded');
expect(adapter.getReliability()).toBeLessThan(70);
});
it('should recover status after a success', async () => {
// Given
adapter.setShouldFail(true);
await adapter.performHealthCheck();
await adapter.performHealthCheck();
await adapter.performHealthCheck();
expect(adapter.getStatus()).toBe('disconnected');
// When
adapter.setShouldFail(false);
await adapter.performHealthCheck();
// Then
expect(adapter.getStatus()).toBe('connected');
expect(adapter.isAvailable()).toBe(true);
});
});
describe('Metrics', () => {
it('should track average response time', async () => {
// Given
adapter.setResponseTime(10);
await adapter.performHealthCheck();
adapter.setResponseTime(20);
await adapter.performHealthCheck();
// Then
const health = adapter.getHealth();
expect(health.averageResponseTime).toBe(15);
expect(health.totalRequests).toBe(2);
});
});
describe('Maintenance', () => {
it('should clear state', async () => {
// Given
await adapter.performHealthCheck();
expect(adapter.getHealth().totalRequests).toBe(1);
// When
adapter.clear();
// Then
expect(adapter.getHealth().totalRequests).toBe(0);
expect(adapter.getStatus()).toBe('disconnected'); // Initial state
});
});
});

View File

@@ -0,0 +1,63 @@
import { Request, Response } from 'express';
import { getHttpRequestContext, requestContextMiddleware, tryGetHttpRequestContext } from './RequestContext';
describe('RequestContext', () => {
it('should return null when accessed outside of middleware', () => {
// When
const ctx = tryGetHttpRequestContext();
// Then
expect(ctx).toBeNull();
});
it('should throw error when getHttpRequestContext is called outside of middleware', () => {
// When & Then
expect(() => getHttpRequestContext()).toThrow('HttpRequestContext is not available');
});
it('should provide request and response within middleware scope', () => {
// Given
const mockReq = { id: 'req-1' } as unknown as Request;
const mockRes = { id: 'res-1' } as unknown as Response;
// When
return new Promise<void>((resolve) => {
requestContextMiddleware(mockReq, mockRes, () => {
// Then
const ctx = getHttpRequestContext();
expect(ctx.req).toBe(mockReq);
expect(ctx.res).toBe(mockRes);
resolve();
});
});
});
it('should maintain separate contexts for concurrent requests', () => {
// Given
const req1 = { id: '1' } as unknown as Request;
const res1 = { id: '1' } as unknown as Response;
const req2 = { id: '2' } as unknown as Request;
const res2 = { id: '2' } as unknown as Response;
// When
const p1 = new Promise<void>((resolve) => {
requestContextMiddleware(req1, res1, () => {
setTimeout(() => {
expect(getHttpRequestContext().req).toBe(req1);
resolve();
}, 10);
});
});
const p2 = new Promise<void>((resolve) => {
requestContextMiddleware(req2, res2, () => {
setTimeout(() => {
expect(getHttpRequestContext().req).toBe(req2);
resolve();
}, 5);
});
});
return Promise.all([p1, p2]);
});
});

View File

@@ -0,0 +1,73 @@
import { InMemoryLeaderboardsRepository } from './InMemoryLeaderboardsRepository';
import { LeaderboardDriverData, LeaderboardTeamData } from '../../../../core/leaderboards/application/ports/LeaderboardsRepository';
describe('InMemoryLeaderboardsRepository', () => {
let repository: InMemoryLeaderboardsRepository;
beforeEach(() => {
repository = new InMemoryLeaderboardsRepository();
});
describe('drivers', () => {
it('should return empty array when no drivers exist', async () => {
// When
const result = await repository.findAllDrivers();
// Then
expect(result).toEqual([]);
});
it('should add and find all drivers', async () => {
// Given
const driver: LeaderboardDriverData = {
id: 'd1',
name: 'Driver 1',
rating: 1500,
raceCount: 10,
teamId: 't1',
teamName: 'Team 1',
};
repository.addDriver(driver);
// When
const result = await repository.findAllDrivers();
// Then
expect(result).toEqual([driver]);
});
it('should find drivers by team id', async () => {
// Given
const d1: LeaderboardDriverData = { id: 'd1', name: 'D1', rating: 1500, raceCount: 10, teamId: 't1', teamName: 'T1' };
const d2: LeaderboardDriverData = { id: 'd2', name: 'D2', rating: 1400, raceCount: 5, teamId: 't2', teamName: 'T2' };
repository.addDriver(d1);
repository.addDriver(d2);
// When
const result = await repository.findDriversByTeamId('t1');
// Then
expect(result).toEqual([d1]);
});
});
describe('teams', () => {
it('should add and find all teams', async () => {
// Given
const team: LeaderboardTeamData = {
id: 't1',
name: 'Team 1',
rating: 3000,
memberCount: 2,
raceCount: 20,
};
repository.addTeam(team);
// When
const result = await repository.findAllTeams();
// Then
expect(result).toEqual([team]);
});
});
});

View File

@@ -0,0 +1,127 @@
import { InMemoryLeagueRepository } from './InMemoryLeagueRepository';
import { LeagueData } from '../../../../core/leagues/application/ports/LeagueRepository';
describe('InMemoryLeagueRepository', () => {
let repository: InMemoryLeagueRepository;
beforeEach(() => {
repository = new InMemoryLeagueRepository();
});
const createLeague = (id: string, name: string, ownerId: string): LeagueData => ({
id,
name,
ownerId,
description: `Description for ${name}`,
visibility: 'public',
status: 'active',
createdAt: new Date(),
updatedAt: new Date(),
maxDrivers: 100,
approvalRequired: false,
lateJoinAllowed: true,
raceFrequency: 'weekly',
raceDay: 'Monday',
raceTime: '20:00',
tracks: ['Spa'],
scoringSystem: null,
bonusPointsEnabled: true,
penaltiesEnabled: true,
protestsEnabled: true,
appealsEnabled: true,
stewardTeam: [],
gameType: 'iRacing',
skillLevel: 'Intermediate',
category: 'Road',
tags: [],
});
describe('create and findById', () => {
it('should return null when league does not exist', async () => {
// When
const result = await repository.findById('non-existent');
// Then
expect(result).toBeNull();
});
it('should create and retrieve a league', async () => {
// Given
const league = createLeague('l1', 'League 1', 'o1');
// When
await repository.create(league);
const result = await repository.findById('l1');
// Then
expect(result).toEqual(league);
});
});
describe('findByName', () => {
it('should find a league by name', async () => {
// Given
const league = createLeague('l1', 'Unique Name', 'o1');
await repository.create(league);
// When
const result = await repository.findByName('Unique Name');
// Then
expect(result).toEqual(league);
});
});
describe('update', () => {
it('should update an existing league', async () => {
// Given
const league = createLeague('l1', 'Original Name', 'o1');
await repository.create(league);
// When
const updated = await repository.update('l1', { name: 'Updated Name' });
// Then
expect(updated.name).toBe('Updated Name');
const result = await repository.findById('l1');
expect(result?.name).toBe('Updated Name');
});
it('should throw error when updating non-existent league', async () => {
// When & Then
await expect(repository.update('non-existent', { name: 'New' })).rejects.toThrow();
});
});
describe('delete', () => {
it('should delete a league', async () => {
// Given
const league = createLeague('l1', 'To Delete', 'o1');
await repository.create(league);
// When
await repository.delete('l1');
// Then
const result = await repository.findById('l1');
expect(result).toBeNull();
});
});
describe('search', () => {
it('should find leagues by name or description', async () => {
// Given
const l1 = createLeague('l1', 'Formula 1', 'o1');
const l2 = createLeague('l2', 'GT3 Masters', 'o1');
await repository.create(l1);
await repository.create(l2);
// When
const results = await repository.search('Formula');
// Then
expect(results).toHaveLength(1);
expect(results[0].id).toBe('l1');
});
});
});

View File

@@ -0,0 +1,23 @@
import { describe, vi } from 'vitest';
import { InMemoryMediaRepository } from './InMemoryMediaRepository';
import { runMediaRepositoryContract } from '../../../../tests/contracts/media/MediaRepository.contract';
describe('InMemoryMediaRepository Contract Compliance', () => {
runMediaRepositoryContract(async () => {
const logger = {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const repository = new InMemoryMediaRepository(logger as any);
return {
repository,
cleanup: async () => {
repository.clear();
}
};
});
});

View File

@@ -0,0 +1,42 @@
import { describe, vi } from 'vitest';
import { TypeOrmMediaRepository } from './TypeOrmMediaRepository';
import { MediaOrmMapper } from '../mappers/MediaOrmMapper';
import { runMediaRepositoryContract } from '../../../../../tests/contracts/media/MediaRepository.contract';
describe('TypeOrmMediaRepository Contract Compliance', () => {
runMediaRepositoryContract(async () => {
// Mocking TypeORM DataSource and Repository for a DB-free contract test
// In a real scenario, this might use an in-memory SQLite database
const ormEntities = new Map<string, any>();
const ormRepo = {
save: vi.fn().mockImplementation(async (entity) => {
ormEntities.set(entity.id, entity);
return entity;
}),
findOne: vi.fn().mockImplementation(async ({ where: { id } }) => {
return ormEntities.get(id) || null;
}),
find: vi.fn().mockImplementation(async ({ where: { uploadedBy } }) => {
return Array.from(ormEntities.values()).filter(e => e.uploadedBy === uploadedBy);
}),
delete: vi.fn().mockImplementation(async ({ id }) => {
ormEntities.delete(id);
}),
};
const dataSource = {
getRepository: vi.fn().mockReturnValue(ormRepo),
};
const mapper = new MediaOrmMapper();
const repository = new TypeOrmMediaRepository(dataSource as any, mapper);
return {
repository,
cleanup: async () => {
ormEntities.clear();
}
};
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DiscordNotificationAdapter } from './DiscordNotificationGateway';
import { Notification } from '@core/notifications/domain/entities/Notification';
describe('DiscordNotificationAdapter', () => {
const webhookUrl = 'https://discord.com/api/webhooks/123/abc';
let adapter: DiscordNotificationAdapter;
beforeEach(() => {
adapter = new DiscordNotificationAdapter({ webhookUrl });
vi.spyOn(console, 'log').mockImplementation(() => {});
});
const createNotification = (overrides: any = {}) => {
return Notification.create({
id: 'notif-123',
recipientId: 'driver-456',
type: 'protest_filed',
title: 'New Protest',
body: 'A new protest has been filed against you.',
channel: 'discord',
...overrides,
});
};
describe('send', () => {
it('should return success when configured', async () => {
// Given
const notification = createNotification();
// When
const result = await adapter.send(notification);
// Then
expect(result.success).toBe(true);
expect(result.channel).toBe('discord');
expect(result.externalId).toContain('discord-stub-');
expect(result.attemptedAt).toBeInstanceOf(Date);
});
it('should return failure when not configured', async () => {
// Given
const unconfiguredAdapter = new DiscordNotificationAdapter();
const notification = createNotification();
// When
const result = await unconfiguredAdapter.send(notification);
// Then
expect(result.success).toBe(false);
expect(result.error).toBe('Discord webhook URL not configured');
});
});
describe('supportsChannel', () => {
it('should return true for discord channel', () => {
expect(adapter.supportsChannel('discord')).toBe(true);
});
it('should return false for other channels', () => {
expect(adapter.supportsChannel('email' as any)).toBe(false);
});
});
describe('isConfigured', () => {
it('should return true when webhookUrl is set', () => {
expect(adapter.isConfigured()).toBe(true);
});
it('should return false when webhookUrl is missing', () => {
const unconfigured = new DiscordNotificationAdapter();
expect(unconfigured.isConfigured()).toBe(false);
});
});
describe('setWebhookUrl', () => {
it('should update the webhook URL', () => {
const unconfigured = new DiscordNotificationAdapter();
unconfigured.setWebhookUrl(webhookUrl);
expect(unconfigured.isConfigured()).toBe(true);
});
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { EmailNotificationAdapter } from './EmailNotificationGateway';
import { Notification } from '@core/notifications/domain/entities/Notification';
describe('EmailNotificationAdapter', () => {
const config = {
smtpHost: 'smtp.example.com',
fromAddress: 'noreply@gridpilot.com',
};
let adapter: EmailNotificationAdapter;
beforeEach(() => {
adapter = new EmailNotificationAdapter(config);
vi.spyOn(console, 'log').mockImplementation(() => {});
});
const createNotification = (overrides: any = {}) => {
return Notification.create({
id: 'notif-123',
recipientId: 'driver-456',
type: 'protest_filed',
title: 'New Protest',
body: 'A new protest has been filed against you.',
channel: 'email',
...overrides,
});
};
describe('send', () => {
it('should return success when configured', async () => {
// Given
const notification = createNotification();
// When
const result = await adapter.send(notification);
// Then
expect(result.success).toBe(true);
expect(result.channel).toBe('email');
expect(result.externalId).toContain('email-stub-');
expect(result.attemptedAt).toBeInstanceOf(Date);
});
it('should return failure when not configured', async () => {
// Given
const unconfiguredAdapter = new EmailNotificationAdapter();
const notification = createNotification();
// When
const result = await unconfiguredAdapter.send(notification);
// Then
expect(result.success).toBe(false);
expect(result.error).toBe('Email SMTP not configured');
});
});
describe('supportsChannel', () => {
it('should return true for email channel', () => {
expect(adapter.supportsChannel('email')).toBe(true);
});
it('should return false for other channels', () => {
expect(adapter.supportsChannel('discord' as any)).toBe(false);
});
});
describe('isConfigured', () => {
it('should return true when smtpHost and fromAddress are set', () => {
expect(adapter.isConfigured()).toBe(true);
});
it('should return false when config is missing', () => {
const unconfigured = new EmailNotificationAdapter();
expect(unconfigured.isConfigured()).toBe(false);
});
});
describe('configure', () => {
it('should update the configuration', () => {
const unconfigured = new EmailNotificationAdapter();
unconfigured.configure(config);
expect(unconfigured.isConfigured()).toBe(true);
});
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { InAppNotificationAdapter } from './InAppNotificationGateway';
import { Notification } from '@core/notifications/domain/entities/Notification';
describe('InAppNotificationAdapter', () => {
let adapter: InAppNotificationAdapter;
beforeEach(() => {
adapter = new InAppNotificationAdapter();
vi.spyOn(console, 'log').mockImplementation(() => {});
});
const createNotification = (overrides: any = {}) => {
return Notification.create({
id: 'notif-123',
recipientId: 'driver-456',
type: 'protest_filed',
title: 'New Protest',
body: 'A new protest has been filed against you.',
channel: 'in_app',
...overrides,
});
};
describe('send', () => {
it('should return success', async () => {
// Given
const notification = createNotification();
// When
const result = await adapter.send(notification);
// Then
expect(result.success).toBe(true);
expect(result.channel).toBe('in_app');
expect(result.externalId).toBe('notif-123');
expect(result.attemptedAt).toBeInstanceOf(Date);
});
});
describe('supportsChannel', () => {
it('should return true for in_app channel', () => {
expect(adapter.supportsChannel('in_app')).toBe(true);
});
it('should return false for other channels', () => {
expect(adapter.supportsChannel('email' as any)).toBe(false);
});
});
describe('isConfigured', () => {
it('should always return true', () => {
expect(adapter.isConfigured()).toBe(true);
});
});
});

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { NotificationGatewayRegistry } from './NotificationGatewayRegistry';
import { Notification } from '@core/notifications/domain/entities/Notification';
import type { NotificationGateway, NotificationDeliveryResult } from '@core/notifications/application/ports/NotificationGateway';
import type { NotificationChannel } from '@core/notifications/domain/types/NotificationTypes';
describe('NotificationGatewayRegistry', () => {
let registry: NotificationGatewayRegistry;
let mockGateway: NotificationGateway;
beforeEach(() => {
mockGateway = {
send: vi.fn(),
supportsChannel: vi.fn().mockReturnValue(true),
isConfigured: vi.fn().mockReturnValue(true),
getChannel: vi.fn().mockReturnValue('email'),
};
registry = new NotificationGatewayRegistry([mockGateway]);
});
const createNotification = (overrides: any = {}) => {
return Notification.create({
id: 'notif-123',
recipientId: 'driver-456',
type: 'protest_filed',
title: 'New Protest',
body: 'A new protest has been filed against you.',
channel: 'email',
...overrides,
});
};
describe('register and get', () => {
it('should register and retrieve a gateway', () => {
const discordGateway = {
...mockGateway,
getChannel: vi.fn().mockReturnValue('discord'),
} as any;
registry.register(discordGateway);
expect(registry.getGateway('discord')).toBe(discordGateway);
});
it('should return null for unregistered channel', () => {
expect(registry.getGateway('discord')).toBeNull();
});
it('should return all registered gateways', () => {
expect(registry.getAllGateways()).toHaveLength(1);
expect(registry.getAllGateways()[0]).toBe(mockGateway);
});
});
describe('send', () => {
it('should route notification to the correct gateway', async () => {
// Given
const notification = createNotification();
const expectedResult: NotificationDeliveryResult = {
success: true,
channel: 'email',
externalId: 'ext-123',
attemptedAt: new Date(),
};
vi.mocked(mockGateway.send).mockResolvedValue(expectedResult);
// When
const result = await registry.send(notification);
// Then
expect(mockGateway.send).toHaveBeenCalledWith(notification);
expect(result).toBe(expectedResult);
});
it('should return failure if no gateway is registered for channel', async () => {
// Given
const notification = createNotification({ channel: 'discord' });
// When
const result = await registry.send(notification);
// Then
expect(result.success).toBe(false);
expect(result.error).toContain('No gateway registered for channel: discord');
});
it('should return failure if gateway is not configured', async () => {
// Given
const notification = createNotification();
vi.mocked(mockGateway.isConfigured).mockReturnValue(false);
// When
const result = await registry.send(notification);
// Then
expect(result.success).toBe(false);
expect(result.error).toContain('Gateway for channel email is not configured');
});
it('should catch and return errors from gateway.send', async () => {
// Given
const notification = createNotification();
vi.mocked(mockGateway.send).mockRejectedValue(new Error('Network error'));
// When
const result = await registry.send(notification);
// Then
expect(result.success).toBe(false);
expect(result.error).toBe('Network error');
});
});
});

View File

@@ -0,0 +1,55 @@
import { InMemoryRaceRepository } from './InMemoryRaceRepository';
import { RaceData } from '../../../../core/dashboard/application/ports/DashboardRepository';
describe('InMemoryRaceRepository', () => {
let repository: InMemoryRaceRepository;
beforeEach(() => {
repository = new InMemoryRaceRepository();
});
describe('getUpcomingRaces', () => {
it('should return empty array when no races for driver', async () => {
// When
const result = await repository.getUpcomingRaces('driver-1');
// Then
expect(result).toEqual([]);
});
it('should return races when they exist', async () => {
// Given
const driverId = 'driver-1';
const races: RaceData[] = [
{
id: 'race-1',
trackName: 'Spa-Francorchamps',
carType: 'GT3',
scheduledDate: new Date(),
},
];
repository.addUpcomingRaces(driverId, races);
// When
const result = await repository.getUpcomingRaces(driverId);
// Then
expect(result).toEqual(races);
});
it('should overwrite races for same driver (idempotency)', async () => {
// Given
const driverId = 'driver-1';
const races1: RaceData[] = [{ id: 'r1', trackName: 'T1', carType: 'C1', scheduledDate: new Date() }];
const races2: RaceData[] = [{ id: 'r2', trackName: 'T2', carType: 'C2', scheduledDate: new Date() }];
// When
repository.addUpcomingRaces(driverId, races1);
repository.addUpcomingRaces(driverId, races2);
const result = await repository.getUpcomingRaces(driverId);
// Then
expect(result).toEqual(races2);
});
});
});

View File

@@ -0,0 +1,86 @@
import { InMemoryRatingRepository } from './InMemoryRatingRepository';
import { Rating } from '../../../../core/rating/domain/Rating';
import { DriverId } from '../../../../core/racing/domain/entities/DriverId';
import { RaceId } from '../../../../core/racing/domain/entities/RaceId';
describe('InMemoryRatingRepository', () => {
let repository: InMemoryRatingRepository;
beforeEach(() => {
repository = new InMemoryRatingRepository();
});
const createRating = (driverId: string, raceId: string, ratingValue: number) => {
return Rating.create({
driverId: DriverId.create(driverId),
raceId: RaceId.create(raceId),
rating: ratingValue,
components: {
resultsStrength: ratingValue,
consistency: 0,
cleanDriving: 0,
racecraft: 0,
reliability: 0,
teamContribution: 0,
},
timestamp: new Date(),
});
};
describe('save and findByDriverAndRace', () => {
it('should return null when rating does not exist', async () => {
// When
const result = await repository.findByDriverAndRace('d1', 'r1');
// Then
expect(result).toBeNull();
});
it('should save and retrieve a rating', async () => {
// Given
const rating = createRating('d1', 'r1', 1500);
// When
await repository.save(rating);
const result = await repository.findByDriverAndRace('d1', 'r1');
// Then
expect(result).toEqual(rating);
});
it('should overwrite rating for same driver and race (idempotency)', async () => {
// Given
const r1 = createRating('d1', 'r1', 1500);
const r2 = createRating('d1', 'r1', 1600);
// When
await repository.save(r1);
await repository.save(r2);
const result = await repository.findByDriverAndRace('d1', 'r1');
// Then
expect(result?.rating).toBe(1600);
});
});
describe('findByDriver', () => {
it('should return all ratings for a driver', async () => {
// Given
const r1 = createRating('d1', 'r1', 1500);
const r2 = createRating('d1', 'r2', 1600);
const r3 = createRating('d2', 'r1', 1400);
await repository.save(r1);
await repository.save(r2);
await repository.save(r3);
// When
const result = await repository.findByDriver('d1');
// Then
expect(result).toHaveLength(2);
expect(result).toContainEqual(r1);
expect(result).toContainEqual(r2);
});
});
});

View File

@@ -0,0 +1,303 @@
import { describe, expect, it } from 'vitest';
import { AdminDomainError, AdminDomainValidationError, AdminDomainInvariantError, AuthorizationError } from './AdminDomainError';
describe('AdminDomainError', () => {
describe('TDD - Test First', () => {
describe('AdminDomainError', () => {
it('should create an error with correct properties', () => {
// Arrange & Act
const error = new (class extends AdminDomainError {
readonly kind = 'validation' as const;
})('Test error message');
// Assert
expect(error.message).toBe('Test error message');
expect(error.type).toBe('domain');
expect(error.context).toBe('admin-domain');
expect(error.kind).toBe('validation');
});
it('should have correct error name', () => {
// Arrange & Act
const error = new (class extends AdminDomainError {
readonly kind = 'validation' as const;
})('Test error');
// Assert
expect(error.name).toBe('AdminDomainError');
});
it('should preserve prototype chain', () => {
// Arrange & Act
const error = new (class extends AdminDomainError {
readonly kind = 'validation' as const;
})('Test error');
// Assert
expect(error instanceof AdminDomainError).toBe(true);
expect(error instanceof Error).toBe(true);
});
it('should handle empty message', () => {
// Arrange & Act
const error = new (class extends AdminDomainError {
readonly kind = 'validation' as const;
})('');
// Assert
expect(error.message).toBe('');
});
it('should handle long message', () => {
// Arrange
const longMessage = 'This is a very long error message that contains many characters and should be handled correctly by the error class';
// Act
const error = new (class extends AdminDomainError {
readonly kind = 'validation' as const;
})(longMessage);
// Assert
expect(error.message).toBe(longMessage);
});
});
describe('AdminDomainValidationError', () => {
it('should create a validation error', () => {
// Arrange & Act
const error = new AdminDomainValidationError('Invalid email format');
// Assert
expect(error.message).toBe('Invalid email format');
expect(error.type).toBe('domain');
expect(error.context).toBe('admin-domain');
expect(error.kind).toBe('validation');
});
it('should have correct error name', () => {
// Arrange & Act
const error = new AdminDomainValidationError('Test error');
// Assert
expect(error.name).toBe('AdminDomainValidationError');
});
it('should be instance of AdminDomainError', () => {
// Arrange & Act
const error = new AdminDomainValidationError('Test error');
// Assert
expect(error instanceof AdminDomainError).toBe(true);
expect(error instanceof AdminDomainValidationError).toBe(true);
expect(error instanceof Error).toBe(true);
});
it('should handle empty message', () => {
// Arrange & Act
const error = new AdminDomainValidationError('');
// Assert
expect(error.message).toBe('');
});
it('should handle complex validation message', () => {
// Arrange
const message = 'Field "email" must be a valid email address. Received: "invalid-email"';
// Act
const error = new AdminDomainValidationError(message);
// Assert
expect(error.message).toBe(message);
});
});
describe('AdminDomainInvariantError', () => {
it('should create an invariant error', () => {
// Arrange & Act
const error = new AdminDomainInvariantError('User must have at least one role');
// Assert
expect(error.message).toBe('User must have at least one role');
expect(error.type).toBe('domain');
expect(error.context).toBe('admin-domain');
expect(error.kind).toBe('invariant');
});
it('should have correct error name', () => {
// Arrange & Act
const error = new AdminDomainInvariantError('Test error');
// Assert
expect(error.name).toBe('AdminDomainInvariantError');
});
it('should be instance of AdminDomainError', () => {
// Arrange & Act
const error = new AdminDomainInvariantError('Test error');
// Assert
expect(error instanceof AdminDomainError).toBe(true);
expect(error instanceof AdminDomainInvariantError).toBe(true);
expect(error instanceof Error).toBe(true);
});
it('should handle empty message', () => {
// Arrange & Act
const error = new AdminDomainInvariantError('');
// Assert
expect(error.message).toBe('');
});
it('should handle complex invariant message', () => {
// Arrange
const message = 'Invariant violation: User status "active" cannot be changed to "deleted" without proper authorization';
// Act
const error = new AdminDomainInvariantError(message);
// Assert
expect(error.message).toBe(message);
});
});
describe('AuthorizationError', () => {
it('should create an authorization error', () => {
// Arrange & Act
const error = new AuthorizationError('User does not have permission to perform this action');
// Assert
expect(error.message).toBe('User does not have permission to perform this action');
expect(error.type).toBe('domain');
expect(error.context).toBe('admin-domain');
expect(error.kind).toBe('authorization');
});
it('should have correct error name', () => {
// Arrange & Act
const error = new AuthorizationError('Test error');
// Assert
expect(error.name).toBe('AuthorizationError');
});
it('should be instance of AdminDomainError', () => {
// Arrange & Act
const error = new AuthorizationError('Test error');
// Assert
expect(error instanceof AdminDomainError).toBe(true);
expect(error instanceof AuthorizationError).toBe(true);
expect(error instanceof Error).toBe(true);
});
it('should handle empty message', () => {
// Arrange & Act
const error = new AuthorizationError('');
// Assert
expect(error.message).toBe('');
});
it('should handle complex authorization message', () => {
// Arrange
const message = 'Authorization failed: User "admin@example.com" (role: admin) attempted to modify role of user "owner@example.com" (role: owner)';
// Act
const error = new AuthorizationError(message);
// Assert
expect(error.message).toBe(message);
});
});
describe('Error hierarchy', () => {
it('should have correct inheritance chain for AdminDomainValidationError', () => {
// Arrange & Act
const error = new AdminDomainValidationError('Test');
// Assert
expect(error instanceof AdminDomainError).toBe(true);
expect(error instanceof Error).toBe(true);
});
it('should have correct inheritance chain for AdminDomainInvariantError', () => {
// Arrange & Act
const error = new AdminDomainInvariantError('Test');
// Assert
expect(error instanceof AdminDomainError).toBe(true);
expect(error instanceof Error).toBe(true);
});
it('should have correct inheritance chain for AuthorizationError', () => {
// Arrange & Act
const error = new AuthorizationError('Test');
// Assert
expect(error instanceof AdminDomainError).toBe(true);
expect(error instanceof Error).toBe(true);
});
it('should have consistent type and context across all error types', () => {
// Arrange
const errors = [
new AdminDomainValidationError('Test'),
new AdminDomainInvariantError('Test'),
new AuthorizationError('Test'),
];
// Assert
errors.forEach(error => {
expect(error.type).toBe('domain');
expect(error.context).toBe('admin-domain');
});
});
it('should have different kinds for different error types', () => {
// Arrange
const validationError = new AdminDomainValidationError('Test');
const invariantError = new AdminDomainInvariantError('Test');
const authorizationError = new AuthorizationError('Test');
// Assert
expect(validationError.kind).toBe('validation');
expect(invariantError.kind).toBe('invariant');
expect(authorizationError.kind).toBe('authorization');
});
});
describe('Error stack trace', () => {
it('should have a stack trace', () => {
// Arrange & Act
const error = new AdminDomainValidationError('Test error');
// Assert
expect(error.stack).toBeDefined();
expect(typeof error.stack).toBe('string');
expect(error.stack).toContain('AdminDomainValidationError');
});
it('should have stack trace for AdminDomainInvariantError', () => {
// Arrange & Act
const error = new AdminDomainInvariantError('Test error');
// Assert
expect(error.stack).toBeDefined();
expect(typeof error.stack).toBe('string');
expect(error.stack).toContain('AdminDomainInvariantError');
});
it('should have stack trace for AuthorizationError', () => {
// Arrange & Act
const error = new AuthorizationError('Test error');
// Assert
expect(error.stack).toBeDefined();
expect(typeof error.stack).toBe('string');
expect(error.stack).toContain('AuthorizationError');
});
});
});
});

View File

@@ -0,0 +1,721 @@
import { describe, expect, it, vi } from 'vitest';
import { AdminUser } from '../entities/AdminUser';
import { Email } from '../value-objects/Email';
import { UserId } from '../value-objects/UserId';
import { UserRole } from '../value-objects/UserRole';
import { UserStatus } from '../value-objects/UserStatus';
import type {
AdminUserRepository,
UserFilter,
UserSort,
UserPagination,
UserListQuery,
UserListResult,
StoredAdminUser
} from './AdminUserRepository';
describe('AdminUserRepository', () => {
describe('TDD - Test First', () => {
describe('UserFilter interface', () => {
it('should allow optional role filter', () => {
// Arrange
const filter: UserFilter = {
role: UserRole.fromString('admin'),
};
// Assert
expect(filter.role).toBeDefined();
expect(filter.role!.value).toBe('admin');
});
it('should allow optional status filter', () => {
// Arrange
const filter: UserFilter = {
status: UserStatus.fromString('active'),
};
// Assert
expect(filter.status).toBeDefined();
expect(filter.status!.value).toBe('active');
});
it('should allow optional email filter', () => {
// Arrange
const filter: UserFilter = {
email: Email.create('test@example.com'),
};
// Assert
expect(filter.email).toBeDefined();
expect(filter.email!.value).toBe('test@example.com');
});
it('should allow optional search filter', () => {
// Arrange
const filter: UserFilter = {
search: 'john',
};
// Assert
expect(filter.search).toBe('john');
});
it('should allow all filters combined', () => {
// Arrange
const filter: UserFilter = {
role: UserRole.fromString('admin'),
status: UserStatus.fromString('active'),
email: Email.create('admin@example.com'),
search: 'admin',
};
// Assert
expect(filter.role!.value).toBe('admin');
expect(filter.status!.value).toBe('active');
expect(filter.email!.value).toBe('admin@example.com');
expect(filter.search).toBe('admin');
});
});
describe('UserSort interface', () => {
it('should allow email field with asc direction', () => {
// Arrange
const sort: UserSort = {
field: 'email',
direction: 'asc',
};
// Assert
expect(sort.field).toBe('email');
expect(sort.direction).toBe('asc');
});
it('should allow email field with desc direction', () => {
// Arrange
const sort: UserSort = {
field: 'email',
direction: 'desc',
};
// Assert
expect(sort.field).toBe('email');
expect(sort.direction).toBe('desc');
});
it('should allow displayName field', () => {
// Arrange
const sort: UserSort = {
field: 'displayName',
direction: 'asc',
};
// Assert
expect(sort.field).toBe('displayName');
});
it('should allow createdAt field', () => {
// Arrange
const sort: UserSort = {
field: 'createdAt',
direction: 'desc',
};
// Assert
expect(sort.field).toBe('createdAt');
});
it('should allow lastLoginAt field', () => {
// Arrange
const sort: UserSort = {
field: 'lastLoginAt',
direction: 'asc',
};
// Assert
expect(sort.field).toBe('lastLoginAt');
});
it('should allow status field', () => {
// Arrange
const sort: UserSort = {
field: 'status',
direction: 'desc',
};
// Assert
expect(sort.field).toBe('status');
});
});
describe('UserPagination interface', () => {
it('should allow valid pagination', () => {
// Arrange
const pagination: UserPagination = {
page: 1,
limit: 10,
};
// Assert
expect(pagination.page).toBe(1);
expect(pagination.limit).toBe(10);
});
it('should allow pagination with different values', () => {
// Arrange
const pagination: UserPagination = {
page: 5,
limit: 50,
};
// Assert
expect(pagination.page).toBe(5);
expect(pagination.limit).toBe(50);
});
});
describe('UserListQuery interface', () => {
it('should allow query with all optional fields', () => {
// Arrange
const query: UserListQuery = {
filter: {
role: UserRole.fromString('admin'),
},
sort: {
field: 'email',
direction: 'asc',
},
pagination: {
page: 1,
limit: 10,
},
};
// Assert
expect(query.filter).toBeDefined();
expect(query.sort).toBeDefined();
expect(query.pagination).toBeDefined();
});
it('should allow query with only filter', () => {
// Arrange
const query: UserListQuery = {
filter: {
status: UserStatus.fromString('active'),
},
};
// Assert
expect(query.filter).toBeDefined();
expect(query.sort).toBeUndefined();
expect(query.pagination).toBeUndefined();
});
it('should allow query with only sort', () => {
// Arrange
const query: UserListQuery = {
sort: {
field: 'displayName',
direction: 'desc',
},
};
// Assert
expect(query.filter).toBeUndefined();
expect(query.sort).toBeDefined();
expect(query.pagination).toBeUndefined();
});
it('should allow query with only pagination', () => {
// Arrange
const query: UserListQuery = {
pagination: {
page: 2,
limit: 20,
},
};
// Assert
expect(query.filter).toBeUndefined();
expect(query.sort).toBeUndefined();
expect(query.pagination).toBeDefined();
});
it('should allow empty query', () => {
// Arrange
const query: UserListQuery = {};
// Assert
expect(query.filter).toBeUndefined();
expect(query.sort).toBeUndefined();
expect(query.pagination).toBeUndefined();
});
});
describe('UserListResult interface', () => {
it('should allow valid result with users', () => {
// Arrange
const user = AdminUser.create({
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
roles: ['user'],
status: 'active',
});
const result: UserListResult = {
users: [user],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
// Assert
expect(result.users).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.limit).toBe(10);
expect(result.totalPages).toBe(1);
});
it('should allow result with multiple users', () => {
// Arrange
const user1 = AdminUser.create({
id: 'user-1',
email: 'user1@example.com',
displayName: 'User 1',
roles: ['user'],
status: 'active',
});
const user2 = AdminUser.create({
id: 'user-2',
email: 'user2@example.com',
displayName: 'User 2',
roles: ['user'],
status: 'active',
});
const result: UserListResult = {
users: [user1, user2],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
};
// Assert
expect(result.users).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should allow result with pagination info', () => {
// Arrange
const users = Array.from({ length: 50 }, (_, i) =>
AdminUser.create({
id: `user-${i}`,
email: `user${i}@example.com`,
displayName: `User ${i}`,
roles: ['user'],
status: 'active',
}),
);
const result: UserListResult = {
users: users.slice(0, 10),
total: 50,
page: 1,
limit: 10,
totalPages: 5,
};
// Assert
expect(result.users).toHaveLength(10);
expect(result.total).toBe(50);
expect(result.page).toBe(1);
expect(result.limit).toBe(10);
expect(result.totalPages).toBe(5);
});
});
describe('StoredAdminUser interface', () => {
it('should allow stored user with all required fields', () => {
// Arrange
const stored: StoredAdminUser = {
id: 'user-1',
email: 'test@example.com',
roles: ['admin'],
status: 'active',
displayName: 'Test User',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
};
// Assert
expect(stored.id).toBe('user-1');
expect(stored.email).toBe('test@example.com');
expect(stored.roles).toEqual(['admin']);
expect(stored.status).toBe('active');
expect(stored.displayName).toBe('Test User');
expect(stored.createdAt).toBeInstanceOf(Date);
expect(stored.updatedAt).toBeInstanceOf(Date);
});
it('should allow stored user with optional fields', () => {
// Arrange
const stored: StoredAdminUser = {
id: 'user-1',
email: 'test@example.com',
roles: ['admin'],
status: 'active',
displayName: 'Test User',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
lastLoginAt: new Date('2024-01-03'),
primaryDriverId: 'driver-123',
};
// Assert
expect(stored.lastLoginAt).toBeInstanceOf(Date);
expect(stored.primaryDriverId).toBe('driver-123');
});
it('should allow stored user with multiple roles', () => {
// Arrange
const stored: StoredAdminUser = {
id: 'user-1',
email: 'test@example.com',
roles: ['owner', 'admin', 'user'],
status: 'active',
displayName: 'Test User',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
};
// Assert
expect(stored.roles).toHaveLength(3);
expect(stored.roles).toContain('owner');
expect(stored.roles).toContain('admin');
expect(stored.roles).toContain('user');
});
});
describe('Repository interface methods', () => {
it('should define findById method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.findById).toBeDefined();
expect(typeof mockRepository.findById).toBe('function');
});
it('should define findByEmail method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.findByEmail).toBeDefined();
expect(typeof mockRepository.findByEmail).toBe('function');
});
it('should define emailExists method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.emailExists).toBeDefined();
expect(typeof mockRepository.emailExists).toBe('function');
});
it('should define existsById method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.existsById).toBeDefined();
expect(typeof mockRepository.existsById).toBe('function');
});
it('should define existsByEmail method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.existsByEmail).toBeDefined();
expect(typeof mockRepository.existsByEmail).toBe('function');
});
it('should define list method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.list).toBeDefined();
expect(typeof mockRepository.list).toBe('function');
});
it('should define count method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.count).toBeDefined();
expect(typeof mockRepository.count).toBe('function');
});
it('should define create method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.create).toBeDefined();
expect(typeof mockRepository.create).toBe('function');
});
it('should define update method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.update).toBeDefined();
expect(typeof mockRepository.update).toBe('function');
});
it('should define delete method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.delete).toBeDefined();
expect(typeof mockRepository.delete).toBe('function');
});
it('should define toStored method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.toStored).toBeDefined();
expect(typeof mockRepository.toStored).toBe('function');
});
it('should define fromStored method signature', () => {
// Arrange
const mockRepository: AdminUserRepository = {
findById: vi.fn(),
findByEmail: vi.fn(),
emailExists: vi.fn(),
existsById: vi.fn(),
existsByEmail: vi.fn(),
list: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
toStored: vi.fn(),
fromStored: vi.fn(),
};
// Assert
expect(mockRepository.fromStored).toBeDefined();
expect(typeof mockRepository.fromStored).toBe('function');
});
it('should handle repository operations with mock implementation', async () => {
// Arrange
const user = AdminUser.create({
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
roles: ['user'],
status: 'active',
});
const mockRepository: AdminUserRepository = {
findById: vi.fn().mockResolvedValue(user),
findByEmail: vi.fn().mockResolvedValue(user),
emailExists: vi.fn().mockResolvedValue(true),
existsById: vi.fn().mockResolvedValue(true),
existsByEmail: vi.fn().mockResolvedValue(true),
list: vi.fn().mockResolvedValue({
users: [user],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
}),
count: vi.fn().mockResolvedValue(1),
create: vi.fn().mockResolvedValue(user),
update: vi.fn().mockResolvedValue(user),
delete: vi.fn().mockResolvedValue(undefined),
toStored: vi.fn().mockReturnValue({
id: 'user-1',
email: 'test@example.com',
roles: ['user'],
status: 'active',
displayName: 'Test User',
createdAt: new Date(),
updatedAt: new Date(),
}),
fromStored: vi.fn().mockReturnValue(user),
};
// Act
const foundUser = await mockRepository.findById(UserId.create('user-1'));
const emailExists = await mockRepository.emailExists(Email.create('test@example.com'));
const listResult = await mockRepository.list();
// Assert
expect(foundUser).toBe(user);
expect(emailExists).toBe(true);
expect(listResult.users).toHaveLength(1);
expect(mockRepository.findById).toHaveBeenCalledWith(UserId.create('user-1'));
expect(mockRepository.emailExists).toHaveBeenCalledWith(Email.create('test@example.com'));
});
});
});
});

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { DashboardPresenter } from './DashboardPresenter';
import { DashboardDTO } from '../dto/DashboardDTO';
describe('DashboardPresenter', () => {
it('should return the data as is (identity transformation)', () => {
const presenter = new DashboardPresenter();
const mockData: DashboardDTO = {
driver: {
id: '1',
name: 'John Doe',
avatar: 'http://example.com/avatar.png',
},
statistics: {
rating: 1500,
rank: 10,
starts: 50,
wins: 5,
podiums: 15,
leagues: 3,
},
upcomingRaces: [],
championshipStandings: [],
recentActivity: [],
};
const result = presenter.present(mockData);
expect(result).toBe(mockData);
});
});

View File

@@ -0,0 +1,320 @@
/**
* Unit tests for GetDashboardUseCase
*
* Tests cover:
* 1) Validation of driverId (empty and whitespace)
* 2) Driver not found
* 3) Filters invalid races (missing trackName, past dates)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetDashboardUseCase } from './GetDashboardUseCase';
import { ValidationError } from '../../../shared/errors/ValidationError';
import { DriverNotFoundError } from '../../domain/errors/DriverNotFoundError';
import { DashboardRepository } from '../ports/DashboardRepository';
import { DashboardEventPublisher } from '../ports/DashboardEventPublisher';
import { Logger } from '../../../shared/domain/Logger';
import { DriverData, RaceData, LeagueStandingData, ActivityData } from '../ports/DashboardRepository';
describe('GetDashboardUseCase', () => {
let mockDriverRepository: DashboardRepository;
let mockRaceRepository: DashboardRepository;
let mockLeagueRepository: DashboardRepository;
let mockActivityRepository: DashboardRepository;
let mockEventPublisher: DashboardEventPublisher;
let mockLogger: Logger;
let useCase: GetDashboardUseCase;
beforeEach(() => {
// Mock all ports with vi.fn()
mockDriverRepository = {
findDriverById: vi.fn(),
getUpcomingRaces: vi.fn(),
getLeagueStandings: vi.fn(),
getRecentActivity: vi.fn(),
getFriends: vi.fn(),
};
mockRaceRepository = {
findDriverById: vi.fn(),
getUpcomingRaces: vi.fn(),
getLeagueStandings: vi.fn(),
getRecentActivity: vi.fn(),
getFriends: vi.fn(),
};
mockLeagueRepository = {
findDriverById: vi.fn(),
getUpcomingRaces: vi.fn(),
getLeagueStandings: vi.fn(),
getRecentActivity: vi.fn(),
getFriends: vi.fn(),
};
mockActivityRepository = {
findDriverById: vi.fn(),
getUpcomingRaces: vi.fn(),
getLeagueStandings: vi.fn(),
getRecentActivity: vi.fn(),
getFriends: vi.fn(),
};
mockEventPublisher = {
publishDashboardAccessed: vi.fn(),
publishDashboardError: vi.fn(),
};
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
useCase = new GetDashboardUseCase({
driverRepository: mockDriverRepository,
raceRepository: mockRaceRepository,
leagueRepository: mockLeagueRepository,
activityRepository: mockActivityRepository,
eventPublisher: mockEventPublisher,
logger: mockLogger,
});
});
describe('Scenario 1: Validation of driverId', () => {
it('should throw ValidationError when driverId is empty string', async () => {
// Given
const query = { driverId: '' };
// When & Then
await expect(useCase.execute(query)).rejects.toThrow(ValidationError);
await expect(useCase.execute(query)).rejects.toThrow('Driver ID cannot be empty');
// Verify no repositories were called
expect(mockDriverRepository.findDriverById).not.toHaveBeenCalled();
expect(mockRaceRepository.getUpcomingRaces).not.toHaveBeenCalled();
expect(mockLeagueRepository.getLeagueStandings).not.toHaveBeenCalled();
expect(mockActivityRepository.getRecentActivity).not.toHaveBeenCalled();
expect(mockEventPublisher.publishDashboardAccessed).not.toHaveBeenCalled();
});
it('should throw ValidationError when driverId is whitespace only', async () => {
// Given
const query = { driverId: ' ' };
// When & Then
await expect(useCase.execute(query)).rejects.toThrow(ValidationError);
await expect(useCase.execute(query)).rejects.toThrow('Driver ID cannot be empty');
// Verify no repositories were called
expect(mockDriverRepository.findDriverById).not.toHaveBeenCalled();
expect(mockRaceRepository.getUpcomingRaces).not.toHaveBeenCalled();
expect(mockLeagueRepository.getLeagueStandings).not.toHaveBeenCalled();
expect(mockActivityRepository.getRecentActivity).not.toHaveBeenCalled();
expect(mockEventPublisher.publishDashboardAccessed).not.toHaveBeenCalled();
});
});
describe('Scenario 2: Driver not found', () => {
it('should throw DriverNotFoundError when driverRepository.findDriverById returns null', async () => {
// Given
const query = { driverId: 'driver-123' };
(mockDriverRepository.findDriverById as any).mockResolvedValue(null);
// When & Then
await expect(useCase.execute(query)).rejects.toThrow(DriverNotFoundError);
await expect(useCase.execute(query)).rejects.toThrow('Driver with ID "driver-123" not found');
// Verify driver repository was called
expect(mockDriverRepository.findDriverById).toHaveBeenCalledWith('driver-123');
// Verify other repositories were not called (since driver not found)
expect(mockRaceRepository.getUpcomingRaces).not.toHaveBeenCalled();
expect(mockLeagueRepository.getLeagueStandings).not.toHaveBeenCalled();
expect(mockActivityRepository.getRecentActivity).not.toHaveBeenCalled();
expect(mockEventPublisher.publishDashboardAccessed).not.toHaveBeenCalled();
});
});
describe('Scenario 3: Filters invalid races', () => {
it('should exclude races missing trackName', async () => {
// Given
const query = { driverId: 'driver-123' };
// Mock driver exists
(mockDriverRepository.findDriverById as any).mockResolvedValue({
id: 'driver-123',
name: 'Test Driver',
rating: 1500,
rank: 10,
starts: 50,
wins: 10,
podiums: 20,
leagues: 3,
} as DriverData);
// Mock races with missing trackName
(mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([
{
id: 'race-1',
trackName: '', // Missing trackName
carType: 'GT3',
scheduledDate: new Date('2026-01-25T10:00:00.000Z'),
},
{
id: 'race-2',
trackName: 'Track A',
carType: 'GT3',
scheduledDate: new Date('2026-01-26T10:00:00.000Z'),
},
] as RaceData[]);
(mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]);
(mockActivityRepository.getRecentActivity as any).mockResolvedValue([]);
// When
const result = await useCase.execute(query);
// Then
expect(result.upcomingRaces).toHaveLength(1);
expect(result.upcomingRaces[0].trackName).toBe('Track A');
});
it('should exclude races with past scheduledDate', async () => {
// Given
const query = { driverId: 'driver-123' };
// Mock driver exists
(mockDriverRepository.findDriverById as any).mockResolvedValue({
id: 'driver-123',
name: 'Test Driver',
rating: 1500,
rank: 10,
starts: 50,
wins: 10,
podiums: 20,
leagues: 3,
} as DriverData);
// Mock races with past dates
(mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([
{
id: 'race-1',
trackName: 'Track A',
carType: 'GT3',
scheduledDate: new Date('2026-01-23T10:00:00.000Z'), // Past
},
{
id: 'race-2',
trackName: 'Track B',
carType: 'GT3',
scheduledDate: new Date('2026-01-25T10:00:00.000Z'), // Future
},
] as RaceData[]);
(mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]);
(mockActivityRepository.getRecentActivity as any).mockResolvedValue([]);
// When
const result = await useCase.execute(query);
// Then
expect(result.upcomingRaces).toHaveLength(1);
expect(result.upcomingRaces[0].trackName).toBe('Track B');
});
it('should exclude races with missing trackName and past dates', async () => {
// Given
const query = { driverId: 'driver-123' };
// Mock driver exists
(mockDriverRepository.findDriverById as any).mockResolvedValue({
id: 'driver-123',
name: 'Test Driver',
rating: 1500,
rank: 10,
starts: 50,
wins: 10,
podiums: 20,
leagues: 3,
} as DriverData);
// Mock races with various invalid states
(mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([
{
id: 'race-1',
trackName: '', // Missing trackName
carType: 'GT3',
scheduledDate: new Date('2026-01-25T10:00:00.000Z'), // Future
},
{
id: 'race-2',
trackName: 'Track A',
carType: 'GT3',
scheduledDate: new Date('2026-01-23T10:00:00.000Z'), // Past
},
{
id: 'race-3',
trackName: 'Track B',
carType: 'GT3',
scheduledDate: new Date('2026-01-26T10:00:00.000Z'), // Future
},
] as RaceData[]);
(mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]);
(mockActivityRepository.getRecentActivity as any).mockResolvedValue([]);
// When
const result = await useCase.execute(query);
// Then
expect(result.upcomingRaces).toHaveLength(1);
expect(result.upcomingRaces[0].trackName).toBe('Track B');
});
it('should include only valid races with trackName and future dates', async () => {
// Given
const query = { driverId: 'driver-123' };
// Mock driver exists
(mockDriverRepository.findDriverById as any).mockResolvedValue({
id: 'driver-123',
name: 'Test Driver',
rating: 1500,
rank: 10,
starts: 50,
wins: 10,
podiums: 20,
leagues: 3,
} as DriverData);
// Mock races with valid data
(mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([
{
id: 'race-1',
trackName: 'Track A',
carType: 'GT3',
scheduledDate: new Date('2026-01-25T10:00:00.000Z'),
},
{
id: 'race-2',
trackName: 'Track B',
carType: 'GT4',
scheduledDate: new Date('2026-01-26T10:00:00.000Z'),
},
] as RaceData[]);
(mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]);
(mockActivityRepository.getRecentActivity as any).mockResolvedValue([]);
// When
const result = await useCase.execute(query);
// Then
expect(result.upcomingRaces).toHaveLength(2);
expect(result.upcomingRaces[0].trackName).toBe('Track A');
expect(result.upcomingRaces[1].trackName).toBe('Track B');
});
});
});

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { DriverNotFoundError } from './DriverNotFoundError';
describe('DriverNotFoundError', () => {
it('should create an error with the correct message and properties', () => {
const driverId = 'driver-123';
const error = new DriverNotFoundError(driverId);
expect(error.message).toBe(`Driver with ID "${driverId}" not found`);
expect(error.name).toBe('DriverNotFoundError');
expect(error.type).toBe('domain');
expect(error.context).toBe('dashboard');
expect(error.kind).toBe('not_found');
});
it('should be an instance of Error', () => {
const error = new DriverNotFoundError('123');
expect(error).toBeInstanceOf(Error);
});
});

View File

@@ -0,0 +1,116 @@
const { RuleTester } = require('eslint');
const rule = require('./domain-no-application');
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: false,
},
},
});
ruleTester.run('domain-no-application', rule, {
valid: [
// Domain file importing from domain
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { UserId } from './UserId';",
},
// Domain file importing from shared
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { ValueObject } from '../shared/ValueObject';",
},
// Domain file importing from ports
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { UserRepository } from '../ports/UserRepository';",
},
// Non-domain file importing from application
{
filename: '/path/to/core/application/user/CreateUser.ts',
code: "import { CreateUserCommand } from './CreateUserCommand';",
},
// Non-domain file importing from application
{
filename: '/path/to/core/application/user/CreateUser.ts',
code: "import { UserService } from '../services/UserService';",
},
// Domain file with no imports
{
filename: '/path/to/core/domain/user/User.ts',
code: "export class User {}",
},
// Domain file with multiple imports, none from application
{
filename: '/path/to/core/domain/user/User.ts',
code: `
import { UserId } from './UserId';
import { UserName } from './UserName';
import { ValueObject } from '../shared/ValueObject';
`,
},
],
invalid: [
// Domain file importing from application
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { CreateUserCommand } from '../application/user/CreateUserCommand';",
errors: [
{
messageId: 'forbiddenImport',
data: {
source: '../application/user/CreateUserCommand',
},
},
],
},
// Domain file importing from application with different path
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { UserService } from '../../application/services/UserService';",
errors: [
{
messageId: 'forbiddenImport',
data: {
source: '../../application/services/UserService',
},
},
],
},
// Domain file importing from application with absolute path
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { CreateUserCommand } from 'core/application/user/CreateUserCommand';",
errors: [
{
messageId: 'forbiddenImport',
data: {
source: 'core/application/user/CreateUserCommand',
},
},
],
},
// Domain file with multiple imports, one from application
{
filename: '/path/to/core/domain/user/User.ts',
code: `
import { UserId } from './UserId';
import { CreateUserCommand } from '../application/user/CreateUserCommand';
import { UserName } from './UserName';
`,
errors: [
{
messageId: 'forbiddenImport',
data: {
source: '../application/user/CreateUserCommand',
},
},
],
},
],
});

View File

@@ -0,0 +1,79 @@
const index = require('./index');
describe('eslint-rules index', () => {
describe('rules', () => {
it('should export no-index-files rule', () => {
expect(index.rules['no-index-files']).toBeDefined();
expect(index.rules['no-index-files'].meta).toBeDefined();
expect(index.rules['no-index-files'].create).toBeDefined();
});
it('should export no-framework-imports rule', () => {
expect(index.rules['no-framework-imports']).toBeDefined();
expect(index.rules['no-framework-imports'].meta).toBeDefined();
expect(index.rules['no-framework-imports'].create).toBeDefined();
});
it('should export domain-no-application rule', () => {
expect(index.rules['domain-no-application']).toBeDefined();
expect(index.rules['domain-no-application'].meta).toBeDefined();
expect(index.rules['domain-no-application'].create).toBeDefined();
});
it('should have exactly 3 rules', () => {
expect(Object.keys(index.rules)).toHaveLength(3);
});
});
describe('configs', () => {
it('should export recommended config', () => {
expect(index.configs.recommended).toBeDefined();
});
it('recommended config should have gridpilot-core-rules plugin', () => {
expect(index.configs.recommended.plugins).toContain('gridpilot-core-rules');
});
it('recommended config should enable all rules', () => {
expect(index.configs.recommended.rules['gridpilot-core-rules/no-index-files']).toBe('error');
expect(index.configs.recommended.rules['gridpilot-core-rules/no-framework-imports']).toBe('error');
expect(index.configs.recommended.rules['gridpilot-core-rules/domain-no-application']).toBe('error');
});
it('recommended config should have exactly 3 rules', () => {
expect(Object.keys(index.configs.recommended.rules)).toHaveLength(3);
});
});
describe('rule metadata', () => {
it('no-index-files should have correct metadata', () => {
const rule = index.rules['no-index-files'];
expect(rule.meta.type).toBe('problem');
expect(rule.meta.docs.category).toBe('Best Practices');
expect(rule.meta.docs.recommended).toBe(true);
expect(rule.meta.fixable).toBe(null);
expect(rule.meta.schema).toEqual([]);
expect(rule.meta.messages.indexFile).toBeDefined();
});
it('no-framework-imports should have correct metadata', () => {
const rule = index.rules['no-framework-imports'];
expect(rule.meta.type).toBe('problem');
expect(rule.meta.docs.category).toBe('Architecture');
expect(rule.meta.docs.recommended).toBe(true);
expect(rule.meta.fixable).toBe(null);
expect(rule.meta.schema).toEqual([]);
expect(rule.meta.messages.frameworkImport).toBeDefined();
});
it('domain-no-application should have correct metadata', () => {
const rule = index.rules['domain-no-application'];
expect(rule.meta.type).toBe('problem');
expect(rule.meta.docs.category).toBe('Architecture');
expect(rule.meta.docs.recommended).toBe(true);
expect(rule.meta.fixable).toBe(null);
expect(rule.meta.schema).toEqual([]);
expect(rule.meta.messages.forbiddenImport).toBeDefined();
});
});
});

View File

@@ -0,0 +1,166 @@
const { RuleTester } = require('eslint');
const rule = require('./no-framework-imports');
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: false,
},
},
});
ruleTester.run('no-framework-imports', rule, {
valid: [
// Import from domain
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { UserId } from './UserId';",
},
// Import from application
{
filename: '/path/to/core/application/user/CreateUser.ts',
code: "import { CreateUserCommand } from './CreateUserCommand';",
},
// Import from shared
{
filename: '/path/to/core/shared/ValueObject.ts',
code: "import { ValueObject } from './ValueObject';",
},
// Import from ports
{
filename: '/path/to/core/ports/UserRepository.ts',
code: "import { User } from '../domain/user/User';",
},
// Import from external packages (not frameworks)
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { v4 as uuidv4 } from 'uuid';",
},
// Import from internal packages
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { SomeUtil } from '@core/shared/SomeUtil';",
},
// No imports
{
filename: '/path/to/core/domain/user/User.ts',
code: "export class User {}",
},
// Multiple valid imports
{
filename: '/path/to/core/domain/user/User.ts',
code: `
import { UserId } from './UserId';
import { UserName } from './UserName';
import { ValueObject } from '../shared/ValueObject';
`,
},
],
invalid: [
// Import from @nestjs
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { Injectable } from '@nestjs/common';",
errors: [
{
messageId: 'frameworkImport',
data: {
source: '@nestjs/common',
},
},
],
},
// Import from @nestjs/core
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { Module } from '@nestjs/core';",
errors: [
{
messageId: 'frameworkImport',
data: {
source: '@nestjs/core',
},
},
],
},
// Import from express
{
filename: '/path/to/core/domain/user/User.ts',
code: "import express from 'express';",
errors: [
{
messageId: 'frameworkImport',
data: {
source: 'express',
},
},
],
},
// Import from react
{
filename: '/path/to/core/domain/user/User.ts',
code: "import React from 'react';",
errors: [
{
messageId: 'frameworkImport',
data: {
source: 'react',
},
},
],
},
// Import from next
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { useRouter } from 'next/router';",
errors: [
{
messageId: 'frameworkImport',
data: {
source: 'next/router',
},
},
],
},
// Import from @nestjs with subpath
{
filename: '/path/to/core/domain/user/User.ts',
code: "import { Controller } from '@nestjs/common';",
errors: [
{
messageId: 'frameworkImport',
data: {
source: '@nestjs/common',
},
},
],
},
// Multiple framework imports
{
filename: '/path/to/core/domain/user/User.ts',
code: `
import { Injectable } from '@nestjs/common';
import { UserId } from './UserId';
import React from 'react';
`,
errors: [
{
messageId: 'frameworkImport',
data: {
source: '@nestjs/common',
},
},
{
messageId: 'frameworkImport',
data: {
source: 'react',
},
},
],
},
],
});

View File

@@ -0,0 +1,131 @@
const { RuleTester } = require('eslint');
const rule = require('./no-index-files');
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
ecmaFeatures: {
jsx: false,
},
},
});
ruleTester.run('no-index-files', rule, {
valid: [
// Regular file in domain
{
filename: '/path/to/core/domain/user/User.ts',
code: "export class User {}",
},
// Regular file in application
{
filename: '/path/to/core/application/user/CreateUser.ts',
code: "export class CreateUser {}",
},
// Regular file in shared
{
filename: '/path/to/core/shared/ValueObject.ts',
code: "export class ValueObject {}",
},
// Regular file in ports
{
filename: '/path/to/core/ports/UserRepository.ts',
code: "export interface UserRepository {}",
},
// File with index in the middle of the path
{
filename: '/path/to/core/domain/user/index/User.ts',
code: "export class User {}",
},
// File with index in the name but not at the end
{
filename: '/path/to/core/domain/user/indexHelper.ts',
code: "export class IndexHelper {}",
},
// Root index.ts is allowed
{
filename: '/path/to/core/index.ts',
code: "export * from './domain';",
},
// File with index.ts in the middle of the path
{
filename: '/path/to/core/domain/index/User.ts',
code: "export class User {}",
},
],
invalid: [
// index.ts in domain
{
filename: '/path/to/core/domain/user/index.ts',
code: "export * from './User';",
errors: [
{
messageId: 'indexFile',
},
],
},
// index.ts in application
{
filename: '/path/to/core/application/user/index.ts',
code: "export * from './CreateUser';",
errors: [
{
messageId: 'indexFile',
},
],
},
// index.ts in shared
{
filename: '/path/to/core/shared/index.ts',
code: "export * from './ValueObject';",
errors: [
{
messageId: 'indexFile',
},
],
},
// index.ts in ports
{
filename: '/path/to/core/ports/index.ts',
code: "export * from './UserRepository';",
errors: [
{
messageId: 'indexFile',
},
],
},
// index.ts with Windows path separator
{
filename: 'C:\\path\\to\\core\\domain\\user\\index.ts',
code: "export * from './User';",
errors: [
{
messageId: 'indexFile',
},
],
},
// index.ts at the start of path
{
filename: 'index.ts',
code: "export * from './domain';",
errors: [
{
messageId: 'indexFile',
},
],
},
// index.ts in nested directory
{
filename: '/path/to/core/domain/user/profile/index.ts',
code: "export * from './Profile';",
errors: [
{
messageId: 'indexFile',
},
],
},
],
});

View File

@@ -0,0 +1,145 @@
/**
* CheckApiHealthUseCase Test
*
* Tests for the health check use case that orchestrates health checks and emits events.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CheckApiHealthUseCase, CheckApiHealthUseCasePorts } from './CheckApiHealthUseCase';
import { HealthCheckQuery, HealthCheckResult } from '../ports/HealthCheckQuery';
import { HealthEventPublisher } from '../ports/HealthEventPublisher';
describe('CheckApiHealthUseCase', () => {
let mockHealthCheckAdapter: HealthCheckQuery;
let mockEventPublisher: HealthEventPublisher;
let useCase: CheckApiHealthUseCase;
beforeEach(() => {
mockHealthCheckAdapter = {
performHealthCheck: vi.fn(),
getStatus: vi.fn(),
getHealth: vi.fn(),
getReliability: vi.fn(),
isAvailable: vi.fn(),
};
mockEventPublisher = {
publishHealthCheckCompleted: vi.fn(),
publishHealthCheckFailed: vi.fn(),
publishHealthCheckTimeout: vi.fn(),
publishConnected: vi.fn(),
publishDisconnected: vi.fn(),
publishDegraded: vi.fn(),
publishChecking: vi.fn(),
};
useCase = new CheckApiHealthUseCase({
healthCheckAdapter: mockHealthCheckAdapter,
eventPublisher: mockEventPublisher,
});
});
describe('execute', () => {
it('should perform health check and publish completed event when healthy', async () => {
const mockResult: HealthCheckResult = {
healthy: true,
responseTime: 100,
timestamp: new Date('2024-01-01T00:00:00Z'),
};
mockHealthCheckAdapter.performHealthCheck.mockResolvedValue(mockResult);
const result = await useCase.execute();
expect(mockHealthCheckAdapter.performHealthCheck).toHaveBeenCalledTimes(1);
expect(mockEventPublisher.publishHealthCheckCompleted).toHaveBeenCalledWith({
healthy: true,
responseTime: 100,
timestamp: mockResult.timestamp,
});
expect(mockEventPublisher.publishHealthCheckFailed).not.toHaveBeenCalled();
expect(result).toEqual(mockResult);
});
it('should perform health check and publish failed event when unhealthy', async () => {
const mockResult: HealthCheckResult = {
healthy: false,
responseTime: 200,
error: 'Connection timeout',
timestamp: new Date('2024-01-01T00:00:00Z'),
};
mockHealthCheckAdapter.performHealthCheck.mockResolvedValue(mockResult);
const result = await useCase.execute();
expect(mockHealthCheckAdapter.performHealthCheck).toHaveBeenCalledTimes(1);
expect(mockEventPublisher.publishHealthCheckFailed).toHaveBeenCalledWith({
error: 'Connection timeout',
timestamp: mockResult.timestamp,
});
expect(mockEventPublisher.publishHealthCheckCompleted).not.toHaveBeenCalled();
expect(result).toEqual(mockResult);
});
it('should handle errors during health check and publish failed event', async () => {
const errorMessage = 'Network error';
mockHealthCheckAdapter.performHealthCheck.mockRejectedValue(new Error(errorMessage));
const result = await useCase.execute();
expect(mockHealthCheckAdapter.performHealthCheck).toHaveBeenCalledTimes(1);
expect(mockEventPublisher.publishHealthCheckFailed).toHaveBeenCalledWith({
error: errorMessage,
timestamp: expect.any(Date),
});
expect(mockEventPublisher.publishHealthCheckCompleted).not.toHaveBeenCalled();
expect(result.healthy).toBe(false);
expect(result.responseTime).toBe(0);
expect(result.error).toBe(errorMessage);
expect(result.timestamp).toBeInstanceOf(Date);
});
it('should handle non-Error objects during health check', async () => {
mockHealthCheckAdapter.performHealthCheck.mockRejectedValue('String error');
const result = await useCase.execute();
expect(mockEventPublisher.publishHealthCheckFailed).toHaveBeenCalledWith({
error: 'String error',
timestamp: expect.any(Date),
});
expect(result.error).toBe('String error');
});
it('should handle unknown errors during health check', async () => {
mockHealthCheckAdapter.performHealthCheck.mockRejectedValue(null);
const result = await useCase.execute();
expect(mockEventPublisher.publishHealthCheckFailed).toHaveBeenCalledWith({
error: 'Unknown error',
timestamp: expect.any(Date),
});
expect(result.error).toBe('Unknown error');
});
it('should use default error message when result has no error', async () => {
const mockResult: HealthCheckResult = {
healthy: false,
responseTime: 150,
timestamp: new Date('2024-01-01T00:00:00Z'),
};
mockHealthCheckAdapter.performHealthCheck.mockResolvedValue(mockResult);
const result = await useCase.execute();
expect(mockEventPublisher.publishHealthCheckFailed).toHaveBeenCalledWith({
error: 'Unknown error',
timestamp: mockResult.timestamp,
});
expect(result.error).toBe('Unknown error');
});
});
});

View File

@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest';
import { GetConnectionStatusUseCase, GetConnectionStatusUseCasePorts } from './GetConnectionStatusUseCase';
import { HealthCheckQuery, ConnectionHealth } from '../ports/HealthCheckQuery';
describe('GetConnectionStatusUseCase', () => {
it('should return connection status and metrics from the health check adapter', async () => {
// Arrange
const mockHealth: ConnectionHealth = {
status: 'connected',
lastCheck: new Date('2024-01-01T10:00:00Z'),
lastSuccess: new Date('2024-01-01T10:00:00Z'),
lastFailure: null,
consecutiveFailures: 0,
totalRequests: 100,
successfulRequests: 99,
failedRequests: 1,
averageResponseTime: 150,
};
const mockReliability = 0.99;
const mockHealthCheckAdapter = {
getHealth: vi.fn().mockReturnValue(mockHealth),
getReliability: vi.fn().mockReturnValue(mockReliability),
performHealthCheck: vi.fn(),
getStatus: vi.fn(),
isAvailable: vi.fn(),
} as unknown as HealthCheckQuery;
const ports: GetConnectionStatusUseCasePorts = {
healthCheckAdapter: mockHealthCheckAdapter,
};
const useCase = new GetConnectionStatusUseCase(ports);
// Act
const result = await useCase.execute();
// Assert
expect(mockHealthCheckAdapter.getHealth).toHaveBeenCalled();
expect(mockHealthCheckAdapter.getReliability).toHaveBeenCalled();
expect(result).toEqual({
status: 'connected',
reliability: 0.99,
totalRequests: 100,
successfulRequests: 99,
failedRequests: 1,
consecutiveFailures: 0,
averageResponseTime: 150,
lastCheck: mockHealth.lastCheck,
lastSuccess: mockHealth.lastSuccess,
lastFailure: null,
});
});
});

View File

@@ -0,0 +1,160 @@
/**
* Application Query Tests: GetUserRatingLedgerQuery
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetUserRatingLedgerQueryHandler } from './GetUserRatingLedgerQuery';
import { RatingEventRepository } from '../../domain/repositories/RatingEventRepository';
// Mock repository
const createMockRepository = () => ({
save: vi.fn(),
findByUserId: vi.fn(),
findByIds: vi.fn(),
getAllByUserId: vi.fn(),
findEventsPaginated: vi.fn(),
});
describe('GetUserRatingLedgerQueryHandler', () => {
let handler: GetUserRatingLedgerQueryHandler;
let mockRepository: ReturnType<typeof createMockRepository>;
beforeEach(() => {
mockRepository = createMockRepository();
handler = new GetUserRatingLedgerQueryHandler(mockRepository as unknown as RatingEventRepository);
vi.clearAllMocks();
});
it('should query repository with default pagination', async () => {
mockRepository.findEventsPaginated.mockResolvedValue({
items: [],
total: 0,
limit: 20,
offset: 0,
hasMore: false,
});
await handler.execute({ userId: 'user-1' });
expect(mockRepository.findEventsPaginated).toHaveBeenCalledWith('user-1', {
limit: 20,
offset: 0,
});
});
it('should query repository with custom pagination', async () => {
mockRepository.findEventsPaginated.mockResolvedValue({
items: [],
total: 0,
limit: 50,
offset: 100,
hasMore: false,
});
await handler.execute({
userId: 'user-1',
limit: 50,
offset: 100,
});
expect(mockRepository.findEventsPaginated).toHaveBeenCalledWith('user-1', {
limit: 50,
offset: 100,
});
});
it('should query repository with filters', async () => {
mockRepository.findEventsPaginated.mockResolvedValue({
items: [],
total: 0,
limit: 20,
offset: 0,
hasMore: false,
});
const filter: any = {
dimensions: ['trust'],
sourceTypes: ['vote'],
from: '2026-01-01T00:00:00Z',
to: '2026-01-31T23:59:59Z',
reasonCodes: ['VOTE_POSITIVE'],
};
await handler.execute({
userId: 'user-1',
filter,
});
expect(mockRepository.findEventsPaginated).toHaveBeenCalledWith('user-1', {
limit: 20,
offset: 0,
filter: {
dimensions: ['trust'],
sourceTypes: ['vote'],
from: new Date('2026-01-01T00:00:00Z'),
to: new Date('2026-01-31T23:59:59Z'),
reasonCodes: ['VOTE_POSITIVE'],
},
});
});
it('should map domain entities to DTOs', async () => {
const mockEvent = {
id: { value: 'event-1' },
userId: 'user-1',
dimension: { value: 'trust' },
delta: { value: 5 },
occurredAt: new Date('2026-01-15T12:00:00Z'),
createdAt: new Date('2026-01-15T12:00:00Z'),
source: 'admin_vote',
reason: 'VOTE_POSITIVE',
visibility: 'public',
weight: 1.0,
};
mockRepository.findEventsPaginated.mockResolvedValue({
items: [mockEvent],
total: 1,
limit: 20,
offset: 0,
hasMore: false,
});
const result = await handler.execute({ userId: 'user-1' });
expect(result.entries).toHaveLength(1);
expect(result.entries[0]).toEqual({
id: 'event-1',
userId: 'user-1',
dimension: 'trust',
delta: 5,
occurredAt: '2026-01-15T12:00:00.000Z',
createdAt: '2026-01-15T12:00:00.000Z',
source: 'admin_vote',
reason: 'VOTE_POSITIVE',
visibility: 'public',
weight: 1.0,
});
});
it('should handle pagination metadata in result', async () => {
mockRepository.findEventsPaginated.mockResolvedValue({
items: [],
total: 100,
limit: 20,
offset: 20,
hasMore: true,
nextOffset: 40,
});
const result = await handler.execute({ userId: 'user-1', limit: 20, offset: 20 });
expect(result.pagination).toEqual({
total: 100,
limit: 20,
offset: 20,
hasMore: true,
nextOffset: 40,
});
});
});

View File

@@ -0,0 +1,399 @@
/**
* Application Use Case Tests: CastAdminVoteUseCase
*
* Tests for casting votes in admin vote sessions
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CastAdminVoteUseCase } from './CastAdminVoteUseCase';
import { AdminVoteSessionRepository } from '../../domain/repositories/AdminVoteSessionRepository';
import { AdminVoteSession } from '../../domain/entities/AdminVoteSession';
// Mock repository
const createMockRepository = () => ({
save: vi.fn(),
findById: vi.fn(),
findActiveForAdmin: vi.fn(),
findByAdminAndLeague: vi.fn(),
findByLeague: vi.fn(),
findClosedUnprocessed: vi.fn(),
});
describe('CastAdminVoteUseCase', () => {
let useCase: CastAdminVoteUseCase;
let mockRepository: ReturnType<typeof createMockRepository>;
beforeEach(() => {
mockRepository = createMockRepository();
useCase = new CastAdminVoteUseCase(mockRepository);
});
describe('Input validation', () => {
it('should reject when voteSessionId is missing', async () => {
const result = await useCase.execute({
voteSessionId: '',
voterId: 'voter-123',
positive: true,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('voteSessionId is required');
});
it('should reject when voterId is missing', async () => {
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: '',
positive: true,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('voterId is required');
});
it('should reject when positive is not a boolean', async () => {
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: 'true' as any,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('positive must be a boolean value');
});
it('should reject when votedAt is not a valid date', async () => {
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
votedAt: 'invalid-date',
});
expect(result.success).toBe(false);
expect(result.errors).toContain('votedAt must be a valid date if provided');
});
it('should accept valid input with all fields', async () => {
mockRepository.findById.mockResolvedValue({
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
});
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
votedAt: '2024-01-01T00:00:00Z',
});
expect(result.success).toBe(true);
expect(result.errors).toBeUndefined();
});
it('should accept valid input without optional votedAt', async () => {
mockRepository.findById.mockResolvedValue({
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
});
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.success).toBe(true);
expect(result.errors).toBeUndefined();
});
});
describe('Session lookup', () => {
it('should reject when vote session is not found', async () => {
mockRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({
voteSessionId: 'non-existent-session',
voterId: 'voter-123',
positive: true,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Vote session not found');
});
it('should find session by ID when provided', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(mockRepository.findById).toHaveBeenCalledWith('session-123');
});
});
describe('Voting window validation', () => {
it('should reject when voting window is not open', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(false),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Vote session is not open for voting');
expect(mockSession.isVotingWindowOpen).toHaveBeenCalled();
});
it('should accept when voting window is open', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.success).toBe(true);
expect(mockSession.isVotingWindowOpen).toHaveBeenCalled();
});
it('should use current time when votedAt is not provided', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(mockSession.isVotingWindowOpen).toHaveBeenCalledWith(expect.any(Date));
});
it('should use provided votedAt when available', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
const votedAt = new Date('2024-01-01T12:00:00Z');
await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
votedAt: votedAt.toISOString(),
});
expect(mockSession.isVotingWindowOpen).toHaveBeenCalledWith(votedAt);
});
});
describe('Vote casting', () => {
it('should cast positive vote when session is open', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(mockSession.castVote).toHaveBeenCalledWith('voter-123', true, expect.any(Date));
});
it('should cast negative vote when session is open', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: false,
});
expect(mockSession.castVote).toHaveBeenCalledWith('voter-123', false, expect.any(Date));
});
it('should save updated session after casting vote', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(mockRepository.save).toHaveBeenCalledWith(mockSession);
});
it('should return success when vote is cast', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.success).toBe(true);
expect(result.voteSessionId).toBe('session-123');
expect(result.voterId).toBe('voter-123');
expect(result.errors).toBeUndefined();
});
});
describe('Error handling', () => {
it('should handle repository errors gracefully', async () => {
mockRepository.findById.mockRejectedValue(new Error('Database error'));
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Failed to cast vote: Database error');
});
it('should handle unexpected errors gracefully', async () => {
mockRepository.findById.mockRejectedValue('Unknown error');
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Failed to cast vote: Unknown error');
});
it('should handle save errors gracefully', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
mockRepository.save.mockRejectedValue(new Error('Save failed'));
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Failed to cast vote: Save failed');
});
});
describe('Return values', () => {
it('should return voteSessionId in success response', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.voteSessionId).toBe('session-123');
});
it('should return voterId in success response', async () => {
const mockSession = {
id: 'session-123',
isVotingWindowOpen: vi.fn().mockReturnValue(true),
castVote: vi.fn(),
};
mockRepository.findById.mockResolvedValue(mockSession);
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.voterId).toBe('voter-123');
});
it('should return voteSessionId in error response', async () => {
mockRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.voteSessionId).toBe('session-123');
});
it('should return voterId in error response', async () => {
mockRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({
voteSessionId: 'session-123',
voterId: 'voter-123',
positive: true,
});
expect(result.voterId).toBe('voter-123');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
/**
* Application Use Case Tests: OpenAdminVoteSessionUseCase
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { OpenAdminVoteSessionUseCase } from './OpenAdminVoteSessionUseCase';
import { AdminVoteSessionRepository } from '../../domain/repositories/AdminVoteSessionRepository';
import { AdminVoteSession } from '../../domain/entities/AdminVoteSession';
// Mock repository
const createMockRepository = () => ({
save: vi.fn(),
findById: vi.fn(),
findActiveForAdmin: vi.fn(),
findByAdminAndLeague: vi.fn(),
findByLeague: vi.fn(),
findClosedUnprocessed: vi.fn(),
});
describe('OpenAdminVoteSessionUseCase', () => {
let useCase: OpenAdminVoteSessionUseCase;
let mockRepository: ReturnType<typeof createMockRepository>;
beforeEach(() => {
mockRepository = createMockRepository();
useCase = new OpenAdminVoteSessionUseCase(mockRepository as unknown as AdminVoteSessionRepository);
vi.clearAllMocks();
});
describe('Input validation', () => {
it('should reject when voteSessionId is missing', async () => {
const result = await useCase.execute({
voteSessionId: '',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '2026-01-01',
endDate: '2026-01-07',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('voteSessionId is required');
});
it('should reject when leagueId is missing', async () => {
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: '',
adminId: 'admin-1',
startDate: '2026-01-01',
endDate: '2026-01-07',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('leagueId is required');
});
it('should reject when adminId is missing', async () => {
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: '',
startDate: '2026-01-01',
endDate: '2026-01-07',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('adminId is required');
});
it('should reject when startDate is missing', async () => {
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '',
endDate: '2026-01-07',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('startDate is required');
});
it('should reject when endDate is missing', async () => {
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '2026-01-01',
endDate: '',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('endDate is required');
});
it('should reject when startDate is invalid', async () => {
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: 'invalid-date',
endDate: '2026-01-07',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('startDate must be a valid date');
});
it('should reject when endDate is invalid', async () => {
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '2026-01-01',
endDate: 'invalid-date',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('endDate must be a valid date');
});
it('should reject when startDate is after endDate', async () => {
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '2026-01-07',
endDate: '2026-01-01',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('startDate must be before endDate');
});
it('should reject when eligibleVoters is empty', async () => {
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '2026-01-01',
endDate: '2026-01-07',
eligibleVoters: [],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('At least one eligible voter is required');
});
it('should reject when eligibleVoters has duplicates', async () => {
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '2026-01-01',
endDate: '2026-01-07',
eligibleVoters: ['voter-1', 'voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Duplicate eligible voters are not allowed');
});
});
describe('Business rules', () => {
it('should reject when session ID already exists', async () => {
mockRepository.findById.mockResolvedValue({ id: 'session-1' } as any);
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '2026-01-01',
endDate: '2026-01-07',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Vote session with this ID already exists');
});
it('should reject when there is an overlapping active session', async () => {
mockRepository.findById.mockResolvedValue(null);
mockRepository.findActiveForAdmin.mockResolvedValue([
{
startDate: new Date('2026-01-05'),
endDate: new Date('2026-01-10'),
}
] as any);
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '2026-01-01',
endDate: '2026-01-07',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Active vote session already exists for this admin in this league with overlapping dates');
});
it('should create and save a new session when valid', async () => {
mockRepository.findById.mockResolvedValue(null);
mockRepository.findActiveForAdmin.mockResolvedValue([]);
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '2026-01-01',
endDate: '2026-01-07',
eligibleVoters: ['voter-1', 'voter-2'],
});
expect(result.success).toBe(true);
expect(mockRepository.save).toHaveBeenCalled();
const savedSession = mockRepository.save.mock.calls[0][0];
expect(savedSession).toBeInstanceOf(AdminVoteSession);
expect(savedSession.id).toBe('session-1');
expect(savedSession.leagueId).toBe('league-1');
expect(savedSession.adminId).toBe('admin-1');
});
});
describe('Error handling', () => {
it('should handle repository errors gracefully', async () => {
mockRepository.findById.mockRejectedValue(new Error('Database error'));
const result = await useCase.execute({
voteSessionId: 'session-1',
leagueId: 'league-1',
adminId: 'admin-1',
startDate: '2026-01-01',
endDate: '2026-01-07',
eligibleVoters: ['voter-1'],
});
expect(result.success).toBe(false);
expect(result.errors?.[0]).toContain('Failed to open vote session: Database error');
});
});
});

View File

@@ -0,0 +1,241 @@
/**
* Domain Entity Tests: Company
*
* Tests for Company entity business rules and invariants
*/
import { describe, it, expect } from 'vitest';
import { Company } from './Company';
import { UserId } from '../value-objects/UserId';
describe('Company', () => {
describe('Creation', () => {
it('should create a company with valid properties', () => {
const userId = UserId.fromString('user-123');
const company = Company.create({
name: 'Acme Racing Team',
ownerUserId: userId,
contactEmail: 'contact@acme.com',
});
expect(company.getName()).toBe('Acme Racing Team');
expect(company.getOwnerUserId()).toEqual(userId);
expect(company.getContactEmail()).toBe('contact@acme.com');
expect(company.getId()).toBeDefined();
expect(company.getCreatedAt()).toBeInstanceOf(Date);
});
it('should create a company without optional contact email', () => {
const userId = UserId.fromString('user-123');
const company = Company.create({
name: 'Acme Racing Team',
ownerUserId: userId,
});
expect(company.getContactEmail()).toBeUndefined();
});
it('should generate unique IDs for different companies', () => {
const userId = UserId.fromString('user-123');
const company1 = Company.create({
name: 'Team A',
ownerUserId: userId,
});
const company2 = Company.create({
name: 'Team B',
ownerUserId: userId,
});
expect(company1.getId()).not.toBe(company2.getId());
});
});
describe('Rehydration', () => {
it('should rehydrate company from stored data', () => {
const userId = UserId.fromString('user-123');
const createdAt = new Date('2024-01-01');
const company = Company.rehydrate({
id: 'comp-123',
name: 'Acme Racing Team',
ownerUserId: 'user-123',
contactEmail: 'contact@acme.com',
createdAt,
});
expect(company.getId()).toBe('comp-123');
expect(company.getName()).toBe('Acme Racing Team');
expect(company.getOwnerUserId()).toEqual(userId);
expect(company.getContactEmail()).toBe('contact@acme.com');
expect(company.getCreatedAt()).toEqual(createdAt);
});
it('should rehydrate company without contact email', () => {
const createdAt = new Date('2024-01-01');
const company = Company.rehydrate({
id: 'comp-123',
name: 'Acme Racing Team',
ownerUserId: 'user-123',
createdAt,
});
expect(company.getContactEmail()).toBeUndefined();
});
});
describe('Validation', () => {
it('should throw error when company name is empty', () => {
const userId = UserId.fromString('user-123');
expect(() => {
Company.create({
name: '',
ownerUserId: userId,
});
}).toThrow('Company name cannot be empty');
});
it('should throw error when company name is only whitespace', () => {
const userId = UserId.fromString('user-123');
expect(() => {
Company.create({
name: ' ',
ownerUserId: userId,
});
}).toThrow('Company name cannot be empty');
});
it('should throw error when company name is too short', () => {
const userId = UserId.fromString('user-123');
expect(() => {
Company.create({
name: 'A',
ownerUserId: userId,
});
}).toThrow('Company name must be at least 2 characters long');
});
it('should throw error when company name is too long', () => {
const userId = UserId.fromString('user-123');
const longName = 'A'.repeat(101);
expect(() => {
Company.create({
name: longName,
ownerUserId: userId,
});
}).toThrow('Company name must be no more than 100 characters');
});
it('should accept company name with exactly 2 characters', () => {
const userId = UserId.fromString('user-123');
const company = Company.create({
name: 'AB',
ownerUserId: userId,
});
expect(company.getName()).toBe('AB');
});
it('should accept company name with exactly 100 characters', () => {
const userId = UserId.fromString('user-123');
const longName = 'A'.repeat(100);
const company = Company.create({
name: longName,
ownerUserId: userId,
});
expect(company.getName()).toBe(longName);
});
it('should trim whitespace from company name during validation', () => {
const userId = UserId.fromString('user-123');
const company = Company.create({
name: ' Acme Racing Team ',
ownerUserId: userId,
});
// Note: The current implementation doesn't trim, it just validates
// So this test documents the current behavior
expect(company.getName()).toBe(' Acme Racing Team ');
});
});
describe('Business Rules', () => {
it('should maintain immutability of properties', () => {
const userId = UserId.fromString('user-123');
const company = Company.create({
name: 'Acme Racing Team',
ownerUserId: userId,
contactEmail: 'contact@acme.com',
});
const originalName = company.getName();
const originalEmail = company.getContactEmail();
// Try to modify (should not work due to readonly properties)
// This is more of a TypeScript compile-time check, but we can verify runtime behavior
expect(company.getName()).toBe(originalName);
expect(company.getContactEmail()).toBe(originalEmail);
});
it('should handle special characters in company name', () => {
const userId = UserId.fromString('user-123');
const company = Company.create({
name: 'Acme & Sons Racing, LLC',
ownerUserId: userId,
});
expect(company.getName()).toBe('Acme & Sons Racing, LLC');
});
it('should handle unicode characters in company name', () => {
const userId = UserId.fromString('user-123');
const company = Company.create({
name: 'Räcing Tëam Ñumber Øne',
ownerUserId: userId,
});
expect(company.getName()).toBe('Räcing Tëam Ñumber Øne');
});
});
describe('Edge Cases', () => {
it('should handle rehydration with null contact email', () => {
const createdAt = new Date('2024-01-01');
const company = Company.rehydrate({
id: 'comp-123',
name: 'Acme Racing Team',
ownerUserId: 'user-123',
contactEmail: null as any,
createdAt,
});
// The entity stores null as null, not undefined
expect(company.getContactEmail()).toBeNull();
});
it('should handle rehydration with undefined contact email', () => {
const createdAt = new Date('2024-01-01');
const company = Company.rehydrate({
id: 'comp-123',
name: 'Acme Racing Team',
ownerUserId: 'user-123',
contactEmail: undefined,
createdAt,
});
expect(company.getContactEmail()).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,221 @@
/**
* Domain Error Tests: IdentityDomainError
*
* Tests for domain error classes and their behavior
*/
import { describe, it, expect } from 'vitest';
import { IdentityDomainError, IdentityDomainValidationError, IdentityDomainInvariantError } from './IdentityDomainError';
describe('IdentityDomainError', () => {
describe('IdentityDomainError (base class)', () => {
it('should create an error with correct properties', () => {
const error = new IdentityDomainValidationError('Test error message');
expect(error.message).toBe('Test error message');
expect(error.type).toBe('domain');
expect(error.context).toBe('identity-domain');
expect(error.kind).toBe('validation');
});
it('should be an instance of Error', () => {
const error = new IdentityDomainValidationError('Test error');
expect(error instanceof Error).toBe(true);
});
it('should be an instance of IdentityDomainError', () => {
const error = new IdentityDomainValidationError('Test error');
expect(error instanceof IdentityDomainError).toBe(true);
});
it('should have correct stack trace', () => {
const error = new IdentityDomainValidationError('Test error');
expect(error.stack).toBeDefined();
expect(error.stack).toContain('IdentityDomainError');
});
it('should handle empty error message', () => {
const error = new IdentityDomainValidationError('');
expect(error.message).toBe('');
});
it('should handle error message with special characters', () => {
const error = new IdentityDomainValidationError('Error: Invalid input @#$%^&*()');
expect(error.message).toBe('Error: Invalid input @#$%^&*()');
});
it('should handle error message with newlines', () => {
const error = new IdentityDomainValidationError('Error line 1\nError line 2');
expect(error.message).toBe('Error line 1\nError line 2');
});
});
describe('IdentityDomainValidationError', () => {
it('should create a validation error with correct kind', () => {
const error = new IdentityDomainValidationError('Invalid email format');
expect(error.kind).toBe('validation');
expect(error.type).toBe('domain');
expect(error.context).toBe('identity-domain');
});
it('should be an instance of IdentityDomainValidationError', () => {
const error = new IdentityDomainValidationError('Invalid email format');
expect(error instanceof IdentityDomainValidationError).toBe(true);
});
it('should be an instance of IdentityDomainError', () => {
const error = new IdentityDomainValidationError('Invalid email format');
expect(error instanceof IdentityDomainError).toBe(true);
});
it('should handle validation error with empty message', () => {
const error = new IdentityDomainValidationError('');
expect(error.kind).toBe('validation');
expect(error.message).toBe('');
});
it('should handle validation error with complex message', () => {
const error = new IdentityDomainValidationError(
'Validation failed: Email must be at least 6 characters long and contain a valid domain'
);
expect(error.kind).toBe('validation');
expect(error.message).toBe(
'Validation failed: Email must be at least 6 characters long and contain a valid domain'
);
});
});
describe('IdentityDomainInvariantError', () => {
it('should create an invariant error with correct kind', () => {
const error = new IdentityDomainInvariantError('User must have a valid email');
expect(error.kind).toBe('invariant');
expect(error.type).toBe('domain');
expect(error.context).toBe('identity-domain');
});
it('should be an instance of IdentityDomainInvariantError', () => {
const error = new IdentityDomainInvariantError('User must have a valid email');
expect(error instanceof IdentityDomainInvariantError).toBe(true);
});
it('should be an instance of IdentityDomainError', () => {
const error = new IdentityDomainInvariantError('User must have a valid email');
expect(error instanceof IdentityDomainError).toBe(true);
});
it('should handle invariant error with empty message', () => {
const error = new IdentityDomainInvariantError('');
expect(error.kind).toBe('invariant');
expect(error.message).toBe('');
});
it('should handle invariant error with complex message', () => {
const error = new IdentityDomainInvariantError(
'Invariant violation: User rating must be between 0 and 100'
);
expect(error.kind).toBe('invariant');
expect(error.message).toBe(
'Invariant violation: User rating must be between 0 and 100'
);
});
});
describe('Error hierarchy', () => {
it('should maintain correct error hierarchy for validation errors', () => {
const error = new IdentityDomainValidationError('Test');
expect(error instanceof IdentityDomainValidationError).toBe(true);
expect(error instanceof IdentityDomainError).toBe(true);
expect(error instanceof Error).toBe(true);
});
it('should maintain correct error hierarchy for invariant errors', () => {
const error = new IdentityDomainInvariantError('Test');
expect(error instanceof IdentityDomainInvariantError).toBe(true);
expect(error instanceof IdentityDomainError).toBe(true);
expect(error instanceof Error).toBe(true);
});
it('should allow catching as IdentityDomainError', () => {
const error = new IdentityDomainValidationError('Test');
try {
throw error;
} catch (e) {
expect(e instanceof IdentityDomainError).toBe(true);
expect((e as IdentityDomainError).kind).toBe('validation');
}
});
it('should allow catching as Error', () => {
const error = new IdentityDomainInvariantError('Test');
try {
throw error;
} catch (e) {
expect(e instanceof Error).toBe(true);
expect((e as Error).message).toBe('Test');
}
});
});
describe('Error properties', () => {
it('should have consistent type property', () => {
const validationError = new IdentityDomainValidationError('Test');
const invariantError = new IdentityDomainInvariantError('Test');
expect(validationError.type).toBe('domain');
expect(invariantError.type).toBe('domain');
});
it('should have consistent context property', () => {
const validationError = new IdentityDomainValidationError('Test');
const invariantError = new IdentityDomainInvariantError('Test');
expect(validationError.context).toBe('identity-domain');
expect(invariantError.context).toBe('identity-domain');
});
it('should have different kind properties', () => {
const validationError = new IdentityDomainValidationError('Test');
const invariantError = new IdentityDomainInvariantError('Test');
expect(validationError.kind).toBe('validation');
expect(invariantError.kind).toBe('invariant');
});
});
describe('Error usage patterns', () => {
it('should be usable in try-catch blocks', () => {
expect(() => {
throw new IdentityDomainValidationError('Invalid input');
}).toThrow(IdentityDomainValidationError);
});
it('should be usable with error instanceof checks', () => {
const error = new IdentityDomainValidationError('Test');
expect(error instanceof IdentityDomainValidationError).toBe(true);
expect(error instanceof IdentityDomainError).toBe(true);
expect(error instanceof Error).toBe(true);
});
it('should be usable with error type narrowing', () => {
const error: IdentityDomainError = new IdentityDomainValidationError('Test');
if (error.kind === 'validation') {
expect(error instanceof IdentityDomainValidationError).toBe(true);
}
});
it('should support error message extraction', () => {
const errorMessage = 'User email is required';
const error = new IdentityDomainValidationError(errorMessage);
expect(error.message).toBe(errorMessage);
});
});
});

View File

@@ -0,0 +1,216 @@
/**
* Domain Service Tests: PasswordHashingService
*
* Tests for password hashing and verification business logic
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { PasswordHashingService } from './PasswordHashingService';
describe('PasswordHashingService', () => {
let service: PasswordHashingService;
beforeEach(() => {
service = new PasswordHashingService();
});
describe('hash', () => {
it('should hash a plain text password', async () => {
const plainPassword = 'mySecurePassword123';
const hash = await service.hash(plainPassword);
expect(hash).toBeDefined();
expect(typeof hash).toBe('string');
expect(hash.length).toBeGreaterThan(0);
// Hash should not be the same as the plain password
expect(hash).not.toBe(plainPassword);
});
it('should produce different hashes for the same password (with salt)', async () => {
const plainPassword = 'mySecurePassword123';
const hash1 = await service.hash(plainPassword);
const hash2 = await service.hash(plainPassword);
// Due to salting, hashes should be different
expect(hash1).not.toBe(hash2);
});
it('should handle empty string password', async () => {
const hash = await service.hash('');
expect(hash).toBeDefined();
expect(typeof hash).toBe('string');
});
it('should handle special characters in password', async () => {
const specialPassword = 'P@ssw0rd!#$%^&*()_+-=[]{}|;:,.<>?';
const hash = await service.hash(specialPassword);
expect(hash).toBeDefined();
expect(typeof hash).toBe('string');
});
it('should handle unicode characters in password', async () => {
const unicodePassword = 'Pässwörd!🔒';
const hash = await service.hash(unicodePassword);
expect(hash).toBeDefined();
expect(typeof hash).toBe('string');
});
it('should handle very long passwords', async () => {
const longPassword = 'a'.repeat(1000);
const hash = await service.hash(longPassword);
expect(hash).toBeDefined();
expect(typeof hash).toBe('string');
});
it('should handle whitespace-only password', async () => {
const whitespacePassword = ' ';
const hash = await service.hash(whitespacePassword);
expect(hash).toBeDefined();
expect(typeof hash).toBe('string');
});
});
describe('verify', () => {
it('should verify correct password against hash', async () => {
const plainPassword = 'mySecurePassword123';
const hash = await service.hash(plainPassword);
const isValid = await service.verify(plainPassword, hash);
expect(isValid).toBe(true);
});
it('should reject incorrect password', async () => {
const plainPassword = 'mySecurePassword123';
const hash = await service.hash(plainPassword);
const isValid = await service.verify('wrongPassword', hash);
expect(isValid).toBe(false);
});
it('should reject empty password against hash', async () => {
const plainPassword = 'mySecurePassword123';
const hash = await service.hash(plainPassword);
const isValid = await service.verify('', hash);
expect(isValid).toBe(false);
});
it('should handle verification with special characters', async () => {
const specialPassword = 'P@ssw0rd!#$%^&*()_+-=[]{}|;:,.<>?';
const hash = await service.hash(specialPassword);
const isValid = await service.verify(specialPassword, hash);
expect(isValid).toBe(true);
});
it('should handle verification with unicode characters', async () => {
const unicodePassword = 'Pässwörd!🔒';
const hash = await service.hash(unicodePassword);
const isValid = await service.verify(unicodePassword, hash);
expect(isValid).toBe(true);
});
it('should handle verification with very long passwords', async () => {
const longPassword = 'a'.repeat(1000);
const hash = await service.hash(longPassword);
const isValid = await service.verify(longPassword, hash);
expect(isValid).toBe(true);
});
it('should handle verification with whitespace-only password', async () => {
const whitespacePassword = ' ';
const hash = await service.hash(whitespacePassword);
const isValid = await service.verify(whitespacePassword, hash);
expect(isValid).toBe(true);
});
it('should reject verification with null hash', async () => {
// bcrypt throws an error when hash is null, which is expected behavior
await expect(service.verify('password', null as any)).rejects.toThrow();
});
it('should reject verification with empty hash', async () => {
const isValid = await service.verify('password', '');
expect(isValid).toBe(false);
});
it('should reject verification with invalid hash format', async () => {
const isValid = await service.verify('password', 'invalid-hash-format');
expect(isValid).toBe(false);
});
});
describe('Hash Consistency', () => {
it('should consistently verify the same password-hash pair', async () => {
const plainPassword = 'testPassword123';
const hash = await service.hash(plainPassword);
// Verify multiple times
const result1 = await service.verify(plainPassword, hash);
const result2 = await service.verify(plainPassword, hash);
const result3 = await service.verify(plainPassword, hash);
expect(result1).toBe(true);
expect(result2).toBe(true);
expect(result3).toBe(true);
}, 10000);
it('should consistently reject wrong password', async () => {
const plainPassword = 'testPassword123';
const wrongPassword = 'wrongPassword';
const hash = await service.hash(plainPassword);
// Verify multiple times with wrong password
const result1 = await service.verify(wrongPassword, hash);
const result2 = await service.verify(wrongPassword, hash);
const result3 = await service.verify(wrongPassword, hash);
expect(result1).toBe(false);
expect(result2).toBe(false);
expect(result3).toBe(false);
}, 10000);
});
describe('Security Properties', () => {
it('should not leak information about the original password from hash', async () => {
const password1 = 'password123';
const password2 = 'password456';
const hash1 = await service.hash(password1);
const hash2 = await service.hash(password2);
// Hashes should be different
expect(hash1).not.toBe(hash2);
// Neither hash should contain the original password
expect(hash1).not.toContain(password1);
expect(hash2).not.toContain(password2);
});
it('should handle case sensitivity correctly', async () => {
const password1 = 'Password';
const password2 = 'password';
const hash1 = await service.hash(password1);
const hash2 = await service.hash(password2);
// Should be treated as different passwords
const isValid1 = await service.verify(password1, hash1);
const isValid2 = await service.verify(password2, hash2);
const isCrossValid1 = await service.verify(password1, hash2);
const isCrossValid2 = await service.verify(password2, hash1);
expect(isValid1).toBe(true);
expect(isValid2).toBe(true);
expect(isCrossValid1).toBe(false);
expect(isCrossValid2).toBe(false);
}, 10000);
});
});

View File

@@ -0,0 +1,338 @@
/**
* Domain Types Tests: EmailAddress
*
* Tests for email validation and disposable email detection
*/
import { describe, it, expect } from 'vitest';
import { validateEmail, isDisposableEmail, DISPOSABLE_DOMAINS } from './EmailAddress';
describe('EmailAddress', () => {
describe('validateEmail', () => {
describe('Valid emails', () => {
it('should validate standard email format', () => {
const result = validateEmail('user@example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.email).toBe('user@example.com');
}
});
it('should validate email with subdomain', () => {
const result = validateEmail('user@mail.example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.email).toBe('user@mail.example.com');
}
});
it('should validate email with plus sign', () => {
const result = validateEmail('user+tag@example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.email).toBe('user+tag@example.com');
}
});
it('should validate email with numbers', () => {
const result = validateEmail('user123@example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.email).toBe('user123@example.com');
}
});
it('should validate email with hyphens', () => {
const result = validateEmail('user-name@example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.email).toBe('user-name@example.com');
}
});
it('should validate email with underscores', () => {
const result = validateEmail('user_name@example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.email).toBe('user_name@example.com');
}
});
it('should validate email with dots in local part', () => {
const result = validateEmail('user.name@example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.email).toBe('user.name@example.com');
}
});
it('should validate email with uppercase letters', () => {
const result = validateEmail('User@Example.com');
expect(result.success).toBe(true);
if (result.success) {
// Should be normalized to lowercase
expect(result.email).toBe('user@example.com');
}
});
it('should validate email with leading/trailing whitespace', () => {
const result = validateEmail(' user@example.com ');
expect(result.success).toBe(true);
if (result.success) {
// Should be trimmed
expect(result.email).toBe('user@example.com');
}
});
it('should validate minimum length email (6 chars)', () => {
const result = validateEmail('a@b.cd');
expect(result.success).toBe(true);
if (result.success) {
expect(result.email).toBe('a@b.cd');
}
});
it('should validate maximum length email (254 chars)', () => {
const localPart = 'a'.repeat(64);
const domain = 'example.com';
const email = `${localPart}@${domain}`;
const result = validateEmail(email);
expect(result.success).toBe(true);
if (result.success) {
expect(result.email).toBe(email);
}
});
});
describe('Invalid emails', () => {
it('should reject empty string', () => {
const result = validateEmail('');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should reject whitespace-only string', () => {
const result = validateEmail(' ');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should reject email without @ symbol', () => {
const result = validateEmail('userexample.com');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should reject email without domain', () => {
const result = validateEmail('user@');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should reject email without local part', () => {
const result = validateEmail('@example.com');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should reject email with multiple @ symbols', () => {
const result = validateEmail('user@domain@com');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should reject email with spaces in local part', () => {
const result = validateEmail('user name@example.com');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should reject email with spaces in domain', () => {
const result = validateEmail('user@ex ample.com');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should reject email with invalid characters', () => {
const result = validateEmail('user#name@example.com');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should reject email that is too short', () => {
const result = validateEmail('a@b.c');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should accept email that is exactly 254 characters', () => {
// The maximum email length is 254 characters
const localPart = 'a'.repeat(64);
const domain = 'example.com';
const email = `${localPart}@${domain}`;
const result = validateEmail(email);
expect(result.success).toBe(true);
if (result.success) {
expect(result.email).toBe(email);
}
});
it('should reject email without TLD', () => {
const result = validateEmail('user@example');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
it('should reject email with invalid TLD format', () => {
const result = validateEmail('user@example.');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBeDefined();
}
});
});
describe('Edge cases', () => {
it('should handle null input gracefully', () => {
const result = validateEmail(null as any);
expect(result.success).toBe(false);
});
it('should handle undefined input gracefully', () => {
const result = validateEmail(undefined as any);
expect(result.success).toBe(false);
});
it('should handle non-string input gracefully', () => {
const result = validateEmail(123 as any);
expect(result.success).toBe(false);
});
});
});
describe('isDisposableEmail', () => {
describe('Disposable email domains', () => {
it('should detect tempmail.com as disposable', () => {
expect(isDisposableEmail('user@tempmail.com')).toBe(true);
});
it('should detect throwaway.email as disposable', () => {
expect(isDisposableEmail('user@throwaway.email')).toBe(true);
});
it('should detect guerrillamail.com as disposable', () => {
expect(isDisposableEmail('user@guerrillamail.com')).toBe(true);
});
it('should detect mailinator.com as disposable', () => {
expect(isDisposableEmail('user@mailinator.com')).toBe(true);
});
it('should detect 10minutemail.com as disposable', () => {
expect(isDisposableEmail('user@10minutemail.com')).toBe(true);
});
it('should detect disposable domains case-insensitively', () => {
expect(isDisposableEmail('user@TEMPMAIL.COM')).toBe(true);
expect(isDisposableEmail('user@TempMail.Com')).toBe(true);
});
it('should detect disposable domains with subdomains', () => {
// The current implementation only checks the exact domain, not subdomains
// So this test documents the current behavior
expect(isDisposableEmail('user@subdomain.tempmail.com')).toBe(false);
});
});
describe('Non-disposable email domains', () => {
it('should not detect gmail.com as disposable', () => {
expect(isDisposableEmail('user@gmail.com')).toBe(false);
});
it('should not detect yahoo.com as disposable', () => {
expect(isDisposableEmail('user@yahoo.com')).toBe(false);
});
it('should not detect outlook.com as disposable', () => {
expect(isDisposableEmail('user@outlook.com')).toBe(false);
});
it('should not detect company domains as disposable', () => {
expect(isDisposableEmail('user@example.com')).toBe(false);
expect(isDisposableEmail('user@company.com')).toBe(false);
});
it('should not detect custom domains as disposable', () => {
expect(isDisposableEmail('user@mydomain.com')).toBe(false);
});
});
describe('Edge cases', () => {
it('should handle email without domain', () => {
expect(isDisposableEmail('user@')).toBe(false);
});
it('should handle email without @ symbol', () => {
expect(isDisposableEmail('user')).toBe(false);
});
it('should handle empty string', () => {
expect(isDisposableEmail('')).toBe(false);
});
it('should handle null input', () => {
// The current implementation throws an error when given null
// This is expected behavior - the function expects a string
expect(() => isDisposableEmail(null as any)).toThrow();
});
it('should handle undefined input', () => {
// The current implementation throws an error when given undefined
// This is expected behavior - the function expects a string
expect(() => isDisposableEmail(undefined as any)).toThrow();
});
});
});
describe('DISPOSABLE_DOMAINS', () => {
it('should contain expected disposable domains', () => {
expect(DISPOSABLE_DOMAINS.has('tempmail.com')).toBe(true);
expect(DISPOSABLE_DOMAINS.has('throwaway.email')).toBe(true);
expect(DISPOSABLE_DOMAINS.has('guerrillamail.com')).toBe(true);
expect(DISPOSABLE_DOMAINS.has('mailinator.com')).toBe(true);
expect(DISPOSABLE_DOMAINS.has('10minutemail.com')).toBe(true);
});
it('should not contain non-disposable domains', () => {
expect(DISPOSABLE_DOMAINS.has('gmail.com')).toBe(false);
expect(DISPOSABLE_DOMAINS.has('yahoo.com')).toBe(false);
expect(DISPOSABLE_DOMAINS.has('outlook.com')).toBe(false);
});
it('should be a Set', () => {
expect(DISPOSABLE_DOMAINS instanceof Set).toBe(true);
});
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetDriverRankingsUseCase, GetDriverRankingsUseCasePorts } from './GetDriverRankingsUseCase';
import { ValidationError } from '../../../shared/errors/ValidationError';
describe('GetDriverRankingsUseCase', () => {
let mockLeaderboardsRepository: any;
let mockEventPublisher: any;
let ports: GetDriverRankingsUseCasePorts;
let useCase: GetDriverRankingsUseCase;
const mockDrivers = [
{ id: '1', name: 'Alice', rating: 2000, raceCount: 10, teamId: 't1', teamName: 'Team A' },
{ id: '2', name: 'Bob', rating: 1500, raceCount: 5, teamId: 't2', teamName: 'Team B' },
{ id: '3', name: 'Charlie', rating: 1800, raceCount: 8 },
];
beforeEach(() => {
mockLeaderboardsRepository = {
findAllDrivers: vi.fn().mockResolvedValue([...mockDrivers]),
};
mockEventPublisher = {
publishDriverRankingsAccessed: vi.fn().mockResolvedValue(undefined),
publishLeaderboardsError: vi.fn().mockResolvedValue(undefined),
};
ports = {
leaderboardsRepository: mockLeaderboardsRepository,
eventPublisher: mockEventPublisher,
};
useCase = new GetDriverRankingsUseCase(ports);
});
it('should return all drivers sorted by rating DESC by default', async () => {
const result = await useCase.execute();
expect(result.drivers).toHaveLength(3);
expect(result.drivers[0].name).toBe('Alice');
expect(result.drivers[1].name).toBe('Charlie');
expect(result.drivers[2].name).toBe('Bob');
expect(result.drivers[0].rank).toBe(1);
expect(result.drivers[1].rank).toBe(2);
expect(result.drivers[2].rank).toBe(3);
expect(mockEventPublisher.publishDriverRankingsAccessed).toHaveBeenCalled();
});
it('should filter drivers by search term', async () => {
const result = await useCase.execute({ search: 'ali' });
expect(result.drivers).toHaveLength(1);
expect(result.drivers[0].name).toBe('Alice');
});
it('should filter drivers by minRating', async () => {
const result = await useCase.execute({ minRating: 1700 });
expect(result.drivers).toHaveLength(2);
expect(result.drivers.map(d => d.name)).toContain('Alice');
expect(result.drivers.map(d => d.name)).toContain('Charlie');
});
it('should filter drivers by teamId', async () => {
const result = await useCase.execute({ teamId: 't1' });
expect(result.drivers).toHaveLength(1);
expect(result.drivers[0].name).toBe('Alice');
});
it('should sort drivers by name ASC', async () => {
const result = await useCase.execute({ sortBy: 'name', sortOrder: 'asc' });
expect(result.drivers[0].name).toBe('Alice');
expect(result.drivers[1].name).toBe('Bob');
expect(result.drivers[2].name).toBe('Charlie');
});
it('should paginate results', async () => {
const result = await useCase.execute({ page: 2, limit: 1 });
expect(result.drivers).toHaveLength(1);
expect(result.drivers[0].name).toBe('Charlie'); // Alice (1), Charlie (2), Bob (3)
expect(result.pagination.total).toBe(3);
expect(result.pagination.totalPages).toBe(3);
expect(result.pagination.page).toBe(2);
});
it('should throw ValidationError for invalid page', async () => {
await expect(useCase.execute({ page: 0 })).rejects.toThrow(ValidationError);
expect(mockEventPublisher.publishLeaderboardsError).toHaveBeenCalled();
});
it('should throw ValidationError for invalid limit', async () => {
await expect(useCase.execute({ limit: 0 })).rejects.toThrow(ValidationError);
});
it('should throw ValidationError for invalid sortBy', async () => {
await expect(useCase.execute({ sortBy: 'invalid' as any })).rejects.toThrow(ValidationError);
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetGlobalLeaderboardsUseCase, GetGlobalLeaderboardsUseCasePorts } from './GetGlobalLeaderboardsUseCase';
describe('GetGlobalLeaderboardsUseCase', () => {
let mockLeaderboardsRepository: any;
let mockEventPublisher: any;
let ports: GetGlobalLeaderboardsUseCasePorts;
let useCase: GetGlobalLeaderboardsUseCase;
const mockDrivers = [
{ id: 'd1', name: 'Alice', rating: 2000, raceCount: 10 },
{ id: 'd2', name: 'Bob', rating: 1500, raceCount: 5 },
];
const mockTeams = [
{ id: 't1', name: 'Team A', rating: 2500, memberCount: 5, raceCount: 20 },
{ id: 't2', name: 'Team B', rating: 2200, memberCount: 3, raceCount: 15 },
];
beforeEach(() => {
mockLeaderboardsRepository = {
findAllDrivers: vi.fn().mockResolvedValue([...mockDrivers]),
findAllTeams: vi.fn().mockResolvedValue([...mockTeams]),
};
mockEventPublisher = {
publishGlobalLeaderboardsAccessed: vi.fn().mockResolvedValue(undefined),
publishLeaderboardsError: vi.fn().mockResolvedValue(undefined),
};
ports = {
leaderboardsRepository: mockLeaderboardsRepository,
eventPublisher: mockEventPublisher,
};
useCase = new GetGlobalLeaderboardsUseCase(ports);
});
it('should return top drivers and teams', async () => {
const result = await useCase.execute();
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0].name).toBe('Alice');
expect(result.drivers[1].name).toBe('Bob');
expect(result.teams).toHaveLength(2);
expect(result.teams[0].name).toBe('Team A');
expect(result.teams[1].name).toBe('Team B');
expect(mockEventPublisher.publishGlobalLeaderboardsAccessed).toHaveBeenCalled();
});
it('should respect driver and team limits', async () => {
const result = await useCase.execute({ driverLimit: 1, teamLimit: 1 });
expect(result.drivers).toHaveLength(1);
expect(result.drivers[0].name).toBe('Alice');
expect(result.teams).toHaveLength(1);
expect(result.teams[0].name).toBe('Team A');
});
it('should handle errors and publish error event', async () => {
mockLeaderboardsRepository.findAllDrivers.mockRejectedValue(new Error('Repo error'));
await expect(useCase.execute()).rejects.toThrow('Repo error');
expect(mockEventPublisher.publishLeaderboardsError).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetTeamRankingsUseCase, GetTeamRankingsUseCasePorts } from './GetTeamRankingsUseCase';
import { ValidationError } from '../../../shared/errors/ValidationError';
describe('GetTeamRankingsUseCase', () => {
let mockLeaderboardsRepository: any;
let mockEventPublisher: any;
let ports: GetTeamRankingsUseCasePorts;
let useCase: GetTeamRankingsUseCase;
const mockTeams = [
{ id: 't1', name: 'Team A', rating: 2500, memberCount: 0, raceCount: 20 },
{ id: 't2', name: 'Team B', rating: 2200, memberCount: 0, raceCount: 15 },
];
const mockDrivers = [
{ id: 'd1', name: 'Alice', rating: 2000, raceCount: 10, teamId: 't1', teamName: 'Team A' },
{ id: 'd2', name: 'Bob', rating: 1500, raceCount: 5, teamId: 't1', teamName: 'Team A' },
{ id: 'd3', name: 'Charlie', rating: 1800, raceCount: 8, teamId: 't2', teamName: 'Team B' },
{ id: 'd4', name: 'David', rating: 1600, raceCount: 2, teamId: 't3', teamName: 'Discovered Team' },
];
beforeEach(() => {
mockLeaderboardsRepository = {
findAllTeams: vi.fn().mockResolvedValue([...mockTeams]),
findAllDrivers: vi.fn().mockResolvedValue([...mockDrivers]),
};
mockEventPublisher = {
publishTeamRankingsAccessed: vi.fn().mockResolvedValue(undefined),
publishLeaderboardsError: vi.fn().mockResolvedValue(undefined),
};
ports = {
leaderboardsRepository: mockLeaderboardsRepository,
eventPublisher: mockEventPublisher,
};
useCase = new GetTeamRankingsUseCase(ports);
});
it('should return teams with aggregated member counts', async () => {
const result = await useCase.execute();
expect(result.teams).toHaveLength(3); // Team A, Team B, and discovered Team t3
const teamA = result.teams.find(t => t.id === 't1');
expect(teamA?.memberCount).toBe(2);
const teamB = result.teams.find(t => t.id === 't2');
expect(teamB?.memberCount).toBe(1);
const teamDiscovered = result.teams.find(t => t.id === 't3');
expect(teamDiscovered?.memberCount).toBe(1);
expect(teamDiscovered?.name).toBe('Discovered Team');
expect(mockEventPublisher.publishTeamRankingsAccessed).toHaveBeenCalled();
});
it('should filter teams by search term', async () => {
const result = await useCase.execute({ search: 'team a' });
expect(result.teams).toHaveLength(1);
expect(result.teams[0].name).toBe('Team A');
});
it('should filter teams by minMemberCount', async () => {
const result = await useCase.execute({ minMemberCount: 2 });
expect(result.teams).toHaveLength(1);
expect(result.teams[0].id).toBe('t1');
});
it('should sort teams by rating DESC by default', async () => {
const result = await useCase.execute();
expect(result.teams[0].id).toBe('t1'); // 2500
expect(result.teams[1].id).toBe('t2'); // 2200
expect(result.teams[2].id).toBe('t3'); // 0
});
it('should throw ValidationError for invalid minMemberCount', async () => {
await expect(useCase.execute({ minMemberCount: -1 })).rejects.toThrow(ValidationError);
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CreateLeagueUseCase } from './CreateLeagueUseCase';
import { LeagueCreateCommand } from '../ports/LeagueCreateCommand';
describe('CreateLeagueUseCase', () => {
let mockLeagueRepository: any;
let mockEventPublisher: any;
let useCase: CreateLeagueUseCase;
beforeEach(() => {
mockLeagueRepository = {
create: vi.fn().mockImplementation((data) => Promise.resolve(data)),
updateStats: vi.fn().mockResolvedValue(undefined),
updateFinancials: vi.fn().mockResolvedValue(undefined),
updateStewardingMetrics: vi.fn().mockResolvedValue(undefined),
updatePerformanceMetrics: vi.fn().mockResolvedValue(undefined),
updateRatingMetrics: vi.fn().mockResolvedValue(undefined),
updateTrendMetrics: vi.fn().mockResolvedValue(undefined),
updateSuccessRateMetrics: vi.fn().mockResolvedValue(undefined),
updateResolutionTimeMetrics: vi.fn().mockResolvedValue(undefined),
updateComplexSuccessRateMetrics: vi.fn().mockResolvedValue(undefined),
updateComplexResolutionTimeMetrics: vi.fn().mockResolvedValue(undefined),
};
mockEventPublisher = {
emitLeagueCreated: vi.fn().mockResolvedValue(undefined),
};
useCase = new CreateLeagueUseCase(mockLeagueRepository, mockEventPublisher);
});
it('should create a league and initialize all metrics', async () => {
const command: LeagueCreateCommand = {
name: 'New League',
ownerId: 'owner-1',
visibility: 'public',
approvalRequired: false,
lateJoinAllowed: true,
bonusPointsEnabled: true,
penaltiesEnabled: true,
protestsEnabled: true,
appealsEnabled: true,
};
const result = await useCase.execute(command);
expect(result.name).toBe('New League');
expect(result.ownerId).toBe('owner-1');
expect(mockLeagueRepository.create).toHaveBeenCalled();
expect(mockLeagueRepository.updateStats).toHaveBeenCalled();
expect(mockLeagueRepository.updateFinancials).toHaveBeenCalled();
expect(mockEventPublisher.emitLeagueCreated).toHaveBeenCalled();
});
it('should throw error if name is missing', async () => {
const command: any = { ownerId: 'owner-1' };
await expect(useCase.execute(command)).rejects.toThrow('League name is required');
});
it('should throw error if ownerId is missing', async () => {
const command: any = { name: 'League' };
await expect(useCase.execute(command)).rejects.toThrow('Owner ID is required');
});
});

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DemoteAdminUseCase } from './DemoteAdminUseCase';
describe('DemoteAdminUseCase', () => {
let mockLeagueRepository: any;
let mockDriverRepository: any;
let mockEventPublisher: any;
let useCase: DemoteAdminUseCase;
beforeEach(() => {
mockLeagueRepository = {
updateLeagueMember: vi.fn().mockResolvedValue(undefined),
};
mockDriverRepository = {};
mockEventPublisher = {};
useCase = new DemoteAdminUseCase(mockLeagueRepository, mockDriverRepository, mockEventPublisher as any);
});
it('should update member role to member', async () => {
const command = {
leagueId: 'l1',
targetDriverId: 'd1',
actorId: 'owner-1',
};
await useCase.execute(command);
expect(mockLeagueRepository.updateLeagueMember).toHaveBeenCalledWith('l1', 'd1', { role: 'member' });
});
});

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetLeagueRosterUseCase } from './GetLeagueRosterUseCase';
describe('GetLeagueRosterUseCase', () => {
let mockLeagueRepository: any;
let mockEventPublisher: any;
let useCase: GetLeagueRosterUseCase;
const mockLeague = { id: 'league-1' };
const mockMembers = [
{ driverId: 'd1', name: 'Owner', role: 'owner', joinDate: new Date() },
{ driverId: 'd2', name: 'Admin', role: 'admin', joinDate: new Date() },
{ driverId: 'd3', name: 'Member', role: 'member', joinDate: new Date() },
];
const mockRequests = [
{ id: 'r1', driverId: 'd4', name: 'Requester', requestDate: new Date() },
];
beforeEach(() => {
mockLeagueRepository = {
findById: vi.fn().mockResolvedValue(mockLeague),
getLeagueMembers: vi.fn().mockResolvedValue(mockMembers),
getPendingRequests: vi.fn().mockResolvedValue(mockRequests),
};
mockEventPublisher = {
emitLeagueRosterAccessed: vi.fn().mockResolvedValue(undefined),
};
useCase = new GetLeagueRosterUseCase(mockLeagueRepository, mockEventPublisher);
});
it('should return roster with members, requests and stats', async () => {
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.members).toHaveLength(3);
expect(result.pendingRequests).toHaveLength(1);
expect(result.stats.adminCount).toBe(2); // owner + admin
expect(result.stats.driverCount).toBe(1);
expect(mockEventPublisher.emitLeagueRosterAccessed).toHaveBeenCalled();
});
it('should throw error if league not found', async () => {
mockLeagueRepository.findById.mockResolvedValue(null);
await expect(useCase.execute({ leagueId: 'invalid' })).rejects.toThrow('League with id invalid not found');
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetLeagueUseCase, GetLeagueQuery } from './GetLeagueUseCase';
describe('GetLeagueUseCase', () => {
let mockLeagueRepository: any;
let mockEventPublisher: any;
let useCase: GetLeagueUseCase;
const mockLeague = {
id: 'league-1',
name: 'Test League',
ownerId: 'owner-1',
};
beforeEach(() => {
mockLeagueRepository = {
findById: vi.fn().mockResolvedValue(mockLeague),
};
mockEventPublisher = {
emitLeagueAccessed: vi.fn().mockResolvedValue(undefined),
};
useCase = new GetLeagueUseCase(mockLeagueRepository, mockEventPublisher);
});
it('should return league data', async () => {
const query: GetLeagueQuery = { leagueId: 'league-1' };
const result = await useCase.execute(query);
expect(result).toEqual(mockLeague);
expect(mockLeagueRepository.findById).toHaveBeenCalledWith('league-1');
expect(mockEventPublisher.emitLeagueAccessed).not.toHaveBeenCalled();
});
it('should emit event if driverId is provided', async () => {
const query: GetLeagueQuery = { leagueId: 'league-1', driverId: 'driver-1' };
await useCase.execute(query);
expect(mockEventPublisher.emitLeagueAccessed).toHaveBeenCalled();
});
it('should throw error if league not found', async () => {
mockLeagueRepository.findById.mockResolvedValue(null);
const query: GetLeagueQuery = { leagueId: 'non-existent' };
await expect(useCase.execute(query)).rejects.toThrow('League with id non-existent not found');
});
it('should throw error if leagueId is missing', async () => {
const query: any = {};
await expect(useCase.execute(query)).rejects.toThrow('League ID is required');
});
});

View File

@@ -0,0 +1,242 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JoinLeagueUseCase } from './JoinLeagueUseCase';
import type { LeagueRepository } from '../ports/LeagueRepository';
import type { DriverRepository } from '../../../racing/domain/repositories/DriverRepository';
import type { EventPublisher } from '../../../shared/ports/EventPublisher';
import type { JoinLeagueCommand } from '../ports/JoinLeagueCommand';
const mockLeagueRepository = {
findById: vi.fn(),
addPendingRequests: vi.fn(),
addLeagueMembers: vi.fn(),
};
const mockDriverRepository = {
findDriverById: vi.fn(),
};
const mockEventPublisher = {
publish: vi.fn(),
};
describe('JoinLeagueUseCase', () => {
let useCase: JoinLeagueUseCase;
beforeEach(() => {
// Reset mocks
vi.clearAllMocks();
useCase = new JoinLeagueUseCase(
mockLeagueRepository as any,
mockDriverRepository as any,
mockEventPublisher as any
);
});
describe('Scenario 1: League missing', () => {
it('should throw "League not found" when league does not exist', async () => {
// Given
const command: JoinLeagueCommand = {
leagueId: 'league-123',
driverId: 'driver-456',
};
mockLeagueRepository.findById.mockImplementation(() => Promise.resolve(null));
// When & Then
await expect(useCase.execute(command)).rejects.toThrow('League not found');
expect(mockLeagueRepository.findById).toHaveBeenCalledWith('league-123');
});
});
describe('Scenario 2: Driver missing', () => {
it('should throw "Driver not found" when driver does not exist', async () => {
// Given
const command: JoinLeagueCommand = {
leagueId: 'league-123',
driverId: 'driver-456',
};
const mockLeague = {
id: 'league-123',
name: 'Test League',
description: null,
visibility: 'public' as const,
ownerId: 'owner-789',
status: 'active' as const,
createdAt: new Date(),
updatedAt: new Date(),
maxDrivers: null,
approvalRequired: true,
lateJoinAllowed: true,
raceFrequency: null,
raceDay: null,
raceTime: null,
tracks: null,
scoringSystem: null,
bonusPointsEnabled: false,
penaltiesEnabled: false,
protestsEnabled: false,
appealsEnabled: false,
stewardTeam: null,
gameType: null,
skillLevel: null,
category: null,
tags: null,
};
mockLeagueRepository.findById.mockImplementation(() => Promise.resolve(mockLeague));
mockDriverRepository.findDriverById.mockImplementation(() => Promise.resolve(null));
// When & Then
await expect(useCase.execute(command)).rejects.toThrow('Driver not found');
expect(mockLeagueRepository.findById).toHaveBeenCalledWith('league-123');
expect(mockDriverRepository.findDriverById).toHaveBeenCalledWith('driver-456');
});
});
describe('Scenario 3: approvalRequired path uses pending requests + time determinism', () => {
it('should add pending request with deterministic time when approvalRequired is true', async () => {
// Given
const command: JoinLeagueCommand = {
leagueId: 'league-123',
driverId: 'driver-456',
};
const mockLeague = {
id: 'league-123',
name: 'Test League',
description: null,
visibility: 'public' as const,
ownerId: 'owner-789',
status: 'active' as const,
createdAt: new Date(),
updatedAt: new Date(),
maxDrivers: null,
approvalRequired: true,
lateJoinAllowed: true,
raceFrequency: null,
raceDay: null,
raceTime: null,
tracks: null,
scoringSystem: null,
bonusPointsEnabled: false,
penaltiesEnabled: false,
protestsEnabled: false,
appealsEnabled: false,
stewardTeam: null,
gameType: null,
skillLevel: null,
category: null,
tags: null,
};
const mockDriver = {
id: 'driver-456',
name: 'Test Driver',
iracingId: 'iracing-123',
avatarUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
};
// Freeze time for deterministic testing
const frozenTime = new Date('2024-01-01T00:00:00.000Z');
vi.setSystemTime(frozenTime);
mockLeagueRepository.findById.mockResolvedValue(mockLeague);
mockDriverRepository.findDriverById.mockResolvedValue(mockDriver);
// When
await useCase.execute(command);
// Then
expect(mockLeagueRepository.addPendingRequests).toHaveBeenCalledWith(
'league-123',
expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
driverId: 'driver-456',
name: 'Test Driver',
requestDate: frozenTime,
}),
])
);
// Verify no members were added
expect(mockLeagueRepository.addLeagueMembers).not.toHaveBeenCalled();
// Reset system time
vi.useRealTimers();
});
});
describe('Scenario 4: no-approval path adds member', () => {
it('should add member when approvalRequired is false', async () => {
// Given
const command: JoinLeagueCommand = {
leagueId: 'league-123',
driverId: 'driver-456',
};
const mockLeague = {
id: 'league-123',
name: 'Test League',
description: null,
visibility: 'public' as const,
ownerId: 'owner-789',
status: 'active' as const,
createdAt: new Date(),
updatedAt: new Date(),
maxDrivers: null,
approvalRequired: false,
lateJoinAllowed: true,
raceFrequency: null,
raceDay: null,
raceTime: null,
tracks: null,
scoringSystem: null,
bonusPointsEnabled: false,
penaltiesEnabled: false,
protestsEnabled: false,
appealsEnabled: false,
stewardTeam: null,
gameType: null,
skillLevel: null,
category: null,
tags: null,
};
const mockDriver = {
id: 'driver-456',
name: 'Test Driver',
iracingId: 'iracing-123',
avatarUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
};
mockLeagueRepository.findById.mockResolvedValue(mockLeague);
mockDriverRepository.findDriverById.mockResolvedValue(mockDriver);
// When
await useCase.execute(command);
// Then
expect(mockLeagueRepository.addLeagueMembers).toHaveBeenCalledWith(
'league-123',
expect.arrayContaining([
expect.objectContaining({
driverId: 'driver-456',
name: 'Test Driver',
role: 'member',
joinDate: expect.any(Date),
}),
])
);
// Verify no pending requests were added
expect(mockLeagueRepository.addPendingRequests).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SearchLeaguesUseCase, SearchLeaguesQuery } from './SearchLeaguesUseCase';
describe('SearchLeaguesUseCase', () => {
let mockLeagueRepository: any;
let useCase: SearchLeaguesUseCase;
const mockLeagues = [
{ id: '1', name: 'League 1' },
{ id: '2', name: 'League 2' },
{ id: '3', name: 'League 3' },
];
beforeEach(() => {
mockLeagueRepository = {
search: vi.fn().mockResolvedValue([...mockLeagues]),
};
useCase = new SearchLeaguesUseCase(mockLeagueRepository);
});
it('should return search results with default limit', async () => {
const query: SearchLeaguesQuery = { query: 'test' };
const result = await useCase.execute(query);
expect(result).toHaveLength(3);
expect(mockLeagueRepository.search).toHaveBeenCalledWith('test');
});
it('should respect limit and offset', async () => {
const query: SearchLeaguesQuery = { query: 'test', limit: 1, offset: 1 };
const result = await useCase.execute(query);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('2');
});
it('should throw error if query is missing', async () => {
const query: any = { query: '' };
await expect(useCase.execute(query)).rejects.toThrow('Search query is required');
});
});

View File

@@ -0,0 +1,128 @@
import { Result } from '@core/shared/domain/Result';
import { describe, expect, it, vi, type Mock } from 'vitest';
import type { MediaStoragePort } from '../ports/MediaStoragePort';
import { GetUploadedMediaUseCase } from './GetUploadedMediaUseCase';
describe('GetUploadedMediaUseCase', () => {
let mediaStorage: {
getBytes: Mock;
getMetadata: Mock;
};
let useCase: GetUploadedMediaUseCase;
beforeEach(() => {
mediaStorage = {
getBytes: vi.fn(),
getMetadata: vi.fn(),
};
useCase = new GetUploadedMediaUseCase(
mediaStorage as unknown as MediaStoragePort,
);
});
it('returns null when media is not found', async () => {
mediaStorage.getBytes.mockResolvedValue(null);
const input = { storageKey: 'missing-key' };
const result = await useCase.execute(input);
expect(mediaStorage.getBytes).toHaveBeenCalledWith('missing-key');
expect(result).toBeInstanceOf(Result);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBe(null);
});
it('returns media bytes and content type when found', async () => {
const mockBytes = Buffer.from('test data');
const mockMetadata = { size: 9, contentType: 'image/png' };
mediaStorage.getBytes.mockResolvedValue(mockBytes);
mediaStorage.getMetadata.mockResolvedValue(mockMetadata);
const input = { storageKey: 'media-key' };
const result = await useCase.execute(input);
expect(mediaStorage.getBytes).toHaveBeenCalledWith('media-key');
expect(mediaStorage.getMetadata).toHaveBeenCalledWith('media-key');
expect(result.isOk()).toBe(true);
const successResult = result.unwrap();
expect(successResult).not.toBeNull();
expect(successResult!.bytes).toBeInstanceOf(Buffer);
expect(successResult!.bytes.toString()).toBe('test data');
expect(successResult!.contentType).toBe('image/png');
});
it('returns default content type when metadata is null', async () => {
const mockBytes = Buffer.from('test data');
mediaStorage.getBytes.mockResolvedValue(mockBytes);
mediaStorage.getMetadata.mockResolvedValue(null);
const input = { storageKey: 'media-key' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const successResult = result.unwrap();
expect(successResult!.contentType).toBe('application/octet-stream');
});
it('returns default content type when metadata has no contentType', async () => {
const mockBytes = Buffer.from('test data');
const mockMetadata = { size: 9 };
mediaStorage.getBytes.mockResolvedValue(mockBytes);
mediaStorage.getMetadata.mockResolvedValue(mockMetadata as any);
const input = { storageKey: 'media-key' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const successResult = result.unwrap();
expect(successResult!.contentType).toBe('application/octet-stream');
});
it('handles storage errors by returning error', async () => {
mediaStorage.getBytes.mockRejectedValue(new Error('Storage error'));
const input = { storageKey: 'media-key' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.message).toBe('Storage error');
});
it('handles getMetadata errors by returning error', async () => {
const mockBytes = Buffer.from('test data');
mediaStorage.getBytes.mockResolvedValue(mockBytes);
mediaStorage.getMetadata.mockRejectedValue(new Error('Metadata error'));
const input = { storageKey: 'media-key' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.message).toBe('Metadata error');
});
it('returns bytes as Buffer', async () => {
const mockBytes = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello"
mediaStorage.getBytes.mockResolvedValue(mockBytes);
mediaStorage.getMetadata.mockResolvedValue({ size: 5, contentType: 'text/plain' });
const input = { storageKey: 'media-key' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const successResult = result.unwrap();
expect(successResult!.bytes).toBeInstanceOf(Buffer);
expect(successResult!.bytes.toString()).toBe('Hello');
});
});

View File

@@ -0,0 +1,103 @@
import { Result } from '@core/shared/domain/Result';
import { describe, expect, it, vi, type Mock } from 'vitest';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { ResolveMediaReferenceUseCase } from './ResolveMediaReferenceUseCase';
describe('ResolveMediaReferenceUseCase', () => {
let mediaResolver: {
resolve: Mock;
};
let useCase: ResolveMediaReferenceUseCase;
beforeEach(() => {
mediaResolver = {
resolve: vi.fn(),
};
useCase = new ResolveMediaReferenceUseCase(
mediaResolver as unknown as MediaResolverPort,
);
});
it('returns resolved path when media reference is resolved', async () => {
mediaResolver.resolve.mockResolvedValue('/resolved/path/to/media.png');
const input = { reference: { type: 'team', id: 'team-123' } };
const result = await useCase.execute(input);
expect(mediaResolver.resolve).toHaveBeenCalledWith({ type: 'team', id: 'team-123' });
expect(result.isOk()).toBe(true);
const successResult = result.unwrap();
expect(successResult).toBe('/resolved/path/to/media.png');
});
it('returns null when media reference resolves to null', async () => {
mediaResolver.resolve.mockResolvedValue(null);
const input = { reference: { type: 'team', id: 'team-123' } };
const result = await useCase.execute(input);
expect(mediaResolver.resolve).toHaveBeenCalledWith({ type: 'team', id: 'team-123' });
expect(result.isOk()).toBe(true);
const successResult = result.unwrap();
expect(successResult).toBe(null);
});
it('returns empty string when media reference resolves to empty string', async () => {
mediaResolver.resolve.mockResolvedValue('');
const input = { reference: { type: 'team', id: 'team-123' } };
const result = await useCase.execute(input);
expect(mediaResolver.resolve).toHaveBeenCalledWith({ type: 'team', id: 'team-123' });
expect(result.isOk()).toBe(true);
const successResult = result.unwrap();
expect(successResult).toBe('');
});
it('handles resolver errors by returning error', async () => {
mediaResolver.resolve.mockRejectedValue(new Error('Resolver error'));
const input = { reference: { type: 'team', id: 'team-123' } };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.message).toBe('Resolver error');
});
it('handles non-Error exceptions by wrapping in Error', async () => {
mediaResolver.resolve.mockRejectedValue('string error');
const input = { reference: { type: 'team', id: 'team-123' } };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err.message).toBe('string error');
});
it('resolves different reference types', async () => {
const testCases = [
{ type: 'team', id: 'team-123' },
{ type: 'league', id: 'league-456' },
{ type: 'driver', id: 'driver-789' },
];
for (const reference of testCases) {
mediaResolver.resolve.mockResolvedValue(`/resolved/${reference.type}/${reference.id}.png`);
const input = { reference };
const result = await useCase.execute(input);
expect(mediaResolver.resolve).toHaveBeenCalledWith(reference);
expect(result.isOk()).toBe(true);
const successResult = result.unwrap();
expect(successResult).toBe(`/resolved/${reference.type}/${reference.id}.png`);
}
});
});

View File

@@ -1,7 +1,182 @@
import * as mod from '@core/media/domain/entities/Avatar'; import { Avatar } from './Avatar';
import { MediaUrl } from '../value-objects/MediaUrl';
describe('media/domain/entities/Avatar.ts', () => { describe('Avatar', () => {
it('imports', () => { describe('create', () => {
expect(mod).toBeTruthy(); it('creates a new avatar with required properties', () => {
const avatar = Avatar.create({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: 'https://example.com/avatar.png',
});
expect(avatar.id).toBe('avatar-1');
expect(avatar.driverId).toBe('driver-1');
expect(avatar.mediaUrl).toBeInstanceOf(MediaUrl);
expect(avatar.mediaUrl.value).toBe('https://example.com/avatar.png');
expect(avatar.isActive).toBe(true);
expect(avatar.selectedAt).toBeInstanceOf(Date);
});
it('throws error when driverId is missing', () => {
expect(() =>
Avatar.create({
id: 'avatar-1',
driverId: '',
mediaUrl: 'https://example.com/avatar.png',
})
).toThrow('Driver ID is required');
});
it('throws error when mediaUrl is missing', () => {
expect(() =>
Avatar.create({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: '',
})
).toThrow('Media URL is required');
});
it('throws error when mediaUrl is invalid', () => {
expect(() =>
Avatar.create({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: 'invalid-url',
})
).toThrow();
});
});
describe('reconstitute', () => {
it('reconstitutes an avatar from props', () => {
const selectedAt = new Date('2024-01-01T00:00:00.000Z');
const avatar = Avatar.reconstitute({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: 'https://example.com/avatar.png',
selectedAt,
isActive: true,
});
expect(avatar.id).toBe('avatar-1');
expect(avatar.driverId).toBe('driver-1');
expect(avatar.mediaUrl.value).toBe('https://example.com/avatar.png');
expect(avatar.selectedAt).toEqual(selectedAt);
expect(avatar.isActive).toBe(true);
});
it('reconstitutes an inactive avatar', () => {
const avatar = Avatar.reconstitute({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: 'https://example.com/avatar.png',
selectedAt: new Date(),
isActive: false,
});
expect(avatar.isActive).toBe(false);
});
});
describe('deactivate', () => {
it('deactivates an active avatar', () => {
const avatar = Avatar.create({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: 'https://example.com/avatar.png',
});
expect(avatar.isActive).toBe(true);
avatar.deactivate();
expect(avatar.isActive).toBe(false);
});
it('can deactivate an already inactive avatar', () => {
const avatar = Avatar.reconstitute({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: 'https://example.com/avatar.png',
selectedAt: new Date(),
isActive: false,
});
avatar.deactivate();
expect(avatar.isActive).toBe(false);
});
});
describe('toProps', () => {
it('returns correct props for a new avatar', () => {
const avatar = Avatar.create({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: 'https://example.com/avatar.png',
});
const props = avatar.toProps();
expect(props.id).toBe('avatar-1');
expect(props.driverId).toBe('driver-1');
expect(props.mediaUrl).toBe('https://example.com/avatar.png');
expect(props.selectedAt).toBeInstanceOf(Date);
expect(props.isActive).toBe(true);
});
it('returns correct props for an inactive avatar', () => {
const selectedAt = new Date('2024-01-01T00:00:00.000Z');
const avatar = Avatar.reconstitute({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: 'https://example.com/avatar.png',
selectedAt,
isActive: false,
});
const props = avatar.toProps();
expect(props.id).toBe('avatar-1');
expect(props.driverId).toBe('driver-1');
expect(props.mediaUrl).toBe('https://example.com/avatar.png');
expect(props.selectedAt).toEqual(selectedAt);
expect(props.isActive).toBe(false);
});
});
describe('value object validation', () => {
it('validates mediaUrl as MediaUrl value object', () => {
const avatar = Avatar.create({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: 'https://example.com/avatar.png',
});
expect(avatar.mediaUrl).toBeInstanceOf(MediaUrl);
expect(avatar.mediaUrl.value).toBe('https://example.com/avatar.png');
});
it('accepts data URI for mediaUrl', () => {
const avatar = Avatar.create({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: 'data:image/png;base64,abc',
});
expect(avatar.mediaUrl.value).toBe('data:image/png;base64,abc');
});
it('accepts root-relative path for mediaUrl', () => {
const avatar = Avatar.create({
id: 'avatar-1',
driverId: 'driver-1',
mediaUrl: '/images/avatar.png',
});
expect(avatar.mediaUrl.value).toBe('/images/avatar.png');
});
}); });
}); });

View File

@@ -1,7 +1,476 @@
import * as mod from '@core/media/domain/entities/AvatarGenerationRequest'; import { AvatarGenerationRequest } from './AvatarGenerationRequest';
import { MediaUrl } from '../value-objects/MediaUrl';
describe('media/domain/entities/AvatarGenerationRequest.ts', () => { describe('AvatarGenerationRequest', () => {
it('imports', () => { describe('create', () => {
expect(mod).toBeTruthy(); it('creates a new request with required properties', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
style: 'realistic',
});
expect(request.id).toBe('req-1');
expect(request.userId).toBe('user-1');
expect(request.facePhotoUrl).toBeInstanceOf(MediaUrl);
expect(request.facePhotoUrl.value).toBe('data:image/png;base64,abc');
expect(request.suitColor).toBe('red');
expect(request.style).toBe('realistic');
expect(request.status).toBe('pending');
expect(request.generatedAvatarUrls).toEqual([]);
expect(request.selectedAvatarIndex).toBeUndefined();
expect(request.errorMessage).toBeUndefined();
expect(request.createdAt).toBeInstanceOf(Date);
expect(request.updatedAt).toBeInstanceOf(Date);
});
it('creates request with default style when not provided', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'blue',
});
expect(request.style).toBe('realistic');
});
it('throws error when userId is missing', () => {
expect(() =>
AvatarGenerationRequest.create({
id: 'req-1',
userId: '',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
})
).toThrow('User ID is required');
});
it('throws error when facePhotoUrl is missing', () => {
expect(() =>
AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: '',
suitColor: 'red',
})
).toThrow('Face photo URL is required');
});
it('throws error when facePhotoUrl is invalid', () => {
expect(() =>
AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'invalid-url',
suitColor: 'red',
})
).toThrow();
});
});
describe('reconstitute', () => {
it('reconstitutes a request from props', () => {
const createdAt = new Date('2024-01-01T00:00:00.000Z');
const updatedAt = new Date('2024-01-01T01:00:00.000Z');
const request = AvatarGenerationRequest.reconstitute({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
style: 'realistic',
status: 'pending',
generatedAvatarUrls: [],
createdAt,
updatedAt,
});
expect(request.id).toBe('req-1');
expect(request.userId).toBe('user-1');
expect(request.facePhotoUrl.value).toBe('data:image/png;base64,abc');
expect(request.suitColor).toBe('red');
expect(request.style).toBe('realistic');
expect(request.status).toBe('pending');
expect(request.generatedAvatarUrls).toEqual([]);
expect(request.selectedAvatarIndex).toBeUndefined();
expect(request.errorMessage).toBeUndefined();
expect(request.createdAt).toEqual(createdAt);
expect(request.updatedAt).toEqual(updatedAt);
});
it('reconstitutes a request with selected avatar', () => {
const request = AvatarGenerationRequest.reconstitute({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
style: 'realistic',
status: 'completed',
generatedAvatarUrls: ['https://example.com/a.png', 'https://example.com/b.png'],
selectedAvatarIndex: 1,
createdAt: new Date(),
updatedAt: new Date(),
});
expect(request.selectedAvatarIndex).toBe(1);
expect(request.selectedAvatarUrl).toBe('https://example.com/b.png');
});
it('reconstitutes a failed request', () => {
const request = AvatarGenerationRequest.reconstitute({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
style: 'realistic',
status: 'failed',
generatedAvatarUrls: [],
errorMessage: 'Generation failed',
createdAt: new Date(),
updatedAt: new Date(),
});
expect(request.status).toBe('failed');
expect(request.errorMessage).toBe('Generation failed');
});
});
describe('status transitions', () => {
it('transitions from pending to validating', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
expect(request.status).toBe('pending');
request.markAsValidating();
expect(request.status).toBe('validating');
});
it('transitions from validating to generating', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
request.markAsValidating();
request.markAsGenerating();
expect(request.status).toBe('generating');
});
it('throws error when marking as validating from non-pending status', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
request.markAsValidating();
expect(() => request.markAsValidating()).toThrow('Can only start validation from pending status');
});
it('throws error when marking as generating from non-validating status', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
expect(() => request.markAsGenerating()).toThrow('Can only start generation from validating status');
});
it('completes request with avatars', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
request.markAsValidating();
request.markAsGenerating();
request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']);
expect(request.status).toBe('completed');
expect(request.generatedAvatarUrls).toEqual(['https://example.com/a.png', 'https://example.com/b.png']);
});
it('throws error when completing with empty avatar list', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
request.markAsValidating();
request.markAsGenerating();
expect(() => request.completeWithAvatars([])).toThrow('At least one avatar URL is required');
});
it('fails request with error message', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
request.markAsValidating();
request.fail('Face validation failed');
expect(request.status).toBe('failed');
expect(request.errorMessage).toBe('Face validation failed');
});
});
describe('avatar selection', () => {
it('selects avatar when request is completed', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
request.markAsValidating();
request.markAsGenerating();
request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']);
request.selectAvatar(1);
expect(request.selectedAvatarIndex).toBe(1);
expect(request.selectedAvatarUrl).toBe('https://example.com/b.png');
});
it('throws error when selecting avatar from non-completed request', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
request.markAsValidating();
expect(() => request.selectAvatar(0)).toThrow('Can only select avatar when generation is completed');
});
it('throws error when selecting invalid index', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
request.markAsValidating();
request.markAsGenerating();
request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']);
expect(() => request.selectAvatar(-1)).toThrow('Invalid avatar index');
expect(() => request.selectAvatar(2)).toThrow('Invalid avatar index');
});
it('returns undefined for selectedAvatarUrl when no avatar selected', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
request.markAsValidating();
request.markAsGenerating();
request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']);
expect(request.selectedAvatarUrl).toBeUndefined();
});
});
describe('buildPrompt', () => {
it('builds prompt for red suit, realistic style', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
style: 'realistic',
});
const prompt = request.buildPrompt();
expect(prompt).toContain('vibrant racing red');
expect(prompt).toContain('photorealistic, professional motorsport portrait');
expect(prompt).toContain('racing driver');
expect(prompt).toContain('racing suit');
expect(prompt).toContain('helmet');
});
it('builds prompt for blue suit, cartoon style', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'blue',
style: 'cartoon',
});
const prompt = request.buildPrompt();
expect(prompt).toContain('deep motorsport blue');
expect(prompt).toContain('stylized cartoon racing character');
});
it('builds prompt for pixel-art style', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'green',
style: 'pixel-art',
});
const prompt = request.buildPrompt();
expect(prompt).toContain('racing green');
expect(prompt).toContain('8-bit pixel art retro racing avatar');
});
it('builds prompt for all suit colors', () => {
const colors = ['red', 'blue', 'green', 'yellow', 'orange', 'purple', 'black', 'white', 'pink', 'cyan'] as const;
colors.forEach((color) => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: color,
});
const prompt = request.buildPrompt();
expect(prompt).toContain(color);
});
});
});
describe('toProps', () => {
it('returns correct props for a new request', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
style: 'realistic',
});
const props = request.toProps();
expect(props.id).toBe('req-1');
expect(props.userId).toBe('user-1');
expect(props.facePhotoUrl).toBe('data:image/png;base64,abc');
expect(props.suitColor).toBe('red');
expect(props.style).toBe('realistic');
expect(props.status).toBe('pending');
expect(props.generatedAvatarUrls).toEqual([]);
expect(props.selectedAvatarIndex).toBeUndefined();
expect(props.errorMessage).toBeUndefined();
expect(props.createdAt).toBeInstanceOf(Date);
expect(props.updatedAt).toBeInstanceOf(Date);
});
it('returns correct props for a completed request with selected avatar', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
style: 'realistic',
});
request.markAsValidating();
request.markAsGenerating();
request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']);
request.selectAvatar(1);
const props = request.toProps();
expect(props.id).toBe('req-1');
expect(props.userId).toBe('user-1');
expect(props.facePhotoUrl).toBe('data:image/png;base64,abc');
expect(props.suitColor).toBe('red');
expect(props.style).toBe('realistic');
expect(props.status).toBe('completed');
expect(props.generatedAvatarUrls).toEqual(['https://example.com/a.png', 'https://example.com/b.png']);
expect(props.selectedAvatarIndex).toBe(1);
expect(props.errorMessage).toBeUndefined();
});
it('returns correct props for a failed request', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
style: 'realistic',
});
request.markAsValidating();
request.fail('Face validation failed');
const props = request.toProps();
expect(props.id).toBe('req-1');
expect(props.userId).toBe('user-1');
expect(props.facePhotoUrl).toBe('data:image/png;base64,abc');
expect(props.suitColor).toBe('red');
expect(props.style).toBe('realistic');
expect(props.status).toBe('failed');
expect(props.generatedAvatarUrls).toEqual([]);
expect(props.selectedAvatarIndex).toBeUndefined();
expect(props.errorMessage).toBe('Face validation failed');
});
});
describe('value object validation', () => {
it('validates facePhotoUrl as MediaUrl value object', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'data:image/png;base64,abc',
suitColor: 'red',
});
expect(request.facePhotoUrl).toBeInstanceOf(MediaUrl);
expect(request.facePhotoUrl.value).toBe('data:image/png;base64,abc');
});
it('accepts http URL for facePhotoUrl', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: 'https://example.com/face.png',
suitColor: 'red',
});
expect(request.facePhotoUrl.value).toBe('https://example.com/face.png');
});
it('accepts root-relative path for facePhotoUrl', () => {
const request = AvatarGenerationRequest.create({
id: 'req-1',
userId: 'user-1',
facePhotoUrl: '/images/face.png',
suitColor: 'red',
});
expect(request.facePhotoUrl.value).toBe('/images/face.png');
});
}); });
}); });

View File

@@ -1,7 +1,307 @@
import * as mod from '@core/media/domain/entities/Media'; import { Media } from './Media';
import { MediaUrl } from '../value-objects/MediaUrl';
describe('media/domain/entities/Media.ts', () => { describe('Media', () => {
it('imports', () => { describe('create', () => {
expect(mod).toBeTruthy(); it('creates a new media with required properties', () => {
const media = Media.create({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'https://example.com/avatar.png',
type: 'image',
uploadedBy: 'user-1',
});
expect(media.id).toBe('media-1');
expect(media.filename).toBe('avatar.png');
expect(media.originalName).toBe('avatar.png');
expect(media.mimeType).toBe('image/png');
expect(media.size).toBe(123);
expect(media.url).toBeInstanceOf(MediaUrl);
expect(media.url.value).toBe('https://example.com/avatar.png');
expect(media.type).toBe('image');
expect(media.uploadedBy).toBe('user-1');
expect(media.uploadedAt).toBeInstanceOf(Date);
expect(media.metadata).toBeUndefined();
});
it('creates media with metadata', () => {
const media = Media.create({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'https://example.com/avatar.png',
type: 'image',
uploadedBy: 'user-1',
metadata: { width: 100, height: 100 },
});
expect(media.metadata).toEqual({ width: 100, height: 100 });
});
it('throws error when filename is missing', () => {
expect(() =>
Media.create({
id: 'media-1',
filename: '',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'https://example.com/avatar.png',
type: 'image',
uploadedBy: 'user-1',
})
).toThrow('Filename is required');
});
it('throws error when url is missing', () => {
expect(() =>
Media.create({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: '',
type: 'image',
uploadedBy: 'user-1',
})
).toThrow('URL is required');
});
it('throws error when uploadedBy is missing', () => {
expect(() =>
Media.create({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'https://example.com/avatar.png',
type: 'image',
uploadedBy: '',
})
).toThrow('Uploaded by is required');
});
it('throws error when url is invalid', () => {
expect(() =>
Media.create({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'invalid-url',
type: 'image',
uploadedBy: 'user-1',
})
).toThrow();
});
});
describe('reconstitute', () => {
it('reconstitutes a media from props', () => {
const uploadedAt = new Date('2024-01-01T00:00:00.000Z');
const media = Media.reconstitute({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'https://example.com/avatar.png',
type: 'image',
uploadedBy: 'user-1',
uploadedAt,
});
expect(media.id).toBe('media-1');
expect(media.filename).toBe('avatar.png');
expect(media.originalName).toBe('avatar.png');
expect(media.mimeType).toBe('image/png');
expect(media.size).toBe(123);
expect(media.url.value).toBe('https://example.com/avatar.png');
expect(media.type).toBe('image');
expect(media.uploadedBy).toBe('user-1');
expect(media.uploadedAt).toEqual(uploadedAt);
expect(media.metadata).toBeUndefined();
});
it('reconstitutes a media with metadata', () => {
const media = Media.reconstitute({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'https://example.com/avatar.png',
type: 'image',
uploadedBy: 'user-1',
uploadedAt: new Date(),
metadata: { width: 100, height: 100 },
});
expect(media.metadata).toEqual({ width: 100, height: 100 });
});
it('reconstitutes a video media', () => {
const media = Media.reconstitute({
id: 'media-1',
filename: 'video.mp4',
originalName: 'video.mp4',
mimeType: 'video/mp4',
size: 1024,
url: 'https://example.com/video.mp4',
type: 'video',
uploadedBy: 'user-1',
uploadedAt: new Date(),
});
expect(media.type).toBe('video');
});
it('reconstitutes a document media', () => {
const media = Media.reconstitute({
id: 'media-1',
filename: 'document.pdf',
originalName: 'document.pdf',
mimeType: 'application/pdf',
size: 2048,
url: 'https://example.com/document.pdf',
type: 'document',
uploadedBy: 'user-1',
uploadedAt: new Date(),
});
expect(media.type).toBe('document');
});
});
describe('toProps', () => {
it('returns correct props for a new media', () => {
const media = Media.create({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'https://example.com/avatar.png',
type: 'image',
uploadedBy: 'user-1',
});
const props = media.toProps();
expect(props.id).toBe('media-1');
expect(props.filename).toBe('avatar.png');
expect(props.originalName).toBe('avatar.png');
expect(props.mimeType).toBe('image/png');
expect(props.size).toBe(123);
expect(props.url).toBe('https://example.com/avatar.png');
expect(props.type).toBe('image');
expect(props.uploadedBy).toBe('user-1');
expect(props.uploadedAt).toBeInstanceOf(Date);
expect(props.metadata).toBeUndefined();
});
it('returns correct props for a media with metadata', () => {
const media = Media.create({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'https://example.com/avatar.png',
type: 'image',
uploadedBy: 'user-1',
metadata: { width: 100, height: 100 },
});
const props = media.toProps();
expect(props.metadata).toEqual({ width: 100, height: 100 });
});
it('returns correct props for a reconstituted media', () => {
const uploadedAt = new Date('2024-01-01T00:00:00.000Z');
const media = Media.reconstitute({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'https://example.com/avatar.png',
type: 'image',
uploadedBy: 'user-1',
uploadedAt,
metadata: { width: 100, height: 100 },
});
const props = media.toProps();
expect(props.id).toBe('media-1');
expect(props.filename).toBe('avatar.png');
expect(props.originalName).toBe('avatar.png');
expect(props.mimeType).toBe('image/png');
expect(props.size).toBe(123);
expect(props.url).toBe('https://example.com/avatar.png');
expect(props.type).toBe('image');
expect(props.uploadedBy).toBe('user-1');
expect(props.uploadedAt).toEqual(uploadedAt);
expect(props.metadata).toEqual({ width: 100, height: 100 });
});
});
describe('value object validation', () => {
it('validates url as MediaUrl value object', () => {
const media = Media.create({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'https://example.com/avatar.png',
type: 'image',
uploadedBy: 'user-1',
});
expect(media.url).toBeInstanceOf(MediaUrl);
expect(media.url.value).toBe('https://example.com/avatar.png');
});
it('accepts data URI for url', () => {
const media = Media.create({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: 'data:image/png;base64,abc',
type: 'image',
uploadedBy: 'user-1',
});
expect(media.url.value).toBe('data:image/png;base64,abc');
});
it('accepts root-relative path for url', () => {
const media = Media.create({
id: 'media-1',
filename: 'avatar.png',
originalName: 'avatar.png',
mimeType: 'image/png',
size: 123,
url: '/images/avatar.png',
type: 'image',
uploadedBy: 'user-1',
});
expect(media.url.value).toBe('/images/avatar.png');
});
}); });
}); });

View File

@@ -0,0 +1,223 @@
import { MediaGenerationService } from './MediaGenerationService';
describe('MediaGenerationService', () => {
let service: MediaGenerationService;
beforeEach(() => {
service = new MediaGenerationService();
});
describe('generateTeamLogo', () => {
it('generates a deterministic logo URL for a team', () => {
const url1 = service.generateTeamLogo('team-123');
const url2 = service.generateTeamLogo('team-123');
expect(url1).toBe(url2);
expect(url1).toContain('https://picsum.photos/seed/team-123/200/200');
});
it('generates different URLs for different team IDs', () => {
const url1 = service.generateTeamLogo('team-123');
const url2 = service.generateTeamLogo('team-456');
expect(url1).not.toBe(url2);
});
it('generates URL with correct format', () => {
const url = service.generateTeamLogo('team-123');
expect(url).toMatch(/^https:\/\/picsum\.photos\/seed\/team-123\/200\/200$/);
});
});
describe('generateLeagueLogo', () => {
it('generates a deterministic logo URL for a league', () => {
const url1 = service.generateLeagueLogo('league-123');
const url2 = service.generateLeagueLogo('league-123');
expect(url1).toBe(url2);
expect(url1).toContain('https://picsum.photos/seed/l-league-123/200/200');
});
it('generates different URLs for different league IDs', () => {
const url1 = service.generateLeagueLogo('league-123');
const url2 = service.generateLeagueLogo('league-456');
expect(url1).not.toBe(url2);
});
it('generates URL with correct format', () => {
const url = service.generateLeagueLogo('league-123');
expect(url).toMatch(/^https:\/\/picsum\.photos\/seed\/l-league-123\/200\/200$/);
});
});
describe('generateDriverAvatar', () => {
it('generates a deterministic avatar URL for a driver', () => {
const url1 = service.generateDriverAvatar('driver-123');
const url2 = service.generateDriverAvatar('driver-123');
expect(url1).toBe(url2);
expect(url1).toContain('https://i.pravatar.cc/150?u=driver-123');
});
it('generates different URLs for different driver IDs', () => {
const url1 = service.generateDriverAvatar('driver-123');
const url2 = service.generateDriverAvatar('driver-456');
expect(url1).not.toBe(url2);
});
it('generates URL with correct format', () => {
const url = service.generateDriverAvatar('driver-123');
expect(url).toMatch(/^https:\/\/i\.pravatar\.cc\/150\?u=driver-123$/);
});
});
describe('generateLeagueCover', () => {
it('generates a deterministic cover URL for a league', () => {
const url1 = service.generateLeagueCover('league-123');
const url2 = service.generateLeagueCover('league-123');
expect(url1).toBe(url2);
expect(url1).toContain('https://picsum.photos/seed/c-league-123/800/200');
});
it('generates different URLs for different league IDs', () => {
const url1 = service.generateLeagueCover('league-123');
const url2 = service.generateLeagueCover('league-456');
expect(url1).not.toBe(url2);
});
it('generates URL with correct format', () => {
const url = service.generateLeagueCover('league-123');
expect(url).toMatch(/^https:\/\/picsum\.photos\/seed\/c-league-123\/800\/200$/);
});
});
describe('generateDefaultPNG', () => {
it('generates a PNG buffer for a variant', () => {
const buffer = service.generateDefaultPNG('test-variant');
expect(buffer).toBeInstanceOf(Buffer);
expect(buffer.length).toBeGreaterThan(0);
});
it('generates deterministic PNG for same variant', () => {
const buffer1 = service.generateDefaultPNG('test-variant');
const buffer2 = service.generateDefaultPNG('test-variant');
expect(buffer1.equals(buffer2)).toBe(true);
});
it('generates different PNGs for different variants', () => {
const buffer1 = service.generateDefaultPNG('variant-1');
const buffer2 = service.generateDefaultPNG('variant-2');
expect(buffer1.equals(buffer2)).toBe(false);
});
it('generates valid PNG header', () => {
const buffer = service.generateDefaultPNG('test-variant');
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
expect(buffer[0]).toBe(0x89);
expect(buffer[1]).toBe(0x50); // 'P'
expect(buffer[2]).toBe(0x4E); // 'N'
expect(buffer[3]).toBe(0x47); // 'G'
expect(buffer[4]).toBe(0x0D);
expect(buffer[5]).toBe(0x0A);
expect(buffer[6]).toBe(0x1A);
expect(buffer[7]).toBe(0x0A);
});
it('generates PNG with IHDR chunk', () => {
const buffer = service.generateDefaultPNG('test-variant');
// IHDR chunk starts at byte 8
// Length: 13 (0x00 0x00 0x00 0x0D)
expect(buffer[8]).toBe(0x00);
expect(buffer[9]).toBe(0x00);
expect(buffer[10]).toBe(0x00);
expect(buffer[11]).toBe(0x0D);
// Type: IHDR (0x49 0x48 0x44 0x52)
expect(buffer[12]).toBe(0x49); // 'I'
expect(buffer[13]).toBe(0x48); // 'H'
expect(buffer[14]).toBe(0x44); // 'D'
expect(buffer[15]).toBe(0x52); // 'R'
});
it('generates PNG with 1x1 dimensions', () => {
const buffer = service.generateDefaultPNG('test-variant');
// Width: 1 (0x00 0x00 0x00 0x01) at byte 16
expect(buffer[16]).toBe(0x00);
expect(buffer[17]).toBe(0x00);
expect(buffer[18]).toBe(0x00);
expect(buffer[19]).toBe(0x01);
// Height: 1 (0x00 0x00 0x00 0x01) at byte 20
expect(buffer[20]).toBe(0x00);
expect(buffer[21]).toBe(0x00);
expect(buffer[22]).toBe(0x00);
expect(buffer[23]).toBe(0x01);
});
it('generates PNG with RGB color type', () => {
const buffer = service.generateDefaultPNG('test-variant');
// Color type: RGB (0x02) at byte 25
expect(buffer[25]).toBe(0x02);
});
it('generates PNG with RGB pixel data', () => {
const buffer = service.generateDefaultPNG('test-variant');
// RGB pixel data should be present in IDAT chunk
// IDAT chunk starts after IHDR (byte 37)
// We should find RGB values somewhere in the buffer
const hasRGB = buffer.some((byte, index) => {
// Check if we have a sequence that looks like RGB data
// This is a simplified check
return index > 37 && index < buffer.length - 10;
});
expect(hasRGB).toBe(true);
});
});
describe('deterministic generation', () => {
it('generates same team logo for same team ID across different instances', () => {
const service1 = new MediaGenerationService();
const service2 = new MediaGenerationService();
const url1 = service1.generateTeamLogo('team-123');
const url2 = service2.generateTeamLogo('team-123');
expect(url1).toBe(url2);
});
it('generates same driver avatar for same driver ID across different instances', () => {
const service1 = new MediaGenerationService();
const service2 = new MediaGenerationService();
const url1 = service1.generateDriverAvatar('driver-123');
const url2 = service2.generateDriverAvatar('driver-123');
expect(url1).toBe(url2);
});
it('generates same PNG for same variant across different instances', () => {
const service1 = new MediaGenerationService();
const service2 = new MediaGenerationService();
const buffer1 = service1.generateDefaultPNG('test-variant');
const buffer2 = service2.generateDefaultPNG('test-variant');
expect(buffer1.equals(buffer2)).toBe(true);
});
});
});

View File

@@ -1,7 +1,83 @@
import * as mod from '@core/media/domain/value-objects/AvatarId'; import { AvatarId } from './AvatarId';
describe('media/domain/value-objects/AvatarId.ts', () => { describe('AvatarId', () => {
it('imports', () => { describe('create', () => {
expect(mod).toBeTruthy(); it('creates from valid string', () => {
const avatarId = AvatarId.create('avatar-123');
expect(avatarId.toString()).toBe('avatar-123');
});
it('trims whitespace', () => {
const avatarId = AvatarId.create(' avatar-123 ');
expect(avatarId.toString()).toBe('avatar-123');
});
it('throws error when empty', () => {
expect(() => AvatarId.create('')).toThrow('Avatar ID cannot be empty');
});
it('throws error when only whitespace', () => {
expect(() => AvatarId.create(' ')).toThrow('Avatar ID cannot be empty');
});
it('throws error when null', () => {
expect(() => AvatarId.create(null as any)).toThrow('Avatar ID cannot be empty');
});
it('throws error when undefined', () => {
expect(() => AvatarId.create(undefined as any)).toThrow('Avatar ID cannot be empty');
});
});
describe('toString', () => {
it('returns the string value', () => {
const avatarId = AvatarId.create('avatar-123');
expect(avatarId.toString()).toBe('avatar-123');
});
});
describe('equals', () => {
it('returns true for equal avatar IDs', () => {
const avatarId1 = AvatarId.create('avatar-123');
const avatarId2 = AvatarId.create('avatar-123');
expect(avatarId1.equals(avatarId2)).toBe(true);
});
it('returns false for different avatar IDs', () => {
const avatarId1 = AvatarId.create('avatar-123');
const avatarId2 = AvatarId.create('avatar-456');
expect(avatarId1.equals(avatarId2)).toBe(false);
});
it('returns false for different case', () => {
const avatarId1 = AvatarId.create('avatar-123');
const avatarId2 = AvatarId.create('AVATAR-123');
expect(avatarId1.equals(avatarId2)).toBe(false);
});
});
describe('value object equality', () => {
it('implements value-based equality', () => {
const avatarId1 = AvatarId.create('avatar-123');
const avatarId2 = AvatarId.create('avatar-123');
const avatarId3 = AvatarId.create('avatar-456');
expect(avatarId1.equals(avatarId2)).toBe(true);
expect(avatarId1.equals(avatarId3)).toBe(false);
});
it('maintains equality after toString', () => {
const avatarId1 = AvatarId.create('avatar-123');
const avatarId2 = AvatarId.create('avatar-123');
expect(avatarId1.toString()).toBe(avatarId2.toString());
expect(avatarId1.equals(avatarId2)).toBe(true);
});
}); });
}); });

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

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

View File

@@ -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}`,
);
});
});

View File

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

View File

@@ -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();
});
});

View File

@@ -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();
});
});

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

View File

@@ -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'; import { describe, expect, it } from 'vitest';
describe('payments/domain/entities/MemberPayment.ts', () => { describe('payments/domain/entities/MemberPayment', () => {
it('imports', () => { describe('MemberPaymentStatus enum', () => {
expect(mod).toBeTruthy(); 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);
});
}); });
}); });

View File

@@ -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'; import { describe, expect, it } from 'vitest';
describe('payments/domain/entities/MembershipFee.ts', () => { describe('payments/domain/entities/MembershipFee', () => {
it('imports', () => { describe('MembershipFeeType enum', () => {
expect(mod).toBeTruthy(); 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');
});
}); });
}); });

View File

@@ -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'; import { describe, expect, it } from 'vitest';
describe('payments/domain/entities/Payment.ts', () => { describe('payments/domain/entities/Payment', () => {
it('imports', () => { describe('PaymentType enum', () => {
expect(mod).toBeTruthy(); 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);
});
}); });
}); });

View File

@@ -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'; import { describe, expect, it } from 'vitest';
describe('payments/domain/entities/Prize.ts', () => { describe('payments/domain/entities/Prize', () => {
it('imports', () => { describe('PrizeType enum', () => {
expect(mod).toBeTruthy(); 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');
});
}); });
}); });

View File

@@ -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'; import { describe, expect, it } from 'vitest';
describe('payments/domain/entities/Wallet.ts', () => { describe('payments/domain/entities/Wallet', () => {
it('imports', () => { describe('TransactionType enum', () => {
expect(mod).toBeTruthy(); 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');
});
}); });
}); });

View 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\//);
});
});
});

View 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();
});
});

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

View File

@@ -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();
});
});

View 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([]);
});
});

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

View File

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

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

View File

@@ -59,7 +59,7 @@ export class ChampionshipAggregator {
totalPoints, totalPoints,
resultsCounted, resultsCounted,
resultsDropped, resultsDropped,
position: 0, position: 1,
}), }),
); );
} }

View File

@@ -1,278 +1,72 @@
import { calculateRaceDates, getNextWeekday, type ScheduleConfig } from '@core/racing/domain/services/ScheduleCalculator'; import { describe, it, expect } from 'vitest';
import type { Weekday } from '@core/racing/domain/types/Weekday'; import { calculateRaceDates, getNextWeekday, ScheduleConfig } from './ScheduleCalculator';
import { describe, expect, it } from 'vitest';
describe('ScheduleCalculator', () => { describe('ScheduleCalculator', () => {
describe('calculateRaceDates', () => { describe('calculateRaceDates', () => {
describe('with empty or invalid input', () => { it('should return empty array if no weekdays or rounds', () => {
it('should return empty array when weekdays is empty', () => {
// Given
const config: ScheduleConfig = { const config: ScheduleConfig = {
weekdays: [], weekdays: [],
frequency: 'weekly', frequency: 'weekly',
rounds: 8, rounds: 10,
startDate: new Date('2024-01-01'), startDate: new Date('2024-01-01'),
}; };
expect(calculateRaceDates(config).raceDates).toHaveLength(0);
// When
const result = calculateRaceDates(config);
// Then
expect(result.raceDates).toEqual([]);
expect(result.seasonDurationWeeks).toBe(0);
}); });
it('should return empty array when rounds is 0', () => { it('should schedule weekly races', () => {
// Given
const config: ScheduleConfig = { const config: ScheduleConfig = {
weekdays: ['Sat'] as Weekday[], weekdays: ['Mon'],
frequency: 'weekly', frequency: 'weekly',
rounds: 0, rounds: 3,
startDate: new Date('2024-01-01'),
};
// When
const result = calculateRaceDates(config);
// Then
expect(result.raceDates).toEqual([]);
});
it('should return empty array when rounds is negative', () => {
// Given
const config: ScheduleConfig = {
weekdays: ['Sat'] as Weekday[],
frequency: 'weekly',
rounds: -5,
startDate: new Date('2024-01-01'),
};
// When
const result = calculateRaceDates(config);
// Then
expect(result.raceDates).toEqual([]);
});
});
describe('weekly scheduling', () => {
it('should schedule 8 races on Saturdays starting from a Saturday', () => {
// Given - January 6, 2024 is a Saturday
const config: ScheduleConfig = {
weekdays: ['Sat'] as Weekday[],
frequency: 'weekly',
rounds: 8,
startDate: new Date('2024-01-06'),
};
// When
const result = calculateRaceDates(config);
// Then
expect(result.raceDates.length).toBe(8);
// All dates should be Saturdays
result.raceDates.forEach(date => {
expect(date.getDay()).toBe(6); // Saturday
});
// First race should be Jan 6
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
// Last race should be 7 weeks later (Feb 24)
expect(result.raceDates[7]!.toISOString().split('T')[0]).toBe('2024-02-24');
});
it('should schedule races on multiple weekdays', () => {
// Given
const config: ScheduleConfig = {
weekdays: ['Wed', 'Sat'] as Weekday[],
frequency: 'weekly',
rounds: 8,
startDate: new Date('2024-01-01'), // Monday startDate: new Date('2024-01-01'), // Monday
}; };
// When
const result = calculateRaceDates(config); const result = calculateRaceDates(config);
expect(result.raceDates).toHaveLength(3);
// Then expect(result.raceDates[0].getDay()).toBe(1);
expect(result.raceDates.length).toBe(8); expect(result.raceDates[1].getDay()).toBe(1);
// Should alternate between Wednesday and Saturday expect(result.raceDates[2].getDay()).toBe(1);
result.raceDates.forEach(date => { // Check dates are 7 days apart
const day = date.getDay(); const diff = result.raceDates[1].getTime() - result.raceDates[0].getTime();
expect([3, 6]).toContain(day); // Wed=3, Sat=6 expect(diff).toBe(7 * 24 * 60 * 60 * 1000);
});
}); });
it('should schedule 8 races on Sundays', () => { it('should schedule bi-weekly races', () => {
// Given - January 7, 2024 is a Sunday
const config: ScheduleConfig = { const config: ScheduleConfig = {
weekdays: ['Sun'] as Weekday[], weekdays: ['Mon'],
frequency: 'weekly', frequency: 'everyNWeeks',
rounds: 8, intervalWeeks: 2,
rounds: 2,
startDate: new Date('2024-01-01'), startDate: new Date('2024-01-01'),
}; };
// When
const result = calculateRaceDates(config); const result = calculateRaceDates(config);
expect(result.raceDates).toHaveLength(2);
// Then const diff = result.raceDates[1].getTime() - result.raceDates[0].getTime();
expect(result.raceDates.length).toBe(8); expect(diff).toBe(14 * 24 * 60 * 60 * 1000);
result.raceDates.forEach(date => {
expect(date.getDay()).toBe(0); // Sunday
});
});
}); });
describe('bi-weekly scheduling', () => { it('should distribute races between start and end date', () => {
it('should schedule races every 2 weeks on Saturdays', () => {
// Given - January 6, 2024 is a Saturday
const config: ScheduleConfig = { const config: ScheduleConfig = {
weekdays: ['Sat'] as Weekday[], weekdays: ['Mon', 'Wed', 'Fri'],
frequency: 'everyNWeeks',
rounds: 4,
startDate: new Date('2024-01-06'),
intervalWeeks: 2,
};
// When
const result = calculateRaceDates(config);
// Then
expect(result.raceDates.length).toBe(4);
// First race Jan 6
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
// Second race 2 weeks later (Jan 20)
expect(result.raceDates[1]!.toISOString().split('T')[0]).toBe('2024-01-20');
// Third race 2 weeks later (Feb 3)
expect(result.raceDates[2]!.toISOString().split('T')[0]).toBe('2024-02-03');
// Fourth race 2 weeks later (Feb 17)
expect(result.raceDates[3]!.toISOString().split('T')[0]).toBe('2024-02-17');
});
});
describe('with start and end dates', () => {
it('should evenly distribute races across the date range', () => {
// Given - 3 month season
const config: ScheduleConfig = {
weekdays: ['Sat'] as Weekday[],
frequency: 'weekly', frequency: 'weekly',
rounds: 8, rounds: 2,
startDate: new Date('2024-01-06'), startDate: new Date('2024-01-01'), // Mon
endDate: new Date('2024-03-30'), endDate: new Date('2024-01-15'), // Mon
}; };
// When
const result = calculateRaceDates(config); const result = calculateRaceDates(config);
expect(result.raceDates).toHaveLength(2);
// Then // Use getTime() to avoid timezone issues in comparison
expect(result.raceDates.length).toBe(8); const expectedDate = new Date('2024-01-01');
// First race should be at or near start expectedDate.setHours(12, 0, 0, 0);
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06'); expect(result.raceDates[0].getTime()).toBe(expectedDate.getTime());
// Races should be spread across the range, not consecutive weeks
});
it('should use all available days if fewer than rounds requested', () => {
// Given - short period with only 3 Saturdays
const config: ScheduleConfig = {
weekdays: ['Sat'] as Weekday[],
frequency: 'weekly',
rounds: 10,
startDate: new Date('2024-01-06'),
endDate: new Date('2024-01-21'),
};
// When
const result = calculateRaceDates(config);
// Then
// Only 3 Saturdays in this range: Jan 6, 13, 20
expect(result.raceDates.length).toBe(3);
});
});
describe('season duration calculation', () => {
it('should calculate correct season duration in weeks', () => {
// Given
const config: ScheduleConfig = {
weekdays: ['Sat'] as Weekday[],
frequency: 'weekly',
rounds: 8,
startDate: new Date('2024-01-06'),
};
// When
const result = calculateRaceDates(config);
// Then
// 8 races, 1 week apart = 7 weeks duration
expect(result.seasonDurationWeeks).toBe(7);
});
it('should return 0 duration for single race', () => {
// Given
const config: ScheduleConfig = {
weekdays: ['Sat'] as Weekday[],
frequency: 'weekly',
rounds: 1,
startDate: new Date('2024-01-06'),
};
// When
const result = calculateRaceDates(config);
// Then
expect(result.raceDates.length).toBe(1);
expect(result.seasonDurationWeeks).toBe(0);
});
}); });
}); });
describe('getNextWeekday', () => { describe('getNextWeekday', () => {
it('should return next Saturday from a Monday', () => { it('should return the next Monday', () => {
// Given - January 1, 2024 is a Monday const from = new Date('2024-01-01'); // Monday
const fromDate = new Date('2024-01-01'); const next = getNextWeekday(from, 'Mon');
expect(next.getDay()).toBe(1);
// When expect(next.getDate()).toBe(8);
const result = getNextWeekday(fromDate, 'Sat');
// Then
expect(result.toISOString().split('T')[0]).toBe('2024-01-06');
expect(result.getDay()).toBe(6);
});
it('should return next occurrence when already on that weekday', () => {
// Given - January 6, 2024 is a Saturday
const fromDate = new Date('2024-01-06');
// When
const result = getNextWeekday(fromDate, 'Sat');
// Then
// Should return NEXT Saturday (7 days later), not same day
expect(result.toISOString().split('T')[0]).toBe('2024-01-13');
});
it('should return next Sunday from a Friday', () => {
// Given - January 5, 2024 is a Friday
const fromDate = new Date('2024-01-05');
// When
const result = getNextWeekday(fromDate, 'Sun');
// Then
expect(result.toISOString().split('T')[0]).toBe('2024-01-07');
expect(result.getDay()).toBe(0);
});
it('should return next Wednesday from a Thursday', () => {
// Given - January 4, 2024 is a Thursday
const fromDate = new Date('2024-01-04');
// When
const result = getNextWeekday(fromDate, 'Wed');
// Then
// Next Wednesday is 6 days later
expect(result.toISOString().split('T')[0]).toBe('2024-01-10');
expect(result.getDay()).toBe(3);
}); });
}); });
}); });

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

View File

@@ -0,0 +1,48 @@
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', () => {
expect(SkillLevelService.getSkillLevel(2500)).toBe('advanced');
expect(SkillLevelService.getSkillLevel(2999)).toBe('advanced');
});
it('should return intermediate for rating >= 1800', () => {
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(0)).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', () => {
expect(SkillLevelService.getTeamPerformanceLevel(3000)).toBe('advanced');
});
it('should return intermediate for rating >= 2000', () => {
expect(SkillLevelService.getTeamPerformanceLevel(2000)).toBe('intermediate');
});
it('should return beginner for rating < 2000', () => {
expect(SkillLevelService.getTeamPerformanceLevel(1999)).toBe('beginner');
});
});
});

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { AverageStrengthOfFieldCalculator, DriverRating } from './StrengthOfFieldCalculator';
describe('AverageStrengthOfFieldCalculator', () => {
const calculator = new AverageStrengthOfFieldCalculator();
it('should return null for empty list', () => {
expect(calculator.calculate([])).toBeNull();
});
it('should return null if no valid ratings (>0)', () => {
const ratings: DriverRating[] = [
{ driverId: '1', rating: 0 },
{ driverId: '2', rating: -100 },
];
expect(calculator.calculate(ratings)).toBeNull();
});
it('should calculate average of valid ratings', () => {
const ratings: DriverRating[] = [
{ driverId: '1', rating: 1000 },
{ driverId: '2', rating: 2000 },
{ driverId: '3', rating: 0 }, // Should be ignored
];
expect(calculator.calculate(ratings)).toBe(1500);
});
it('should round the result', () => {
const ratings: DriverRating[] = [
{ driverId: '1', rating: 1000 },
{ driverId: '2', rating: 1001 },
];
expect(calculator.calculate(ratings)).toBe(1001); // (1000+1001)/2 = 1000.5 -> 1001
});
});

View File

@@ -0,0 +1,354 @@
/**
* Unit tests for CalculateRatingUseCase
*
* Tests business logic and orchestration using mocked ports.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CalculateRatingUseCase } from './CalculateRatingUseCase';
import { Driver } from '../../../racing/domain/entities/Driver';
import { Race } from '../../../racing/domain/entities/Race';
import { Result } from '../../../racing/domain/entities/result/Result';
import { Rating } from '../../domain/Rating';
import { RatingCalculatedEvent } from '../../domain/events/RatingCalculatedEvent';
// Mock repositories and publisher
const mockDriverRepository = {
findById: vi.fn(),
};
const mockRaceRepository = {
findById: vi.fn(),
};
const mockResultRepository = {
findByRaceId: vi.fn(),
};
const mockRatingRepository = {
save: vi.fn(),
};
const mockEventPublisher = {
publish: vi.fn(),
};
describe('CalculateRatingUseCase', () => {
let useCase: CalculateRatingUseCase;
beforeEach(() => {
vi.clearAllMocks();
useCase = new CalculateRatingUseCase({
driverRepository: mockDriverRepository as any,
raceRepository: mockRaceRepository as any,
resultRepository: mockResultRepository as any,
ratingRepository: mockRatingRepository as any,
eventPublisher: mockEventPublisher as any,
});
});
describe('Scenario 1: Driver missing', () => {
it('should return error when driver is not found', async () => {
// Given
mockDriverRepository.findById.mockResolvedValue(null);
// When
const result = await useCase.execute({
driverId: 'driver-123',
raceId: 'race-456',
});
// Then
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Driver not found');
expect(mockRatingRepository.save).not.toHaveBeenCalled();
});
});
describe('Scenario 2: Race missing', () => {
it('should return error when race is not found', async () => {
// Given
const mockDriver = Driver.create({
id: 'driver-123',
iracingId: 'iracing-123',
name: 'Test Driver',
country: 'US',
});
mockDriverRepository.findById.mockResolvedValue(mockDriver);
mockRaceRepository.findById.mockResolvedValue(null);
// When
const result = await useCase.execute({
driverId: 'driver-123',
raceId: 'race-456',
});
// Then
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Race not found');
});
});
describe('Scenario 3: No results', () => {
it('should return error when no results found for race', async () => {
// Given
const mockDriver = Driver.create({
id: 'driver-123',
iracingId: 'iracing-123',
name: 'Test Driver',
country: 'US',
});
const mockRace = Race.create({
id: 'race-456',
leagueId: 'league-789',
scheduledAt: new Date(),
track: 'Test Track',
car: 'Test Car',
});
mockDriverRepository.findById.mockResolvedValue(mockDriver);
mockRaceRepository.findById.mockResolvedValue(mockRace);
mockResultRepository.findByRaceId.mockResolvedValue([]);
// When
const result = await useCase.execute({
driverId: 'driver-123',
raceId: 'race-456',
});
// Then
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('No results found for race');
});
});
describe('Scenario 4: Driver not present in results', () => {
it('should return error when driver is not in race results', async () => {
// Given
const mockDriver = Driver.create({
id: 'driver-123',
iracingId: 'iracing-123',
name: 'Test Driver',
country: 'US',
});
const mockRace = Race.create({
id: 'race-456',
leagueId: 'league-789',
scheduledAt: new Date(),
track: 'Test Track',
car: 'Test Car',
});
const otherResult = Result.create({
id: 'result-1',
raceId: 'race-456',
driverId: 'driver-456',
position: 1,
fastestLap: 60000,
incidents: 0,
startPosition: 1,
points: 25,
});
mockDriverRepository.findById.mockResolvedValue(mockDriver);
mockRaceRepository.findById.mockResolvedValue(mockRace);
mockResultRepository.findByRaceId.mockResolvedValue([otherResult]);
// When
const result = await useCase.execute({
driverId: 'driver-123',
raceId: 'race-456',
});
// Then
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Driver not found in race results');
});
});
describe('Scenario 5: Publishes event after save', () => {
it('should call ratingRepository.save before eventPublisher.publish', async () => {
// Given
const mockDriver = Driver.create({
id: 'driver-123',
iracingId: 'iracing-123',
name: 'Test Driver',
country: 'US',
});
const mockRace = Race.create({
id: 'race-456',
leagueId: 'league-789',
scheduledAt: new Date(),
track: 'Test Track',
car: 'Test Car',
});
const mockResult = Result.create({
id: 'result-1',
raceId: 'race-456',
driverId: 'driver-123',
position: 1,
fastestLap: 60000,
incidents: 0,
startPosition: 1,
points: 25,
});
mockDriverRepository.findById.mockResolvedValue(mockDriver);
mockRaceRepository.findById.mockResolvedValue(mockRace);
mockResultRepository.findByRaceId.mockResolvedValue([mockResult]);
mockRatingRepository.save.mockResolvedValue(undefined);
mockEventPublisher.publish.mockResolvedValue(undefined);
// When
const result = await useCase.execute({
driverId: 'driver-123',
raceId: 'race-456',
});
// Then
expect(result.isOk()).toBe(true);
expect(mockRatingRepository.save).toHaveBeenCalledTimes(1);
expect(mockEventPublisher.publish).toHaveBeenCalledTimes(1);
// Verify call order: save should be called before publish
const saveCallOrder = mockRatingRepository.save.mock.invocationCallOrder[0];
const publishCallOrder = mockEventPublisher.publish.mock.invocationCallOrder[0];
expect(saveCallOrder).toBeLessThan(publishCallOrder);
});
});
describe('Scenario 6: Component boundaries for cleanDriving', () => {
it('should return cleanDriving = 100 when incidents = 0', async () => {
// Given
const mockDriver = Driver.create({
id: 'driver-123',
iracingId: 'iracing-123',
name: 'Test Driver',
country: 'US',
});
const mockRace = Race.create({
id: 'race-456',
leagueId: 'league-789',
scheduledAt: new Date(),
track: 'Test Track',
car: 'Test Car',
});
const mockResult = Result.create({
id: 'result-1',
raceId: 'race-456',
driverId: 'driver-123',
position: 1,
fastestLap: 60000,
incidents: 0,
startPosition: 1,
points: 25,
});
mockDriverRepository.findById.mockResolvedValue(mockDriver);
mockRaceRepository.findById.mockResolvedValue(mockRace);
mockResultRepository.findByRaceId.mockResolvedValue([mockResult]);
mockRatingRepository.save.mockResolvedValue(undefined);
mockEventPublisher.publish.mockResolvedValue(undefined);
// When
const result = await useCase.execute({
driverId: 'driver-123',
raceId: 'race-456',
});
// Then
expect(result.isOk()).toBe(true);
const rating = result.unwrap();
expect(rating.components.cleanDriving).toBe(100);
});
it('should return cleanDriving = 20 when incidents >= 5', async () => {
// Given
const mockDriver = Driver.create({
id: 'driver-123',
iracingId: 'iracing-123',
name: 'Test Driver',
country: 'US',
});
const mockRace = Race.create({
id: 'race-456',
leagueId: 'league-789',
scheduledAt: new Date(),
track: 'Test Track',
car: 'Test Car',
});
const mockResult = Result.create({
id: 'result-1',
raceId: 'race-456',
driverId: 'driver-123',
position: 1,
fastestLap: 60000,
incidents: 5,
startPosition: 1,
points: 25,
});
mockDriverRepository.findById.mockResolvedValue(mockDriver);
mockRaceRepository.findById.mockResolvedValue(mockRace);
mockResultRepository.findByRaceId.mockResolvedValue([mockResult]);
mockRatingRepository.save.mockResolvedValue(undefined);
mockEventPublisher.publish.mockResolvedValue(undefined);
// When
const result = await useCase.execute({
driverId: 'driver-123',
raceId: 'race-456',
});
// Then
expect(result.isOk()).toBe(true);
const rating = result.unwrap();
expect(rating.components.cleanDriving).toBe(20);
});
});
describe('Scenario 7: Time-dependent output', () => {
it('should produce deterministic timestamp when time is frozen', async () => {
// Given
const frozenTime = new Date('2024-01-01T12:00:00.000Z');
vi.useFakeTimers();
vi.setSystemTime(frozenTime);
const mockDriver = Driver.create({
id: 'driver-123',
iracingId: 'iracing-123',
name: 'Test Driver',
country: 'US',
});
const mockRace = Race.create({
id: 'race-456',
leagueId: 'league-789',
scheduledAt: new Date(),
track: 'Test Track',
car: 'Test Car',
});
const mockResult = Result.create({
id: 'result-1',
raceId: 'race-456',
driverId: 'driver-123',
position: 1,
fastestLap: 60000,
incidents: 0,
startPosition: 1,
points: 25,
});
mockDriverRepository.findById.mockResolvedValue(mockDriver);
mockRaceRepository.findById.mockResolvedValue(mockRace);
mockResultRepository.findByRaceId.mockResolvedValue([mockResult]);
mockRatingRepository.save.mockResolvedValue(undefined);
mockEventPublisher.publish.mockResolvedValue(undefined);
// When
const result = await useCase.execute({
driverId: 'driver-123',
raceId: 'race-456',
});
// Then
expect(result.isOk()).toBe(true);
const rating = result.unwrap();
expect(rating.timestamp).toEqual(frozenTime);
vi.useRealTimers();
});
});
});

Some files were not shown because too many files have changed in this diff Show More