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

This commit is contained in:
2026-01-24 21:39:59 +01:00
parent 1e821c4a5c
commit 838f1602de
29 changed files with 4518 additions and 1 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

@@ -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,248 @@
# Testing concept: fully testing [`adapters/`](adapters/:1)
This is a Clean Architecture-aligned testing concept for completely testing the code under [`adapters/`](adapters/:1), using:
- [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md:1) (where test types belong)
- [`docs/architecture/shared/ADAPTERS.md`](docs/architecture/shared/ADAPTERS.md:1) (what adapters are)
- [`docs/architecture/shared/REPOSITORY_STRUCTURE.md`](docs/architecture/shared/REPOSITORY_STRUCTURE.md:1) (where things live)
- [`docs/architecture/shared/DATA_FLOW.md`](docs/architecture/shared/DATA_FLOW.md:1) (dependency rule)
- [`docs/TESTS.md`](docs/TESTS.md:1) (current repo testing practices)
---
## 1) Goal + constraints
### 1.1 Goal
Make [`adapters/`](adapters/:1) **safe to change** by covering:
1. Correct port behavior (adapters implement Core ports correctly)
2. Correct mapping across boundaries (domain ⇄ persistence, domain ⇄ external system)
3. Correct error shaping at boundaries (adapter-scoped schema errors)
4. Correct composition (small clusters like composite resolvers)
5. Correct wiring assumptions (DI boundaries: repositories dont construct their own mappers)
### 1.2 Constraints / non-negotiables
- Dependencies point inward: delivery apps → adapters → core per [`docs/architecture/shared/DATA_FLOW.md`](docs/architecture/shared/DATA_FLOW.md:13)
- Adapters are reusable infrastructure implementations (no delivery concerns) per [`docs/architecture/shared/REPOSITORY_STRUCTURE.md`](docs/architecture/shared/REPOSITORY_STRUCTURE.md:25)
- Tests live as close as possible to the code they verify per [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md:6)
---
## 2) Test taxonomy for adapters (mapped to repo locations)
This section translates [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md:1) into concrete rules for adapter code.
### 2.1 Local tests (live inside [`adapters/`](adapters/:1))
These are the default for adapter correctness.
#### A) Unit tests (file-adjacent)
**Use for:**
- schema guards (validate persisted/remote shapes)
- error types (message formatting, details)
- pure mappers (domain ⇄ orm/DTO)
- in-memory repositories and deterministic services
**Location:** next to implementation, e.g. [`adapters/logging/ConsoleLogger.test.ts`](adapters/logging/ConsoleLogger.test.ts:1)
**Style:** behavior-focused with BDD structure from [`docs/TESTS.md`](docs/TESTS.md:23). Use simple `Given/When/Then` comments; do not assert internal calls unless thats the observable contract.
Reference anchor: [`typescript.describe()`](adapters/logging/ConsoleLogger.test.ts:4)
#### B) Sociable unit tests (small collaborating cluster)
**Use for:**
- a repository using an injected mapper (repository + mapper + schema guard)
- composite adapters (delegation and resolution order)
**Location:** still adjacent to the “root” of the cluster, not necessarily to each file.
Reference anchor: [`adapters/media/MediaResolverAdapter.test.ts`](adapters/media/MediaResolverAdapter.test.ts:1)
#### C) Component / module tests (module invariants without infrastructure)
**Use for:**
- “module-level” adapter compositions that should behave consistently as a unit (e.g. a group of in-memory repos that are expected to work together)
**Location:** adjacent to the module root.
Reference anchor: [`adapters/racing/persistence/inmemory/InMemoryScoringRepositories.test.ts`](adapters/racing/persistence/inmemory/InMemoryScoringRepositories.test.ts:1)
### 2.2 Global tests (live outside adapters)
#### D) Contract tests (boundary tests)
Contract tests belong at system boundaries per [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md:88).
For this repo there are two contract categories:
1. **External system contracts** (API ↔ website) already documented in [`docs/CONTRACT_TESTING.md`](docs/CONTRACT_TESTING.md:1)
2. **Internal port contracts** (core port interface ↔ adapter implementation)
Internal port contracts are still valuable, but they are not “between systems”. Treat them as **shared executable specifications** for a port.
**Proposed location:** [`tests/contracts/`](tests/:1)
Principle: the contract suite imports the port interface from core and runs the same assertions against multiple adapter implementations (in-memory and TypeORM-DB-free where possible).
#### E) Integration / E2E (system-level)
Per [`docs/TESTS.md`](docs/TESTS.md:106):
- Integration tests live in [`tests/integration/`](tests/:1) and use in-memory adapters.
- E2E tests live in [`tests/e2e/`](tests/:1) and can use TypeORM/Postgres.
Adapter code should *enable* these tests, but adapter *unit correctness* should not depend on these tests.
---
## 3) Canonical adapter test recipes (what to test, not how)
These are reusable patterns to standardize how we test adapters.
### 3.1 In-memory repositories (pure adapter behavior)
**Minimum spec for an in-memory repository implementation:**
- persists and retrieves the aggregate/value (happy path)
- supports negative paths (not found returns null / empty)
- enforces invariants that the real implementation must also enforce (uniqueness, idempotency)
- does not leak references if immutability is expected (optional; depends on domain semantics)
Examples:
- [`adapters/identity/persistence/inmemory/InMemoryUserRepository.test.ts`](adapters/identity/persistence/inmemory/InMemoryUserRepository.test.ts:1)
- [`adapters/racing/persistence/inmemory/InMemorySessionRepository.test.ts`](adapters/racing/persistence/inmemory/InMemorySessionRepository.test.ts:1)
### 3.2 TypeORM mappers (mapping + validation)
**Minimum spec for a mapper:**
- domain → orm mapping produces a persistable shape
- orm → domain mapping reconstitutes without calling “create” semantics (i.e., preserves persisted identity)
- invalid persisted shape throws adapter-scoped schema error type
Examples:
- [`adapters/media/persistence/typeorm/mappers/MediaOrmMapper.test.ts`](adapters/media/persistence/typeorm/mappers/MediaOrmMapper.test.ts:1)
- [`adapters/racing/persistence/typeorm/mappers/DriverOrmMapper.test.ts`](adapters/racing/persistence/typeorm/mappers/DriverOrmMapper.test.ts:1)
### 3.3 TypeORM repositories (DB-free correctness + DI boundaries)
**We split repository tests into 2 categories:**
1. **DB-free repository behavior tests**: verify mapping is applied and correct ORM repository methods are called with expected shapes (using a stubbed TypeORM repository).
2. **DI boundary tests**: verify no internal instantiation of mappers and that constructor requires injected dependencies.
Examples:
- [`adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.test.ts`](adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.test.ts:1)
- [`adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository.test.ts`](adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository.test.ts:1)
### 3.4 Schema guards + schema errors (adapter boundary hardening)
**Minimum spec:**
- guard accepts valid shapes
- guard rejects invalid shapes with deterministic error messages
- schema error contains enough details to debug (entity, field, reason)
Examples:
- [`adapters/admin/persistence/typeorm/schema/TypeOrmAdminSchemaGuards.test.ts`](adapters/admin/persistence/typeorm/schema/TypeOrmAdminSchemaGuards.test.ts:1)
- [`adapters/admin/persistence/typeorm/errors/TypeOrmAdminSchemaError.test.ts`](adapters/admin/persistence/typeorm/errors/TypeOrmAdminSchemaError.test.ts:1)
### 3.5 Gateways (external side effects)
**Minimum spec:**
- correct request construction (mapping domain intent → external API payload)
- error handling and retries (if present)
- logging behavior (only observable outputs)
These tests should stub the external client; no real network.
---
## 4) Gap matrix (folder-level)
Legend:
- ✅ = present (at least one meaningful test exists)
- ⚠️ = partially covered
- ❌ = missing
> Important: this matrix is based on the current directory contents under [`adapters/`](adapters/:1). Its folder-level, not per-class.
| Adapter folder | What exists | Local tests status | Missing tests (minimum) |
|---|---|---:|---|
| [`adapters/achievement/`](adapters/achievement/:1) | TypeORM entities/mappers/repository/schema guard | ❌ | Mapper tests, schema guard tests, repo DI boundary tests, schema error tests |
| [`adapters/activity/`](adapters/activity/:1) | In-memory repository | ❌ | In-memory repo behavior test suite |
| [`adapters/admin/`](adapters/admin/:1) | In-memory repo + TypeORM layer | ✅ | Consider adding DB-free repo tests consistency patterns for TypeORM (if not already), ensure schema guard coverage is complete |
| [`adapters/analytics/`](adapters/analytics/:1) | In-memory repos + TypeORM layer | ⚠️ | Tests for TypeORM repos without tests, tests for non-tested mappers (`AnalyticsSnapshotOrmMapper`, `EngagementEventOrmMapper`), schema guard tests, schema error tests |
| [`adapters/automation/`](adapters/automation/:1) | Config objects | ❌ | Unit tests for config parsing/merging defaults (if behavior exists); otherwise explicitly accept no tests |
| [`adapters/bootstrap/`](adapters/bootstrap/:1) | Seeders + many config modules + factories | ⚠️ | Add unit tests for critical deterministic configs/factories not yet covered; establish module tests for seeding workflows (DB-free) |
| [`adapters/drivers/`](adapters/drivers/:1) | In-memory repository | ❌ | In-memory repo behavior tests |
| [`adapters/events/`](adapters/events/:1) | In-memory event publishers | ❌ | Behavior tests: publishes expected events to subscribers/collectors; ensure “no-op” safety |
| [`adapters/health/`](adapters/health/:1) | In-memory health check adapter | ❌ | Behavior tests: healthy/unhealthy reporting, edge cases |
| [`adapters/http/`](adapters/http/:1) | Request context module | ❌ | Unit tests for any parsing/propagation logic; otherwise explicitly accept no tests |
| [`adapters/identity/`](adapters/identity/:1) | In-memory repos + TypeORM repos/mappers + services + session adapter | ⚠️ | Add tests for in-memory files without tests (company/external game rating), tests for TypeORM repos without tests, schema guards tests, cookie session adapter tests |
| [`adapters/leaderboards/`](adapters/leaderboards/:1) | In-memory repo + event publisher | ❌ | Repo tests + publisher tests |
| [`adapters/leagues/`](adapters/leagues/:1) | In-memory repo + event publisher | ❌ | Repo tests + publisher tests |
| [`adapters/logging/`](adapters/logging/:1) | Console logger + error reporter | ⚠️ | Add tests for error reporter behavior; keep logger tests |
| [`adapters/media/`](adapters/media/:1) | Resolvers + in-memory repos + TypeORM layer + ports | ⚠️ | Add tests for in-memory repos without tests, file-system storage adapter tests, gateway/event publisher tests if behavior exists |
| [`adapters/notifications/`](adapters/notifications/:1) | Gateways + persistence + ports | ⚠️ | Add gateway tests, registry tests, port adapter tests; schema guard tests for TypeORM |
| [`adapters/payments/`](adapters/payments/:1) | In-memory repos + TypeORM layer | ⚠️ | Add tests for non-tested mappers, non-tested repos, schema guard tests |
| [`adapters/persistence/`](adapters/persistence/:1) | In-memory achievement repo + migration script | ⚠️ | Decide whether migrations are tested (usually via E2E/integration). If treated as code, add smoke test for migration shape |
| [`adapters/races/`](adapters/races/:1) | In-memory repository | ❌ | In-memory repo behavior tests |
| [`adapters/racing/`](adapters/racing/:1) | Large in-memory + TypeORM layer; many tests | ✅ | Add tests for remaining untested files (notably some in-memory repos and TypeORM repos/mappers without tests) |
| [`adapters/rating/`](adapters/rating/:1) | In-memory repository | ❌ | In-memory repo behavior tests |
| [`adapters/social/`](adapters/social/:1) | In-memory + TypeORM; some tests | ⚠️ | Add tests for TypeORM social graph repository, schema guards, and any missing in-memory invariants |
| [`adapters/eslint-rules/`](adapters/eslint-rules/:1) | ESLint rules | ⚠️ | Optional: rule tests (if the project values rule stability); otherwise accept manual verification |
---
## 5) Priority order (risk-first)
If “completely tested” is the goal, this is the order Id implement missing tests.
1. Persistence adapters that can corrupt or misread data (TypeORM mappers + schema guards) under [`adapters/racing/persistence/typeorm/`](adapters/racing/persistence/typeorm/:1), [`adapters/identity/persistence/typeorm/`](adapters/identity/persistence/typeorm/:1), [`adapters/payments/persistence/typeorm/`](adapters/payments/persistence/typeorm/:1)
2. Un-tested persistence folders with real production impact: [`adapters/achievement/`](adapters/achievement/:1), [`adapters/analytics/`](adapters/analytics/:1)
3. External side-effect gateways: [`adapters/notifications/gateways/`](adapters/notifications/gateways/:1)
4. Small but foundational shared utilities (request context, health, event publishers): [`adapters/http/`](adapters/http/:1), [`adapters/health/`](adapters/health/:1), [`adapters/events/`](adapters/events/:1)
5. Remaining in-memory repos to keep integration tests trustworthy: [`adapters/activity/`](adapters/activity/:1), [`adapters/drivers/`](adapters/drivers/:1), [`adapters/races/`](adapters/races/:1), [`adapters/rating/`](adapters/rating/:1), [`adapters/leaderboards/`](adapters/leaderboards/:1), [`adapters/leagues/`](adapters/leagues/:1)
---
## 6) Definition of done (what “completely tested adapters” means)
For each adapter module under [`adapters/`](adapters/:1):
1. Every in-memory repository has a behavior test (happy path + at least one negative path).
2. Every TypeORM mapper has a mapping test and an invalid-shape test.
3. Every TypeORM repository has at least a DB-free test proving:
- dependencies are injected (no internal `new Mapper()` patterns)
- mapping is applied on save/load
4. Every schema guard and schema error class is tested.
5. Every external gateway has a stubbed-client unit test verifying payload mapping and error shaping.
6. At least one module-level test exists for any composite adapter (delegation order + null-handling).
7. Anything that is intentionally “not worth unit-testing” is explicitly declared and justified in the gap matrix (to avoid silent omissions).
---
## 7) Optional: internal port-contract test harness (shared executable specs)
If we want the same behavioral contract applied across multiple adapter implementations, add a tiny harness under [`tests/contracts/`](tests/:1):
- `tests/contracts/<feature>/<PortName>.contract.ts`
- exports a function that takes a factory creating an implementation
- Each adapter test imports that contract and runs it
This keeps contracts central **without** moving tests away from the code (the adapter still owns the “run this contract for my implementation” test file).
---
## 8) Mode switch intent
After you approve this concept, the implementation phase is to add the missing tests adjacent to the adapter files and (optionally) introduce `tests/contracts/` without breaking dependency rules.

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Media } from '../../../core/media/domain/entities/Media';
import { MediaRepository } from '../../../core/media/domain/repositories/MediaRepository';
export function runMediaRepositoryContract(
factory: () => Promise<{
repository: MediaRepository;
cleanup?: () => Promise<void>;
}>
) {
describe('MediaRepository Contract', () => {
let repository: MediaRepository;
let cleanup: (() => Promise<void>) | undefined;
beforeEach(async () => {
const result = await factory();
repository = result.repository;
cleanup = result.cleanup;
});
afterEach(async () => {
if (cleanup) {
await cleanup();
}
});
it('should save and find a media entity by ID', async () => {
const media = Media.create({
id: 'media-1',
filename: 'test.jpg',
originalName: 'test.jpg',
mimeType: 'image/jpeg',
size: 1024,
url: 'https://example.com/test.jpg',
type: 'image',
uploadedBy: 'user-1',
});
await repository.save(media);
const found = await repository.findById('media-1');
expect(found).toBeDefined();
expect(found?.id).toBe(media.id);
expect(found?.filename).toBe(media.filename);
});
it('should return null when finding a non-existent media entity', async () => {
const found = await repository.findById('non-existent');
expect(found).toBeNull();
});
it('should find all media entities uploaded by a specific user', async () => {
const user1 = 'user-1';
const user2 = 'user-2';
const media1 = Media.create({
id: 'm1',
filename: 'f1.jpg',
originalName: 'f1.jpg',
mimeType: 'image/jpeg',
size: 100,
url: 'https://example.com/url1',
type: 'image',
uploadedBy: user1,
});
const media2 = Media.create({
id: 'm2',
filename: 'f2.jpg',
originalName: 'f2.jpg',
mimeType: 'image/jpeg',
size: 200,
url: 'https://example.com/url2',
type: 'image',
uploadedBy: user1,
});
const media3 = Media.create({
id: 'm3',
filename: 'f3.jpg',
originalName: 'f3.jpg',
mimeType: 'image/jpeg',
size: 300,
url: 'https://example.com/url3',
type: 'image',
uploadedBy: user2,
});
await repository.save(media1);
await repository.save(media2);
await repository.save(media3);
const user1Media = await repository.findByUploadedBy(user1);
expect(user1Media).toHaveLength(2);
expect(user1Media.map(m => m.id)).toContain('m1');
expect(user1Media.map(m => m.id)).toContain('m2');
});
it('should delete a media entity', async () => {
const media = Media.create({
id: 'to-delete',
filename: 'del.jpg',
originalName: 'del.jpg',
mimeType: 'image/jpeg',
size: 100,
url: 'https://example.com/url',
type: 'image',
uploadedBy: 'user',
});
await repository.save(media);
await repository.delete('to-delete');
const found = await repository.findById('to-delete');
expect(found).toBeNull();
});
});
}