tests/core-2 #6
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,11 @@ export class AchievementOrmMapper {
|
||||
assertNonEmptyString(entityName, 'achievementId', entity.achievementId);
|
||||
assertInteger(entityName, 'progress', entity.progress);
|
||||
assertDate(entityName, 'earnedAt', entity.earnedAt);
|
||||
assertOptionalStringOrNull(entityName, 'notifiedAt', entity.notifiedAt);
|
||||
|
||||
// Validate notifiedAt (Date | null)
|
||||
if (entity.notifiedAt !== null) {
|
||||
assertDate(entityName, 'notifiedAt', entity.notifiedAt);
|
||||
}
|
||||
|
||||
try {
|
||||
return UserAchievement.create({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AdminUser } from '../../domain/entities/AdminUser';
|
||||
import { UserRole } from '../../domain/value-objects/UserRole';
|
||||
import { UserStatus } from '../../domain/value-objects/UserStatus';
|
||||
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||
import { UserRole } from '@core/admin/domain/value-objects/UserRole';
|
||||
import { UserStatus } from '@core/admin/domain/value-objects/UserStatus';
|
||||
import { InMemoryAdminUserRepository } from './InMemoryAdminUserRepository';
|
||||
|
||||
describe('InMemoryAdminUserRepository', () => {
|
||||
@@ -787,4 +787,4 @@ describe('InMemoryAdminUserRepository', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AdminUser } from '../../domain/entities/AdminUser';
|
||||
import { AdminUserRepository, StoredAdminUser, UserFilter, UserListQuery, UserListResult } from '../../domain/repositories/AdminUserRepository';
|
||||
import { Email } from '../../domain/value-objects/Email';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||
import { AdminUserRepository, StoredAdminUser, UserFilter, UserListQuery, UserListResult } from '@core/admin/domain/repositories/AdminUserRepository';
|
||||
import { Email } from '@core/admin/domain/value-objects/Email';
|
||||
import { UserId } from '@core/admin/domain/value-objects/UserId';
|
||||
|
||||
/**
|
||||
* In-memory implementation of AdminUserRepository for testing and development
|
||||
@@ -254,4 +254,4 @@ export class InMemoryAdminUserRepository implements AdminUserRepository {
|
||||
|
||||
return AdminUser.rehydrate(props);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AdminUserOrmEntity } from './AdminUserOrmEntity';
|
||||
|
||||
describe('AdminUserOrmEntity', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
describe('entity properties', () => {
|
||||
it('should have id property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('should have email property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('email');
|
||||
});
|
||||
|
||||
it('should have displayName property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('displayName');
|
||||
});
|
||||
|
||||
it('should have roles property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('roles');
|
||||
});
|
||||
|
||||
it('should have status property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('should have primaryDriverId property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('primaryDriverId');
|
||||
});
|
||||
|
||||
it('should have lastLoginAt property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('lastLoginAt');
|
||||
});
|
||||
|
||||
it('should have createdAt property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('createdAt');
|
||||
});
|
||||
|
||||
it('should have updatedAt property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('updatedAt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('property types', () => {
|
||||
it('should have id as string', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
entity.id = 'test-id';
|
||||
|
||||
// Act & Assert
|
||||
expect(typeof entity.id).toBe('string');
|
||||
expect(entity.id).toBe('test-id');
|
||||
});
|
||||
|
||||
it('should have email as string', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
entity.email = 'test@example.com';
|
||||
|
||||
// Act & Assert
|
||||
expect(typeof entity.email).toBe('string');
|
||||
expect(entity.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should have displayName as string', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
entity.displayName = 'Test User';
|
||||
|
||||
// Act & Assert
|
||||
expect(typeof entity.displayName).toBe('string');
|
||||
expect(entity.displayName).toBe('Test User');
|
||||
});
|
||||
|
||||
it('should have roles as string array', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
entity.roles = ['admin', 'user'];
|
||||
|
||||
// Act & Assert
|
||||
expect(Array.isArray(entity.roles)).toBe(true);
|
||||
expect(entity.roles).toEqual(['admin', 'user']);
|
||||
});
|
||||
|
||||
it('should have status as string', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
entity.status = 'active';
|
||||
|
||||
// Act & Assert
|
||||
expect(typeof entity.status).toBe('string');
|
||||
expect(entity.status).toBe('active');
|
||||
});
|
||||
|
||||
it('should have primaryDriverId as optional string', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity.primaryDriverId).toBeUndefined();
|
||||
|
||||
entity.primaryDriverId = 'driver-123';
|
||||
expect(typeof entity.primaryDriverId).toBe('string');
|
||||
expect(entity.primaryDriverId).toBe('driver-123');
|
||||
});
|
||||
|
||||
it('should have lastLoginAt as optional Date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity.lastLoginAt).toBeUndefined();
|
||||
|
||||
const now = new Date();
|
||||
entity.lastLoginAt = now;
|
||||
expect(entity.lastLoginAt).toBeInstanceOf(Date);
|
||||
expect(entity.lastLoginAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should have createdAt as Date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
entity.createdAt = now;
|
||||
|
||||
// Act & Assert
|
||||
expect(entity.createdAt).toBeInstanceOf(Date);
|
||||
expect(entity.createdAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should have updatedAt as Date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Act & Assert
|
||||
expect(entity.updatedAt).toBeInstanceOf(Date);
|
||||
expect(entity.updatedAt).toBe(now);
|
||||
});
|
||||
});
|
||||
|
||||
describe('property values', () => {
|
||||
it('should handle valid UUID for id', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
|
||||
// Act
|
||||
entity.id = uuid;
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should handle email with special characters', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const email = 'user+tag@example-domain.com';
|
||||
|
||||
// Act
|
||||
entity.email = email;
|
||||
|
||||
// Assert
|
||||
expect(entity.email).toBe(email);
|
||||
});
|
||||
|
||||
it('should handle display name with spaces', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const displayName = 'John Doe Smith';
|
||||
|
||||
// Act
|
||||
entity.displayName = displayName;
|
||||
|
||||
// Assert
|
||||
expect(entity.displayName).toBe(displayName);
|
||||
});
|
||||
|
||||
it('should handle roles with multiple entries', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const roles = ['owner', 'admin', 'user', 'moderator'];
|
||||
|
||||
// Act
|
||||
entity.roles = roles;
|
||||
|
||||
// Assert
|
||||
expect(entity.roles).toEqual(roles);
|
||||
expect(entity.roles).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should handle status with different values', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
entity.status = 'active';
|
||||
expect(entity.status).toBe('active');
|
||||
|
||||
entity.status = 'suspended';
|
||||
expect(entity.status).toBe('suspended');
|
||||
|
||||
entity.status = 'deleted';
|
||||
expect(entity.status).toBe('deleted');
|
||||
});
|
||||
|
||||
it('should handle primaryDriverId with valid driver ID', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const driverId = 'driver-abc123';
|
||||
|
||||
// Act
|
||||
entity.primaryDriverId = driverId;
|
||||
|
||||
// Assert
|
||||
expect(entity.primaryDriverId).toBe(driverId);
|
||||
});
|
||||
|
||||
it('should handle lastLoginAt with current date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.lastLoginAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.lastLoginAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should handle createdAt with specific date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const specificDate = new Date('2024-01-01T00:00:00.000Z');
|
||||
|
||||
// Act
|
||||
entity.createdAt = specificDate;
|
||||
|
||||
// Assert
|
||||
expect(entity.createdAt).toBe(specificDate);
|
||||
});
|
||||
|
||||
it('should handle updatedAt with specific date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const specificDate = new Date('2024-01-02T00:00:00.000Z');
|
||||
|
||||
// Act
|
||||
entity.updatedAt = specificDate;
|
||||
|
||||
// Assert
|
||||
expect(entity.updatedAt).toBe(specificDate);
|
||||
});
|
||||
});
|
||||
|
||||
describe('property assignments', () => {
|
||||
it('should allow setting all properties', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.id = 'user-123';
|
||||
entity.email = 'test@example.com';
|
||||
entity.displayName = 'Test User';
|
||||
entity.roles = ['admin'];
|
||||
entity.status = 'active';
|
||||
entity.primaryDriverId = 'driver-456';
|
||||
entity.lastLoginAt = now;
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBe('user-123');
|
||||
expect(entity.email).toBe('test@example.com');
|
||||
expect(entity.displayName).toBe('Test User');
|
||||
expect(entity.roles).toEqual(['admin']);
|
||||
expect(entity.status).toBe('active');
|
||||
expect(entity.primaryDriverId).toBe('driver-456');
|
||||
expect(entity.lastLoginAt).toBe(now);
|
||||
expect(entity.createdAt).toBe(now);
|
||||
expect(entity.updatedAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should allow updating properties', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
const later = new Date(now.getTime() + 1000);
|
||||
|
||||
// Act
|
||||
entity.id = 'user-123';
|
||||
entity.email = 'test@example.com';
|
||||
entity.displayName = 'Test User';
|
||||
entity.roles = ['user'];
|
||||
entity.status = 'active';
|
||||
entity.primaryDriverId = 'driver-456';
|
||||
entity.lastLoginAt = now;
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Update
|
||||
entity.displayName = 'Updated Name';
|
||||
entity.roles = ['admin', 'user'];
|
||||
entity.status = 'suspended';
|
||||
entity.lastLoginAt = later;
|
||||
entity.updatedAt = later;
|
||||
|
||||
// Assert
|
||||
expect(entity.displayName).toBe('Updated Name');
|
||||
expect(entity.roles).toEqual(['admin', 'user']);
|
||||
expect(entity.status).toBe('suspended');
|
||||
expect(entity.lastLoginAt).toBe(later);
|
||||
expect(entity.updatedAt).toBe(later);
|
||||
});
|
||||
|
||||
it('should allow clearing optional properties', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.primaryDriverId = 'driver-123';
|
||||
entity.lastLoginAt = now;
|
||||
|
||||
// Clear
|
||||
entity.primaryDriverId = undefined;
|
||||
entity.lastLoginAt = undefined;
|
||||
|
||||
// Assert
|
||||
expect(entity.primaryDriverId).toBeUndefined();
|
||||
expect(entity.lastLoginAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty entity', () => {
|
||||
it('should create entity with undefined properties', () => {
|
||||
// Arrange & Act
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBeUndefined();
|
||||
expect(entity.email).toBeUndefined();
|
||||
expect(entity.displayName).toBeUndefined();
|
||||
expect(entity.roles).toBeUndefined();
|
||||
expect(entity.status).toBeUndefined();
|
||||
expect(entity.primaryDriverId).toBeUndefined();
|
||||
expect(entity.lastLoginAt).toBeUndefined();
|
||||
expect(entity.createdAt).toBeUndefined();
|
||||
expect(entity.updatedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow partial initialization', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act
|
||||
entity.id = 'user-123';
|
||||
entity.email = 'test@example.com';
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBe('user-123');
|
||||
expect(entity.email).toBe('test@example.com');
|
||||
expect(entity.displayName).toBeUndefined();
|
||||
expect(entity.roles).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should handle complete user entity', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.id = '123e4567-e89b-12d3-a456-426614174000';
|
||||
entity.email = 'admin@example.com';
|
||||
entity.displayName = 'Administrator';
|
||||
entity.roles = ['owner', 'admin'];
|
||||
entity.status = 'active';
|
||||
entity.primaryDriverId = 'driver-789';
|
||||
entity.lastLoginAt = now;
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBe('123e4567-e89b-12d3-a456-426614174000');
|
||||
expect(entity.email).toBe('admin@example.com');
|
||||
expect(entity.displayName).toBe('Administrator');
|
||||
expect(entity.roles).toEqual(['owner', 'admin']);
|
||||
expect(entity.status).toBe('active');
|
||||
expect(entity.primaryDriverId).toBe('driver-789');
|
||||
expect(entity.lastLoginAt).toBe(now);
|
||||
expect(entity.createdAt).toBe(now);
|
||||
expect(entity.updatedAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should handle user without primary driver', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.id = 'user-456';
|
||||
entity.email = 'user@example.com';
|
||||
entity.displayName = 'Regular User';
|
||||
entity.roles = ['user'];
|
||||
entity.status = 'active';
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.primaryDriverId).toBeUndefined();
|
||||
expect(entity.lastLoginAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle suspended user', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.id = 'user-789';
|
||||
entity.email = 'suspended@example.com';
|
||||
entity.displayName = 'Suspended User';
|
||||
entity.roles = ['user'];
|
||||
entity.status = 'suspended';
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.status).toBe('suspended');
|
||||
});
|
||||
|
||||
it('should handle user with many roles', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.id = 'user-999';
|
||||
entity.email = 'multi@example.com';
|
||||
entity.displayName = 'Multi Role User';
|
||||
entity.roles = ['owner', 'admin', 'user', 'moderator', 'viewer'];
|
||||
entity.status = 'active';
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.roles).toHaveLength(5);
|
||||
expect(entity.roles).toContain('owner');
|
||||
expect(entity.roles).toContain('admin');
|
||||
expect(entity.roles).toContain('user');
|
||||
expect(entity.roles).toContain('moderator');
|
||||
expect(entity.roles).toContain('viewer');
|
||||
});
|
||||
|
||||
it('should handle user with recent login', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
const recentLogin = new Date(now.getTime() - 60000); // 1 minute ago
|
||||
|
||||
// Act
|
||||
entity.id = 'user-111';
|
||||
entity.email = 'active@example.com';
|
||||
entity.displayName = 'Active User';
|
||||
entity.roles = ['user'];
|
||||
entity.status = 'active';
|
||||
entity.primaryDriverId = 'driver-222';
|
||||
entity.lastLoginAt = recentLogin;
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.lastLoginAt).toBe(recentLogin);
|
||||
expect(entity.lastLoginAt!.getTime()).toBeLessThan(now.getTime());
|
||||
});
|
||||
|
||||
it('should handle user with old login', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
const oldLogin = new Date(now.getTime() - 86400000); // 1 day ago
|
||||
|
||||
// Act
|
||||
entity.id = 'user-333';
|
||||
entity.email = 'old@example.com';
|
||||
entity.displayName = 'Old Login User';
|
||||
entity.roles = ['user'];
|
||||
entity.status = 'active';
|
||||
entity.lastLoginAt = oldLogin;
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.lastLoginAt).toBe(oldLogin);
|
||||
expect(entity.lastLoginAt!.getTime()).toBeLessThan(now.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string values', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act
|
||||
entity.id = '';
|
||||
entity.email = '';
|
||||
entity.displayName = '';
|
||||
entity.status = '';
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBe('');
|
||||
expect(entity.email).toBe('');
|
||||
expect(entity.displayName).toBe('');
|
||||
expect(entity.status).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty roles array', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act
|
||||
entity.roles = [];
|
||||
|
||||
// Assert
|
||||
expect(entity.roles).toEqual([]);
|
||||
expect(entity.roles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle null values for optional properties', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act
|
||||
entity.primaryDriverId = null as any;
|
||||
entity.lastLoginAt = null as any;
|
||||
|
||||
// Assert
|
||||
expect(entity.primaryDriverId).toBeNull();
|
||||
expect(entity.lastLoginAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle very long strings', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const longString = 'a'.repeat(1000);
|
||||
|
||||
// Act
|
||||
entity.email = `${longString}@example.com`;
|
||||
entity.displayName = longString;
|
||||
|
||||
// Assert
|
||||
expect(entity.email).toBe(`${longString}@example.com`);
|
||||
expect(entity.displayName).toBe(longString);
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act
|
||||
entity.email = '用户@例子.测试';
|
||||
entity.displayName = '用户 例子';
|
||||
|
||||
// Assert
|
||||
expect(entity.email).toBe('用户@例子.测试');
|
||||
expect(entity.displayName).toBe('用户 例子');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TypeOrmAdminSchemaError } from './TypeOrmAdminSchemaError';
|
||||
|
||||
describe('TypeOrmAdminSchemaError', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
describe('constructor', () => {
|
||||
it('should create an error with all required details', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid format',
|
||||
message: 'Email must be a valid email address',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.name).toBe('TypeOrmAdminSchemaError');
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Invalid format - Email must be a valid email address');
|
||||
});
|
||||
|
||||
it('should create an error with minimal details', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'id',
|
||||
reason: 'Missing',
|
||||
message: 'ID field is required',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.id: Missing - ID field is required');
|
||||
});
|
||||
|
||||
it('should create an error with complex entity name', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUserOrmEntity',
|
||||
fieldName: 'roles',
|
||||
reason: 'Type mismatch',
|
||||
message: 'Expected simple-json but got text',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUserOrmEntity.roles: Type mismatch - Expected simple-json but got text');
|
||||
});
|
||||
|
||||
it('should create an error with long field name', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'veryLongFieldNameThatExceedsNormalLength',
|
||||
reason: 'Constraint violation',
|
||||
message: 'Field length exceeds maximum allowed',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.veryLongFieldNameThatExceedsNormalLength: Constraint violation - Field length exceeds maximum allowed');
|
||||
});
|
||||
|
||||
it('should create an error with special characters in message', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Validation failed',
|
||||
message: 'Email "test@example.com" contains invalid characters: @, ., com',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Validation failed - Email "test@example.com" contains invalid characters: @, ., com');
|
||||
});
|
||||
|
||||
it('should create an error with empty reason', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: '',
|
||||
message: 'Email is required',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: - Email is required');
|
||||
});
|
||||
|
||||
it('should create an error with empty message', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: '',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Invalid - ');
|
||||
});
|
||||
|
||||
it('should create an error with empty reason and message', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: '',
|
||||
message: '',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: - ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error properties', () => {
|
||||
it('should have correct error name', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.name).toBe('TypeOrmAdminSchemaError');
|
||||
});
|
||||
|
||||
it('should be instance of Error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect(error instanceof TypeOrmAdminSchemaError).toBe(true);
|
||||
});
|
||||
|
||||
it('should have a stack trace', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.stack).toBeDefined();
|
||||
expect(typeof error.stack).toBe('string');
|
||||
expect(error.stack).toContain('TypeOrmAdminSchemaError');
|
||||
});
|
||||
|
||||
it('should preserve details object reference', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toBe(details);
|
||||
});
|
||||
|
||||
it('should allow modification of details after creation', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Act
|
||||
error.details.reason = 'Updated reason';
|
||||
|
||||
// Assert
|
||||
expect(error.details.reason).toBe('Updated reason');
|
||||
expect(error.message).toContain('Updated reason');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message formatting', () => {
|
||||
it('should format message with all parts', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Validation failed',
|
||||
message: 'Email must be a valid email address',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Validation failed - Email must be a valid email address');
|
||||
});
|
||||
|
||||
it('should handle multiple words in entity name', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'Admin User Entity',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] Admin User Entity.email: Invalid - Test');
|
||||
});
|
||||
|
||||
it('should handle multiple words in field name', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email address',
|
||||
reason: 'Invalid',
|
||||
message: 'Test',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email address: Invalid - Test');
|
||||
});
|
||||
|
||||
it('should handle multiple words in reason', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Validation failed completely',
|
||||
message: 'Test',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Validation failed completely - Test');
|
||||
});
|
||||
|
||||
it('should handle multiple words in message', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'This is a very long error message that contains many words',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Invalid - This is a very long error message that contains many words');
|
||||
});
|
||||
|
||||
it('should handle special characters in all parts', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'Admin_User-Entity',
|
||||
fieldName: 'email@address',
|
||||
reason: 'Validation failed: @, ., com',
|
||||
message: 'Email "test@example.com" is invalid',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] Admin_User-Entity.email@address: Validation failed: @, ., com - Email "test@example.com" is invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error inheritance', () => {
|
||||
it('should be instance of Error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it('should be instance of TypeOrmAdminSchemaError', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error instanceof TypeOrmAdminSchemaError).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be instance of other error types', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error instanceof TypeError).toBe(false);
|
||||
expect(error instanceof RangeError).toBe(false);
|
||||
expect(error instanceof ReferenceError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should handle missing column error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'primaryDriverId',
|
||||
reason: 'Column not found',
|
||||
message: 'Column "primary_driver_id" does not exist in table "admin_users"',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.primaryDriverId: Column not found - Column "primary_driver_id" does not exist in table "admin_users"');
|
||||
});
|
||||
|
||||
it('should handle type mismatch error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'roles',
|
||||
reason: 'Type mismatch',
|
||||
message: 'Expected type "simple-json" but got "text" for column "roles"',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.roles: Type mismatch - Expected type "simple-json" but got "text" for column "roles"');
|
||||
});
|
||||
|
||||
it('should handle constraint violation error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Constraint violation',
|
||||
message: 'UNIQUE constraint failed: admin_users.email',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Constraint violation - UNIQUE constraint failed: admin_users.email');
|
||||
});
|
||||
|
||||
it('should handle nullable constraint error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'displayName',
|
||||
reason: 'Constraint violation',
|
||||
message: 'NOT NULL constraint failed: admin_users.display_name',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.displayName: Constraint violation - NOT NULL constraint failed: admin_users.display_name');
|
||||
});
|
||||
|
||||
it('should handle foreign key constraint error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'primaryDriverId',
|
||||
reason: 'Constraint violation',
|
||||
message: 'FOREIGN KEY constraint failed: admin_users.primary_driver_id references drivers.id',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.primaryDriverId: Constraint violation - FOREIGN KEY constraint failed: admin_users.primary_driver_id references drivers.id');
|
||||
});
|
||||
|
||||
it('should handle index creation error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Index creation failed',
|
||||
message: 'Failed to create unique index on column "email"',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Index creation failed - Failed to create unique index on column "email"');
|
||||
});
|
||||
|
||||
it('should handle default value error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'status',
|
||||
reason: 'Default value error',
|
||||
message: 'Default value "active" is not valid for column "status"',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.status: Default value error - Default value "active" is not valid for column "status"');
|
||||
});
|
||||
|
||||
it('should handle timestamp column error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'createdAt',
|
||||
reason: 'Type error',
|
||||
message: 'Column "created_at" has invalid type "datetime" for PostgreSQL',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.createdAt: Type error - Column "created_at" has invalid type "datetime" for PostgreSQL');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,4 +10,4 @@ export class TypeOrmAdminSchemaError extends Error {
|
||||
super(`[TypeOrmAdminSchemaError] ${details.entityName}.${details.fieldName}: ${details.reason} - ${details.message}`);
|
||||
this.name = 'TypeOrmAdminSchemaError';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,4 +92,4 @@ export class AdminUserOrmMapper {
|
||||
toStored(entity: AdminUserOrmEntity): AdminUser {
|
||||
return this.toDomain(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1016,4 +1016,4 @@ describe('TypeOrmAdminUserRepository', () => {
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -185,4 +185,4 @@ export class TypeOrmAdminUserRepository implements AdminUserRepository {
|
||||
|
||||
return AdminUser.rehydrate(props);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TypeOrmAdminSchemaError } from '../errors/TypeOrmAdminSchemaError';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertStringArray,
|
||||
assertDate,
|
||||
assertOptionalDate,
|
||||
assertOptionalString,
|
||||
} from './TypeOrmAdminSchemaGuards';
|
||||
|
||||
describe('TypeOrmAdminSchemaGuards', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
describe('assertNonEmptyString', () => {
|
||||
it('should pass for valid non-empty string', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = 'test@example.com';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = '';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw error for string with only spaces', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = ' ';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw error for non-string value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = 123;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw error for null value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw error for undefined value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertStringArray', () => {
|
||||
it('should pass for valid string array', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = ['admin', 'user'];
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for empty array', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = [];
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for non-array value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = 'admin';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw error for array with non-string items', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = ['admin', 123];
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw error for null value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw error for undefined value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertDate', () => {
|
||||
it('should pass for valid Date', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = new Date();
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for specific date', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = new Date('2024-01-01');
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for invalid date', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = new Date('invalid');
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw error for non-Date value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = '2024-01-01';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw error for null value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw error for undefined value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalDate', () => {
|
||||
it('should pass for valid Date', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'lastLoginAt';
|
||||
const value = new Date();
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for null value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'lastLoginAt';
|
||||
const value = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for undefined value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'lastLoginAt';
|
||||
const value = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for invalid date', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'lastLoginAt';
|
||||
const value = new Date('invalid');
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow('Field lastLoginAt must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw error for non-Date value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'lastLoginAt';
|
||||
const value = '2024-01-01';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow('Field lastLoginAt must be a valid Date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalString', () => {
|
||||
it('should pass for valid string', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'primaryDriverId';
|
||||
const value = 'driver-123';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for null value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'primaryDriverId';
|
||||
const value = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for undefined value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'primaryDriverId';
|
||||
const value = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for non-string value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'primaryDriverId';
|
||||
const value = 123;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow('Field primaryDriverId must be a string or undefined');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'primaryDriverId';
|
||||
const value = '';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow('Field primaryDriverId must be a string or undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should validate complete admin user entity', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const id = 'user-123';
|
||||
const email = 'admin@example.com';
|
||||
const displayName = 'Admin User';
|
||||
const roles = ['owner', 'admin'];
|
||||
const status = 'active';
|
||||
const createdAt = new Date();
|
||||
const updatedAt = new Date();
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, 'id', id)).not.toThrow();
|
||||
expect(() => assertNonEmptyString(entityName, 'email', email)).not.toThrow();
|
||||
expect(() => assertNonEmptyString(entityName, 'displayName', displayName)).not.toThrow();
|
||||
expect(() => assertStringArray(entityName, 'roles', roles)).not.toThrow();
|
||||
expect(() => assertNonEmptyString(entityName, 'status', status)).not.toThrow();
|
||||
expect(() => assertDate(entityName, 'createdAt', createdAt)).not.toThrow();
|
||||
expect(() => assertDate(entityName, 'updatedAt', updatedAt)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should validate admin user with optional fields', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const primaryDriverId = 'driver-456';
|
||||
const lastLoginAt = new Date();
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, 'primaryDriverId', primaryDriverId)).not.toThrow();
|
||||
expect(() => assertOptionalDate(entityName, 'lastLoginAt', lastLoginAt)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should validate admin user without optional fields', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const primaryDriverId = undefined;
|
||||
const lastLoginAt = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, 'primaryDriverId', primaryDriverId)).not.toThrow();
|
||||
expect(() => assertOptionalDate(entityName, 'lastLoginAt', lastLoginAt)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,4 +52,4 @@ export function assertOptionalString(entityName: string, fieldName: string, valu
|
||||
message: `Field ${fieldName} must be a string or undefined`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
77
adapters/events/InMemoryEventPublisher.test.ts
Normal file
77
adapters/events/InMemoryEventPublisher.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
103
adapters/events/InMemoryHealthEventPublisher.test.ts
Normal file
103
adapters/events/InMemoryHealthEventPublisher.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
63
adapters/http/RequestContext.test.ts
Normal file
63
adapters/http/RequestContext.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
303
core/admin/domain/errors/AdminDomainError.test.ts
Normal file
303
core/admin/domain/errors/AdminDomainError.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AdminDomainError, AdminDomainValidationError, AdminDomainInvariantError, AuthorizationError } from './AdminDomainError';
|
||||
|
||||
describe('AdminDomainError', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
describe('AdminDomainError', () => {
|
||||
it('should create an error with correct properties', () => {
|
||||
// Arrange & Act
|
||||
const error = new (class extends AdminDomainError {
|
||||
readonly kind = 'validation' as const;
|
||||
})('Test error message');
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('Test error message');
|
||||
expect(error.type).toBe('domain');
|
||||
expect(error.context).toBe('admin-domain');
|
||||
expect(error.kind).toBe('validation');
|
||||
});
|
||||
|
||||
it('should have correct error name', () => {
|
||||
// Arrange & Act
|
||||
const error = new (class extends AdminDomainError {
|
||||
readonly kind = 'validation' as const;
|
||||
})('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error.name).toBe('AdminDomainError');
|
||||
});
|
||||
|
||||
it('should preserve prototype chain', () => {
|
||||
// Arrange & Act
|
||||
const error = new (class extends AdminDomainError {
|
||||
readonly kind = 'validation' as const;
|
||||
})('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error instanceof AdminDomainError).toBe(true);
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty message', () => {
|
||||
// Arrange & Act
|
||||
const error = new (class extends AdminDomainError {
|
||||
readonly kind = 'validation' as const;
|
||||
})('');
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('');
|
||||
});
|
||||
|
||||
it('should handle long message', () => {
|
||||
// Arrange
|
||||
const longMessage = 'This is a very long error message that contains many characters and should be handled correctly by the error class';
|
||||
|
||||
// Act
|
||||
const error = new (class extends AdminDomainError {
|
||||
readonly kind = 'validation' as const;
|
||||
})(longMessage);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe(longMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminDomainValidationError', () => {
|
||||
it('should create a validation error', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainValidationError('Invalid email format');
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('Invalid email format');
|
||||
expect(error.type).toBe('domain');
|
||||
expect(error.context).toBe('admin-domain');
|
||||
expect(error.kind).toBe('validation');
|
||||
});
|
||||
|
||||
it('should have correct error name', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainValidationError('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error.name).toBe('AdminDomainValidationError');
|
||||
});
|
||||
|
||||
it('should be instance of AdminDomainError', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainValidationError('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error instanceof AdminDomainError).toBe(true);
|
||||
expect(error instanceof AdminDomainValidationError).toBe(true);
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty message', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainValidationError('');
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('');
|
||||
});
|
||||
|
||||
it('should handle complex validation message', () => {
|
||||
// Arrange
|
||||
const message = 'Field "email" must be a valid email address. Received: "invalid-email"';
|
||||
|
||||
// Act
|
||||
const error = new AdminDomainValidationError(message);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminDomainInvariantError', () => {
|
||||
it('should create an invariant error', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainInvariantError('User must have at least one role');
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('User must have at least one role');
|
||||
expect(error.type).toBe('domain');
|
||||
expect(error.context).toBe('admin-domain');
|
||||
expect(error.kind).toBe('invariant');
|
||||
});
|
||||
|
||||
it('should have correct error name', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainInvariantError('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error.name).toBe('AdminDomainInvariantError');
|
||||
});
|
||||
|
||||
it('should be instance of AdminDomainError', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainInvariantError('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error instanceof AdminDomainError).toBe(true);
|
||||
expect(error instanceof AdminDomainInvariantError).toBe(true);
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty message', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainInvariantError('');
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('');
|
||||
});
|
||||
|
||||
it('should handle complex invariant message', () => {
|
||||
// Arrange
|
||||
const message = 'Invariant violation: User status "active" cannot be changed to "deleted" without proper authorization';
|
||||
|
||||
// Act
|
||||
const error = new AdminDomainInvariantError(message);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthorizationError', () => {
|
||||
it('should create an authorization error', () => {
|
||||
// Arrange & Act
|
||||
const error = new AuthorizationError('User does not have permission to perform this action');
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('User does not have permission to perform this action');
|
||||
expect(error.type).toBe('domain');
|
||||
expect(error.context).toBe('admin-domain');
|
||||
expect(error.kind).toBe('authorization');
|
||||
});
|
||||
|
||||
it('should have correct error name', () => {
|
||||
// Arrange & Act
|
||||
const error = new AuthorizationError('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error.name).toBe('AuthorizationError');
|
||||
});
|
||||
|
||||
it('should be instance of AdminDomainError', () => {
|
||||
// Arrange & Act
|
||||
const error = new AuthorizationError('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error instanceof AdminDomainError).toBe(true);
|
||||
expect(error instanceof AuthorizationError).toBe(true);
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty message', () => {
|
||||
// Arrange & Act
|
||||
const error = new AuthorizationError('');
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('');
|
||||
});
|
||||
|
||||
it('should handle complex authorization message', () => {
|
||||
// Arrange
|
||||
const message = 'Authorization failed: User "admin@example.com" (role: admin) attempted to modify role of user "owner@example.com" (role: owner)';
|
||||
|
||||
// Act
|
||||
const error = new AuthorizationError(message);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error hierarchy', () => {
|
||||
it('should have correct inheritance chain for AdminDomainValidationError', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainValidationError('Test');
|
||||
|
||||
// Assert
|
||||
expect(error instanceof AdminDomainError).toBe(true);
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it('should have correct inheritance chain for AdminDomainInvariantError', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainInvariantError('Test');
|
||||
|
||||
// Assert
|
||||
expect(error instanceof AdminDomainError).toBe(true);
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it('should have correct inheritance chain for AuthorizationError', () => {
|
||||
// Arrange & Act
|
||||
const error = new AuthorizationError('Test');
|
||||
|
||||
// Assert
|
||||
expect(error instanceof AdminDomainError).toBe(true);
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it('should have consistent type and context across all error types', () => {
|
||||
// Arrange
|
||||
const errors = [
|
||||
new AdminDomainValidationError('Test'),
|
||||
new AdminDomainInvariantError('Test'),
|
||||
new AuthorizationError('Test'),
|
||||
];
|
||||
|
||||
// Assert
|
||||
errors.forEach(error => {
|
||||
expect(error.type).toBe('domain');
|
||||
expect(error.context).toBe('admin-domain');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have different kinds for different error types', () => {
|
||||
// Arrange
|
||||
const validationError = new AdminDomainValidationError('Test');
|
||||
const invariantError = new AdminDomainInvariantError('Test');
|
||||
const authorizationError = new AuthorizationError('Test');
|
||||
|
||||
// Assert
|
||||
expect(validationError.kind).toBe('validation');
|
||||
expect(invariantError.kind).toBe('invariant');
|
||||
expect(authorizationError.kind).toBe('authorization');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error stack trace', () => {
|
||||
it('should have a stack trace', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainValidationError('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error.stack).toBeDefined();
|
||||
expect(typeof error.stack).toBe('string');
|
||||
expect(error.stack).toContain('AdminDomainValidationError');
|
||||
});
|
||||
|
||||
it('should have stack trace for AdminDomainInvariantError', () => {
|
||||
// Arrange & Act
|
||||
const error = new AdminDomainInvariantError('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error.stack).toBeDefined();
|
||||
expect(typeof error.stack).toBe('string');
|
||||
expect(error.stack).toContain('AdminDomainInvariantError');
|
||||
});
|
||||
|
||||
it('should have stack trace for AuthorizationError', () => {
|
||||
// Arrange & Act
|
||||
const error = new AuthorizationError('Test error');
|
||||
|
||||
// Assert
|
||||
expect(error.stack).toBeDefined();
|
||||
expect(typeof error.stack).toBe('string');
|
||||
expect(error.stack).toContain('AuthorizationError');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
721
core/admin/domain/repositories/AdminUserRepository.test.ts
Normal file
721
core/admin/domain/repositories/AdminUserRepository.test.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AdminUser } from '../entities/AdminUser';
|
||||
import { Email } from '../value-objects/Email';
|
||||
import { UserId } from '../value-objects/UserId';
|
||||
import { UserRole } from '../value-objects/UserRole';
|
||||
import { UserStatus } from '../value-objects/UserStatus';
|
||||
import type {
|
||||
AdminUserRepository,
|
||||
UserFilter,
|
||||
UserSort,
|
||||
UserPagination,
|
||||
UserListQuery,
|
||||
UserListResult,
|
||||
StoredAdminUser
|
||||
} from './AdminUserRepository';
|
||||
|
||||
describe('AdminUserRepository', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
describe('UserFilter interface', () => {
|
||||
it('should allow optional role filter', () => {
|
||||
// Arrange
|
||||
const filter: UserFilter = {
|
||||
role: UserRole.fromString('admin'),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(filter.role).toBeDefined();
|
||||
expect(filter.role!.value).toBe('admin');
|
||||
});
|
||||
|
||||
it('should allow optional status filter', () => {
|
||||
// Arrange
|
||||
const filter: UserFilter = {
|
||||
status: UserStatus.fromString('active'),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(filter.status).toBeDefined();
|
||||
expect(filter.status!.value).toBe('active');
|
||||
});
|
||||
|
||||
it('should allow optional email filter', () => {
|
||||
// Arrange
|
||||
const filter: UserFilter = {
|
||||
email: Email.create('test@example.com'),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(filter.email).toBeDefined();
|
||||
expect(filter.email!.value).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should allow optional search filter', () => {
|
||||
// Arrange
|
||||
const filter: UserFilter = {
|
||||
search: 'john',
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(filter.search).toBe('john');
|
||||
});
|
||||
|
||||
it('should allow all filters combined', () => {
|
||||
// Arrange
|
||||
const filter: UserFilter = {
|
||||
role: UserRole.fromString('admin'),
|
||||
status: UserStatus.fromString('active'),
|
||||
email: Email.create('admin@example.com'),
|
||||
search: 'admin',
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(filter.role!.value).toBe('admin');
|
||||
expect(filter.status!.value).toBe('active');
|
||||
expect(filter.email!.value).toBe('admin@example.com');
|
||||
expect(filter.search).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserSort interface', () => {
|
||||
it('should allow email field with asc direction', () => {
|
||||
// Arrange
|
||||
const sort: UserSort = {
|
||||
field: 'email',
|
||||
direction: 'asc',
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(sort.field).toBe('email');
|
||||
expect(sort.direction).toBe('asc');
|
||||
});
|
||||
|
||||
it('should allow email field with desc direction', () => {
|
||||
// Arrange
|
||||
const sort: UserSort = {
|
||||
field: 'email',
|
||||
direction: 'desc',
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(sort.field).toBe('email');
|
||||
expect(sort.direction).toBe('desc');
|
||||
});
|
||||
|
||||
it('should allow displayName field', () => {
|
||||
// Arrange
|
||||
const sort: UserSort = {
|
||||
field: 'displayName',
|
||||
direction: 'asc',
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(sort.field).toBe('displayName');
|
||||
});
|
||||
|
||||
it('should allow createdAt field', () => {
|
||||
// Arrange
|
||||
const sort: UserSort = {
|
||||
field: 'createdAt',
|
||||
direction: 'desc',
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(sort.field).toBe('createdAt');
|
||||
});
|
||||
|
||||
it('should allow lastLoginAt field', () => {
|
||||
// Arrange
|
||||
const sort: UserSort = {
|
||||
field: 'lastLoginAt',
|
||||
direction: 'asc',
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(sort.field).toBe('lastLoginAt');
|
||||
});
|
||||
|
||||
it('should allow status field', () => {
|
||||
// Arrange
|
||||
const sort: UserSort = {
|
||||
field: 'status',
|
||||
direction: 'desc',
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(sort.field).toBe('status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserPagination interface', () => {
|
||||
it('should allow valid pagination', () => {
|
||||
// Arrange
|
||||
const pagination: UserPagination = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(pagination.page).toBe(1);
|
||||
expect(pagination.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('should allow pagination with different values', () => {
|
||||
// Arrange
|
||||
const pagination: UserPagination = {
|
||||
page: 5,
|
||||
limit: 50,
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(pagination.page).toBe(5);
|
||||
expect(pagination.limit).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserListQuery interface', () => {
|
||||
it('should allow query with all optional fields', () => {
|
||||
// Arrange
|
||||
const query: UserListQuery = {
|
||||
filter: {
|
||||
role: UserRole.fromString('admin'),
|
||||
},
|
||||
sort: {
|
||||
field: 'email',
|
||||
direction: 'asc',
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
},
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(query.filter).toBeDefined();
|
||||
expect(query.sort).toBeDefined();
|
||||
expect(query.pagination).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow query with only filter', () => {
|
||||
// Arrange
|
||||
const query: UserListQuery = {
|
||||
filter: {
|
||||
status: UserStatus.fromString('active'),
|
||||
},
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(query.filter).toBeDefined();
|
||||
expect(query.sort).toBeUndefined();
|
||||
expect(query.pagination).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow query with only sort', () => {
|
||||
// Arrange
|
||||
const query: UserListQuery = {
|
||||
sort: {
|
||||
field: 'displayName',
|
||||
direction: 'desc',
|
||||
},
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(query.filter).toBeUndefined();
|
||||
expect(query.sort).toBeDefined();
|
||||
expect(query.pagination).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow query with only pagination', () => {
|
||||
// Arrange
|
||||
const query: UserListQuery = {
|
||||
pagination: {
|
||||
page: 2,
|
||||
limit: 20,
|
||||
},
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(query.filter).toBeUndefined();
|
||||
expect(query.sort).toBeUndefined();
|
||||
expect(query.pagination).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow empty query', () => {
|
||||
// Arrange
|
||||
const query: UserListQuery = {};
|
||||
|
||||
// Assert
|
||||
expect(query.filter).toBeUndefined();
|
||||
expect(query.sort).toBeUndefined();
|
||||
expect(query.pagination).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UserListResult interface', () => {
|
||||
it('should allow valid result with users', () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const result: UserListResult = {
|
||||
users: [user],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should allow result with multiple users', () => {
|
||||
// Arrange
|
||||
const user1 = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
displayName: 'User 1',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const user2 = AdminUser.create({
|
||||
id: 'user-2',
|
||||
email: 'user2@example.com',
|
||||
displayName: 'User 2',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const result: UserListResult = {
|
||||
users: [user1, user2],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
});
|
||||
|
||||
it('should allow result with pagination info', () => {
|
||||
// Arrange
|
||||
const users = Array.from({ length: 50 }, (_, i) =>
|
||||
AdminUser.create({
|
||||
id: `user-${i}`,
|
||||
email: `user${i}@example.com`,
|
||||
displayName: `User ${i}`,
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
|
||||
const result: UserListResult = {
|
||||
users: users.slice(0, 10),
|
||||
total: 50,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 5,
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(result.users).toHaveLength(10);
|
||||
expect(result.total).toBe(50);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.totalPages).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StoredAdminUser interface', () => {
|
||||
it('should allow stored user with all required fields', () => {
|
||||
// Arrange
|
||||
const stored: StoredAdminUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
displayName: 'Test User',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(stored.id).toBe('user-1');
|
||||
expect(stored.email).toBe('test@example.com');
|
||||
expect(stored.roles).toEqual(['admin']);
|
||||
expect(stored.status).toBe('active');
|
||||
expect(stored.displayName).toBe('Test User');
|
||||
expect(stored.createdAt).toBeInstanceOf(Date);
|
||||
expect(stored.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should allow stored user with optional fields', () => {
|
||||
// Arrange
|
||||
const stored: StoredAdminUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
roles: ['admin'],
|
||||
status: 'active',
|
||||
displayName: 'Test User',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
lastLoginAt: new Date('2024-01-03'),
|
||||
primaryDriverId: 'driver-123',
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(stored.lastLoginAt).toBeInstanceOf(Date);
|
||||
expect(stored.primaryDriverId).toBe('driver-123');
|
||||
});
|
||||
|
||||
it('should allow stored user with multiple roles', () => {
|
||||
// Arrange
|
||||
const stored: StoredAdminUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
roles: ['owner', 'admin', 'user'],
|
||||
status: 'active',
|
||||
displayName: 'Test User',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(stored.roles).toHaveLength(3);
|
||||
expect(stored.roles).toContain('owner');
|
||||
expect(stored.roles).toContain('admin');
|
||||
expect(stored.roles).toContain('user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository interface methods', () => {
|
||||
it('should define findById method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.findById).toBeDefined();
|
||||
expect(typeof mockRepository.findById).toBe('function');
|
||||
});
|
||||
|
||||
it('should define findByEmail method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.findByEmail).toBeDefined();
|
||||
expect(typeof mockRepository.findByEmail).toBe('function');
|
||||
});
|
||||
|
||||
it('should define emailExists method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.emailExists).toBeDefined();
|
||||
expect(typeof mockRepository.emailExists).toBe('function');
|
||||
});
|
||||
|
||||
it('should define existsById method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.existsById).toBeDefined();
|
||||
expect(typeof mockRepository.existsById).toBe('function');
|
||||
});
|
||||
|
||||
it('should define existsByEmail method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.existsByEmail).toBeDefined();
|
||||
expect(typeof mockRepository.existsByEmail).toBe('function');
|
||||
});
|
||||
|
||||
it('should define list method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.list).toBeDefined();
|
||||
expect(typeof mockRepository.list).toBe('function');
|
||||
});
|
||||
|
||||
it('should define count method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.count).toBeDefined();
|
||||
expect(typeof mockRepository.count).toBe('function');
|
||||
});
|
||||
|
||||
it('should define create method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.create).toBeDefined();
|
||||
expect(typeof mockRepository.create).toBe('function');
|
||||
});
|
||||
|
||||
it('should define update method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.update).toBeDefined();
|
||||
expect(typeof mockRepository.update).toBe('function');
|
||||
});
|
||||
|
||||
it('should define delete method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.delete).toBeDefined();
|
||||
expect(typeof mockRepository.delete).toBe('function');
|
||||
});
|
||||
|
||||
it('should define toStored method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.toStored).toBeDefined();
|
||||
expect(typeof mockRepository.toStored).toBe('function');
|
||||
});
|
||||
|
||||
it('should define fromStored method signature', () => {
|
||||
// Arrange
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn(),
|
||||
findByEmail: vi.fn(),
|
||||
emailExists: vi.fn(),
|
||||
existsById: vi.fn(),
|
||||
existsByEmail: vi.fn(),
|
||||
list: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
toStored: vi.fn(),
|
||||
fromStored: vi.fn(),
|
||||
};
|
||||
|
||||
// Assert
|
||||
expect(mockRepository.fromStored).toBeDefined();
|
||||
expect(typeof mockRepository.fromStored).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle repository operations with mock implementation', async () => {
|
||||
// Arrange
|
||||
const user = AdminUser.create({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const mockRepository: AdminUserRepository = {
|
||||
findById: vi.fn().mockResolvedValue(user),
|
||||
findByEmail: vi.fn().mockResolvedValue(user),
|
||||
emailExists: vi.fn().mockResolvedValue(true),
|
||||
existsById: vi.fn().mockResolvedValue(true),
|
||||
existsByEmail: vi.fn().mockResolvedValue(true),
|
||||
list: vi.fn().mockResolvedValue({
|
||||
users: [user],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
}),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn().mockResolvedValue(user),
|
||||
update: vi.fn().mockResolvedValue(user),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
toStored: vi.fn().mockReturnValue({
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
roles: ['user'],
|
||||
status: 'active',
|
||||
displayName: 'Test User',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
fromStored: vi.fn().mockReturnValue(user),
|
||||
};
|
||||
|
||||
// Act
|
||||
const foundUser = await mockRepository.findById(UserId.create('user-1'));
|
||||
const emailExists = await mockRepository.emailExists(Email.create('test@example.com'));
|
||||
const listResult = await mockRepository.list();
|
||||
|
||||
// Assert
|
||||
expect(foundUser).toBe(user);
|
||||
expect(emailExists).toBe(true);
|
||||
expect(listResult.users).toHaveLength(1);
|
||||
expect(mockRepository.findById).toHaveBeenCalledWith(UserId.create('user-1'));
|
||||
expect(mockRepository.emailExists).toHaveBeenCalledWith(Email.create('test@example.com'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,253 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertStringArray,
|
||||
assertDate,
|
||||
assertOptionalDate,
|
||||
assertOptionalString,
|
||||
} from './TypeOrmAdminSchemaGuards';
|
||||
import { TypeOrmAdminSchemaError } from '../errors/TypeOrmAdminSchemaError';
|
||||
|
||||
describe('TypeOrmAdminSchemaGuards', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
describe('assertNonEmptyString', () => {
|
||||
it('should not throw for valid non-empty string', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', 'valid string')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for empty string', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', '')).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', '')).toThrow('Field fieldName must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw for string with only whitespace', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', ' ')).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', ' ')).toThrow('Field fieldName must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw for null', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', null)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', null)).toThrow('Field fieldName must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw for undefined', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', undefined)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', undefined)).toThrow('Field fieldName must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw for number', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', 123)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', 123)).toThrow('Field fieldName must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw for object', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', {})).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', {})).toThrow('Field fieldName must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw for array', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', [])).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', [])).toThrow('Field fieldName must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should include entity name in error message', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertNonEmptyString('AdminUser', 'email', '')).toThrow('[TypeOrmAdminSchemaError] AdminUser.email: INVALID_STRING - Field email must be a non-empty string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertStringArray', () => {
|
||||
it('should not throw for valid string array', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', 'b', 'c'])).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for empty array', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', [])).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for non-array', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', 'not an array')).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', 'not an array')).toThrow('Field fieldName must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw for null', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', null)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', null)).toThrow('Field fieldName must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw for undefined', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', undefined)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', undefined)).toThrow('Field fieldName must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw for array with non-string items', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', 123, 'c'])).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', 123, 'c'])).toThrow('Field fieldName must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw for array with null items', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', null, 'c'])).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', null, 'c'])).toThrow('Field fieldName must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw for array with undefined items', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', undefined, 'c'])).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', undefined, 'c'])).toThrow('Field fieldName must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw for array with object items', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', {}, 'c'])).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', {}, 'c'])).toThrow('Field fieldName must be an array of strings');
|
||||
});
|
||||
|
||||
it('should include entity name in error message', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertStringArray('AdminUser', 'roles', null)).toThrow('[TypeOrmAdminSchemaError] AdminUser.roles: INVALID_STRING_ARRAY - Field roles must be an array of strings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertDate', () => {
|
||||
it('should not throw for valid Date', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertDate('TestEntity', 'fieldName', new Date())).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for Date with valid timestamp', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertDate('TestEntity', 'fieldName', new Date('2024-01-01'))).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for null', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertDate('TestEntity', 'fieldName', null)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate('TestEntity', 'fieldName', null)).toThrow('Field fieldName must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw for undefined', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertDate('TestEntity', 'fieldName', undefined)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate('TestEntity', 'fieldName', undefined)).toThrow('Field fieldName must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw for string', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertDate('TestEntity', 'fieldName', '2024-01-01')).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate('TestEntity', 'fieldName', '2024-01-01')).toThrow('Field fieldName must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw for number', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertDate('TestEntity', 'fieldName', 1234567890)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate('TestEntity', 'fieldName', 1234567890)).toThrow('Field fieldName must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw for object', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertDate('TestEntity', 'fieldName', {})).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate('TestEntity', 'fieldName', {})).toThrow('Field fieldName must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw for invalid Date (NaN)', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertDate('TestEntity', 'fieldName', new Date('invalid'))).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate('TestEntity', 'fieldName', new Date('invalid'))).toThrow('Field fieldName must be a valid Date');
|
||||
});
|
||||
|
||||
it('should include entity name in error message', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertDate('AdminUser', 'createdAt', null)).toThrow('[TypeOrmAdminSchemaError] AdminUser.createdAt: INVALID_DATE - Field createdAt must be a valid Date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalDate', () => {
|
||||
it('should not throw for valid Date', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', new Date())).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for null', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for undefined', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for invalid Date', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', new Date('invalid'))).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', new Date('invalid'))).toThrow('Field fieldName must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw for string', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', '2024-01-01')).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', '2024-01-01')).toThrow('Field fieldName must be a valid Date');
|
||||
});
|
||||
|
||||
it('should include entity name in error message', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalDate('AdminUser', 'lastLoginAt', new Date('invalid'))).toThrow('[TypeOrmAdminSchemaError] AdminUser.lastLoginAt: INVALID_DATE - Field lastLoginAt must be a valid Date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalString', () => {
|
||||
it('should not throw for valid string', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalString('TestEntity', 'fieldName', 'valid string')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for null', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalString('TestEntity', 'fieldName', null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for undefined', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalString('TestEntity', 'fieldName', undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw for number', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalString('TestEntity', 'fieldName', 123)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalString('TestEntity', 'fieldName', 123)).toThrow('Field fieldName must be a string or undefined');
|
||||
});
|
||||
|
||||
it('should throw for object', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalString('TestEntity', 'fieldName', {})).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalString('TestEntity', 'fieldName', {})).toThrow('Field fieldName must be a string or undefined');
|
||||
});
|
||||
|
||||
it('should throw for array', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalString('TestEntity', 'fieldName', [])).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalString('TestEntity', 'fieldName', [])).toThrow('Field fieldName must be a string or undefined');
|
||||
});
|
||||
|
||||
it('should include entity name in error message', () => {
|
||||
// Arrange & Act & Assert
|
||||
expect(() => assertOptionalString('AdminUser', 'primaryDriverId', 123)).toThrow('[TypeOrmAdminSchemaError] AdminUser.primaryDriverId: INVALID_OPTIONAL_STRING - Field primaryDriverId must be a string or undefined');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DashboardPresenter } from './DashboardPresenter';
|
||||
import { DashboardDTO } from '../dto/DashboardDTO';
|
||||
|
||||
describe('DashboardPresenter', () => {
|
||||
it('should return the data as is (identity transformation)', () => {
|
||||
const presenter = new DashboardPresenter();
|
||||
const mockData: DashboardDTO = {
|
||||
driver: {
|
||||
id: '1',
|
||||
name: 'John Doe',
|
||||
avatar: 'http://example.com/avatar.png',
|
||||
},
|
||||
statistics: {
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 50,
|
||||
wins: 5,
|
||||
podiums: 15,
|
||||
leagues: 3,
|
||||
},
|
||||
upcomingRaces: [],
|
||||
championshipStandings: [],
|
||||
recentActivity: [],
|
||||
};
|
||||
|
||||
const result = presenter.present(mockData);
|
||||
|
||||
expect(result).toBe(mockData);
|
||||
});
|
||||
});
|
||||
320
core/dashboard/application/use-cases/GetDashboardUseCase.test.ts
Normal file
320
core/dashboard/application/use-cases/GetDashboardUseCase.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Unit tests for GetDashboardUseCase
|
||||
*
|
||||
* Tests cover:
|
||||
* 1) Validation of driverId (empty and whitespace)
|
||||
* 2) Driver not found
|
||||
* 3) Filters invalid races (missing trackName, past dates)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GetDashboardUseCase } from './GetDashboardUseCase';
|
||||
import { ValidationError } from '../../../shared/errors/ValidationError';
|
||||
import { DriverNotFoundError } from '../../domain/errors/DriverNotFoundError';
|
||||
import { DashboardRepository } from '../ports/DashboardRepository';
|
||||
import { DashboardEventPublisher } from '../ports/DashboardEventPublisher';
|
||||
import { Logger } from '../../../shared/domain/Logger';
|
||||
import { DriverData, RaceData, LeagueStandingData, ActivityData } from '../ports/DashboardRepository';
|
||||
|
||||
describe('GetDashboardUseCase', () => {
|
||||
let mockDriverRepository: DashboardRepository;
|
||||
let mockRaceRepository: DashboardRepository;
|
||||
let mockLeagueRepository: DashboardRepository;
|
||||
let mockActivityRepository: DashboardRepository;
|
||||
let mockEventPublisher: DashboardEventPublisher;
|
||||
let mockLogger: Logger;
|
||||
|
||||
let useCase: GetDashboardUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock all ports with vi.fn()
|
||||
mockDriverRepository = {
|
||||
findDriverById: vi.fn(),
|
||||
getUpcomingRaces: vi.fn(),
|
||||
getLeagueStandings: vi.fn(),
|
||||
getRecentActivity: vi.fn(),
|
||||
getFriends: vi.fn(),
|
||||
};
|
||||
|
||||
mockRaceRepository = {
|
||||
findDriverById: vi.fn(),
|
||||
getUpcomingRaces: vi.fn(),
|
||||
getLeagueStandings: vi.fn(),
|
||||
getRecentActivity: vi.fn(),
|
||||
getFriends: vi.fn(),
|
||||
};
|
||||
|
||||
mockLeagueRepository = {
|
||||
findDriverById: vi.fn(),
|
||||
getUpcomingRaces: vi.fn(),
|
||||
getLeagueStandings: vi.fn(),
|
||||
getRecentActivity: vi.fn(),
|
||||
getFriends: vi.fn(),
|
||||
};
|
||||
|
||||
mockActivityRepository = {
|
||||
findDriverById: vi.fn(),
|
||||
getUpcomingRaces: vi.fn(),
|
||||
getLeagueStandings: vi.fn(),
|
||||
getRecentActivity: vi.fn(),
|
||||
getFriends: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventPublisher = {
|
||||
publishDashboardAccessed: vi.fn(),
|
||||
publishDashboardError: vi.fn(),
|
||||
};
|
||||
|
||||
mockLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new GetDashboardUseCase({
|
||||
driverRepository: mockDriverRepository,
|
||||
raceRepository: mockRaceRepository,
|
||||
leagueRepository: mockLeagueRepository,
|
||||
activityRepository: mockActivityRepository,
|
||||
eventPublisher: mockEventPublisher,
|
||||
logger: mockLogger,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 1: Validation of driverId', () => {
|
||||
it('should throw ValidationError when driverId is empty string', async () => {
|
||||
// Given
|
||||
const query = { driverId: '' };
|
||||
|
||||
// When & Then
|
||||
await expect(useCase.execute(query)).rejects.toThrow(ValidationError);
|
||||
await expect(useCase.execute(query)).rejects.toThrow('Driver ID cannot be empty');
|
||||
|
||||
// Verify no repositories were called
|
||||
expect(mockDriverRepository.findDriverById).not.toHaveBeenCalled();
|
||||
expect(mockRaceRepository.getUpcomingRaces).not.toHaveBeenCalled();
|
||||
expect(mockLeagueRepository.getLeagueStandings).not.toHaveBeenCalled();
|
||||
expect(mockActivityRepository.getRecentActivity).not.toHaveBeenCalled();
|
||||
expect(mockEventPublisher.publishDashboardAccessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw ValidationError when driverId is whitespace only', async () => {
|
||||
// Given
|
||||
const query = { driverId: ' ' };
|
||||
|
||||
// When & Then
|
||||
await expect(useCase.execute(query)).rejects.toThrow(ValidationError);
|
||||
await expect(useCase.execute(query)).rejects.toThrow('Driver ID cannot be empty');
|
||||
|
||||
// Verify no repositories were called
|
||||
expect(mockDriverRepository.findDriverById).not.toHaveBeenCalled();
|
||||
expect(mockRaceRepository.getUpcomingRaces).not.toHaveBeenCalled();
|
||||
expect(mockLeagueRepository.getLeagueStandings).not.toHaveBeenCalled();
|
||||
expect(mockActivityRepository.getRecentActivity).not.toHaveBeenCalled();
|
||||
expect(mockEventPublisher.publishDashboardAccessed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 2: Driver not found', () => {
|
||||
it('should throw DriverNotFoundError when driverRepository.findDriverById returns null', async () => {
|
||||
// Given
|
||||
const query = { driverId: 'driver-123' };
|
||||
(mockDriverRepository.findDriverById as any).mockResolvedValue(null);
|
||||
|
||||
// When & Then
|
||||
await expect(useCase.execute(query)).rejects.toThrow(DriverNotFoundError);
|
||||
await expect(useCase.execute(query)).rejects.toThrow('Driver with ID "driver-123" not found');
|
||||
|
||||
// Verify driver repository was called
|
||||
expect(mockDriverRepository.findDriverById).toHaveBeenCalledWith('driver-123');
|
||||
|
||||
// Verify other repositories were not called (since driver not found)
|
||||
expect(mockRaceRepository.getUpcomingRaces).not.toHaveBeenCalled();
|
||||
expect(mockLeagueRepository.getLeagueStandings).not.toHaveBeenCalled();
|
||||
expect(mockActivityRepository.getRecentActivity).not.toHaveBeenCalled();
|
||||
expect(mockEventPublisher.publishDashboardAccessed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 3: Filters invalid races', () => {
|
||||
it('should exclude races missing trackName', async () => {
|
||||
// Given
|
||||
const query = { driverId: 'driver-123' };
|
||||
|
||||
// Mock driver exists
|
||||
(mockDriverRepository.findDriverById as any).mockResolvedValue({
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 50,
|
||||
wins: 10,
|
||||
podiums: 20,
|
||||
leagues: 3,
|
||||
} as DriverData);
|
||||
|
||||
// Mock races with missing trackName
|
||||
(mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([
|
||||
{
|
||||
id: 'race-1',
|
||||
trackName: '', // Missing trackName
|
||||
carType: 'GT3',
|
||||
scheduledDate: new Date('2026-01-25T10:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
trackName: 'Track A',
|
||||
carType: 'GT3',
|
||||
scheduledDate: new Date('2026-01-26T10:00:00.000Z'),
|
||||
},
|
||||
] as RaceData[]);
|
||||
|
||||
(mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]);
|
||||
(mockActivityRepository.getRecentActivity as any).mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute(query);
|
||||
|
||||
// Then
|
||||
expect(result.upcomingRaces).toHaveLength(1);
|
||||
expect(result.upcomingRaces[0].trackName).toBe('Track A');
|
||||
});
|
||||
|
||||
it('should exclude races with past scheduledDate', async () => {
|
||||
// Given
|
||||
const query = { driverId: 'driver-123' };
|
||||
|
||||
// Mock driver exists
|
||||
(mockDriverRepository.findDriverById as any).mockResolvedValue({
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 50,
|
||||
wins: 10,
|
||||
podiums: 20,
|
||||
leagues: 3,
|
||||
} as DriverData);
|
||||
|
||||
// Mock races with past dates
|
||||
(mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([
|
||||
{
|
||||
id: 'race-1',
|
||||
trackName: 'Track A',
|
||||
carType: 'GT3',
|
||||
scheduledDate: new Date('2026-01-23T10:00:00.000Z'), // Past
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
trackName: 'Track B',
|
||||
carType: 'GT3',
|
||||
scheduledDate: new Date('2026-01-25T10:00:00.000Z'), // Future
|
||||
},
|
||||
] as RaceData[]);
|
||||
|
||||
(mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]);
|
||||
(mockActivityRepository.getRecentActivity as any).mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute(query);
|
||||
|
||||
// Then
|
||||
expect(result.upcomingRaces).toHaveLength(1);
|
||||
expect(result.upcomingRaces[0].trackName).toBe('Track B');
|
||||
});
|
||||
|
||||
it('should exclude races with missing trackName and past dates', async () => {
|
||||
// Given
|
||||
const query = { driverId: 'driver-123' };
|
||||
|
||||
// Mock driver exists
|
||||
(mockDriverRepository.findDriverById as any).mockResolvedValue({
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 50,
|
||||
wins: 10,
|
||||
podiums: 20,
|
||||
leagues: 3,
|
||||
} as DriverData);
|
||||
|
||||
// Mock races with various invalid states
|
||||
(mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([
|
||||
{
|
||||
id: 'race-1',
|
||||
trackName: '', // Missing trackName
|
||||
carType: 'GT3',
|
||||
scheduledDate: new Date('2026-01-25T10:00:00.000Z'), // Future
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
trackName: 'Track A',
|
||||
carType: 'GT3',
|
||||
scheduledDate: new Date('2026-01-23T10:00:00.000Z'), // Past
|
||||
},
|
||||
{
|
||||
id: 'race-3',
|
||||
trackName: 'Track B',
|
||||
carType: 'GT3',
|
||||
scheduledDate: new Date('2026-01-26T10:00:00.000Z'), // Future
|
||||
},
|
||||
] as RaceData[]);
|
||||
|
||||
(mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]);
|
||||
(mockActivityRepository.getRecentActivity as any).mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute(query);
|
||||
|
||||
// Then
|
||||
expect(result.upcomingRaces).toHaveLength(1);
|
||||
expect(result.upcomingRaces[0].trackName).toBe('Track B');
|
||||
});
|
||||
|
||||
it('should include only valid races with trackName and future dates', async () => {
|
||||
// Given
|
||||
const query = { driverId: 'driver-123' };
|
||||
|
||||
// Mock driver exists
|
||||
(mockDriverRepository.findDriverById as any).mockResolvedValue({
|
||||
id: 'driver-123',
|
||||
name: 'Test Driver',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 50,
|
||||
wins: 10,
|
||||
podiums: 20,
|
||||
leagues: 3,
|
||||
} as DriverData);
|
||||
|
||||
// Mock races with valid data
|
||||
(mockRaceRepository.getUpcomingRaces as any).mockResolvedValue([
|
||||
{
|
||||
id: 'race-1',
|
||||
trackName: 'Track A',
|
||||
carType: 'GT3',
|
||||
scheduledDate: new Date('2026-01-25T10:00:00.000Z'),
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
trackName: 'Track B',
|
||||
carType: 'GT4',
|
||||
scheduledDate: new Date('2026-01-26T10:00:00.000Z'),
|
||||
},
|
||||
] as RaceData[]);
|
||||
|
||||
(mockLeagueRepository.getLeagueStandings as any).mockResolvedValue([]);
|
||||
(mockActivityRepository.getRecentActivity as any).mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute(query);
|
||||
|
||||
// Then
|
||||
expect(result.upcomingRaces).toHaveLength(2);
|
||||
expect(result.upcomingRaces[0].trackName).toBe('Track A');
|
||||
expect(result.upcomingRaces[1].trackName).toBe('Track B');
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/dashboard/domain/errors/DriverNotFoundError.test.ts
Normal file
20
core/dashboard/domain/errors/DriverNotFoundError.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverNotFoundError } from './DriverNotFoundError';
|
||||
|
||||
describe('DriverNotFoundError', () => {
|
||||
it('should create an error with the correct message and properties', () => {
|
||||
const driverId = 'driver-123';
|
||||
const error = new DriverNotFoundError(driverId);
|
||||
|
||||
expect(error.message).toBe(`Driver with ID "${driverId}" not found`);
|
||||
expect(error.name).toBe('DriverNotFoundError');
|
||||
expect(error.type).toBe('domain');
|
||||
expect(error.context).toBe('dashboard');
|
||||
expect(error.kind).toBe('not_found');
|
||||
});
|
||||
|
||||
it('should be an instance of Error', () => {
|
||||
const error = new DriverNotFoundError('123');
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
145
core/health/use-cases/CheckApiHealthUseCase.test.ts
Normal file
145
core/health/use-cases/CheckApiHealthUseCase.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* CheckApiHealthUseCase Test
|
||||
*
|
||||
* Tests for the health check use case that orchestrates health checks and emits events.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CheckApiHealthUseCase, CheckApiHealthUseCasePorts } from './CheckApiHealthUseCase';
|
||||
import { HealthCheckQuery, HealthCheckResult } from '../ports/HealthCheckQuery';
|
||||
import { HealthEventPublisher } from '../ports/HealthEventPublisher';
|
||||
|
||||
describe('CheckApiHealthUseCase', () => {
|
||||
let mockHealthCheckAdapter: HealthCheckQuery;
|
||||
let mockEventPublisher: HealthEventPublisher;
|
||||
let useCase: CheckApiHealthUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHealthCheckAdapter = {
|
||||
performHealthCheck: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
getHealth: vi.fn(),
|
||||
getReliability: vi.fn(),
|
||||
isAvailable: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventPublisher = {
|
||||
publishHealthCheckCompleted: vi.fn(),
|
||||
publishHealthCheckFailed: vi.fn(),
|
||||
publishHealthCheckTimeout: vi.fn(),
|
||||
publishConnected: vi.fn(),
|
||||
publishDisconnected: vi.fn(),
|
||||
publishDegraded: vi.fn(),
|
||||
publishChecking: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new CheckApiHealthUseCase({
|
||||
healthCheckAdapter: mockHealthCheckAdapter,
|
||||
eventPublisher: mockEventPublisher,
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should perform health check and publish completed event when healthy', async () => {
|
||||
const mockResult: HealthCheckResult = {
|
||||
healthy: true,
|
||||
responseTime: 100,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
mockHealthCheckAdapter.performHealthCheck.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(mockHealthCheckAdapter.performHealthCheck).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventPublisher.publishHealthCheckCompleted).toHaveBeenCalledWith({
|
||||
healthy: true,
|
||||
responseTime: 100,
|
||||
timestamp: mockResult.timestamp,
|
||||
});
|
||||
expect(mockEventPublisher.publishHealthCheckFailed).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should perform health check and publish failed event when unhealthy', async () => {
|
||||
const mockResult: HealthCheckResult = {
|
||||
healthy: false,
|
||||
responseTime: 200,
|
||||
error: 'Connection timeout',
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
mockHealthCheckAdapter.performHealthCheck.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(mockHealthCheckAdapter.performHealthCheck).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventPublisher.publishHealthCheckFailed).toHaveBeenCalledWith({
|
||||
error: 'Connection timeout',
|
||||
timestamp: mockResult.timestamp,
|
||||
});
|
||||
expect(mockEventPublisher.publishHealthCheckCompleted).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it('should handle errors during health check and publish failed event', async () => {
|
||||
const errorMessage = 'Network error';
|
||||
mockHealthCheckAdapter.performHealthCheck.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(mockHealthCheckAdapter.performHealthCheck).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventPublisher.publishHealthCheckFailed).toHaveBeenCalledWith({
|
||||
error: errorMessage,
|
||||
timestamp: expect.any(Date),
|
||||
});
|
||||
expect(mockEventPublisher.publishHealthCheckCompleted).not.toHaveBeenCalled();
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(result.responseTime).toBe(0);
|
||||
expect(result.error).toBe(errorMessage);
|
||||
expect(result.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should handle non-Error objects during health check', async () => {
|
||||
mockHealthCheckAdapter.performHealthCheck.mockRejectedValue('String error');
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(mockEventPublisher.publishHealthCheckFailed).toHaveBeenCalledWith({
|
||||
error: 'String error',
|
||||
timestamp: expect.any(Date),
|
||||
});
|
||||
expect(result.error).toBe('String error');
|
||||
});
|
||||
|
||||
it('should handle unknown errors during health check', async () => {
|
||||
mockHealthCheckAdapter.performHealthCheck.mockRejectedValue(null);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(mockEventPublisher.publishHealthCheckFailed).toHaveBeenCalledWith({
|
||||
error: 'Unknown error',
|
||||
timestamp: expect.any(Date),
|
||||
});
|
||||
expect(result.error).toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('should use default error message when result has no error', async () => {
|
||||
const mockResult: HealthCheckResult = {
|
||||
healthy: false,
|
||||
responseTime: 150,
|
||||
timestamp: new Date('2024-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
mockHealthCheckAdapter.performHealthCheck.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(mockEventPublisher.publishHealthCheckFailed).toHaveBeenCalledWith({
|
||||
error: 'Unknown error',
|
||||
timestamp: mockResult.timestamp,
|
||||
});
|
||||
expect(result.error).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
});
|
||||
54
core/health/use-cases/GetConnectionStatusUseCase.test.ts
Normal file
54
core/health/use-cases/GetConnectionStatusUseCase.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { GetConnectionStatusUseCase, GetConnectionStatusUseCasePorts } from './GetConnectionStatusUseCase';
|
||||
import { HealthCheckQuery, ConnectionHealth } from '../ports/HealthCheckQuery';
|
||||
|
||||
describe('GetConnectionStatusUseCase', () => {
|
||||
it('should return connection status and metrics from the health check adapter', async () => {
|
||||
// Arrange
|
||||
const mockHealth: ConnectionHealth = {
|
||||
status: 'connected',
|
||||
lastCheck: new Date('2024-01-01T10:00:00Z'),
|
||||
lastSuccess: new Date('2024-01-01T10:00:00Z'),
|
||||
lastFailure: null,
|
||||
consecutiveFailures: 0,
|
||||
totalRequests: 100,
|
||||
successfulRequests: 99,
|
||||
failedRequests: 1,
|
||||
averageResponseTime: 150,
|
||||
};
|
||||
const mockReliability = 0.99;
|
||||
|
||||
const mockHealthCheckAdapter = {
|
||||
getHealth: vi.fn().mockReturnValue(mockHealth),
|
||||
getReliability: vi.fn().mockReturnValue(mockReliability),
|
||||
performHealthCheck: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
isAvailable: vi.fn(),
|
||||
} as unknown as HealthCheckQuery;
|
||||
|
||||
const ports: GetConnectionStatusUseCasePorts = {
|
||||
healthCheckAdapter: mockHealthCheckAdapter,
|
||||
};
|
||||
|
||||
const useCase = new GetConnectionStatusUseCase(ports);
|
||||
|
||||
// Act
|
||||
const result = await useCase.execute();
|
||||
|
||||
// Assert
|
||||
expect(mockHealthCheckAdapter.getHealth).toHaveBeenCalled();
|
||||
expect(mockHealthCheckAdapter.getReliability).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
status: 'connected',
|
||||
reliability: 0.99,
|
||||
totalRequests: 100,
|
||||
successfulRequests: 99,
|
||||
failedRequests: 1,
|
||||
consecutiveFailures: 0,
|
||||
averageResponseTime: 150,
|
||||
lastCheck: mockHealth.lastCheck,
|
||||
lastSuccess: mockHealth.lastSuccess,
|
||||
lastFailure: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GetDriverRankingsUseCase, GetDriverRankingsUseCasePorts } from './GetDriverRankingsUseCase';
|
||||
import { ValidationError } from '../../../shared/errors/ValidationError';
|
||||
|
||||
describe('GetDriverRankingsUseCase', () => {
|
||||
let mockLeaderboardsRepository: any;
|
||||
let mockEventPublisher: any;
|
||||
let ports: GetDriverRankingsUseCasePorts;
|
||||
let useCase: GetDriverRankingsUseCase;
|
||||
|
||||
const mockDrivers = [
|
||||
{ id: '1', name: 'Alice', rating: 2000, raceCount: 10, teamId: 't1', teamName: 'Team A' },
|
||||
{ id: '2', name: 'Bob', rating: 1500, raceCount: 5, teamId: 't2', teamName: 'Team B' },
|
||||
{ id: '3', name: 'Charlie', rating: 1800, raceCount: 8 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeaderboardsRepository = {
|
||||
findAllDrivers: vi.fn().mockResolvedValue([...mockDrivers]),
|
||||
};
|
||||
mockEventPublisher = {
|
||||
publishDriverRankingsAccessed: vi.fn().mockResolvedValue(undefined),
|
||||
publishLeaderboardsError: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
ports = {
|
||||
leaderboardsRepository: mockLeaderboardsRepository,
|
||||
eventPublisher: mockEventPublisher,
|
||||
};
|
||||
useCase = new GetDriverRankingsUseCase(ports);
|
||||
});
|
||||
|
||||
it('should return all drivers sorted by rating DESC by default', async () => {
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.drivers).toHaveLength(3);
|
||||
expect(result.drivers[0].name).toBe('Alice');
|
||||
expect(result.drivers[1].name).toBe('Charlie');
|
||||
expect(result.drivers[2].name).toBe('Bob');
|
||||
expect(result.drivers[0].rank).toBe(1);
|
||||
expect(result.drivers[1].rank).toBe(2);
|
||||
expect(result.drivers[2].rank).toBe(3);
|
||||
expect(mockEventPublisher.publishDriverRankingsAccessed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter drivers by search term', async () => {
|
||||
const result = await useCase.execute({ search: 'ali' });
|
||||
|
||||
expect(result.drivers).toHaveLength(1);
|
||||
expect(result.drivers[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should filter drivers by minRating', async () => {
|
||||
const result = await useCase.execute({ minRating: 1700 });
|
||||
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.drivers.map(d => d.name)).toContain('Alice');
|
||||
expect(result.drivers.map(d => d.name)).toContain('Charlie');
|
||||
});
|
||||
|
||||
it('should filter drivers by teamId', async () => {
|
||||
const result = await useCase.execute({ teamId: 't1' });
|
||||
|
||||
expect(result.drivers).toHaveLength(1);
|
||||
expect(result.drivers[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
it('should sort drivers by name ASC', async () => {
|
||||
const result = await useCase.execute({ sortBy: 'name', sortOrder: 'asc' });
|
||||
|
||||
expect(result.drivers[0].name).toBe('Alice');
|
||||
expect(result.drivers[1].name).toBe('Bob');
|
||||
expect(result.drivers[2].name).toBe('Charlie');
|
||||
});
|
||||
|
||||
it('should paginate results', async () => {
|
||||
const result = await useCase.execute({ page: 2, limit: 1 });
|
||||
|
||||
expect(result.drivers).toHaveLength(1);
|
||||
expect(result.drivers[0].name).toBe('Charlie'); // Alice (1), Charlie (2), Bob (3)
|
||||
expect(result.pagination.total).toBe(3);
|
||||
expect(result.pagination.totalPages).toBe(3);
|
||||
expect(result.pagination.page).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw ValidationError for invalid page', async () => {
|
||||
await expect(useCase.execute({ page: 0 })).rejects.toThrow(ValidationError);
|
||||
expect(mockEventPublisher.publishLeaderboardsError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw ValidationError for invalid limit', async () => {
|
||||
await expect(useCase.execute({ limit: 0 })).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw ValidationError for invalid sortBy', async () => {
|
||||
await expect(useCase.execute({ sortBy: 'invalid' as any })).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GetGlobalLeaderboardsUseCase, GetGlobalLeaderboardsUseCasePorts } from './GetGlobalLeaderboardsUseCase';
|
||||
|
||||
describe('GetGlobalLeaderboardsUseCase', () => {
|
||||
let mockLeaderboardsRepository: any;
|
||||
let mockEventPublisher: any;
|
||||
let ports: GetGlobalLeaderboardsUseCasePorts;
|
||||
let useCase: GetGlobalLeaderboardsUseCase;
|
||||
|
||||
const mockDrivers = [
|
||||
{ id: 'd1', name: 'Alice', rating: 2000, raceCount: 10 },
|
||||
{ id: 'd2', name: 'Bob', rating: 1500, raceCount: 5 },
|
||||
];
|
||||
|
||||
const mockTeams = [
|
||||
{ id: 't1', name: 'Team A', rating: 2500, memberCount: 5, raceCount: 20 },
|
||||
{ id: 't2', name: 'Team B', rating: 2200, memberCount: 3, raceCount: 15 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeaderboardsRepository = {
|
||||
findAllDrivers: vi.fn().mockResolvedValue([...mockDrivers]),
|
||||
findAllTeams: vi.fn().mockResolvedValue([...mockTeams]),
|
||||
};
|
||||
mockEventPublisher = {
|
||||
publishGlobalLeaderboardsAccessed: vi.fn().mockResolvedValue(undefined),
|
||||
publishLeaderboardsError: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
ports = {
|
||||
leaderboardsRepository: mockLeaderboardsRepository,
|
||||
eventPublisher: mockEventPublisher,
|
||||
};
|
||||
useCase = new GetGlobalLeaderboardsUseCase(ports);
|
||||
});
|
||||
|
||||
it('should return top drivers and teams', async () => {
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.drivers[0].name).toBe('Alice');
|
||||
expect(result.drivers[1].name).toBe('Bob');
|
||||
|
||||
expect(result.teams).toHaveLength(2);
|
||||
expect(result.teams[0].name).toBe('Team A');
|
||||
expect(result.teams[1].name).toBe('Team B');
|
||||
|
||||
expect(mockEventPublisher.publishGlobalLeaderboardsAccessed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respect driver and team limits', async () => {
|
||||
const result = await useCase.execute({ driverLimit: 1, teamLimit: 1 });
|
||||
|
||||
expect(result.drivers).toHaveLength(1);
|
||||
expect(result.drivers[0].name).toBe('Alice');
|
||||
expect(result.teams).toHaveLength(1);
|
||||
expect(result.teams[0].name).toBe('Team A');
|
||||
});
|
||||
|
||||
it('should handle errors and publish error event', async () => {
|
||||
mockLeaderboardsRepository.findAllDrivers.mockRejectedValue(new Error('Repo error'));
|
||||
|
||||
await expect(useCase.execute()).rejects.toThrow('Repo error');
|
||||
expect(mockEventPublisher.publishLeaderboardsError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GetTeamRankingsUseCase, GetTeamRankingsUseCasePorts } from './GetTeamRankingsUseCase';
|
||||
import { ValidationError } from '../../../shared/errors/ValidationError';
|
||||
|
||||
describe('GetTeamRankingsUseCase', () => {
|
||||
let mockLeaderboardsRepository: any;
|
||||
let mockEventPublisher: any;
|
||||
let ports: GetTeamRankingsUseCasePorts;
|
||||
let useCase: GetTeamRankingsUseCase;
|
||||
|
||||
const mockTeams = [
|
||||
{ id: 't1', name: 'Team A', rating: 2500, memberCount: 0, raceCount: 20 },
|
||||
{ id: 't2', name: 'Team B', rating: 2200, memberCount: 0, raceCount: 15 },
|
||||
];
|
||||
|
||||
const mockDrivers = [
|
||||
{ id: 'd1', name: 'Alice', rating: 2000, raceCount: 10, teamId: 't1', teamName: 'Team A' },
|
||||
{ id: 'd2', name: 'Bob', rating: 1500, raceCount: 5, teamId: 't1', teamName: 'Team A' },
|
||||
{ id: 'd3', name: 'Charlie', rating: 1800, raceCount: 8, teamId: 't2', teamName: 'Team B' },
|
||||
{ id: 'd4', name: 'David', rating: 1600, raceCount: 2, teamId: 't3', teamName: 'Discovered Team' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeaderboardsRepository = {
|
||||
findAllTeams: vi.fn().mockResolvedValue([...mockTeams]),
|
||||
findAllDrivers: vi.fn().mockResolvedValue([...mockDrivers]),
|
||||
};
|
||||
mockEventPublisher = {
|
||||
publishTeamRankingsAccessed: vi.fn().mockResolvedValue(undefined),
|
||||
publishLeaderboardsError: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
ports = {
|
||||
leaderboardsRepository: mockLeaderboardsRepository,
|
||||
eventPublisher: mockEventPublisher,
|
||||
};
|
||||
useCase = new GetTeamRankingsUseCase(ports);
|
||||
});
|
||||
|
||||
it('should return teams with aggregated member counts', async () => {
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.teams).toHaveLength(3); // Team A, Team B, and discovered Team t3
|
||||
|
||||
const teamA = result.teams.find(t => t.id === 't1');
|
||||
expect(teamA?.memberCount).toBe(2);
|
||||
|
||||
const teamB = result.teams.find(t => t.id === 't2');
|
||||
expect(teamB?.memberCount).toBe(1);
|
||||
|
||||
const teamDiscovered = result.teams.find(t => t.id === 't3');
|
||||
expect(teamDiscovered?.memberCount).toBe(1);
|
||||
expect(teamDiscovered?.name).toBe('Discovered Team');
|
||||
|
||||
expect(mockEventPublisher.publishTeamRankingsAccessed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter teams by search term', async () => {
|
||||
const result = await useCase.execute({ search: 'team a' });
|
||||
|
||||
expect(result.teams).toHaveLength(1);
|
||||
expect(result.teams[0].name).toBe('Team A');
|
||||
});
|
||||
|
||||
it('should filter teams by minMemberCount', async () => {
|
||||
const result = await useCase.execute({ minMemberCount: 2 });
|
||||
|
||||
expect(result.teams).toHaveLength(1);
|
||||
expect(result.teams[0].id).toBe('t1');
|
||||
});
|
||||
|
||||
it('should sort teams by rating DESC by default', async () => {
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.teams[0].id).toBe('t1'); // 2500
|
||||
expect(result.teams[1].id).toBe('t2'); // 2200
|
||||
expect(result.teams[2].id).toBe('t3'); // 0
|
||||
});
|
||||
|
||||
it('should throw ValidationError for invalid minMemberCount', async () => {
|
||||
await expect(useCase.execute({ minMemberCount: -1 })).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CreateLeagueUseCase } from './CreateLeagueUseCase';
|
||||
import { LeagueCreateCommand } from '../ports/LeagueCreateCommand';
|
||||
|
||||
describe('CreateLeagueUseCase', () => {
|
||||
let mockLeagueRepository: any;
|
||||
let mockEventPublisher: any;
|
||||
let useCase: CreateLeagueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeagueRepository = {
|
||||
create: vi.fn().mockImplementation((data) => Promise.resolve(data)),
|
||||
updateStats: vi.fn().mockResolvedValue(undefined),
|
||||
updateFinancials: vi.fn().mockResolvedValue(undefined),
|
||||
updateStewardingMetrics: vi.fn().mockResolvedValue(undefined),
|
||||
updatePerformanceMetrics: vi.fn().mockResolvedValue(undefined),
|
||||
updateRatingMetrics: vi.fn().mockResolvedValue(undefined),
|
||||
updateTrendMetrics: vi.fn().mockResolvedValue(undefined),
|
||||
updateSuccessRateMetrics: vi.fn().mockResolvedValue(undefined),
|
||||
updateResolutionTimeMetrics: vi.fn().mockResolvedValue(undefined),
|
||||
updateComplexSuccessRateMetrics: vi.fn().mockResolvedValue(undefined),
|
||||
updateComplexResolutionTimeMetrics: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockEventPublisher = {
|
||||
emitLeagueCreated: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
useCase = new CreateLeagueUseCase(mockLeagueRepository, mockEventPublisher);
|
||||
});
|
||||
|
||||
it('should create a league and initialize all metrics', async () => {
|
||||
const command: LeagueCreateCommand = {
|
||||
name: 'New League',
|
||||
ownerId: 'owner-1',
|
||||
visibility: 'public',
|
||||
approvalRequired: false,
|
||||
lateJoinAllowed: true,
|
||||
bonusPointsEnabled: true,
|
||||
penaltiesEnabled: true,
|
||||
protestsEnabled: true,
|
||||
appealsEnabled: true,
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.name).toBe('New League');
|
||||
expect(result.ownerId).toBe('owner-1');
|
||||
expect(mockLeagueRepository.create).toHaveBeenCalled();
|
||||
expect(mockLeagueRepository.updateStats).toHaveBeenCalled();
|
||||
expect(mockLeagueRepository.updateFinancials).toHaveBeenCalled();
|
||||
expect(mockEventPublisher.emitLeagueCreated).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error if name is missing', async () => {
|
||||
const command: any = { ownerId: 'owner-1' };
|
||||
await expect(useCase.execute(command)).rejects.toThrow('League name is required');
|
||||
});
|
||||
|
||||
it('should throw error if ownerId is missing', async () => {
|
||||
const command: any = { name: 'League' };
|
||||
await expect(useCase.execute(command)).rejects.toThrow('Owner ID is required');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DemoteAdminUseCase } from './DemoteAdminUseCase';
|
||||
|
||||
describe('DemoteAdminUseCase', () => {
|
||||
let mockLeagueRepository: any;
|
||||
let mockDriverRepository: any;
|
||||
let mockEventPublisher: any;
|
||||
let useCase: DemoteAdminUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeagueRepository = {
|
||||
updateLeagueMember: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockDriverRepository = {};
|
||||
mockEventPublisher = {};
|
||||
useCase = new DemoteAdminUseCase(mockLeagueRepository, mockDriverRepository, mockEventPublisher as any);
|
||||
});
|
||||
|
||||
it('should update member role to member', async () => {
|
||||
const command = {
|
||||
leagueId: 'l1',
|
||||
targetDriverId: 'd1',
|
||||
actorId: 'owner-1',
|
||||
};
|
||||
|
||||
await useCase.execute(command);
|
||||
|
||||
expect(mockLeagueRepository.updateLeagueMember).toHaveBeenCalledWith('l1', 'd1', { role: 'member' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GetLeagueRosterUseCase } from './GetLeagueRosterUseCase';
|
||||
|
||||
describe('GetLeagueRosterUseCase', () => {
|
||||
let mockLeagueRepository: any;
|
||||
let mockEventPublisher: any;
|
||||
let useCase: GetLeagueRosterUseCase;
|
||||
|
||||
const mockLeague = { id: 'league-1' };
|
||||
const mockMembers = [
|
||||
{ driverId: 'd1', name: 'Owner', role: 'owner', joinDate: new Date() },
|
||||
{ driverId: 'd2', name: 'Admin', role: 'admin', joinDate: new Date() },
|
||||
{ driverId: 'd3', name: 'Member', role: 'member', joinDate: new Date() },
|
||||
];
|
||||
const mockRequests = [
|
||||
{ id: 'r1', driverId: 'd4', name: 'Requester', requestDate: new Date() },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeagueRepository = {
|
||||
findById: vi.fn().mockResolvedValue(mockLeague),
|
||||
getLeagueMembers: vi.fn().mockResolvedValue(mockMembers),
|
||||
getPendingRequests: vi.fn().mockResolvedValue(mockRequests),
|
||||
};
|
||||
mockEventPublisher = {
|
||||
emitLeagueRosterAccessed: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
useCase = new GetLeagueRosterUseCase(mockLeagueRepository, mockEventPublisher);
|
||||
});
|
||||
|
||||
it('should return roster with members, requests and stats', async () => {
|
||||
const result = await useCase.execute({ leagueId: 'league-1' });
|
||||
|
||||
expect(result.members).toHaveLength(3);
|
||||
expect(result.pendingRequests).toHaveLength(1);
|
||||
expect(result.stats.adminCount).toBe(2); // owner + admin
|
||||
expect(result.stats.driverCount).toBe(1);
|
||||
expect(mockEventPublisher.emitLeagueRosterAccessed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error if league not found', async () => {
|
||||
mockLeagueRepository.findById.mockResolvedValue(null);
|
||||
await expect(useCase.execute({ leagueId: 'invalid' })).rejects.toThrow('League with id invalid not found');
|
||||
});
|
||||
});
|
||||
52
core/leagues/application/use-cases/GetLeagueUseCase.test.ts
Normal file
52
core/leagues/application/use-cases/GetLeagueUseCase.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GetLeagueUseCase, GetLeagueQuery } from './GetLeagueUseCase';
|
||||
|
||||
describe('GetLeagueUseCase', () => {
|
||||
let mockLeagueRepository: any;
|
||||
let mockEventPublisher: any;
|
||||
let useCase: GetLeagueUseCase;
|
||||
|
||||
const mockLeague = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
ownerId: 'owner-1',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeagueRepository = {
|
||||
findById: vi.fn().mockResolvedValue(mockLeague),
|
||||
};
|
||||
mockEventPublisher = {
|
||||
emitLeagueAccessed: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
useCase = new GetLeagueUseCase(mockLeagueRepository, mockEventPublisher);
|
||||
});
|
||||
|
||||
it('should return league data', async () => {
|
||||
const query: GetLeagueQuery = { leagueId: 'league-1' };
|
||||
const result = await useCase.execute(query);
|
||||
|
||||
expect(result).toEqual(mockLeague);
|
||||
expect(mockLeagueRepository.findById).toHaveBeenCalledWith('league-1');
|
||||
expect(mockEventPublisher.emitLeagueAccessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit event if driverId is provided', async () => {
|
||||
const query: GetLeagueQuery = { leagueId: 'league-1', driverId: 'driver-1' };
|
||||
await useCase.execute(query);
|
||||
|
||||
expect(mockEventPublisher.emitLeagueAccessed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error if league not found', async () => {
|
||||
mockLeagueRepository.findById.mockResolvedValue(null);
|
||||
const query: GetLeagueQuery = { leagueId: 'non-existent' };
|
||||
|
||||
await expect(useCase.execute(query)).rejects.toThrow('League with id non-existent not found');
|
||||
});
|
||||
|
||||
it('should throw error if leagueId is missing', async () => {
|
||||
const query: any = {};
|
||||
await expect(useCase.execute(query)).rejects.toThrow('League ID is required');
|
||||
});
|
||||
});
|
||||
242
core/leagues/application/use-cases/JoinLeagueUseCase.test.ts
Normal file
242
core/leagues/application/use-cases/JoinLeagueUseCase.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { JoinLeagueUseCase } from './JoinLeagueUseCase';
|
||||
import type { LeagueRepository } from '../ports/LeagueRepository';
|
||||
import type { DriverRepository } from '../../../racing/domain/repositories/DriverRepository';
|
||||
import type { EventPublisher } from '../../../shared/ports/EventPublisher';
|
||||
import type { JoinLeagueCommand } from '../ports/JoinLeagueCommand';
|
||||
|
||||
const mockLeagueRepository = {
|
||||
findById: vi.fn(),
|
||||
addPendingRequests: vi.fn(),
|
||||
addLeagueMembers: vi.fn(),
|
||||
};
|
||||
|
||||
const mockDriverRepository = {
|
||||
findDriverById: vi.fn(),
|
||||
};
|
||||
|
||||
const mockEventPublisher = {
|
||||
publish: vi.fn(),
|
||||
};
|
||||
|
||||
describe('JoinLeagueUseCase', () => {
|
||||
let useCase: JoinLeagueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
useCase = new JoinLeagueUseCase(
|
||||
mockLeagueRepository as any,
|
||||
mockDriverRepository as any,
|
||||
mockEventPublisher as any
|
||||
);
|
||||
});
|
||||
|
||||
describe('Scenario 1: League missing', () => {
|
||||
it('should throw "League not found" when league does not exist', async () => {
|
||||
// Given
|
||||
const command: JoinLeagueCommand = {
|
||||
leagueId: 'league-123',
|
||||
driverId: 'driver-456',
|
||||
};
|
||||
|
||||
mockLeagueRepository.findById.mockImplementation(() => Promise.resolve(null));
|
||||
|
||||
// When & Then
|
||||
await expect(useCase.execute(command)).rejects.toThrow('League not found');
|
||||
expect(mockLeagueRepository.findById).toHaveBeenCalledWith('league-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 2: Driver missing', () => {
|
||||
it('should throw "Driver not found" when driver does not exist', async () => {
|
||||
// Given
|
||||
const command: JoinLeagueCommand = {
|
||||
leagueId: 'league-123',
|
||||
driverId: 'driver-456',
|
||||
};
|
||||
|
||||
const mockLeague = {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
description: null,
|
||||
visibility: 'public' as const,
|
||||
ownerId: 'owner-789',
|
||||
status: 'active' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
maxDrivers: null,
|
||||
approvalRequired: true,
|
||||
lateJoinAllowed: true,
|
||||
raceFrequency: null,
|
||||
raceDay: null,
|
||||
raceTime: null,
|
||||
tracks: null,
|
||||
scoringSystem: null,
|
||||
bonusPointsEnabled: false,
|
||||
penaltiesEnabled: false,
|
||||
protestsEnabled: false,
|
||||
appealsEnabled: false,
|
||||
stewardTeam: null,
|
||||
gameType: null,
|
||||
skillLevel: null,
|
||||
category: null,
|
||||
tags: null,
|
||||
};
|
||||
|
||||
mockLeagueRepository.findById.mockImplementation(() => Promise.resolve(mockLeague));
|
||||
mockDriverRepository.findDriverById.mockImplementation(() => Promise.resolve(null));
|
||||
|
||||
// When & Then
|
||||
await expect(useCase.execute(command)).rejects.toThrow('Driver not found');
|
||||
expect(mockLeagueRepository.findById).toHaveBeenCalledWith('league-123');
|
||||
expect(mockDriverRepository.findDriverById).toHaveBeenCalledWith('driver-456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 3: approvalRequired path uses pending requests + time determinism', () => {
|
||||
it('should add pending request with deterministic time when approvalRequired is true', async () => {
|
||||
// Given
|
||||
const command: JoinLeagueCommand = {
|
||||
leagueId: 'league-123',
|
||||
driverId: 'driver-456',
|
||||
};
|
||||
|
||||
const mockLeague = {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
description: null,
|
||||
visibility: 'public' as const,
|
||||
ownerId: 'owner-789',
|
||||
status: 'active' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
maxDrivers: null,
|
||||
approvalRequired: true,
|
||||
lateJoinAllowed: true,
|
||||
raceFrequency: null,
|
||||
raceDay: null,
|
||||
raceTime: null,
|
||||
tracks: null,
|
||||
scoringSystem: null,
|
||||
bonusPointsEnabled: false,
|
||||
penaltiesEnabled: false,
|
||||
protestsEnabled: false,
|
||||
appealsEnabled: false,
|
||||
stewardTeam: null,
|
||||
gameType: null,
|
||||
skillLevel: null,
|
||||
category: null,
|
||||
tags: null,
|
||||
};
|
||||
|
||||
const mockDriver = {
|
||||
id: 'driver-456',
|
||||
name: 'Test Driver',
|
||||
iracingId: 'iracing-123',
|
||||
avatarUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Freeze time for deterministic testing
|
||||
const frozenTime = new Date('2024-01-01T00:00:00.000Z');
|
||||
vi.setSystemTime(frozenTime);
|
||||
|
||||
mockLeagueRepository.findById.mockResolvedValue(mockLeague);
|
||||
mockDriverRepository.findDriverById.mockResolvedValue(mockDriver);
|
||||
|
||||
// When
|
||||
await useCase.execute(command);
|
||||
|
||||
// Then
|
||||
expect(mockLeagueRepository.addPendingRequests).toHaveBeenCalledWith(
|
||||
'league-123',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
driverId: 'driver-456',
|
||||
name: 'Test Driver',
|
||||
requestDate: frozenTime,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
// Verify no members were added
|
||||
expect(mockLeagueRepository.addLeagueMembers).not.toHaveBeenCalled();
|
||||
|
||||
// Reset system time
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 4: no-approval path adds member', () => {
|
||||
it('should add member when approvalRequired is false', async () => {
|
||||
// Given
|
||||
const command: JoinLeagueCommand = {
|
||||
leagueId: 'league-123',
|
||||
driverId: 'driver-456',
|
||||
};
|
||||
|
||||
const mockLeague = {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
description: null,
|
||||
visibility: 'public' as const,
|
||||
ownerId: 'owner-789',
|
||||
status: 'active' as const,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
maxDrivers: null,
|
||||
approvalRequired: false,
|
||||
lateJoinAllowed: true,
|
||||
raceFrequency: null,
|
||||
raceDay: null,
|
||||
raceTime: null,
|
||||
tracks: null,
|
||||
scoringSystem: null,
|
||||
bonusPointsEnabled: false,
|
||||
penaltiesEnabled: false,
|
||||
protestsEnabled: false,
|
||||
appealsEnabled: false,
|
||||
stewardTeam: null,
|
||||
gameType: null,
|
||||
skillLevel: null,
|
||||
category: null,
|
||||
tags: null,
|
||||
};
|
||||
|
||||
const mockDriver = {
|
||||
id: 'driver-456',
|
||||
name: 'Test Driver',
|
||||
iracingId: 'iracing-123',
|
||||
avatarUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockLeagueRepository.findById.mockResolvedValue(mockLeague);
|
||||
mockDriverRepository.findDriverById.mockResolvedValue(mockDriver);
|
||||
|
||||
// When
|
||||
await useCase.execute(command);
|
||||
|
||||
// Then
|
||||
expect(mockLeagueRepository.addLeagueMembers).toHaveBeenCalledWith(
|
||||
'league-123',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
driverId: 'driver-456',
|
||||
name: 'Test Driver',
|
||||
role: 'member',
|
||||
joinDate: expect.any(Date),
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
// Verify no pending requests were added
|
||||
expect(mockLeagueRepository.addPendingRequests).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SearchLeaguesUseCase, SearchLeaguesQuery } from './SearchLeaguesUseCase';
|
||||
|
||||
describe('SearchLeaguesUseCase', () => {
|
||||
let mockLeagueRepository: any;
|
||||
let useCase: SearchLeaguesUseCase;
|
||||
|
||||
const mockLeagues = [
|
||||
{ id: '1', name: 'League 1' },
|
||||
{ id: '2', name: 'League 2' },
|
||||
{ id: '3', name: 'League 3' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeagueRepository = {
|
||||
search: vi.fn().mockResolvedValue([...mockLeagues]),
|
||||
};
|
||||
useCase = new SearchLeaguesUseCase(mockLeagueRepository);
|
||||
});
|
||||
|
||||
it('should return search results with default limit', async () => {
|
||||
const query: SearchLeaguesQuery = { query: 'test' };
|
||||
const result = await useCase.execute(query);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(mockLeagueRepository.search).toHaveBeenCalledWith('test');
|
||||
});
|
||||
|
||||
it('should respect limit and offset', async () => {
|
||||
const query: SearchLeaguesQuery = { query: 'test', limit: 1, offset: 1 };
|
||||
const result = await useCase.execute(query);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should throw error if query is missing', async () => {
|
||||
const query: any = { query: '' };
|
||||
await expect(useCase.execute(query)).rejects.toThrow('Search query is required');
|
||||
});
|
||||
});
|
||||
@@ -1,278 +1,72 @@
|
||||
import { calculateRaceDates, getNextWeekday, type ScheduleConfig } from '@core/racing/domain/services/ScheduleCalculator';
|
||||
import type { Weekday } from '@core/racing/domain/types/Weekday';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateRaceDates, getNextWeekday, ScheduleConfig } from './ScheduleCalculator';
|
||||
|
||||
describe('ScheduleCalculator', () => {
|
||||
describe('calculateRaceDates', () => {
|
||||
describe('with empty or invalid input', () => {
|
||||
it('should return empty array when weekdays is empty', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: [],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
expect(result.seasonDurationWeeks).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array when rounds is 0', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 0,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when rounds is negative', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: -5,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates).toEqual([]);
|
||||
});
|
||||
it('should return empty array if no weekdays or rounds', () => {
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: [],
|
||||
frequency: 'weekly',
|
||||
rounds: 10,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
expect(calculateRaceDates(config).raceDates).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('weekly scheduling', () => {
|
||||
it('should schedule 8 races on Saturdays starting from a Saturday', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// All dates should be Saturdays
|
||||
result.raceDates.forEach(date => {
|
||||
expect(date.getDay()).toBe(6); // Saturday
|
||||
});
|
||||
// First race should be Jan 6
|
||||
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Last race should be 7 weeks later (Feb 24)
|
||||
expect(result.raceDates[7]!.toISOString().split('T')[0]).toBe('2024-02-24');
|
||||
});
|
||||
|
||||
it('should schedule races on multiple weekdays', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Wed', 'Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'), // Monday
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// Should alternate between Wednesday and Saturday
|
||||
result.raceDates.forEach(date => {
|
||||
const day = date.getDay();
|
||||
expect([3, 6]).toContain(day); // Wed=3, Sat=6
|
||||
});
|
||||
});
|
||||
|
||||
it('should schedule 8 races on Sundays', () => {
|
||||
// Given - January 7, 2024 is a Sunday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sun'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
result.raceDates.forEach(date => {
|
||||
expect(date.getDay()).toBe(0); // Sunday
|
||||
});
|
||||
});
|
||||
it('should schedule weekly races', () => {
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Mon'],
|
||||
frequency: 'weekly',
|
||||
rounds: 3,
|
||||
startDate: new Date('2024-01-01'), // Monday
|
||||
};
|
||||
const result = calculateRaceDates(config);
|
||||
expect(result.raceDates).toHaveLength(3);
|
||||
expect(result.raceDates[0].getDay()).toBe(1);
|
||||
expect(result.raceDates[1].getDay()).toBe(1);
|
||||
expect(result.raceDates[2].getDay()).toBe(1);
|
||||
// Check dates are 7 days apart
|
||||
const diff = result.raceDates[1].getTime() - result.raceDates[0].getTime();
|
||||
expect(diff).toBe(7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
describe('bi-weekly scheduling', () => {
|
||||
it('should schedule races every 2 weeks on Saturdays', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'everyNWeeks',
|
||||
rounds: 4,
|
||||
startDate: new Date('2024-01-06'),
|
||||
intervalWeeks: 2,
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(4);
|
||||
// First race Jan 6
|
||||
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Second race 2 weeks later (Jan 20)
|
||||
expect(result.raceDates[1]!.toISOString().split('T')[0]).toBe('2024-01-20');
|
||||
// Third race 2 weeks later (Feb 3)
|
||||
expect(result.raceDates[2]!.toISOString().split('T')[0]).toBe('2024-02-03');
|
||||
// Fourth race 2 weeks later (Feb 17)
|
||||
expect(result.raceDates[3]!.toISOString().split('T')[0]).toBe('2024-02-17');
|
||||
});
|
||||
it('should schedule bi-weekly races', () => {
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Mon'],
|
||||
frequency: 'everyNWeeks',
|
||||
intervalWeeks: 2,
|
||||
rounds: 2,
|
||||
startDate: new Date('2024-01-01'),
|
||||
};
|
||||
const result = calculateRaceDates(config);
|
||||
expect(result.raceDates).toHaveLength(2);
|
||||
const diff = result.raceDates[1].getTime() - result.raceDates[0].getTime();
|
||||
expect(diff).toBe(14 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
describe('with start and end dates', () => {
|
||||
it('should evenly distribute races across the date range', () => {
|
||||
// Given - 3 month season
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
endDate: new Date('2024-03-30'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(8);
|
||||
// First race should be at or near start
|
||||
expect(result.raceDates[0]!.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
// Races should be spread across the range, not consecutive weeks
|
||||
});
|
||||
|
||||
it('should use all available days if fewer than rounds requested', () => {
|
||||
// Given - short period with only 3 Saturdays
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 10,
|
||||
startDate: new Date('2024-01-06'),
|
||||
endDate: new Date('2024-01-21'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
// Only 3 Saturdays in this range: Jan 6, 13, 20
|
||||
expect(result.raceDates.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('season duration calculation', () => {
|
||||
it('should calculate correct season duration in weeks', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 8,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
// 8 races, 1 week apart = 7 weeks duration
|
||||
expect(result.seasonDurationWeeks).toBe(7);
|
||||
});
|
||||
|
||||
it('should return 0 duration for single race', () => {
|
||||
// Given
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Sat'] as Weekday[],
|
||||
frequency: 'weekly',
|
||||
rounds: 1,
|
||||
startDate: new Date('2024-01-06'),
|
||||
};
|
||||
|
||||
// When
|
||||
const result = calculateRaceDates(config);
|
||||
|
||||
// Then
|
||||
expect(result.raceDates.length).toBe(1);
|
||||
expect(result.seasonDurationWeeks).toBe(0);
|
||||
});
|
||||
it('should distribute races between start and end date', () => {
|
||||
const config: ScheduleConfig = {
|
||||
weekdays: ['Mon', 'Wed', 'Fri'],
|
||||
frequency: 'weekly',
|
||||
rounds: 2,
|
||||
startDate: new Date('2024-01-01'), // Mon
|
||||
endDate: new Date('2024-01-15'), // Mon
|
||||
};
|
||||
const result = calculateRaceDates(config);
|
||||
expect(result.raceDates).toHaveLength(2);
|
||||
// Use getTime() to avoid timezone issues in comparison
|
||||
const expectedDate = new Date('2024-01-01');
|
||||
expectedDate.setHours(12, 0, 0, 0);
|
||||
expect(result.raceDates[0].getTime()).toBe(expectedDate.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextWeekday', () => {
|
||||
it('should return next Saturday from a Monday', () => {
|
||||
// Given - January 1, 2024 is a Monday
|
||||
const fromDate = new Date('2024-01-01');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sat');
|
||||
|
||||
// Then
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-06');
|
||||
expect(result.getDay()).toBe(6);
|
||||
});
|
||||
|
||||
it('should return next occurrence when already on that weekday', () => {
|
||||
// Given - January 6, 2024 is a Saturday
|
||||
const fromDate = new Date('2024-01-06');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sat');
|
||||
|
||||
// Then
|
||||
// Should return NEXT Saturday (7 days later), not same day
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-13');
|
||||
});
|
||||
|
||||
it('should return next Sunday from a Friday', () => {
|
||||
// Given - January 5, 2024 is a Friday
|
||||
const fromDate = new Date('2024-01-05');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Sun');
|
||||
|
||||
// Then
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-07');
|
||||
expect(result.getDay()).toBe(0);
|
||||
});
|
||||
|
||||
it('should return next Wednesday from a Thursday', () => {
|
||||
// Given - January 4, 2024 is a Thursday
|
||||
const fromDate = new Date('2024-01-04');
|
||||
|
||||
// When
|
||||
const result = getNextWeekday(fromDate, 'Wed');
|
||||
|
||||
// Then
|
||||
// Next Wednesday is 6 days later
|
||||
expect(result.toISOString().split('T')[0]).toBe('2024-01-10');
|
||||
expect(result.getDay()).toBe(3);
|
||||
it('should return the next Monday', () => {
|
||||
const from = new Date('2024-01-01'); // Monday
|
||||
const next = getNextWeekday(from, 'Mon');
|
||||
expect(next.getDay()).toBe(1);
|
||||
expect(next.getDate()).toBe(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,19 +8,19 @@ describe('SkillLevelService', () => {
|
||||
expect(SkillLevelService.getSkillLevel(5000)).toBe('pro');
|
||||
});
|
||||
|
||||
it('should return advanced for rating >= 2500 and < 3000', () => {
|
||||
it('should return advanced for rating >= 2500', () => {
|
||||
expect(SkillLevelService.getSkillLevel(2500)).toBe('advanced');
|
||||
expect(SkillLevelService.getSkillLevel(2999)).toBe('advanced');
|
||||
});
|
||||
|
||||
it('should return intermediate for rating >= 1800 and < 2500', () => {
|
||||
it('should return intermediate for rating >= 1800', () => {
|
||||
expect(SkillLevelService.getSkillLevel(1800)).toBe('intermediate');
|
||||
expect(SkillLevelService.getSkillLevel(2499)).toBe('intermediate');
|
||||
});
|
||||
|
||||
it('should return beginner for rating < 1800', () => {
|
||||
expect(SkillLevelService.getSkillLevel(1799)).toBe('beginner');
|
||||
expect(SkillLevelService.getSkillLevel(500)).toBe('beginner');
|
||||
expect(SkillLevelService.getSkillLevel(0)).toBe('beginner');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,14 +33,12 @@ describe('SkillLevelService', () => {
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(4500)).toBe('pro');
|
||||
});
|
||||
|
||||
it('should return advanced for rating >= 3000 and < 4500', () => {
|
||||
it('should return advanced for rating >= 3000', () => {
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(3000)).toBe('advanced');
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(4499)).toBe('advanced');
|
||||
});
|
||||
|
||||
it('should return intermediate for rating >= 2000 and < 3000', () => {
|
||||
it('should return intermediate for rating >= 2000', () => {
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(2000)).toBe('intermediate');
|
||||
expect(SkillLevelService.getTeamPerformanceLevel(2999)).toBe('intermediate');
|
||||
});
|
||||
|
||||
it('should return beginner for rating < 2000', () => {
|
||||
|
||||
@@ -1,54 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AverageStrengthOfFieldCalculator } from './StrengthOfFieldCalculator';
|
||||
import { AverageStrengthOfFieldCalculator, DriverRating } from './StrengthOfFieldCalculator';
|
||||
|
||||
describe('AverageStrengthOfFieldCalculator', () => {
|
||||
const calculator = new AverageStrengthOfFieldCalculator();
|
||||
|
||||
it('should calculate average SOF and round it', () => {
|
||||
const ratings = [
|
||||
{ driverId: 'd1', rating: 1500 },
|
||||
{ driverId: 'd2', rating: 2000 },
|
||||
{ driverId: 'd3', rating: 1750 },
|
||||
];
|
||||
|
||||
const sof = calculator.calculate(ratings);
|
||||
|
||||
expect(sof).toBe(1750);
|
||||
});
|
||||
|
||||
it('should handle rounding correctly', () => {
|
||||
const ratings = [
|
||||
{ driverId: 'd1', rating: 1000 },
|
||||
{ driverId: 'd2', rating: 1001 },
|
||||
];
|
||||
|
||||
const sof = calculator.calculate(ratings);
|
||||
|
||||
expect(sof).toBe(1001); // (1000 + 1001) / 2 = 1000.5 -> 1001
|
||||
});
|
||||
|
||||
it('should return null for empty ratings', () => {
|
||||
it('should return null for empty list', () => {
|
||||
expect(calculator.calculate([])).toBeNull();
|
||||
});
|
||||
|
||||
it('should filter out non-positive ratings', () => {
|
||||
const ratings = [
|
||||
{ driverId: 'd1', rating: 1500 },
|
||||
{ driverId: 'd2', rating: 0 },
|
||||
{ driverId: 'd3', rating: -100 },
|
||||
it('should return null if no valid ratings (>0)', () => {
|
||||
const ratings: DriverRating[] = [
|
||||
{ driverId: '1', rating: 0 },
|
||||
{ driverId: '2', rating: -100 },
|
||||
];
|
||||
|
||||
const sof = calculator.calculate(ratings);
|
||||
|
||||
expect(sof).toBe(1500);
|
||||
});
|
||||
|
||||
it('should return null if all ratings are non-positive', () => {
|
||||
const ratings = [
|
||||
{ driverId: 'd1', rating: 0 },
|
||||
{ driverId: 'd2', rating: -500 },
|
||||
];
|
||||
|
||||
expect(calculator.calculate(ratings)).toBeNull();
|
||||
});
|
||||
|
||||
it('should calculate average of valid ratings', () => {
|
||||
const ratings: DriverRating[] = [
|
||||
{ driverId: '1', rating: 1000 },
|
||||
{ driverId: '2', rating: 2000 },
|
||||
{ driverId: '3', rating: 0 }, // Should be ignored
|
||||
];
|
||||
expect(calculator.calculate(ratings)).toBe(1500);
|
||||
});
|
||||
|
||||
it('should round the result', () => {
|
||||
const ratings: DriverRating[] = [
|
||||
{ driverId: '1', rating: 1000 },
|
||||
{ driverId: '2', rating: 1001 },
|
||||
];
|
||||
expect(calculator.calculate(ratings)).toBe(1001); // (1000+1001)/2 = 1000.5 -> 1001
|
||||
});
|
||||
});
|
||||
|
||||
354
core/rating/application/use-cases/CalculateRatingUseCase.test.ts
Normal file
354
core/rating/application/use-cases/CalculateRatingUseCase.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Unit tests for CalculateRatingUseCase
|
||||
*
|
||||
* Tests business logic and orchestration using mocked ports.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CalculateRatingUseCase } from './CalculateRatingUseCase';
|
||||
import { Driver } from '../../../racing/domain/entities/Driver';
|
||||
import { Race } from '../../../racing/domain/entities/Race';
|
||||
import { Result } from '../../../racing/domain/entities/result/Result';
|
||||
import { Rating } from '../../domain/Rating';
|
||||
import { RatingCalculatedEvent } from '../../domain/events/RatingCalculatedEvent';
|
||||
|
||||
// Mock repositories and publisher
|
||||
const mockDriverRepository = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRaceRepository = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
const mockResultRepository = {
|
||||
findByRaceId: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRatingRepository = {
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
const mockEventPublisher = {
|
||||
publish: vi.fn(),
|
||||
};
|
||||
|
||||
describe('CalculateRatingUseCase', () => {
|
||||
let useCase: CalculateRatingUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useCase = new CalculateRatingUseCase({
|
||||
driverRepository: mockDriverRepository as any,
|
||||
raceRepository: mockRaceRepository as any,
|
||||
resultRepository: mockResultRepository as any,
|
||||
ratingRepository: mockRatingRepository as any,
|
||||
eventPublisher: mockEventPublisher as any,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 1: Driver missing', () => {
|
||||
it('should return error when driver is not found', async () => {
|
||||
// Given
|
||||
mockDriverRepository.findById.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute({
|
||||
driverId: 'driver-123',
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toBe('Driver not found');
|
||||
expect(mockRatingRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 2: Race missing', () => {
|
||||
it('should return error when race is not found', async () => {
|
||||
// Given
|
||||
const mockDriver = Driver.create({
|
||||
id: 'driver-123',
|
||||
iracingId: 'iracing-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
});
|
||||
mockDriverRepository.findById.mockResolvedValue(mockDriver);
|
||||
mockRaceRepository.findById.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute({
|
||||
driverId: 'driver-123',
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toBe('Race not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 3: No results', () => {
|
||||
it('should return error when no results found for race', async () => {
|
||||
// Given
|
||||
const mockDriver = Driver.create({
|
||||
id: 'driver-123',
|
||||
iracingId: 'iracing-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
});
|
||||
const mockRace = Race.create({
|
||||
id: 'race-456',
|
||||
leagueId: 'league-789',
|
||||
scheduledAt: new Date(),
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
});
|
||||
mockDriverRepository.findById.mockResolvedValue(mockDriver);
|
||||
mockRaceRepository.findById.mockResolvedValue(mockRace);
|
||||
mockResultRepository.findByRaceId.mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute({
|
||||
driverId: 'driver-123',
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toBe('No results found for race');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 4: Driver not present in results', () => {
|
||||
it('should return error when driver is not in race results', async () => {
|
||||
// Given
|
||||
const mockDriver = Driver.create({
|
||||
id: 'driver-123',
|
||||
iracingId: 'iracing-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
});
|
||||
const mockRace = Race.create({
|
||||
id: 'race-456',
|
||||
leagueId: 'league-789',
|
||||
scheduledAt: new Date(),
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
});
|
||||
const otherResult = Result.create({
|
||||
id: 'result-1',
|
||||
raceId: 'race-456',
|
||||
driverId: 'driver-456',
|
||||
position: 1,
|
||||
fastestLap: 60000,
|
||||
incidents: 0,
|
||||
startPosition: 1,
|
||||
points: 25,
|
||||
});
|
||||
mockDriverRepository.findById.mockResolvedValue(mockDriver);
|
||||
mockRaceRepository.findById.mockResolvedValue(mockRace);
|
||||
mockResultRepository.findByRaceId.mockResolvedValue([otherResult]);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute({
|
||||
driverId: 'driver-123',
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().message).toBe('Driver not found in race results');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 5: Publishes event after save', () => {
|
||||
it('should call ratingRepository.save before eventPublisher.publish', async () => {
|
||||
// Given
|
||||
const mockDriver = Driver.create({
|
||||
id: 'driver-123',
|
||||
iracingId: 'iracing-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
});
|
||||
const mockRace = Race.create({
|
||||
id: 'race-456',
|
||||
leagueId: 'league-789',
|
||||
scheduledAt: new Date(),
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
});
|
||||
const mockResult = Result.create({
|
||||
id: 'result-1',
|
||||
raceId: 'race-456',
|
||||
driverId: 'driver-123',
|
||||
position: 1,
|
||||
fastestLap: 60000,
|
||||
incidents: 0,
|
||||
startPosition: 1,
|
||||
points: 25,
|
||||
});
|
||||
mockDriverRepository.findById.mockResolvedValue(mockDriver);
|
||||
mockRaceRepository.findById.mockResolvedValue(mockRace);
|
||||
mockResultRepository.findByRaceId.mockResolvedValue([mockResult]);
|
||||
mockRatingRepository.save.mockResolvedValue(undefined);
|
||||
mockEventPublisher.publish.mockResolvedValue(undefined);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute({
|
||||
driverId: 'driver-123',
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockRatingRepository.save).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventPublisher.publish).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify call order: save should be called before publish
|
||||
const saveCallOrder = mockRatingRepository.save.mock.invocationCallOrder[0];
|
||||
const publishCallOrder = mockEventPublisher.publish.mock.invocationCallOrder[0];
|
||||
expect(saveCallOrder).toBeLessThan(publishCallOrder);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 6: Component boundaries for cleanDriving', () => {
|
||||
it('should return cleanDriving = 100 when incidents = 0', async () => {
|
||||
// Given
|
||||
const mockDriver = Driver.create({
|
||||
id: 'driver-123',
|
||||
iracingId: 'iracing-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
});
|
||||
const mockRace = Race.create({
|
||||
id: 'race-456',
|
||||
leagueId: 'league-789',
|
||||
scheduledAt: new Date(),
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
});
|
||||
const mockResult = Result.create({
|
||||
id: 'result-1',
|
||||
raceId: 'race-456',
|
||||
driverId: 'driver-123',
|
||||
position: 1,
|
||||
fastestLap: 60000,
|
||||
incidents: 0,
|
||||
startPosition: 1,
|
||||
points: 25,
|
||||
});
|
||||
mockDriverRepository.findById.mockResolvedValue(mockDriver);
|
||||
mockRaceRepository.findById.mockResolvedValue(mockRace);
|
||||
mockResultRepository.findByRaceId.mockResolvedValue([mockResult]);
|
||||
mockRatingRepository.save.mockResolvedValue(undefined);
|
||||
mockEventPublisher.publish.mockResolvedValue(undefined);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute({
|
||||
driverId: 'driver-123',
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.isOk()).toBe(true);
|
||||
const rating = result.unwrap();
|
||||
expect(rating.components.cleanDriving).toBe(100);
|
||||
});
|
||||
|
||||
it('should return cleanDriving = 20 when incidents >= 5', async () => {
|
||||
// Given
|
||||
const mockDriver = Driver.create({
|
||||
id: 'driver-123',
|
||||
iracingId: 'iracing-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
});
|
||||
const mockRace = Race.create({
|
||||
id: 'race-456',
|
||||
leagueId: 'league-789',
|
||||
scheduledAt: new Date(),
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
});
|
||||
const mockResult = Result.create({
|
||||
id: 'result-1',
|
||||
raceId: 'race-456',
|
||||
driverId: 'driver-123',
|
||||
position: 1,
|
||||
fastestLap: 60000,
|
||||
incidents: 5,
|
||||
startPosition: 1,
|
||||
points: 25,
|
||||
});
|
||||
mockDriverRepository.findById.mockResolvedValue(mockDriver);
|
||||
mockRaceRepository.findById.mockResolvedValue(mockRace);
|
||||
mockResultRepository.findByRaceId.mockResolvedValue([mockResult]);
|
||||
mockRatingRepository.save.mockResolvedValue(undefined);
|
||||
mockEventPublisher.publish.mockResolvedValue(undefined);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute({
|
||||
driverId: 'driver-123',
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.isOk()).toBe(true);
|
||||
const rating = result.unwrap();
|
||||
expect(rating.components.cleanDriving).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 7: Time-dependent output', () => {
|
||||
it('should produce deterministic timestamp when time is frozen', async () => {
|
||||
// Given
|
||||
const frozenTime = new Date('2024-01-01T12:00:00.000Z');
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(frozenTime);
|
||||
|
||||
const mockDriver = Driver.create({
|
||||
id: 'driver-123',
|
||||
iracingId: 'iracing-123',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
});
|
||||
const mockRace = Race.create({
|
||||
id: 'race-456',
|
||||
leagueId: 'league-789',
|
||||
scheduledAt: new Date(),
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
});
|
||||
const mockResult = Result.create({
|
||||
id: 'result-1',
|
||||
raceId: 'race-456',
|
||||
driverId: 'driver-123',
|
||||
position: 1,
|
||||
fastestLap: 60000,
|
||||
incidents: 0,
|
||||
startPosition: 1,
|
||||
points: 25,
|
||||
});
|
||||
mockDriverRepository.findById.mockResolvedValue(mockDriver);
|
||||
mockRaceRepository.findById.mockResolvedValue(mockRace);
|
||||
mockResultRepository.findByRaceId.mockResolvedValue([mockResult]);
|
||||
mockRatingRepository.save.mockResolvedValue(undefined);
|
||||
mockEventPublisher.publish.mockResolvedValue(undefined);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute({
|
||||
driverId: 'driver-123',
|
||||
raceId: 'race-456',
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.isOk()).toBe(true);
|
||||
const rating = result.unwrap();
|
||||
expect(rating.timestamp).toEqual(frozenTime);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Unit tests for CalculateTeamContributionUseCase
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { CalculateTeamContributionUseCase } from './CalculateTeamContributionUseCase';
|
||||
import { Driver } from '../../../racing/domain/entities/Driver';
|
||||
import { Race } from '../../../racing/domain/entities/Race';
|
||||
import { Result } from '../../../racing/domain/entities/result/Result';
|
||||
import { Rating } from '../../domain/Rating';
|
||||
|
||||
const mockRatingRepository = {
|
||||
findByDriverAndRace: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
const mockDriverRepository = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRaceRepository = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
const mockResultRepository = {
|
||||
findByRaceId: vi.fn(),
|
||||
};
|
||||
|
||||
describe('CalculateTeamContributionUseCase', () => {
|
||||
let useCase: CalculateTeamContributionUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useCase = new CalculateTeamContributionUseCase({
|
||||
ratingRepository: mockRatingRepository as any,
|
||||
driverRepository: mockDriverRepository as any,
|
||||
raceRepository: mockRaceRepository as any,
|
||||
resultRepository: mockResultRepository as any,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 8: Creates rating when missing', () => {
|
||||
it('should create and save a new rating when none exists', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const raceId = 'race-1';
|
||||
const points = 25;
|
||||
|
||||
mockDriverRepository.findById.mockResolvedValue(Driver.create({
|
||||
id: driverId,
|
||||
iracingId: 'ir-1',
|
||||
name: 'Driver 1',
|
||||
country: 'US'
|
||||
}));
|
||||
mockRaceRepository.findById.mockResolvedValue(Race.create({
|
||||
id: raceId,
|
||||
leagueId: 'l-1',
|
||||
scheduledAt: new Date(),
|
||||
track: 'Track',
|
||||
car: 'Car'
|
||||
}));
|
||||
mockResultRepository.findByRaceId.mockResolvedValue([
|
||||
Result.create({
|
||||
id: 'res-1',
|
||||
raceId,
|
||||
driverId,
|
||||
position: 1,
|
||||
points,
|
||||
incidents: 0,
|
||||
startPosition: 1,
|
||||
fastestLap: 0
|
||||
})
|
||||
]);
|
||||
mockRatingRepository.findByDriverAndRace.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute({ driverId, raceId });
|
||||
|
||||
// Then
|
||||
expect(mockRatingRepository.save).toHaveBeenCalled();
|
||||
const savedRating = mockRatingRepository.save.mock.calls[0][0] as Rating;
|
||||
expect(savedRating.components.teamContribution).toBe(100); // 25/25 * 100
|
||||
expect(result.teamContribution).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 9: Updates existing rating', () => {
|
||||
it('should preserve other fields and only update teamContribution', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const raceId = 'race-1';
|
||||
const points = 12.5; // 50% contribution
|
||||
|
||||
const existingRating = Rating.create({
|
||||
driverId: 'driver-1' as any, // Simplified for test
|
||||
raceId: 'race-1' as any,
|
||||
rating: 1500,
|
||||
components: {
|
||||
resultsStrength: 80,
|
||||
consistency: 70,
|
||||
cleanDriving: 90,
|
||||
racecraft: 75,
|
||||
reliability: 85,
|
||||
teamContribution: 10, // Old value
|
||||
},
|
||||
timestamp: new Date('2023-01-01')
|
||||
});
|
||||
|
||||
mockDriverRepository.findById.mockResolvedValue({ id: driverId } as any);
|
||||
mockRaceRepository.findById.mockResolvedValue({ id: raceId } as any);
|
||||
mockResultRepository.findByRaceId.mockResolvedValue([
|
||||
{ driverId: { toString: () => driverId }, points } as any
|
||||
]);
|
||||
mockRatingRepository.findByDriverAndRace.mockResolvedValue(existingRating);
|
||||
|
||||
// When
|
||||
const result = await useCase.execute({ driverId, raceId });
|
||||
|
||||
// Then
|
||||
expect(mockRatingRepository.save).toHaveBeenCalled();
|
||||
const savedRating = mockRatingRepository.save.mock.calls[0][0] as Rating;
|
||||
|
||||
// Check preserved fields
|
||||
expect(savedRating.rating).toBe(1500);
|
||||
expect(savedRating.components.resultsStrength).toBe(80);
|
||||
|
||||
// Check updated field
|
||||
expect(savedRating.components.teamContribution).toBe(50); // 12.5/25 * 100
|
||||
expect(result.teamContribution).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Unit tests for GetRatingLeaderboardUseCase
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GetRatingLeaderboardUseCase } from './GetRatingLeaderboardUseCase';
|
||||
import { Rating } from '../../domain/Rating';
|
||||
|
||||
const mockRatingRepository = {
|
||||
findByDriver: vi.fn(),
|
||||
};
|
||||
|
||||
const mockDriverRepository = {
|
||||
findAll: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
describe('GetRatingLeaderboardUseCase', () => {
|
||||
let useCase: GetRatingLeaderboardUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useCase = new GetRatingLeaderboardUseCase({
|
||||
ratingRepository: mockRatingRepository as any,
|
||||
driverRepository: mockDriverRepository as any,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 10: Pagination + Sorting', () => {
|
||||
it('should return latest rating per driver, sorted desc, sliced by limit/offset', async () => {
|
||||
// Given
|
||||
const drivers = [
|
||||
{ id: 'd1', name: { toString: () => 'Driver 1' } },
|
||||
{ id: 'd2', name: { toString: () => 'Driver 2' } },
|
||||
{ id: 'd3', name: { toString: () => 'Driver 3' } },
|
||||
];
|
||||
|
||||
const ratingsD1 = [
|
||||
Rating.create({
|
||||
driverId: 'd1' as any,
|
||||
raceId: 'r1' as any,
|
||||
rating: 1000,
|
||||
components: {} as any,
|
||||
timestamp: new Date('2023-01-01')
|
||||
}),
|
||||
Rating.create({
|
||||
driverId: 'd1' as any,
|
||||
raceId: 'r2' as any,
|
||||
rating: 1200, // Latest for D1
|
||||
components: {} as any,
|
||||
timestamp: new Date('2023-01-02')
|
||||
})
|
||||
];
|
||||
|
||||
const ratingsD2 = [
|
||||
Rating.create({
|
||||
driverId: 'd2' as any,
|
||||
raceId: 'r1' as any,
|
||||
rating: 1500, // Latest for D2
|
||||
components: {} as any,
|
||||
timestamp: new Date('2023-01-01')
|
||||
})
|
||||
];
|
||||
|
||||
const ratingsD3 = [
|
||||
Rating.create({
|
||||
driverId: 'd3' as any,
|
||||
raceId: 'r1' as any,
|
||||
rating: 800, // Latest for D3
|
||||
components: {} as any,
|
||||
timestamp: new Date('2023-01-01')
|
||||
})
|
||||
];
|
||||
|
||||
mockDriverRepository.findAll.mockResolvedValue(drivers);
|
||||
mockDriverRepository.findById.mockImplementation((id) =>
|
||||
Promise.resolve(drivers.find(d => d.id === id))
|
||||
);
|
||||
mockRatingRepository.findByDriver.mockImplementation((id) => {
|
||||
if (id === 'd1') return Promise.resolve(ratingsD1);
|
||||
if (id === 'd2') return Promise.resolve(ratingsD2);
|
||||
if (id === 'd3') return Promise.resolve(ratingsD3);
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
// When: limit 2, offset 0
|
||||
const result = await useCase.execute({ limit: 2, offset: 0 });
|
||||
|
||||
// Then: Sorted D2 (1500), D1 (1200), D3 (800). Slice(0, 2) -> D2, D1
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].driverId).toBe('d2');
|
||||
expect(result[0].rating).toBe(1500);
|
||||
expect(result[1].driverId).toBe('d1');
|
||||
expect(result[1].rating).toBe(1200);
|
||||
|
||||
// When: limit 2, offset 1
|
||||
const resultOffset = await useCase.execute({ limit: 2, offset: 1 });
|
||||
|
||||
// Then: Slice(1, 3) -> D1, D3
|
||||
expect(resultOffset).toHaveLength(2);
|
||||
expect(resultOffset[0].driverId).toBe('d1');
|
||||
expect(resultOffset[1].driverId).toBe('d3');
|
||||
});
|
||||
});
|
||||
});
|
||||
48
core/rating/application/use-cases/SaveRatingUseCase.test.ts
Normal file
48
core/rating/application/use-cases/SaveRatingUseCase.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Unit tests for SaveRatingUseCase
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SaveRatingUseCase } from './SaveRatingUseCase';
|
||||
|
||||
const mockRatingRepository = {
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
describe('SaveRatingUseCase', () => {
|
||||
let useCase: SaveRatingUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useCase = new SaveRatingUseCase({
|
||||
ratingRepository: mockRatingRepository as any,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 11: Repository error wraps correctly', () => {
|
||||
it('should wrap repository error with specific prefix', async () => {
|
||||
// Given
|
||||
const request = {
|
||||
driverId: 'd1',
|
||||
raceId: 'r1',
|
||||
rating: 1200,
|
||||
components: {
|
||||
resultsStrength: 80,
|
||||
consistency: 70,
|
||||
cleanDriving: 90,
|
||||
racecraft: 75,
|
||||
reliability: 85,
|
||||
teamContribution: 60,
|
||||
},
|
||||
};
|
||||
|
||||
const repoError = new Error('Database connection failed');
|
||||
mockRatingRepository.save.mockRejectedValue(repoError);
|
||||
|
||||
// When & Then
|
||||
await expect(useCase.execute(request)).rejects.toThrow(
|
||||
'Failed to save rating: Error: Database connection failed'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
core/rating/domain/Rating.test.ts
Normal file
69
core/rating/domain/Rating.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Unit tests for Rating domain entity
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Rating } from './Rating';
|
||||
import { DriverId } from '../../racing/domain/entities/DriverId';
|
||||
import { RaceId } from '../../racing/domain/entities/RaceId';
|
||||
|
||||
describe('Rating Entity', () => {
|
||||
it('should create a rating with correct properties', () => {
|
||||
// Given
|
||||
const props = {
|
||||
driverId: DriverId.create('d1'),
|
||||
raceId: RaceId.create('r1'),
|
||||
rating: 1200,
|
||||
components: {
|
||||
resultsStrength: 80,
|
||||
consistency: 70,
|
||||
cleanDriving: 90,
|
||||
racecraft: 75,
|
||||
reliability: 85,
|
||||
teamContribution: 60,
|
||||
},
|
||||
timestamp: new Date('2024-01-01T12:00:00Z'),
|
||||
};
|
||||
|
||||
// When
|
||||
const rating = Rating.create(props);
|
||||
|
||||
// Then
|
||||
expect(rating.driverId).toBe(props.driverId);
|
||||
expect(rating.raceId).toBe(props.raceId);
|
||||
expect(rating.rating).toBe(props.rating);
|
||||
expect(rating.components).toEqual(props.components);
|
||||
expect(rating.timestamp).toEqual(props.timestamp);
|
||||
});
|
||||
|
||||
it('should convert to JSON correctly', () => {
|
||||
// Given
|
||||
const props = {
|
||||
driverId: DriverId.create('d1'),
|
||||
raceId: RaceId.create('r1'),
|
||||
rating: 1200,
|
||||
components: {
|
||||
resultsStrength: 80,
|
||||
consistency: 70,
|
||||
cleanDriving: 90,
|
||||
racecraft: 75,
|
||||
reliability: 85,
|
||||
teamContribution: 60,
|
||||
},
|
||||
timestamp: new Date('2024-01-01T12:00:00Z'),
|
||||
};
|
||||
const rating = Rating.create(props);
|
||||
|
||||
// When
|
||||
const json = rating.toJSON();
|
||||
|
||||
// Then
|
||||
expect(json).toEqual({
|
||||
driverId: 'd1',
|
||||
raceId: 'r1',
|
||||
rating: 1200,
|
||||
components: props.components,
|
||||
timestamp: '2024-01-01T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
248
plans/testing-concept-adapters.md
Normal file
248
plans/testing-concept-adapters.md
Normal 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 don’t 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 that’s 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). It’s 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 I’d 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.
|
||||
|
||||
229
plans/testing-gaps-core.md
Normal file
229
plans/testing-gaps-core.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Testing gaps in `core` (unit tests only, no infra/adapters)
|
||||
|
||||
## Scope / rules (agreed)
|
||||
|
||||
* **In scope:** code under [`core/`](core:1) only.
|
||||
* **Unit tests only:** tests should validate business rules and orchestration using **ports mocked in-test** (e.g., `vi.fn()`), not real persistence, HTTP, frameworks, or adapters.
|
||||
* **Out of scope:** any test that relies on real IO, real repositories, or infrastructure code (including [`core/**/infrastructure/`](core/rating/infrastructure:1)).
|
||||
|
||||
## How gaps were identified
|
||||
|
||||
1. Inventory of application and domain units was built from file structure under [`core/`](core:1).
|
||||
2. Existing tests were located via `describe(` occurrences in `*.test.ts` and mapped to corresponding production units.
|
||||
3. Gaps were prioritized by:
|
||||
* **Business criticality:** identity/security, payments/money flows.
|
||||
* **Complex branching / invariants:** state machines, decision tables.
|
||||
* **Time-dependent logic:** `Date.now()`, `new Date()`, time windows.
|
||||
* **Error handling paths:** repository errors, partial failures.
|
||||
|
||||
---
|
||||
|
||||
## Highest-priority testing gaps (P0)
|
||||
|
||||
### 1) `rating` module has **no unit tests**
|
||||
|
||||
Why high risk: scoring/rating is a cross-cutting “truth source”, and current implementations contain test-driven hacks and inconsistent error handling.
|
||||
|
||||
Targets:
|
||||
* [`core/rating/application/use-cases/CalculateRatingUseCase.ts`](core/rating/application/use-cases/CalculateRatingUseCase.ts:1)
|
||||
* [`core/rating/application/use-cases/CalculateTeamContributionUseCase.ts`](core/rating/application/use-cases/CalculateTeamContributionUseCase.ts:1)
|
||||
* [`core/rating/application/use-cases/GetRatingLeaderboardUseCase.ts`](core/rating/application/use-cases/GetRatingLeaderboardUseCase.ts:1)
|
||||
* [`core/rating/application/use-cases/SaveRatingUseCase.ts`](core/rating/application/use-cases/SaveRatingUseCase.ts:1)
|
||||
* [`core/rating/domain/Rating.ts`](core/rating/domain/Rating.ts:1)
|
||||
|
||||
Proposed unit tests (Given/When/Then):
|
||||
1. **CalculateRatingUseCase: driver missing**
|
||||
* Given `driverRepository.findById` returns `null`
|
||||
* When executing with `{ driverId, raceId }`
|
||||
* Then returns `Result.err` with message `Driver not found` and does not call `ratingRepository.save`.
|
||||
2. **CalculateRatingUseCase: race missing**
|
||||
* Given driver exists, `raceRepository.findById` returns `null`
|
||||
* When execute
|
||||
* Then returns `Result.err` with message `Race not found`.
|
||||
3. **CalculateRatingUseCase: no results**
|
||||
* Given driver & race exist, `resultRepository.findByRaceId` returns `[]`
|
||||
* When execute
|
||||
* Then returns `Result.err` with message `No results found for race`.
|
||||
4. **CalculateRatingUseCase: driver not present in results**
|
||||
* Given results array without matching `driverId`
|
||||
* When execute
|
||||
* Then returns `Result.err` with message `Driver not found in race results`.
|
||||
5. **CalculateRatingUseCase: publishes event after save**
|
||||
* Given all repositories return happy-path objects
|
||||
* When execute
|
||||
* Then `ratingRepository.save` is called once before `eventPublisher.publish`.
|
||||
6. **CalculateRatingUseCase: component boundaries**
|
||||
* Given a result with `incidents = 0`
|
||||
* When execute
|
||||
* Then `components.cleanDriving === 100`.
|
||||
* Given `incidents >= 5`
|
||||
* Then `components.cleanDriving === 20`.
|
||||
7. **CalculateRatingUseCase: time-dependent output**
|
||||
* Given frozen time (use `vi.setSystemTime`)
|
||||
* When execute
|
||||
* Then emitted rating has deterministic `timestamp`.
|
||||
8. **CalculateTeamContributionUseCase: creates rating when missing**
|
||||
* Given `ratingRepository.findByDriverAndRace` returns `null`
|
||||
* When execute
|
||||
* Then `ratingRepository.save` is called with a rating whose `components.teamContribution` matches calculation.
|
||||
9. **CalculateTeamContributionUseCase: updates existing rating**
|
||||
* Given existing rating with components set
|
||||
* When execute
|
||||
* Then only `components.teamContribution` is changed and other fields preserved.
|
||||
10. **GetRatingLeaderboardUseCase: pagination + sorting**
|
||||
* Given multiple drivers and multiple ratings per driver
|
||||
* When execute with `{ limit, offset }`
|
||||
* Then returns latest per driver, sorted desc, sliced by pagination.
|
||||
11. **SaveRatingUseCase: repository error wraps correctly**
|
||||
* Given `ratingRepository.save` throws
|
||||
* When execute
|
||||
* Then throws `Failed to save rating:` prefixed error.
|
||||
|
||||
Ports to mock: `driverRepository`, `raceRepository`, `resultRepository`, `ratingRepository`, `eventPublisher`.
|
||||
|
||||
---
|
||||
|
||||
### 2) `dashboard` orchestration has no unit tests
|
||||
|
||||
Target:
|
||||
* [`core/dashboard/application/use-cases/GetDashboardUseCase.ts`](core/dashboard/application/use-cases/GetDashboardUseCase.ts:1)
|
||||
|
||||
Why high risk: timeouts, parallelization, filtering/sorting, and “log but don’t fail” event publishing.
|
||||
|
||||
Proposed unit tests (Given/When/Then):
|
||||
1. **Validation of driverId**
|
||||
* Given `driverId` is `''` or whitespace
|
||||
* When execute
|
||||
* Then throws [`ValidationError`](core/shared/errors/ValidationError.ts:1) (or the module’s equivalent) and does not hit repositories.
|
||||
2. **Driver not found**
|
||||
* Given `driverRepository.findDriverById` returns `null`
|
||||
* When execute
|
||||
* Then throws [`DriverNotFoundError`](core/dashboard/domain/errors/DriverNotFoundError.ts:1).
|
||||
3. **Filters invalid races**
|
||||
* Given `getUpcomingRaces` returns races missing `trackName` or with past `scheduledDate`
|
||||
* When execute
|
||||
* Then `upcomingRaces` in DTO excludes them.
|
||||
4. **Limits upcoming races to 3 and sorts by date ascending**
|
||||
* Given 5 valid upcoming races out of order
|
||||
* When execute
|
||||
* Then DTO contains only 3 earliest.
|
||||
5. **Activity is sorted newest-first**
|
||||
* Given activities with different timestamps
|
||||
* When execute
|
||||
* Then DTO is sorted desc by timestamp.
|
||||
6. **Repository failures are logged and rethrown**
|
||||
* Given one of the repositories rejects
|
||||
* When execute
|
||||
* Then logger.error called and error is rethrown.
|
||||
7. **Event publishing failure is swallowed**
|
||||
* Given `eventPublisher.publishDashboardAccessed` throws
|
||||
* When execute
|
||||
* Then use case still returns DTO and logger.error was called.
|
||||
8. **Timeout behavior** (if retained)
|
||||
* Given `raceRepository.getUpcomingRaces` never resolves
|
||||
* When using fake timers and advancing by TIMEOUT
|
||||
* Then `upcomingRaces` becomes `[]` and use case completes.
|
||||
|
||||
Ports to mock: all repositories, publisher, and [`Logger`](core/shared/domain/Logger.ts:1).
|
||||
|
||||
---
|
||||
|
||||
### 3) `leagues` module has multiple untested use-cases (time-dependent logic)
|
||||
|
||||
Targets likely missing tests:
|
||||
* [`core/leagues/application/use-cases/JoinLeagueUseCase.ts`](core/leagues/application/use-cases/JoinLeagueUseCase.ts:1)
|
||||
* [`core/leagues/application/use-cases/LeaveLeagueUseCase.ts`](core/leagues/application/use-cases/LeaveLeagueUseCase.ts:1)
|
||||
* [`core/leagues/application/use-cases/ApproveMembershipRequestUseCase.ts`](core/leagues/application/use-cases/ApproveMembershipRequestUseCase.ts:1)
|
||||
* plus others without `*.test.ts` siblings in [`core/leagues/application/use-cases/`](core/leagues/application/use-cases:1)
|
||||
|
||||
Proposed unit tests (Given/When/Then):
|
||||
1. **JoinLeagueUseCase: league missing**
|
||||
* Given `leagueRepository.findById` returns `null`
|
||||
* When execute
|
||||
* Then throws `League not found`.
|
||||
2. **JoinLeagueUseCase: driver missing**
|
||||
* Given league exists, `driverRepository.findDriverById` returns `null`
|
||||
* Then throws `Driver not found`.
|
||||
3. **JoinLeagueUseCase: approvalRequired path uses pending requests**
|
||||
* Given `league.approvalRequired === true`
|
||||
* When execute
|
||||
* Then `leagueRepository.addPendingRequests` called with a request containing frozen `Date.now()` and `new Date()`.
|
||||
4. **JoinLeagueUseCase: no-approval path adds member**
|
||||
* Given `approvalRequired === false`
|
||||
* Then `leagueRepository.addLeagueMembers` called with role `member`.
|
||||
5. **ApproveMembershipRequestUseCase: request not found**
|
||||
* Given pending requests list without `requestId`
|
||||
* Then throws `Request not found`.
|
||||
6. **ApproveMembershipRequestUseCase: happy path adds member then removes request**
|
||||
* Given request exists
|
||||
* Then `addLeagueMembers` called before `removePendingRequest`.
|
||||
7. **LeaveLeagueUseCase: delegates to repository**
|
||||
* Given repository mock
|
||||
* Then `removeLeagueMember` is called once with inputs.
|
||||
|
||||
Note: these use cases currently ignore injected `eventPublisher` in several places; tests should either (a) enforce event publication (drive implementation), or (b) remove the unused port.
|
||||
|
||||
---
|
||||
|
||||
## Medium-priority gaps (P1)
|
||||
|
||||
### 4) “Contract tests” that don’t test behavior (replace or move)
|
||||
|
||||
These tests validate TypeScript shapes and mocked method existence, but do not protect business behavior:
|
||||
* [`core/ports/media/MediaResolverPort.test.ts`](core/ports/media/MediaResolverPort.test.ts:1)
|
||||
* [`core/ports/media/MediaResolverPort.comprehensive.test.ts`](core/ports/media/MediaResolverPort.comprehensive.test.ts:1)
|
||||
* [`core/notifications/domain/repositories/NotificationRepository.test.ts`](core/notifications/domain/repositories/NotificationRepository.test.ts:1)
|
||||
* [`core/notifications/application/ports/NotificationService.test.ts`](core/notifications/application/ports/NotificationService.test.ts:1)
|
||||
|
||||
Recommended action:
|
||||
* Either delete these (if they add noise), or replace with **behavior tests of the code that consumes the port**.
|
||||
* If you want explicit “contract tests”, keep them in a dedicated layer and ensure they test the *adapter implementation* (but that would violate the current constraint, so keep them out of this scope).
|
||||
|
||||
### 5) Racing and Notifications include “imports-only” tests
|
||||
|
||||
Several tests are effectively “module loads” checks (no business assertions). Example patterns show up in:
|
||||
* [`core/notifications/domain/entities/Notification.test.ts`](core/notifications/domain/entities/Notification.test.ts:1)
|
||||
* [`core/notifications/domain/entities/NotificationPreference.test.ts`](core/notifications/domain/entities/NotificationPreference.test.ts:1)
|
||||
* many files under [`core/racing/domain/entities/`](core/racing/domain/entities:1)
|
||||
|
||||
Replace with invariant-focused tests:
|
||||
* Given invalid props (empty IDs, invalid status transitions)
|
||||
* When creating or transitioning state
|
||||
* Then throws domain error (or returns `Result.err`) with specific code/kind.
|
||||
|
||||
### 6) Racing use-cases with no tests (spot list)
|
||||
|
||||
From a quick scan of [`core/racing/application/use-cases/`](core/racing/application/use-cases:1), some `.ts` appear without matching `.test.ts` siblings:
|
||||
* [`core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts`](core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts:1)
|
||||
* [`core/racing/application/use-cases/GetRaceProtestsUseCase.ts`](core/racing/application/use-cases/GetRaceProtestsUseCase.ts:1)
|
||||
* [`core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts`](core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts:1) (appears tested, confirm)
|
||||
* [`core/racing/application/use-cases/GetSponsorsUseCase.ts`](core/racing/application/use-cases/GetSponsorsUseCase.ts:1) (no test file listed)
|
||||
* [`core/racing/application/use-cases/GetLeagueAdminUseCase.ts`](core/racing/application/use-cases/GetLeagueAdminUseCase.ts:1)
|
||||
* [`core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase.ts`](core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase.ts:1)
|
||||
* [`core/racing/application/use-cases/SubmitProtestDefenseUseCase.test.ts`](core/racing/application/use-cases/SubmitProtestDefenseUseCase.test.ts:1) exists, confirm content quality
|
||||
|
||||
Suggested scenarios depend on each use case’s branching, but the common minimum is:
|
||||
* repository error → `Result.err` with code
|
||||
* happy path → updates correct aggregates + publishes domain event if applicable
|
||||
* permission/invariant violations → domain error codes
|
||||
|
||||
---
|
||||
|
||||
## Lower-priority gaps (P2)
|
||||
|
||||
### 7) Coverage consistency and determinism
|
||||
|
||||
Patterns to standardize across modules:
|
||||
* Tests that touch time should freeze time (`vi.setSystemTime`) rather than relying on `Date.now()`.
|
||||
* Use cases should return `Result` consistently (some throw, some return `Result`). Testing should expose this inconsistency and drive convergence.
|
||||
|
||||
---
|
||||
|
||||
## Proposed execution plan (next step: implement tests)
|
||||
|
||||
1. Add missing unit tests for `rating` use-cases and `rating/domain/Rating`.
|
||||
2. Add unit tests for `GetDashboardUseCase` focusing on filtering/sorting, timeout, and publish failure behavior.
|
||||
3. Add unit tests for `leagues` membership flow (`JoinLeagueUseCase`, `ApproveMembershipRequestUseCase`, `LeaveLeagueUseCase`).
|
||||
4. Replace “imports-only” tests with invariant tests in `notifications` entities, starting with the most used aggregates.
|
||||
5. Audit remaining racing use-cases without tests and add the top 5 based on branching and business impact.
|
||||
|
||||
118
tests/contracts/media/MediaRepository.contract.ts
Normal file
118
tests/contracts/media/MediaRepository.contract.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user