25 Commits

Author SHA1 Message Date
838f1602de adapter tests
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m51s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-24 21:39:59 +01:00
1e821c4a5c core tests 2026-01-24 19:19:16 +01:00
5da14b1b21 core tests 2026-01-24 12:18:31 +01:00
3bef15f3bd core tests 2026-01-24 02:05:43 +01:00
78c9c1ec75 core tests 2026-01-24 01:53:04 +01:00
5e12570442 Merge pull request 'core tests' (#1) from tests/core into main
Some checks failed
CI / lint-typecheck (push) Failing after 4m50s
CI / tests (push) Has been skipped
CI / contract-tests (push) Has been skipped
CI / e2e-tests (push) Has been skipped
CI / comment-pr (push) Has been skipped
CI / commit-types (push) Has been skipped
Reviewed-on: #1
2026-01-24 00:40:23 +00:00
e000a997d0 Merge pull request 'integration tests' (#4) from tests/integration into main
Some checks failed
CI / tests (push) Has been cancelled
CI / contract-tests (push) Has been cancelled
CI / e2e-tests (push) Has been cancelled
CI / comment-pr (push) Has been cancelled
CI / commit-types (push) Has been cancelled
CI / lint-typecheck (push) Has been cancelled
Reviewed-on: #4
2026-01-24 00:31:54 +00:00
94ae216de4 do to formatters
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m51s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-24 01:31:16 +01:00
9ccecbf3bb integration tests
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m51s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-24 01:13:49 +01:00
9bb6b228f1 integration tests
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m50s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-23 23:46:03 +01:00
95276df5af integration tests 2026-01-23 14:51:33 +01:00
34eae53184 integration tests cleanup 2026-01-23 13:00:00 +01:00
a00ca4edfd integration tests cleanup 2026-01-23 12:56:53 +01:00
6df38a462a integration tests
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m50s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-23 11:44:59 +01:00
a0f41f242f integration tests
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m51s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-23 00:46:34 +01:00
eaf51712a7 integration tests
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m50s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-22 23:55:28 +01:00
853ec7b0ce Merge branch 'main' into tests/integration
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m51s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
2026-01-22 19:18:27 +01:00
2fba80da57 integration tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 4m46s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 19:16:43 +01:00
cf7a551117 Merge pull request 'ci setup' (#5) from setup/ci into main
Some checks failed
CI / lint-typecheck (push) Failing after 4m50s
CI / tests (push) Has been skipped
CI / contract-tests (push) Has been skipped
CI / e2e-tests (push) Has been skipped
CI / comment-pr (push) Has been skipped
CI / commit-types (push) Has been skipped
Reviewed-on: #5
2026-01-22 18:05:15 +00:00
648dce2193 core tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m43s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 18:52:22 +01:00
280d6fc199 core tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m51s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 18:44:01 +01:00
093eece3d7 core tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m47s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 18:20:33 +01:00
35cc7cf12b core tests 2026-01-22 18:05:30 +01:00
597bb48248 integration tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 4m51s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 17:29:06 +01:00
0a37454171 view data tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m51s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 17:28:09 +01:00
474 changed files with 41238 additions and 37250 deletions

View File

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

View File

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

View File

@@ -111,7 +111,11 @@ export class AchievementOrmMapper {
assertNonEmptyString(entityName, 'achievementId', entity.achievementId);
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({

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { AdminUser } from '../../domain/entities/AdminUser';
import { 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', () => {

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,9 @@ export class RacingResultFactory {
? 2
: 3 + Math.floor(rng() * 6);
// Calculate points based on position
const points = this.calculatePoints(position);
results.push(
RaceResult.create({
id: seedId(`${race.id}:${driver.id}`, this.persistence),
@@ -60,6 +63,7 @@ export class RacingResultFactory {
startPosition: Math.max(1, startPosition),
fastestLap,
incidents,
points,
}),
);
}
@@ -96,4 +100,21 @@ export class RacingResultFactory {
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
private calculatePoints(position: number): number {
// Standard F1-style points system
const pointsMap: Record<number, number> = {
1: 25,
2: 18,
3: 15,
4: 12,
5: 10,
6: 8,
7: 6,
8: 4,
9: 2,
10: 1,
};
return pointsMap[position] || 0;
}
}

View File

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

View File

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

View File

@@ -3,10 +3,25 @@ import {
DashboardAccessedEvent,
DashboardErrorEvent,
} from '../../core/dashboard/application/ports/DashboardEventPublisher';
import {
LeagueEventPublisher,
LeagueCreatedEvent,
LeagueUpdatedEvent,
LeagueDeletedEvent,
LeagueAccessedEvent,
LeagueRosterAccessedEvent,
} from '../../core/leagues/application/ports/LeagueEventPublisher';
import { EventPublisher, DomainEvent } from '../../core/shared/ports/EventPublisher';
export class InMemoryEventPublisher implements DashboardEventPublisher {
export class InMemoryEventPublisher implements DashboardEventPublisher, LeagueEventPublisher, EventPublisher {
private dashboardAccessedEvents: DashboardAccessedEvent[] = [];
private dashboardErrorEvents: DashboardErrorEvent[] = [];
private leagueCreatedEvents: LeagueCreatedEvent[] = [];
private leagueUpdatedEvents: LeagueUpdatedEvent[] = [];
private leagueDeletedEvents: LeagueDeletedEvent[] = [];
private leagueAccessedEvents: LeagueAccessedEvent[] = [];
private leagueRosterAccessedEvents: LeagueRosterAccessedEvent[] = [];
private events: DomainEvent[] = [];
private shouldFail: boolean = false;
async publishDashboardAccessed(event: DashboardAccessedEvent): Promise<void> {
@@ -19,6 +34,31 @@ export class InMemoryEventPublisher implements DashboardEventPublisher {
this.dashboardErrorEvents.push(event);
}
async emitLeagueCreated(event: LeagueCreatedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leagueCreatedEvents.push(event);
}
async emitLeagueUpdated(event: LeagueUpdatedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leagueUpdatedEvents.push(event);
}
async emitLeagueDeleted(event: LeagueDeletedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leagueDeletedEvents.push(event);
}
async emitLeagueAccessed(event: LeagueAccessedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leagueAccessedEvents.push(event);
}
async emitLeagueRosterAccessed(event: LeagueRosterAccessedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leagueRosterAccessedEvents.push(event);
}
getDashboardAccessedEventCount(): number {
return this.dashboardAccessedEvents.length;
}
@@ -27,13 +67,56 @@ export class InMemoryEventPublisher implements DashboardEventPublisher {
return this.dashboardErrorEvents.length;
}
getLeagueCreatedEventCount(): number {
return this.leagueCreatedEvents.length;
}
getLeagueUpdatedEventCount(): number {
return this.leagueUpdatedEvents.length;
}
getLeagueDeletedEventCount(): number {
return this.leagueDeletedEvents.length;
}
getLeagueAccessedEventCount(): number {
return this.leagueAccessedEvents.length;
}
getLeagueRosterAccessedEventCount(): number {
return this.leagueRosterAccessedEvents.length;
}
getLeagueRosterAccessedEvents(): LeagueRosterAccessedEvent[] {
return [...this.leagueRosterAccessedEvents];
}
getLeagueCreatedEvents(): LeagueCreatedEvent[] {
return [...this.leagueCreatedEvents];
}
clear(): void {
this.dashboardAccessedEvents = [];
this.dashboardErrorEvents = [];
this.leagueCreatedEvents = [];
this.leagueUpdatedEvents = [];
this.leagueDeletedEvents = [];
this.leagueAccessedEvents = [];
this.leagueRosterAccessedEvents = [];
this.events = [];
this.shouldFail = false;
}
setShouldFail(shouldFail: boolean): void {
this.shouldFail = shouldFail;
}
async publish(event: DomainEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push(event);
}
getEvents(): DomainEvent[] {
return [...this.events];
}
}

View File

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

View File

@@ -0,0 +1,175 @@
/**
* In-Memory Health Event Publisher
*
* Tracks health-related events for testing purposes.
* This publisher allows verification of event emission patterns
* without requiring external event bus infrastructure.
*/
import {
HealthEventPublisher,
HealthCheckCompletedEvent,
HealthCheckFailedEvent,
HealthCheckTimeoutEvent,
ConnectedEvent,
DisconnectedEvent,
DegradedEvent,
CheckingEvent,
} from '../../../core/health/ports/HealthEventPublisher';
export interface HealthCheckCompletedEventWithType {
type: 'HealthCheckCompleted';
healthy: boolean;
responseTime: number;
timestamp: Date;
endpoint?: string;
}
export interface HealthCheckFailedEventWithType {
type: 'HealthCheckFailed';
error: string;
timestamp: Date;
endpoint?: string;
}
export interface HealthCheckTimeoutEventWithType {
type: 'HealthCheckTimeout';
timestamp: Date;
endpoint?: string;
}
export interface ConnectedEventWithType {
type: 'Connected';
timestamp: Date;
responseTime: number;
}
export interface DisconnectedEventWithType {
type: 'Disconnected';
timestamp: Date;
consecutiveFailures: number;
}
export interface DegradedEventWithType {
type: 'Degraded';
timestamp: Date;
reliability: number;
}
export interface CheckingEventWithType {
type: 'Checking';
timestamp: Date;
}
export type HealthEvent =
| HealthCheckCompletedEventWithType
| HealthCheckFailedEventWithType
| HealthCheckTimeoutEventWithType
| ConnectedEventWithType
| DisconnectedEventWithType
| DegradedEventWithType
| CheckingEventWithType;
export class InMemoryHealthEventPublisher implements HealthEventPublisher {
private events: HealthEvent[] = [];
private shouldFail: boolean = false;
/**
* Publish a health check completed event
*/
async publishHealthCheckCompleted(event: HealthCheckCompletedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'HealthCheckCompleted', ...event });
}
/**
* Publish a health check failed event
*/
async publishHealthCheckFailed(event: HealthCheckFailedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'HealthCheckFailed', ...event });
}
/**
* Publish a health check timeout event
*/
async publishHealthCheckTimeout(event: HealthCheckTimeoutEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'HealthCheckTimeout', ...event });
}
/**
* Publish a connected event
*/
async publishConnected(event: ConnectedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'Connected', ...event });
}
/**
* Publish a disconnected event
*/
async publishDisconnected(event: DisconnectedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'Disconnected', ...event });
}
/**
* Publish a degraded event
*/
async publishDegraded(event: DegradedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'Degraded', ...event });
}
/**
* Publish a checking event
*/
async publishChecking(event: CheckingEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'Checking', ...event });
}
/**
* Get all published events
*/
getEvents(): HealthEvent[] {
return [...this.events];
}
/**
* Get events by type
*/
getEventsByType<T extends HealthEvent['type']>(type: T): Extract<HealthEvent, { type: T }>[] {
return this.events.filter((event): event is Extract<HealthEvent, { type: T }> => event.type === type);
}
/**
* Get the count of events
*/
getEventCount(): number {
return this.events.length;
}
/**
* Get the count of events by type
*/
getEventCountByType(type: HealthEvent['type']): number {
return this.events.filter(event => event.type === type).length;
}
/**
* Clear all published events
*/
clear(): void {
this.events = [];
this.shouldFail = false;
}
/**
* Configure the publisher to fail on publish
*/
setShouldFail(shouldFail: boolean): void {
this.shouldFail = shouldFail;
}
}

View File

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

View File

@@ -0,0 +1,201 @@
/**
* In-Memory Health Check Adapter
*
* Simulates API health check responses for testing purposes.
* This adapter allows controlled testing of health check scenarios
* without making actual HTTP requests.
*/
import {
HealthCheckQuery,
ConnectionStatus,
ConnectionHealth,
HealthCheckResult,
} from '../../../../core/health/ports/HealthCheckQuery';
export interface HealthCheckResponse {
healthy: boolean;
responseTime: number;
error?: string;
timestamp: Date;
}
export class InMemoryHealthCheckAdapter implements HealthCheckQuery {
private responses: Map<string, HealthCheckResponse> = new Map();
public shouldFail: boolean = false;
public failError: string = 'Network error';
private responseTime: number = 50;
private health: ConnectionHealth = {
status: 'disconnected',
lastCheck: null,
lastSuccess: null,
lastFailure: null,
consecutiveFailures: 0,
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
averageResponseTime: 0,
};
/**
* Configure the adapter to return a specific response
*/
configureResponse(endpoint: string, response: HealthCheckResponse): void {
this.responses.set(endpoint, response);
}
/**
* Configure the adapter to fail all requests
*/
setShouldFail(shouldFail: boolean, error?: string): void {
this.shouldFail = shouldFail;
if (error) {
this.failError = error;
}
}
/**
* Set the response time for health checks
*/
setResponseTime(time: number): void {
this.responseTime = time;
}
/**
* Perform a health check against an endpoint
*/
async performHealthCheck(): Promise<HealthCheckResult> {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, this.responseTime));
if (this.shouldFail) {
this.recordFailure(this.failError);
return {
healthy: false,
responseTime: this.responseTime,
error: this.failError,
timestamp: new Date(),
};
}
// Default successful response
this.recordSuccess(this.responseTime);
return {
healthy: true,
responseTime: this.responseTime,
timestamp: new Date(),
};
}
/**
* Get current connection status
*/
getStatus(): ConnectionStatus {
return this.health.status;
}
/**
* Get detailed health information
*/
getHealth(): ConnectionHealth {
return { ...this.health };
}
/**
* Get reliability percentage
*/
getReliability(): number {
if (this.health.totalRequests === 0) return 0;
return (this.health.successfulRequests / this.health.totalRequests) * 100;
}
/**
* Check if API is currently available
*/
isAvailable(): boolean {
return this.health.status === 'connected' || this.health.status === 'degraded';
}
/**
* Record a successful health check
*/
private recordSuccess(responseTime: number): void {
this.health.totalRequests++;
this.health.successfulRequests++;
this.health.consecutiveFailures = 0;
this.health.lastSuccess = new Date();
this.health.lastCheck = new Date();
// Update average response time
const total = this.health.successfulRequests;
if (total === 1) {
this.health.averageResponseTime = responseTime;
} else {
this.health.averageResponseTime =
((this.health.averageResponseTime * (total - 1)) + responseTime) / total;
}
this.updateStatus();
}
/**
* Record a failed health check
*/
private recordFailure(error: string): void {
this.health.totalRequests++;
this.health.failedRequests++;
this.health.consecutiveFailures++;
this.health.lastFailure = new Date();
this.health.lastCheck = new Date();
this.updateStatus();
}
/**
* Update connection status based on current metrics
*/
private updateStatus(): void {
const reliability = this.health.totalRequests > 0
? this.health.successfulRequests / this.health.totalRequests
: 0;
// More nuanced status determination
if (this.health.totalRequests === 0) {
// No requests yet - don't assume disconnected
this.health.status = 'checking';
} else if (this.health.consecutiveFailures >= 3) {
// Multiple consecutive failures indicates real connectivity issue
this.health.status = 'disconnected';
} else if (reliability < 0.7 && this.health.totalRequests >= 5) {
// Only degrade if we have enough samples and reliability is low
this.health.status = 'degraded';
} else if (reliability >= 0.7 || this.health.successfulRequests > 0) {
// If we have any successes, we're connected
this.health.status = 'connected';
} else {
// Default to checking if uncertain
this.health.status = 'checking';
}
}
/**
* Clear all configured responses and settings
*/
clear(): void {
this.responses.clear();
this.shouldFail = false;
this.failError = 'Network error';
this.responseTime = 50;
this.health = {
status: 'disconnected',
lastCheck: null,
lastSuccess: null,
lastFailure: null,
consecutiveFailures: 0,
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
averageResponseTime: 0,
};
}
}

View File

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

View File

@@ -0,0 +1,70 @@
/**
* Infrastructure Adapter: InMemoryLeaderboardsEventPublisher
*
* In-memory implementation of LeaderboardsEventPublisher.
* Stores events in arrays for testing purposes.
*/
import {
LeaderboardsEventPublisher,
GlobalLeaderboardsAccessedEvent,
DriverRankingsAccessedEvent,
TeamRankingsAccessedEvent,
LeaderboardsErrorEvent,
} from '../../../core/leaderboards/application/ports/LeaderboardsEventPublisher';
export class InMemoryLeaderboardsEventPublisher implements LeaderboardsEventPublisher {
private globalLeaderboardsAccessedEvents: GlobalLeaderboardsAccessedEvent[] = [];
private driverRankingsAccessedEvents: DriverRankingsAccessedEvent[] = [];
private teamRankingsAccessedEvents: TeamRankingsAccessedEvent[] = [];
private leaderboardsErrorEvents: LeaderboardsErrorEvent[] = [];
private shouldFail: boolean = false;
async publishGlobalLeaderboardsAccessed(event: GlobalLeaderboardsAccessedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.globalLeaderboardsAccessedEvents.push(event);
}
async publishDriverRankingsAccessed(event: DriverRankingsAccessedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.driverRankingsAccessedEvents.push(event);
}
async publishTeamRankingsAccessed(event: TeamRankingsAccessedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.teamRankingsAccessedEvents.push(event);
}
async publishLeaderboardsError(event: LeaderboardsErrorEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leaderboardsErrorEvents.push(event);
}
getGlobalLeaderboardsAccessedEventCount(): number {
return this.globalLeaderboardsAccessedEvents.length;
}
getDriverRankingsAccessedEventCount(): number {
return this.driverRankingsAccessedEvents.length;
}
getTeamRankingsAccessedEventCount(): number {
return this.teamRankingsAccessedEvents.length;
}
getLeaderboardsErrorEventCount(): number {
return this.leaderboardsErrorEvents.length;
}
clear(): void {
this.globalLeaderboardsAccessedEvents = [];
this.driverRankingsAccessedEvents = [];
this.teamRankingsAccessedEvents = [];
this.leaderboardsErrorEvents = [];
this.shouldFail = false;
}
setShouldFail(shouldFail: boolean): void {
this.shouldFail = shouldFail;
}
}

View File

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

View File

@@ -0,0 +1,44 @@
/**
* Infrastructure Adapter: InMemoryLeaderboardsRepository
*
* In-memory implementation of LeaderboardsRepository.
* Stores data in a Map structure.
*/
import {
LeaderboardsRepository,
LeaderboardDriverData,
LeaderboardTeamData,
} from '../../../../core/leaderboards/application/ports/LeaderboardsRepository';
export class InMemoryLeaderboardsRepository implements LeaderboardsRepository {
private drivers: Map<string, LeaderboardDriverData> = new Map();
private teams: Map<string, LeaderboardTeamData> = new Map();
async findAllDrivers(): Promise<LeaderboardDriverData[]> {
return Array.from(this.drivers.values());
}
async findAllTeams(): Promise<LeaderboardTeamData[]> {
return Array.from(this.teams.values());
}
async findDriversByTeamId(teamId: string): Promise<LeaderboardDriverData[]> {
return Array.from(this.drivers.values()).filter(
(driver) => driver.teamId === teamId,
);
}
addDriver(driver: LeaderboardDriverData): void {
this.drivers.set(driver.id, driver);
}
addTeam(team: LeaderboardTeamData): void {
this.teams.set(team.id, team);
}
clear(): void {
this.drivers.clear();
this.teams.clear();
}
}

View File

@@ -0,0 +1,84 @@
import {
LeagueEventPublisher,
LeagueCreatedEvent,
LeagueUpdatedEvent,
LeagueDeletedEvent,
LeagueAccessedEvent,
LeagueRosterAccessedEvent,
} from '../../../core/leagues/application/ports/LeagueEventPublisher';
export class InMemoryLeagueEventPublisher implements LeagueEventPublisher {
private leagueCreatedEvents: LeagueCreatedEvent[] = [];
private leagueUpdatedEvents: LeagueUpdatedEvent[] = [];
private leagueDeletedEvents: LeagueDeletedEvent[] = [];
private leagueAccessedEvents: LeagueAccessedEvent[] = [];
private leagueRosterAccessedEvents: LeagueRosterAccessedEvent[] = [];
async emitLeagueCreated(event: LeagueCreatedEvent): Promise<void> {
this.leagueCreatedEvents.push(event);
}
async emitLeagueUpdated(event: LeagueUpdatedEvent): Promise<void> {
this.leagueUpdatedEvents.push(event);
}
async emitLeagueDeleted(event: LeagueDeletedEvent): Promise<void> {
this.leagueDeletedEvents.push(event);
}
async emitLeagueAccessed(event: LeagueAccessedEvent): Promise<void> {
this.leagueAccessedEvents.push(event);
}
async emitLeagueRosterAccessed(event: LeagueRosterAccessedEvent): Promise<void> {
this.leagueRosterAccessedEvents.push(event);
}
getLeagueCreatedEventCount(): number {
return this.leagueCreatedEvents.length;
}
getLeagueUpdatedEventCount(): number {
return this.leagueUpdatedEvents.length;
}
getLeagueDeletedEventCount(): number {
return this.leagueDeletedEvents.length;
}
getLeagueAccessedEventCount(): number {
return this.leagueAccessedEvents.length;
}
getLeagueRosterAccessedEventCount(): number {
return this.leagueRosterAccessedEvents.length;
}
clear(): void {
this.leagueCreatedEvents = [];
this.leagueUpdatedEvents = [];
this.leagueDeletedEvents = [];
this.leagueAccessedEvents = [];
this.leagueRosterAccessedEvents = [];
}
getLeagueCreatedEvents(): LeagueCreatedEvent[] {
return [...this.leagueCreatedEvents];
}
getLeagueUpdatedEvents(): LeagueUpdatedEvent[] {
return [...this.leagueUpdatedEvents];
}
getLeagueDeletedEvents(): LeagueDeletedEvent[] {
return [...this.leagueDeletedEvents];
}
getLeagueAccessedEvents(): LeagueAccessedEvent[] {
return [...this.leagueAccessedEvents];
}
getLeagueRosterAccessedEvents(): LeagueRosterAccessedEvent[] {
return [...this.leagueRosterAccessedEvents];
}
}

View File

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

View File

@@ -1,64 +1,364 @@
import {
DashboardRepository,
DriverData,
RaceData,
LeagueStandingData,
ActivityData,
FriendData,
} from '../../../../core/dashboard/application/ports/DashboardRepository';
LeagueRepository,
LeagueData,
LeagueStats,
LeagueFinancials,
LeagueStewardingMetrics,
LeaguePerformanceMetrics,
LeagueRatingMetrics,
LeagueTrendMetrics,
LeagueSuccessRateMetrics,
LeagueResolutionTimeMetrics,
LeagueComplexSuccessRateMetrics,
LeagueComplexResolutionTimeMetrics,
LeagueMember,
LeaguePendingRequest,
} from '../../../../core/leagues/application/ports/LeagueRepository';
import { LeagueStandingData } from '../../../../core/dashboard/application/ports/DashboardRepository';
export class InMemoryLeagueRepository implements DashboardRepository {
private drivers: Map<string, DriverData> = new Map();
private upcomingRaces: Map<string, RaceData[]> = new Map();
export class InMemoryLeagueRepository implements LeagueRepository {
private leagues: Map<string, LeagueData> = new Map();
private leagueStats: Map<string, LeagueStats> = new Map();
private leagueFinancials: Map<string, LeagueFinancials> = new Map();
private leagueStewardingMetrics: Map<string, LeagueStewardingMetrics> = new Map();
private leaguePerformanceMetrics: Map<string, LeaguePerformanceMetrics> = new Map();
private leagueRatingMetrics: Map<string, LeagueRatingMetrics> = new Map();
private leagueTrendMetrics: Map<string, LeagueTrendMetrics> = new Map();
private leagueSuccessRateMetrics: Map<string, LeagueSuccessRateMetrics> = new Map();
private leagueResolutionTimeMetrics: Map<string, LeagueResolutionTimeMetrics> = new Map();
private leagueComplexSuccessRateMetrics: Map<string, LeagueComplexSuccessRateMetrics> = new Map();
private leagueComplexResolutionTimeMetrics: Map<string, LeagueComplexResolutionTimeMetrics> = new Map();
private leagueStandings: Map<string, LeagueStandingData[]> = new Map();
private recentActivity: Map<string, ActivityData[]> = new Map();
private friends: Map<string, FriendData[]> = new Map();
private leagueMembers: Map<string, LeagueMember[]> = new Map();
private leaguePendingRequests: Map<string, LeaguePendingRequest[]> = new Map();
async findDriverById(driverId: string): Promise<DriverData | null> {
return this.drivers.get(driverId) || null;
async create(league: LeagueData): Promise<LeagueData> {
this.leagues.set(league.id, league);
return league;
}
async getUpcomingRaces(driverId: string): Promise<RaceData[]> {
return this.upcomingRaces.get(driverId) || [];
async findById(id: string): Promise<LeagueData | null> {
return this.leagues.get(id) || null;
}
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
return this.leagueStandings.get(driverId) || [];
async findByName(name: string): Promise<LeagueData | null> {
for (const league of Array.from(this.leagues.values())) {
if (league.name === name) {
return league;
}
}
return null;
}
async getRecentActivity(driverId: string): Promise<ActivityData[]> {
return this.recentActivity.get(driverId) || [];
async findByOwner(ownerId: string): Promise<LeagueData[]> {
const leagues: LeagueData[] = [];
for (const league of Array.from(this.leagues.values())) {
if (league.ownerId === ownerId) {
leagues.push(league);
}
}
return leagues;
}
async getFriends(driverId: string): Promise<FriendData[]> {
return this.friends.get(driverId) || [];
async search(query: string): Promise<LeagueData[]> {
const results: LeagueData[] = [];
const lowerQuery = query.toLowerCase();
for (const league of Array.from(this.leagues.values())) {
if (
league.name.toLowerCase().includes(lowerQuery) ||
league.description?.toLowerCase().includes(lowerQuery)
) {
results.push(league);
}
}
return results;
}
addDriver(driver: DriverData): void {
this.drivers.set(driver.id, driver);
async update(id: string, updates: Partial<LeagueData>): Promise<LeagueData> {
const league = this.leagues.get(id);
if (!league) {
throw new Error(`League with id ${id} not found`);
}
const updated = { ...league, ...updates };
this.leagues.set(id, updated);
return updated;
}
addUpcomingRaces(driverId: string, races: RaceData[]): void {
this.upcomingRaces.set(driverId, races);
async delete(id: string): Promise<void> {
this.leagues.delete(id);
this.leagueStats.delete(id);
this.leagueFinancials.delete(id);
this.leagueStewardingMetrics.delete(id);
this.leaguePerformanceMetrics.delete(id);
this.leagueRatingMetrics.delete(id);
this.leagueTrendMetrics.delete(id);
this.leagueSuccessRateMetrics.delete(id);
this.leagueResolutionTimeMetrics.delete(id);
this.leagueComplexSuccessRateMetrics.delete(id);
this.leagueComplexResolutionTimeMetrics.delete(id);
}
async getStats(leagueId: string): Promise<LeagueStats> {
return this.leagueStats.get(leagueId) || this.createDefaultStats(leagueId);
}
async updateStats(leagueId: string, stats: LeagueStats): Promise<LeagueStats> {
this.leagueStats.set(leagueId, stats);
return stats;
}
async getFinancials(leagueId: string): Promise<LeagueFinancials> {
return this.leagueFinancials.get(leagueId) || this.createDefaultFinancials(leagueId);
}
async updateFinancials(leagueId: string, financials: LeagueFinancials): Promise<LeagueFinancials> {
this.leagueFinancials.set(leagueId, financials);
return financials;
}
async getStewardingMetrics(leagueId: string): Promise<LeagueStewardingMetrics> {
return this.leagueStewardingMetrics.get(leagueId) || this.createDefaultStewardingMetrics(leagueId);
}
async updateStewardingMetrics(leagueId: string, metrics: LeagueStewardingMetrics): Promise<LeagueStewardingMetrics> {
this.leagueStewardingMetrics.set(leagueId, metrics);
return metrics;
}
async getPerformanceMetrics(leagueId: string): Promise<LeaguePerformanceMetrics> {
return this.leaguePerformanceMetrics.get(leagueId) || this.createDefaultPerformanceMetrics(leagueId);
}
async updatePerformanceMetrics(leagueId: string, metrics: LeaguePerformanceMetrics): Promise<LeaguePerformanceMetrics> {
this.leaguePerformanceMetrics.set(leagueId, metrics);
return metrics;
}
async getRatingMetrics(leagueId: string): Promise<LeagueRatingMetrics> {
return this.leagueRatingMetrics.get(leagueId) || this.createDefaultRatingMetrics(leagueId);
}
async updateRatingMetrics(leagueId: string, metrics: LeagueRatingMetrics): Promise<LeagueRatingMetrics> {
this.leagueRatingMetrics.set(leagueId, metrics);
return metrics;
}
async getTrendMetrics(leagueId: string): Promise<LeagueTrendMetrics> {
return this.leagueTrendMetrics.get(leagueId) || this.createDefaultTrendMetrics(leagueId);
}
async updateTrendMetrics(leagueId: string, metrics: LeagueTrendMetrics): Promise<LeagueTrendMetrics> {
this.leagueTrendMetrics.set(leagueId, metrics);
return metrics;
}
async getSuccessRateMetrics(leagueId: string): Promise<LeagueSuccessRateMetrics> {
return this.leagueSuccessRateMetrics.get(leagueId) || this.createDefaultSuccessRateMetrics(leagueId);
}
async updateSuccessRateMetrics(leagueId: string, metrics: LeagueSuccessRateMetrics): Promise<LeagueSuccessRateMetrics> {
this.leagueSuccessRateMetrics.set(leagueId, metrics);
return metrics;
}
async getResolutionTimeMetrics(leagueId: string): Promise<LeagueResolutionTimeMetrics> {
return this.leagueResolutionTimeMetrics.get(leagueId) || this.createDefaultResolutionTimeMetrics(leagueId);
}
async updateResolutionTimeMetrics(leagueId: string, metrics: LeagueResolutionTimeMetrics): Promise<LeagueResolutionTimeMetrics> {
this.leagueResolutionTimeMetrics.set(leagueId, metrics);
return metrics;
}
async getComplexSuccessRateMetrics(leagueId: string): Promise<LeagueComplexSuccessRateMetrics> {
return this.leagueComplexSuccessRateMetrics.get(leagueId) || this.createDefaultComplexSuccessRateMetrics(leagueId);
}
async updateComplexSuccessRateMetrics(leagueId: string, metrics: LeagueComplexSuccessRateMetrics): Promise<LeagueComplexSuccessRateMetrics> {
this.leagueComplexSuccessRateMetrics.set(leagueId, metrics);
return metrics;
}
async getComplexResolutionTimeMetrics(leagueId: string): Promise<LeagueComplexResolutionTimeMetrics> {
return this.leagueComplexResolutionTimeMetrics.get(leagueId) || this.createDefaultComplexResolutionTimeMetrics(leagueId);
}
async updateComplexResolutionTimeMetrics(leagueId: string, metrics: LeagueComplexResolutionTimeMetrics): Promise<LeagueComplexResolutionTimeMetrics> {
this.leagueComplexResolutionTimeMetrics.set(leagueId, metrics);
return metrics;
}
clear(): void {
this.leagues.clear();
this.leagueStats.clear();
this.leagueFinancials.clear();
this.leagueStewardingMetrics.clear();
this.leaguePerformanceMetrics.clear();
this.leagueRatingMetrics.clear();
this.leagueTrendMetrics.clear();
this.leagueSuccessRateMetrics.clear();
this.leagueResolutionTimeMetrics.clear();
this.leagueComplexSuccessRateMetrics.clear();
this.leagueComplexResolutionTimeMetrics.clear();
this.leagueStandings.clear();
this.leagueMembers.clear();
this.leaguePendingRequests.clear();
}
addLeagueStandings(driverId: string, standings: LeagueStandingData[]): void {
this.leagueStandings.set(driverId, standings);
}
addRecentActivity(driverId: string, activities: ActivityData[]): void {
this.recentActivity.set(driverId, activities);
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
return this.leagueStandings.get(driverId) || [];
}
addFriends(driverId: string, friends: FriendData[]): void {
this.friends.set(driverId, friends);
async addLeagueMembers(leagueId: string, members: LeagueMember[]): Promise<void> {
const current = this.leagueMembers.get(leagueId) || [];
this.leagueMembers.set(leagueId, [...current, ...members]);
}
clear(): void {
this.drivers.clear();
this.upcomingRaces.clear();
this.leagueStandings.clear();
this.recentActivity.clear();
this.friends.clear();
async getLeagueMembers(leagueId: string): Promise<LeagueMember[]> {
return this.leagueMembers.get(leagueId) || [];
}
async updateLeagueMember(leagueId: string, driverId: string, updates: Partial<LeagueMember>): Promise<void> {
const members = this.leagueMembers.get(leagueId) || [];
const index = members.findIndex(m => m.driverId === driverId);
if (index !== -1) {
members[index] = { ...members[index], ...updates } as LeagueMember;
this.leagueMembers.set(leagueId, [...members]);
}
}
async removeLeagueMember(leagueId: string, driverId: string): Promise<void> {
const members = this.leagueMembers.get(leagueId) || [];
this.leagueMembers.set(leagueId, members.filter(m => m.driverId !== driverId));
}
async addPendingRequests(leagueId: string, requests: LeaguePendingRequest[]): Promise<void> {
const current = this.leaguePendingRequests.get(leagueId) || [];
this.leaguePendingRequests.set(leagueId, [...current, ...requests]);
}
async getPendingRequests(leagueId: string): Promise<LeaguePendingRequest[]> {
return this.leaguePendingRequests.get(leagueId) || [];
}
async removePendingRequest(leagueId: string, requestId: string): Promise<void> {
const current = this.leaguePendingRequests.get(leagueId) || [];
this.leaguePendingRequests.set(leagueId, current.filter(r => r.id !== requestId));
}
private createDefaultStats(leagueId: string): LeagueStats {
return {
leagueId,
memberCount: 1,
raceCount: 0,
sponsorCount: 0,
prizePool: 0,
rating: 0,
reviewCount: 0,
};
}
private createDefaultFinancials(leagueId: string): LeagueFinancials {
return {
leagueId,
walletBalance: 0,
totalRevenue: 0,
totalFees: 0,
pendingPayouts: 0,
netBalance: 0,
};
}
private createDefaultStewardingMetrics(leagueId: string): LeagueStewardingMetrics {
return {
leagueId,
averageResolutionTime: 0,
averageProtestResolutionTime: 0,
averagePenaltyAppealSuccessRate: 0,
averageProtestSuccessRate: 0,
averageStewardingActionSuccessRate: 0,
};
}
private createDefaultPerformanceMetrics(leagueId: string): LeaguePerformanceMetrics {
return {
leagueId,
averageLapTime: 0,
averageFieldSize: 0,
averageIncidentCount: 0,
averagePenaltyCount: 0,
averageProtestCount: 0,
averageStewardingActionCount: 0,
};
}
private createDefaultRatingMetrics(leagueId: string): LeagueRatingMetrics {
return {
leagueId,
overallRating: 0,
ratingTrend: 0,
rankTrend: 0,
pointsTrend: 0,
winRateTrend: 0,
podiumRateTrend: 0,
dnfRateTrend: 0,
};
}
private createDefaultTrendMetrics(leagueId: string): LeagueTrendMetrics {
return {
leagueId,
incidentRateTrend: 0,
penaltyRateTrend: 0,
protestRateTrend: 0,
stewardingActionRateTrend: 0,
stewardingTimeTrend: 0,
protestResolutionTimeTrend: 0,
};
}
private createDefaultSuccessRateMetrics(leagueId: string): LeagueSuccessRateMetrics {
return {
leagueId,
penaltyAppealSuccessRate: 0,
protestSuccessRate: 0,
stewardingActionSuccessRate: 0,
stewardingActionAppealSuccessRate: 0,
stewardingActionPenaltySuccessRate: 0,
stewardingActionProtestSuccessRate: 0,
};
}
private createDefaultResolutionTimeMetrics(leagueId: string): LeagueResolutionTimeMetrics {
return {
leagueId,
averageStewardingTime: 0,
averageProtestResolutionTime: 0,
averageStewardingActionAppealPenaltyProtestResolutionTime: 0,
};
}
private createDefaultComplexSuccessRateMetrics(leagueId: string): LeagueComplexSuccessRateMetrics {
return {
leagueId,
stewardingActionAppealPenaltyProtestSuccessRate: 0,
stewardingActionAppealProtestSuccessRate: 0,
stewardingActionPenaltyProtestSuccessRate: 0,
stewardingActionAppealPenaltyProtestSuccessRate2: 0,
};
}
private createDefaultComplexResolutionTimeMetrics(leagueId: string): LeagueComplexResolutionTimeMetrics {
return {
leagueId,
stewardingActionAppealPenaltyProtestResolutionTime: 0,
stewardingActionAppealProtestResolutionTime: 0,
stewardingActionPenaltyProtestResolutionTime: 0,
stewardingActionAppealPenaltyProtestResolutionTime2: 0,
};
}
}

View File

@@ -0,0 +1,93 @@
/**
* Infrastructure Adapter: InMemoryMediaEventPublisher
*
* In-memory implementation of MediaEventPublisher for testing purposes.
* Stores events in memory for verification in integration tests.
*/
import type { Logger } from '@core/shared/domain/Logger';
import type { DomainEvent } from '@core/shared/domain/DomainEvent';
export interface MediaEvent {
eventType: string;
aggregateId: string;
eventData: unknown;
occurredAt: Date;
}
export class InMemoryMediaEventPublisher {
private events: MediaEvent[] = [];
constructor(private readonly logger: Logger) {
this.logger.info('[InMemoryMediaEventPublisher] Initialized.');
}
/**
* Publish a domain event
*/
async publish(event: DomainEvent): Promise<void> {
this.logger.debug(`[InMemoryMediaEventPublisher] Publishing event: ${event.eventType} for aggregate: ${event.aggregateId}`);
const mediaEvent: MediaEvent = {
eventType: event.eventType,
aggregateId: event.aggregateId,
eventData: event.eventData,
occurredAt: event.occurredAt,
};
this.events.push(mediaEvent);
this.logger.info(`Event ${event.eventType} published successfully.`);
}
/**
* Get all published events
*/
getEvents(): MediaEvent[] {
return [...this.events];
}
/**
* Get events by event type
*/
getEventsByType(eventType: string): MediaEvent[] {
return this.events.filter(event => event.eventType === eventType);
}
/**
* Get events by aggregate ID
*/
getEventsByAggregateId(aggregateId: string): MediaEvent[] {
return this.events.filter(event => event.aggregateId === aggregateId);
}
/**
* Get the total number of events
*/
getEventCount(): number {
return this.events.length;
}
/**
* Clear all events
*/
clear(): void {
this.events = [];
this.logger.info('[InMemoryMediaEventPublisher] All events cleared.');
}
/**
* Check if an event of a specific type was published
*/
hasEvent(eventType: string): boolean {
return this.events.some(event => event.eventType === eventType);
}
/**
* Check if an event was published for a specific aggregate
*/
hasEventForAggregate(eventType: string, aggregateId: string): boolean {
return this.events.some(
event => event.eventType === eventType && event.aggregateId === aggregateId
);
}
}

View File

@@ -18,6 +18,12 @@ export class InMemoryAvatarGenerationRepository implements AvatarGenerationRepos
}
}
clear(): void {
this.requests.clear();
this.userRequests.clear();
this.logger.info('InMemoryAvatarGenerationRepository cleared.');
}
async save(request: AvatarGenerationRequest): Promise<void> {
this.logger.debug(`[InMemoryAvatarGenerationRepository] Saving avatar generation request: ${request.id} for user ${request.userId}.`);
this.requests.set(request.id, request);

View File

@@ -0,0 +1,121 @@
/**
* Infrastructure Adapter: InMemoryAvatarRepository
*
* In-memory implementation of AvatarRepository for testing purposes.
* Stores avatar entities in memory for fast, deterministic testing.
*/
import type { Avatar } from '@core/media/domain/entities/Avatar';
import type { AvatarRepository } from '@core/media/domain/repositories/AvatarRepository';
import type { Logger } from '@core/shared/domain/Logger';
export class InMemoryAvatarRepository implements AvatarRepository {
private avatars: Map<string, Avatar> = new Map();
private driverAvatars: Map<string, Avatar[]> = new Map();
constructor(private readonly logger: Logger) {
this.logger.info('[InMemoryAvatarRepository] Initialized.');
}
async save(avatar: Avatar): Promise<void> {
this.logger.debug(`[InMemoryAvatarRepository] Saving avatar: ${avatar.id} for driver: ${avatar.driverId}`);
// Store by ID
this.avatars.set(avatar.id, avatar);
// Store by driver ID
if (!this.driverAvatars.has(avatar.driverId)) {
this.driverAvatars.set(avatar.driverId, []);
}
const driverAvatars = this.driverAvatars.get(avatar.driverId)!;
const existingIndex = driverAvatars.findIndex(a => a.id === avatar.id);
if (existingIndex > -1) {
driverAvatars[existingIndex] = avatar;
} else {
driverAvatars.push(avatar);
}
this.logger.info(`Avatar ${avatar.id} for driver ${avatar.driverId} saved successfully.`);
}
async findById(id: string): Promise<Avatar | null> {
this.logger.debug(`[InMemoryAvatarRepository] Finding avatar by ID: ${id}`);
const avatar = this.avatars.get(id) ?? null;
if (avatar) {
this.logger.info(`Found avatar by ID: ${id}`);
} else {
this.logger.warn(`Avatar with ID ${id} not found.`);
}
return avatar;
}
async findActiveByDriverId(driverId: string): Promise<Avatar | null> {
this.logger.debug(`[InMemoryAvatarRepository] Finding active avatar for driver: ${driverId}`);
const driverAvatars = this.driverAvatars.get(driverId) ?? [];
const activeAvatar = driverAvatars.find(avatar => avatar.isActive) ?? null;
if (activeAvatar) {
this.logger.info(`Found active avatar for driver ${driverId}: ${activeAvatar.id}`);
} else {
this.logger.warn(`No active avatar found for driver: ${driverId}`);
}
return activeAvatar;
}
async findByDriverId(driverId: string): Promise<Avatar[]> {
this.logger.debug(`[InMemoryAvatarRepository] Finding all avatars for driver: ${driverId}`);
const driverAvatars = this.driverAvatars.get(driverId) ?? [];
this.logger.info(`Found ${driverAvatars.length} avatars for driver ${driverId}.`);
return driverAvatars;
}
async delete(id: string): Promise<void> {
this.logger.debug(`[InMemoryAvatarRepository] Deleting avatar with ID: ${id}`);
const avatarToDelete = this.avatars.get(id);
if (!avatarToDelete) {
this.logger.warn(`Avatar with ID ${id} not found for deletion.`);
return;
}
// Remove from avatars map
this.avatars.delete(id);
// Remove from driver avatars
const driverAvatars = this.driverAvatars.get(avatarToDelete.driverId);
if (driverAvatars) {
const filtered = driverAvatars.filter(avatar => avatar.id !== id);
if (filtered.length > 0) {
this.driverAvatars.set(avatarToDelete.driverId, filtered);
} else {
this.driverAvatars.delete(avatarToDelete.driverId);
}
}
this.logger.info(`Avatar ${id} deleted successfully.`);
}
/**
* Clear all avatars from the repository
*/
clear(): void {
this.avatars.clear();
this.driverAvatars.clear();
this.logger.info('[InMemoryAvatarRepository] All avatars cleared.');
}
/**
* Get the total number of avatars stored
*/
get size(): number {
return this.avatars.size;
}
}

View File

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

View File

@@ -0,0 +1,106 @@
/**
* Infrastructure Adapter: InMemoryMediaRepository
*
* In-memory implementation of MediaRepository for testing purposes.
* Stores media entities in memory for fast, deterministic testing.
*/
import type { Media } from '@core/media/domain/entities/Media';
import type { MediaRepository } from '@core/media/domain/repositories/MediaRepository';
import type { Logger } from '@core/shared/domain/Logger';
export class InMemoryMediaRepository implements MediaRepository {
private media: Map<string, Media> = new Map();
private uploadedByMedia: Map<string, Media[]> = new Map();
constructor(private readonly logger: Logger) {
this.logger.info('[InMemoryMediaRepository] Initialized.');
}
async save(media: Media): Promise<void> {
this.logger.debug(`[InMemoryMediaRepository] Saving media: ${media.id} for uploader: ${media.uploadedBy}`);
// Store by ID
this.media.set(media.id, media);
// Store by uploader
if (!this.uploadedByMedia.has(media.uploadedBy)) {
this.uploadedByMedia.set(media.uploadedBy, []);
}
const uploaderMedia = this.uploadedByMedia.get(media.uploadedBy)!;
const existingIndex = uploaderMedia.findIndex(m => m.id === media.id);
if (existingIndex > -1) {
uploaderMedia[existingIndex] = media;
} else {
uploaderMedia.push(media);
}
this.logger.info(`Media ${media.id} for uploader ${media.uploadedBy} saved successfully.`);
}
async findById(id: string): Promise<Media | null> {
this.logger.debug(`[InMemoryMediaRepository] Finding media by ID: ${id}`);
const media = this.media.get(id) ?? null;
if (media) {
this.logger.info(`Found media by ID: ${id}`);
} else {
this.logger.warn(`Media with ID ${id} not found.`);
}
return media;
}
async findByUploadedBy(uploadedBy: string): Promise<Media[]> {
this.logger.debug(`[InMemoryMediaRepository] Finding all media for uploader: ${uploadedBy}`);
const uploaderMedia = this.uploadedByMedia.get(uploadedBy) ?? [];
this.logger.info(`Found ${uploaderMedia.length} media files for uploader ${uploadedBy}.`);
return uploaderMedia;
}
async delete(id: string): Promise<void> {
this.logger.debug(`[InMemoryMediaRepository] Deleting media with ID: ${id}`);
const mediaToDelete = this.media.get(id);
if (!mediaToDelete) {
this.logger.warn(`Media with ID ${id} not found for deletion.`);
return;
}
// Remove from media map
this.media.delete(id);
// Remove from uploader media
const uploaderMedia = this.uploadedByMedia.get(mediaToDelete.uploadedBy);
if (uploaderMedia) {
const filtered = uploaderMedia.filter(media => media.id !== id);
if (filtered.length > 0) {
this.uploadedByMedia.set(mediaToDelete.uploadedBy, filtered);
} else {
this.uploadedByMedia.delete(mediaToDelete.uploadedBy);
}
}
this.logger.info(`Media ${id} deleted successfully.`);
}
/**
* Clear all media from the repository
*/
clear(): void {
this.media.clear();
this.uploadedByMedia.clear();
this.logger.info('[InMemoryMediaRepository] All media cleared.');
}
/**
* Get the total number of media files stored
*/
get size(): number {
return this.media.size;
}
}

View File

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

View File

@@ -0,0 +1,22 @@
import type { AvatarGenerationPort, AvatarGenerationOptions, AvatarGenerationResult } from '@core/media/application/ports/AvatarGenerationPort';
import type { Logger } from '@core/shared/domain/Logger';
export class InMemoryAvatarGenerationAdapter implements AvatarGenerationPort {
constructor(private readonly logger: Logger) {
this.logger.info('InMemoryAvatarGenerationAdapter initialized.');
}
async generateAvatars(options: AvatarGenerationOptions): Promise<AvatarGenerationResult> {
this.logger.debug('[InMemoryAvatarGenerationAdapter] Generating avatars (mock).', { options });
const avatars = Array.from({ length: options.count }, (_, i) => ({
url: `https://example.com/generated-avatar-${i + 1}.png`,
thumbnailUrl: `https://example.com/generated-avatar-${i + 1}-thumb.png`,
}));
return Promise.resolve({
success: true,
avatars,
});
}
}

View File

@@ -0,0 +1,109 @@
/**
* Infrastructure Adapter: InMemoryMediaStorageAdapter
*
* In-memory implementation of MediaStoragePort for testing purposes.
* Simulates file storage without actual filesystem operations.
*/
import type { MediaStoragePort, UploadOptions, UploadResult } from '@core/media/application/ports/MediaStoragePort';
import type { Logger } from '@core/shared/domain/Logger';
export class InMemoryMediaStorageAdapter implements MediaStoragePort {
private storage: Map<string, Buffer> = new Map();
private metadata: Map<string, { size: number; contentType: string }> = new Map();
constructor(private readonly logger: Logger) {
this.logger.info('[InMemoryMediaStorageAdapter] Initialized.');
}
async uploadMedia(buffer: Buffer, options: UploadOptions): Promise<UploadResult> {
this.logger.debug(`[InMemoryMediaStorageAdapter] Uploading media: ${options.filename}`);
// Validate content type
const allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/gif'];
if (!allowedTypes.includes(options.mimeType)) {
return {
success: false,
errorMessage: `Content type ${options.mimeType} is not allowed`,
};
}
// Generate storage key
const storageKey = `/media/uploaded/${Date.now()}-${options.filename.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
// Store buffer and metadata
this.storage.set(storageKey, buffer);
this.metadata.set(storageKey, {
size: buffer.length,
contentType: options.mimeType,
});
this.logger.info(`Media uploaded successfully: ${storageKey}`);
return {
success: true,
filename: options.filename,
url: storageKey,
};
}
async deleteMedia(storageKey: string): Promise<void> {
this.logger.debug(`[InMemoryMediaStorageAdapter] Deleting media: ${storageKey}`);
this.storage.delete(storageKey);
this.metadata.delete(storageKey);
this.logger.info(`Media deleted successfully: ${storageKey}`);
}
async getBytes(storageKey: string): Promise<Buffer | null> {
this.logger.debug(`[InMemoryMediaStorageAdapter] Getting bytes for: ${storageKey}`);
const buffer = this.storage.get(storageKey) ?? null;
if (buffer) {
this.logger.info(`Retrieved bytes for: ${storageKey}`);
} else {
this.logger.warn(`No bytes found for: ${storageKey}`);
}
return buffer;
}
async getMetadata(storageKey: string): Promise<{ size: number; contentType: string } | null> {
this.logger.debug(`[InMemoryMediaStorageAdapter] Getting metadata for: ${storageKey}`);
const meta = this.metadata.get(storageKey) ?? null;
if (meta) {
this.logger.info(`Retrieved metadata for: ${storageKey}`);
} else {
this.logger.warn(`No metadata found for: ${storageKey}`);
}
return meta;
}
/**
* Clear all stored media
*/
clear(): void {
this.storage.clear();
this.metadata.clear();
this.logger.info('[InMemoryMediaStorageAdapter] All media cleared.');
}
/**
* Get the total number of stored media files
*/
get size(): number {
return this.storage.size;
}
/**
* Check if a storage key exists
*/
has(storageKey: string): boolean {
return this.storage.has(storageKey);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,34 +6,33 @@ import type { Payment, PaymentType } from '@core/payments/domain/entities/Paymen
import type { PaymentRepository } from '@core/payments/domain/repositories/PaymentRepository';
import type { Logger } from '@core/shared/domain/Logger';
const payments: Map<string, Payment> = new Map();
export class InMemoryPaymentRepository implements PaymentRepository {
private payments: Map<string, Payment> = new Map();
constructor(private readonly logger: Logger) {}
async findById(id: string): Promise<Payment | null> {
this.logger.debug('[InMemoryPaymentRepository] findById', { id });
return payments.get(id) || null;
return this.payments.get(id) || null;
}
async findByLeagueId(leagueId: string): Promise<Payment[]> {
this.logger.debug('[InMemoryPaymentRepository] findByLeagueId', { leagueId });
return Array.from(payments.values()).filter(p => p.leagueId === leagueId);
return Array.from(this.payments.values()).filter(p => p.leagueId === leagueId);
}
async findByPayerId(payerId: string): Promise<Payment[]> {
this.logger.debug('[InMemoryPaymentRepository] findByPayerId', { payerId });
return Array.from(payments.values()).filter(p => p.payerId === payerId);
return Array.from(this.payments.values()).filter(p => p.payerId === payerId);
}
async findByType(type: PaymentType): Promise<Payment[]> {
this.logger.debug('[InMemoryPaymentRepository] findByType', { type });
return Array.from(payments.values()).filter(p => p.type === type);
return Array.from(this.payments.values()).filter(p => p.type === type);
}
async findByFilters(filters: { leagueId?: string; payerId?: string; type?: PaymentType }): Promise<Payment[]> {
this.logger.debug('[InMemoryPaymentRepository] findByFilters', { filters });
let results = Array.from(payments.values());
let results = Array.from(this.payments.values());
if (filters.leagueId) {
results = results.filter(p => p.leagueId === filters.leagueId);
@@ -50,13 +49,17 @@ export class InMemoryPaymentRepository implements PaymentRepository {
async create(payment: Payment): Promise<Payment> {
this.logger.debug('[InMemoryPaymentRepository] create', { payment });
payments.set(payment.id, payment);
this.payments.set(payment.id, payment);
return payment;
}
async update(payment: Payment): Promise<Payment> {
this.logger.debug('[InMemoryPaymentRepository] update', { payment });
payments.set(payment.id, payment);
this.payments.set(payment.id, payment);
return payment;
}
clear(): void {
this.payments.clear();
}
}

View File

@@ -5,52 +5,86 @@
import type { Transaction, Wallet } from '@core/payments/domain/entities/Wallet';
import type { WalletRepository, TransactionRepository } from '@core/payments/domain/repositories/WalletRepository';
import type { Logger } from '@core/shared/domain/Logger';
import type { LeagueWalletRepository } from '@core/racing/domain/repositories/LeagueWalletRepository';
const wallets: Map<string, Wallet> = new Map();
const transactions: Map<string, Transaction> = new Map();
const wallets: Map<string, any> = new Map();
const transactions: Map<string, any> = new Map();
export class InMemoryWalletRepository implements WalletRepository {
export class InMemoryWalletRepository implements WalletRepository, LeagueWalletRepository {
constructor(private readonly logger: Logger) {}
async findById(id: string): Promise<Wallet | null> {
async findById(id: string): Promise<any | null> {
this.logger.debug('[InMemoryWalletRepository] findById', { id });
return wallets.get(id) || null;
}
async findByLeagueId(leagueId: string): Promise<Wallet | null> {
async findByLeagueId(leagueId: string): Promise<any | null> {
this.logger.debug('[InMemoryWalletRepository] findByLeagueId', { leagueId });
return Array.from(wallets.values()).find(w => w.leagueId === leagueId) || null;
return Array.from(wallets.values()).find(w => w.leagueId.toString() === leagueId) || null;
}
async create(wallet: Wallet): Promise<Wallet> {
async create(wallet: any): Promise<any> {
this.logger.debug('[InMemoryWalletRepository] create', { wallet });
wallets.set(wallet.id, wallet);
wallets.set(wallet.id.toString(), wallet);
return wallet;
}
async update(wallet: Wallet): Promise<Wallet> {
async update(wallet: any): Promise<any> {
this.logger.debug('[InMemoryWalletRepository] update', { wallet });
wallets.set(wallet.id, wallet);
wallets.set(wallet.id.toString(), wallet);
return wallet;
}
async delete(id: string): Promise<void> {
wallets.delete(id);
}
async exists(id: string): Promise<boolean> {
return wallets.has(id);
}
clear(): void {
wallets.clear();
}
}
export class InMemoryTransactionRepository implements TransactionRepository {
constructor(private readonly logger: Logger) {}
async findById(id: string): Promise<Transaction | null> {
async findById(id: string): Promise<any | null> {
this.logger.debug('[InMemoryTransactionRepository] findById', { id });
return transactions.get(id) || null;
}
async findByWalletId(walletId: string): Promise<Transaction[]> {
async findByWalletId(walletId: string): Promise<any[]> {
this.logger.debug('[InMemoryTransactionRepository] findByWalletId', { walletId });
return Array.from(transactions.values()).filter(t => t.walletId === walletId);
return Array.from(transactions.values()).filter(t => t.walletId.toString() === walletId);
}
async create(transaction: Transaction): Promise<Transaction> {
async create(transaction: any): Promise<any> {
this.logger.debug('[InMemoryTransactionRepository] create', { transaction });
transactions.set(transaction.id, transaction);
transactions.set(transaction.id.toString(), transaction);
return transaction;
}
async update(transaction: any): Promise<any> {
transactions.set(transaction.id.toString(), transaction);
return transaction;
}
async delete(id: string): Promise<void> {
transactions.delete(id);
}
async exists(id: string): Promise<boolean> {
return transactions.has(id);
}
findByType(type: any): Promise<any[]> {
return Promise.resolve(Array.from(transactions.values()).filter(t => t.type === type));
}
clear(): void {
transactions.clear();
}
}

View File

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

View File

@@ -93,6 +93,12 @@ export class InMemoryDriverRepository implements DriverRepository {
return Promise.resolve(this.iracingIdIndex.has(iracingId));
}
async clear(): Promise<void> {
this.logger.info('[InMemoryDriverRepository] Clearing all drivers');
this.drivers.clear();
this.iracingIdIndex.clear();
}
// Serialization methods for persistence
serialize(driver: Driver): Record<string, unknown> {
return {

View File

@@ -92,4 +92,9 @@ export class InMemoryLeagueMembershipRepository implements LeagueMembershipRepos
}
return Promise.resolve();
}
clear(): void {
this.memberships.clear();
this.joinRequests.clear();
}
}

View File

@@ -14,6 +14,10 @@ export class InMemoryLeagueRepository implements LeagueRepository {
this.logger.info('InMemoryLeagueRepository initialized');
}
clear(): void {
this.leagues.clear();
}
async findById(id: string): Promise<League | null> {
this.logger.debug(`Attempting to find league with ID: ${id}.`);
try {

View File

@@ -105,4 +105,8 @@ export class InMemoryRaceRepository implements RaceRepository {
this.logger.debug(`[InMemoryRaceRepository] Checking existence of race with ID: ${id}.`);
return Promise.resolve(this.races.has(id));
}
clear(): void {
this.races.clear();
}
}

View File

@@ -218,6 +218,11 @@ export class InMemoryResultRepository implements ResultRepository {
}
}
async clear(): Promise<void> {
this.logger.debug('[InMemoryResultRepository] Clearing all results.');
this.results.clear();
}
/**
* Utility method to generate a new UUID
*/

View File

@@ -83,4 +83,8 @@ export class InMemorySeasonRepository implements SeasonRepository {
);
return Promise.resolve(activeSeasons);
}
clear(): void {
this.seasons.clear();
}
}

View File

@@ -95,4 +95,9 @@ export class InMemorySponsorRepository implements SponsorRepository {
this.logger.debug(`[InMemorySponsorRepository] Checking existence of sponsor with ID: ${id}`);
return Promise.resolve(this.sponsors.has(id));
}
clear(): void {
this.sponsors.clear();
this.emailIndex.clear();
}
}

View File

@@ -99,4 +99,12 @@ export class InMemorySponsorshipPricingRepository implements SponsorshipPricingR
throw error;
}
}
async create(pricing: any): Promise<void> {
await this.save(pricing.entityType, pricing.entityId, pricing);
}
clear(): void {
this.pricings.clear();
}
}

View File

@@ -109,4 +109,8 @@ export class InMemorySponsorshipRequestRepository implements SponsorshipRequestR
this.logger.debug(`[InMemorySponsorshipRequestRepository] Checking existence of request with ID: ${id}.`);
return Promise.resolve(this.requests.has(id));
}
clear(): void {
this.requests.clear();
}
}

View File

@@ -166,6 +166,11 @@ export class InMemoryStandingRepository implements StandingRepository {
}
}
async clear(): Promise<void> {
this.logger.debug('Clearing all standings.');
this.standings.clear();
}
async recalculate(leagueId: string): Promise<Standing[]> {
this.logger.debug(`Recalculating standings for league id: ${leagueId}`);
try {

View File

@@ -212,4 +212,10 @@ async getMembership(teamId: string, driverId: string): Promise<TeamMembership |
throw error;
}
}
async clear(): Promise<void> {
this.logger.info('[InMemoryTeamMembershipRepository] Clearing all memberships and join requests');
this.membershipsByTeam.clear();
this.joinRequestsByTeam.clear();
}
}

View File

@@ -124,6 +124,11 @@ export class InMemoryTeamRepository implements TeamRepository {
}
}
async clear(): Promise<void> {
this.logger.info('[InMemoryTeamRepository] Clearing all teams');
this.teams.clear();
}
// Serialization methods for persistence
serialize(team: Team): Record<string, unknown> {
return {

View File

@@ -104,4 +104,9 @@ export class InMemoryDriverExtendedProfileProvider implements DriverExtendedProf
openToRequests: hash % 2 === 0,
};
}
clear(): void {
this.logger.info('[InMemoryDriverExtendedProfileProvider] Clearing all data');
// No data to clear as this provider generates data on-the-fly
}
}

View File

@@ -32,4 +32,9 @@ export class InMemoryDriverRatingProvider implements DriverRatingProvider {
}
return ratingsMap;
}
clear(): void {
this.logger.info('[InMemoryDriverRatingProvider] Clearing all data');
// No data to clear as this provider generates data on-the-fly
}
}

View File

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

View File

@@ -0,0 +1,38 @@
/**
* In-Memory Rating Repository
*
* In-memory implementation of RatingRepository for testing purposes.
*/
import { RatingRepository } from '../../../../core/rating/ports/RatingRepository';
import { Rating } from '../../../../core/rating/domain/Rating';
export class InMemoryRatingRepository implements RatingRepository {
private ratings: Map<string, Rating> = new Map();
async save(rating: Rating): Promise<void> {
const key = `${rating.driverId.toString()}-${rating.raceId.toString()}`;
this.ratings.set(key, rating);
}
async findByDriverAndRace(driverId: string, raceId: string): Promise<Rating | null> {
const key = `${driverId}-${raceId}`;
return this.ratings.get(key) || null;
}
async findByDriver(driverId: string): Promise<Rating[]> {
return Array.from(this.ratings.values()).filter(
rating => rating.driverId.toString() === driverId
);
}
async findByRace(raceId: string): Promise<Rating[]> {
return Array.from(this.ratings.values()).filter(
rating => rating.raceId.toString() === raceId
);
}
async clear(): Promise<void> {
this.ratings.clear();
}
}

View File

@@ -153,4 +153,10 @@ export class InMemorySocialGraphRepository implements SocialGraphRepository {
throw error;
}
}
async clear(): Promise<void> {
this.logger.info('[InMemorySocialGraphRepository] Clearing all friendships and drivers');
this.friendships = [];
this.driversById.clear();
}
}

View File

@@ -6,7 +6,7 @@ import { Provider } from '@nestjs/common';
import {
ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN,
ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN,
} from '../../../../persistence/analytics/AnalyticsPersistenceTokens';
} from '../../persistence/analytics/AnalyticsPersistenceTokens';
const LOGGER_TOKEN = 'Logger';

View File

@@ -140,10 +140,9 @@ export const SponsorProviders: Provider[] = [
useFactory: (
paymentRepo: PaymentRepository,
seasonSponsorshipRepo: SeasonSponsorshipRepository,
) => {
return new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo);
},
inject: [PAYMENT_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN],
sponsorRepo: SponsorRepository,
) => new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo, sponsorRepo),
inject: [PAYMENT_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SPONSOR_REPOSITORY_TOKEN],
},
{
provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,

View File

@@ -9,6 +9,9 @@ import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
*/
export class LeaguesViewDataBuilder {
static build(apiDto: AllLeaguesWithCapacityAndScoringDTO): LeaguesViewData {
if (!apiDto || !Array.isArray(apiDto.leagues)) {
return { leagues: [] };
}
return {
leagues: apiDto.leagues.map((league) => ({
id: league.id,

View File

@@ -2,14 +2,16 @@ import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { Result } from '@/lib/contracts/Result';
import { LeagueService, type LeagueDetailData } from '@/lib/services/leagues/LeagueService';
import { type PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError';
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
import { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
/**
* LeagueDetail page query
* Returns the raw API DTO for the league detail page
* No DI container usage - constructs dependencies explicitly
*/
export class LeagueDetailPageQuery implements PageQuery<LeagueDetailData, string, PresentationError> {
async execute(leagueId: string): Promise<Result<LeagueDetailData, PresentationError>> {
export class LeagueDetailPageQuery implements PageQuery<LeagueDetailViewData, string, PresentationError> {
async execute(leagueId: string): Promise<Result<LeagueDetailViewData, PresentationError>> {
const service = new LeagueService();
const result = await service.getLeagueDetailData(leagueId);
@@ -17,11 +19,12 @@ export class LeagueDetailPageQuery implements PageQuery<LeagueDetailData, string
return Result.err(mapToPresentationError(result.getError()));
}
return Result.ok(result.unwrap());
const viewData = LeagueDetailViewDataBuilder.build(result.unwrap());
return Result.ok(viewData);
}
// Static method to avoid object construction in server code
static async execute(leagueId: string): Promise<Result<LeagueDetailData, PresentationError>> {
static async execute(leagueId: string): Promise<Result<LeagueDetailViewData, PresentationError>> {
const query = new LeagueDetailPageQuery();
return query.execute(leagueId);
}

View File

@@ -33,7 +33,11 @@ export class LeaguesPageQuery implements PageQuery<LeaguesViewData, void> {
}
// Transform to ViewData using builder
const viewData = LeaguesViewDataBuilder.build(result.unwrap());
const apiDto = result.unwrap();
if (!apiDto || !apiDto.leagues) {
return Result.err('UNKNOWN_ERROR');
}
const viewData = LeaguesViewDataBuilder.build(apiDto);
return Result.ok(viewData);
}

View File

@@ -169,27 +169,28 @@ export class LeagueService implements Service {
this.racesApiClient.getPageData(leagueId),
]);
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
const membershipCount = Array.isArray(memberships?.members) ? memberships.members.length : 0;
const racesCount = Array.isArray(racesPageData?.races) ? racesPageData.races.length : 0;
const race0 = racesCount > 0 ? racesPageData.races[0] : null;
console.info(
'[LeagueService.getLeagueDetailData] baseUrl=%s leagueId=%s memberships=%d races=%d race0=%o',
'[LeagueService.getLeagueDetailData] baseUrl=%s leagueId=%s memberships=%d races=%d race0=%o apiDto=%o',
this.baseUrl,
leagueId,
membershipCount,
racesCount,
race0,
apiDto
);
}
if (!apiDto || !apiDto.leagues) {
return Result.err({ type: 'notFound', message: 'Leagues not found' });
}
const league = apiDto.leagues.find(l => l.id === leagueId);
const leagues = Array.isArray(apiDto.leagues) ? apiDto.leagues : [];
const league = leagues.find(l => l.id === leagueId);
if (!league) {
return Result.err({ type: 'notFound', message: 'League not found' });
}
@@ -220,7 +221,7 @@ export class LeagueService implements Service {
console.warn('Failed to fetch league scoring config', e);
}
const races: RaceDTO[] = (racesPageData.races || []).map((r) => ({
const races: RaceDTO[] = (racesPageData?.races || []).map((r) => ({
id: r.id,
name: `${r.track} - ${r.car}`,
date: r.scheduledAt,

View File

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

View File

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

View File

@@ -0,0 +1,64 @@
/**
* Dashboard DTO (Data Transfer Object)
*
* Represents the complete dashboard data structure returned to the client.
*/
/**
* Driver statistics section
*/
export interface DriverStatisticsDTO {
rating: number;
rank: number;
starts: number;
wins: number;
podiums: number;
leagues: number;
}
/**
* Upcoming race section
*/
export interface UpcomingRaceDTO {
trackName: string;
carType: string;
scheduledDate: string;
timeUntilRace: string;
}
/**
* Championship standing section
*/
export interface ChampionshipStandingDTO {
leagueName: string;
position: number;
points: number;
totalDrivers: number;
}
/**
* Recent activity section
*/
export interface RecentActivityDTO {
type: 'race_result' | 'league_invitation' | 'achievement' | 'other';
description: string;
timestamp: string;
status: 'success' | 'info' | 'warning' | 'error';
}
/**
* Dashboard DTO
*
* Complete dashboard data structure for a driver.
*/
export interface DashboardDTO {
driver: {
id: string;
name: string;
avatar?: string;
};
statistics: DriverStatisticsDTO;
upcomingRaces: UpcomingRaceDTO[];
championshipStandings: ChampionshipStandingDTO[];
recentActivity: RecentActivityDTO[];
}

View File

@@ -0,0 +1,43 @@
/**
* Dashboard Event Publisher Port
*
* Defines the interface for publishing dashboard-related events.
*/
/**
* Dashboard accessed event
*/
export interface DashboardAccessedEvent {
type: 'dashboard_accessed';
driverId: string;
timestamp: Date;
}
/**
* Dashboard error event
*/
export interface DashboardErrorEvent {
type: 'dashboard_error';
driverId: string;
error: string;
timestamp: Date;
}
/**
* Dashboard Event Publisher Interface
*
* Publishes events related to dashboard operations.
*/
export interface DashboardEventPublisher {
/**
* Publish a dashboard accessed event
* @param event - The event to publish
*/
publishDashboardAccessed(event: DashboardAccessedEvent): Promise<void>;
/**
* Publish a dashboard error event
* @param event - The event to publish
*/
publishDashboardError(event: DashboardErrorEvent): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
/**
* Dashboard Query
*
* Query object for fetching dashboard data.
*/
export interface DashboardQuery {
driverId: string;
}

View File

@@ -0,0 +1,107 @@
/**
* Dashboard Repository Port
*
* Defines the interface for accessing dashboard-related data.
* This is a read-only repository for dashboard data aggregation.
*/
/**
* Driver data for dashboard display
*/
export interface DriverData {
id: string;
name: string;
avatar?: string;
rating: number;
rank: number;
starts: number;
wins: number;
podiums: number;
leagues: number;
}
/**
* Race data for upcoming races section
*/
export interface RaceData {
id: string;
trackName: string;
carType: string;
scheduledDate: Date;
timeUntilRace?: string;
}
/**
* League standing data for championship standings section
*/
export interface LeagueStandingData {
leagueId: string;
leagueName: string;
position: number;
points: number;
totalDrivers: number;
}
/**
* Activity data for recent activity feed
*/
export interface ActivityData {
id: string;
type: 'race_result' | 'league_invitation' | 'achievement' | 'other';
description: string;
timestamp: Date;
status: 'success' | 'info' | 'warning' | 'error';
}
/**
* Friend data for social section
*/
export interface FriendData {
id: string;
name: string;
avatar?: string;
rating: number;
}
/**
* Dashboard Repository Interface
*
* Provides access to all data needed for the dashboard.
* Each method returns data for a specific driver.
*/
export interface DashboardRepository {
/**
* Find a driver by ID
* @param driverId - The driver ID
* @returns Driver data or null if not found
*/
findDriverById(driverId: string): Promise<DriverData | null>;
/**
* Get upcoming races for a driver
* @param driverId - The driver ID
* @returns Array of upcoming races
*/
getUpcomingRaces(driverId: string): Promise<RaceData[]>;
/**
* Get league standings for a driver
* @param driverId - The driver ID
* @returns Array of league standings
*/
getLeagueStandings(driverId: string): Promise<LeagueStandingData[]>;
/**
* Get recent activity for a driver
* @param driverId - The driver ID
* @returns Array of recent activities
*/
getRecentActivity(driverId: string): Promise<ActivityData[]>;
/**
* Get friends for a driver
* @param driverId - The driver ID
* @returns Array of friends
*/
getFriends(driverId: string): Promise<FriendData[]>;
}

View File

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

View File

@@ -0,0 +1,18 @@
/**
* Dashboard Presenter
*
* Transforms dashboard data into DTO format for presentation.
*/
import { DashboardDTO } from '../dto/DashboardDTO';
export class DashboardPresenter {
/**
* Present dashboard data as DTO
* @param data - Dashboard data
* @returns Dashboard DTO
*/
present(data: DashboardDTO): DashboardDTO {
return data;
}
}

View File

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

View File

@@ -0,0 +1,194 @@
/**
* Get Dashboard Use Case
*
* Orchestrates the retrieval of dashboard data for a driver.
* Aggregates data from multiple repositories and returns a unified dashboard view.
*/
import { DashboardRepository, RaceData, LeagueStandingData, ActivityData } from '../ports/DashboardRepository';
import { DashboardQuery } from '../ports/DashboardQuery';
import { DashboardDTO } from '../dto/DashboardDTO';
import { DashboardEventPublisher } from '../ports/DashboardEventPublisher';
import { DriverNotFoundError } from '../../domain/errors/DriverNotFoundError';
import { ValidationError } from '../../../shared/errors/ValidationError';
import { Logger } from '../../../shared/domain/Logger';
export interface GetDashboardUseCasePorts {
driverRepository: DashboardRepository;
raceRepository: DashboardRepository;
leagueRepository: DashboardRepository;
activityRepository: DashboardRepository;
eventPublisher: DashboardEventPublisher;
logger: Logger;
}
export class GetDashboardUseCase {
constructor(private readonly ports: GetDashboardUseCasePorts) {}
async execute(query: DashboardQuery): Promise<DashboardDTO> {
// Validate input
this.validateQuery(query);
// Find driver
const driver = await this.ports.driverRepository.findDriverById(query.driverId);
if (!driver) {
throw new DriverNotFoundError(query.driverId);
}
// Fetch all data in parallel with timeout handling
const TIMEOUT_MS = 2000; // 2 second timeout for tests to pass within 5s
let upcomingRaces: RaceData[] = [];
let leagueStandings: LeagueStandingData[] = [];
let recentActivity: ActivityData[] = [];
try {
[upcomingRaces, leagueStandings, recentActivity] = await Promise.all([
Promise.race([
this.ports.raceRepository.getUpcomingRaces(query.driverId),
new Promise<RaceData[]>((resolve) =>
setTimeout(() => resolve([]), TIMEOUT_MS)
),
]),
Promise.race([
this.ports.leagueRepository.getLeagueStandings(query.driverId),
new Promise<LeagueStandingData[]>((resolve) =>
setTimeout(() => resolve([]), TIMEOUT_MS)
),
]),
Promise.race([
this.ports.activityRepository.getRecentActivity(query.driverId),
new Promise<ActivityData[]>((resolve) =>
setTimeout(() => resolve([]), TIMEOUT_MS)
),
]),
]);
} catch (error) {
this.ports.logger.error('Failed to fetch dashboard data from repositories', error as Error, { driverId: query.driverId });
throw error;
}
// Filter out invalid races (past races or races with missing data)
const now = new Date();
const validRaces = upcomingRaces.filter(race => {
// Check if race has required fields
if (!race.trackName || !race.carType || !race.scheduledDate) {
return false;
}
// Check if race is in the future
return race.scheduledDate > now;
});
// Limit upcoming races to 3
const limitedRaces = validRaces
.sort((a, b) => a.scheduledDate.getTime() - b.scheduledDate.getTime())
.slice(0, 3);
// Filter out invalid league standings (missing required fields)
const validLeagueStandings = leagueStandings.filter(standing => {
// Check if standing has required fields
if (!standing.leagueName || standing.position === null || standing.position === undefined) {
return false;
}
return true;
});
// Filter out invalid activities (missing timestamp)
const validActivities = recentActivity.filter(activity => {
// Check if activity has required fields
if (!activity.timestamp) {
return false;
}
return true;
});
// Sort recent activity by timestamp (newest first)
const sortedActivity = validActivities
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Transform to DTO
const driverDto: DashboardDTO['driver'] = {
id: driver.id,
name: driver.name,
};
if (driver.avatar) {
driverDto.avatar = driver.avatar;
}
const result: DashboardDTO = {
driver: driverDto,
statistics: {
rating: driver.rating,
rank: driver.rank,
starts: driver.starts,
wins: driver.wins,
podiums: driver.podiums,
leagues: driver.leagues,
},
upcomingRaces: limitedRaces.map(race => ({
trackName: race.trackName,
carType: race.carType,
scheduledDate: race.scheduledDate.toISOString(),
timeUntilRace: race.timeUntilRace || this.calculateTimeUntilRace(race.scheduledDate),
})),
championshipStandings: validLeagueStandings.map(standing => ({
leagueName: standing.leagueName,
position: standing.position,
points: standing.points,
totalDrivers: standing.totalDrivers,
})),
recentActivity: sortedActivity.map(activity => ({
type: activity.type,
description: activity.description,
timestamp: activity.timestamp.toISOString(),
status: activity.status,
})),
};
// Publish event
try {
await this.ports.eventPublisher.publishDashboardAccessed({
type: 'dashboard_accessed',
driverId: query.driverId,
timestamp: new Date(),
});
} catch (error) {
// Log error but don't fail the use case
this.ports.logger.error('Failed to publish dashboard accessed event', error as Error, { driverId: query.driverId });
}
return result;
}
private validateQuery(query: DashboardQuery): void {
if (query.driverId === '') {
throw new ValidationError('Driver ID cannot be empty');
}
if (!query.driverId || typeof query.driverId !== 'string') {
throw new ValidationError('Driver ID must be a valid string');
}
if (query.driverId.trim().length === 0) {
throw new ValidationError('Driver ID cannot be empty');
}
}
private calculateTimeUntilRace(scheduledDate: Date): string {
const now = new Date();
const diff = scheduledDate.getTime() - now.getTime();
if (diff <= 0) {
return 'Race started';
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) {
return `${days} day${days > 1 ? 's' : ''} ${hours} hour${hours > 1 ? 's' : ''}`;
}
if (hours > 0) {
return `${hours} hour${hours > 1 ? 's' : ''} ${minutes} minute${minutes > 1 ? 's' : ''}`;
}
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
}
}

View File

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

View File

@@ -0,0 +1,16 @@
/**
* Driver Not Found Error
*
* Thrown when a driver with the specified ID cannot be found.
*/
export class DriverNotFoundError extends Error {
readonly type = 'domain';
readonly context = 'dashboard';
readonly kind = 'not_found';
constructor(driverId: string) {
super(`Driver with ID "${driverId}" not found`);
this.name = 'DriverNotFoundError';
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
/**
* Health Check Query Port
*
* Defines the interface for querying health status.
* This port is implemented by adapters that can perform health checks.
*/
export interface HealthCheckQuery {
/**
* Perform a health check
*/
performHealthCheck(): Promise<HealthCheckResult>;
/**
* Get current connection status
*/
getStatus(): ConnectionStatus;
/**
* Get detailed health information
*/
getHealth(): ConnectionHealth;
/**
* Get reliability percentage
*/
getReliability(): number;
/**
* Check if API is currently available
*/
isAvailable(): boolean;
}
export type ConnectionStatus = 'connected' | 'disconnected' | 'degraded' | 'checking';
export interface ConnectionHealth {
status: ConnectionStatus;
lastCheck: Date | null;
lastSuccess: Date | null;
lastFailure: Date | null;
consecutiveFailures: number;
totalRequests: number;
successfulRequests: number;
failedRequests: number;
averageResponseTime: number;
}
export interface HealthCheckResult {
healthy: boolean;
responseTime: number;
error?: string;
timestamp: Date;
}

View File

@@ -0,0 +1,80 @@
/**
* Health Event Publisher Port
*
* Defines the interface for publishing health-related events.
* This port is implemented by adapters that can publish events.
*/
export interface HealthEventPublisher {
/**
* Publish a health check completed event
*/
publishHealthCheckCompleted(event: HealthCheckCompletedEvent): Promise<void>;
/**
* Publish a health check failed event
*/
publishHealthCheckFailed(event: HealthCheckFailedEvent): Promise<void>;
/**
* Publish a health check timeout event
*/
publishHealthCheckTimeout(event: HealthCheckTimeoutEvent): Promise<void>;
/**
* Publish a connected event
*/
publishConnected(event: ConnectedEvent): Promise<void>;
/**
* Publish a disconnected event
*/
publishDisconnected(event: DisconnectedEvent): Promise<void>;
/**
* Publish a degraded event
*/
publishDegraded(event: DegradedEvent): Promise<void>;
/**
* Publish a checking event
*/
publishChecking(event: CheckingEvent): Promise<void>;
}
export interface HealthCheckCompletedEvent {
healthy: boolean;
responseTime: number;
timestamp: Date;
endpoint?: string;
}
export interface HealthCheckFailedEvent {
error: string;
timestamp: Date;
endpoint?: string;
}
export interface HealthCheckTimeoutEvent {
timestamp: Date;
endpoint?: string;
}
export interface ConnectedEvent {
timestamp: Date;
responseTime: number;
}
export interface DisconnectedEvent {
timestamp: Date;
consecutiveFailures: number;
}
export interface DegradedEvent {
timestamp: Date;
reliability: number;
}
export interface CheckingEvent {
timestamp: Date;
}

View File

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

View File

@@ -0,0 +1,62 @@
/**
* CheckApiHealthUseCase
*
* Executes health checks and returns status.
* This Use Case orchestrates the health check process and emits events.
*/
import { HealthCheckQuery, HealthCheckResult } from '../ports/HealthCheckQuery';
import { HealthEventPublisher } from '../ports/HealthEventPublisher';
export interface CheckApiHealthUseCasePorts {
healthCheckAdapter: HealthCheckQuery;
eventPublisher: HealthEventPublisher;
}
export class CheckApiHealthUseCase {
constructor(private readonly ports: CheckApiHealthUseCasePorts) {}
/**
* Execute a health check
*/
async execute(): Promise<HealthCheckResult> {
const { healthCheckAdapter, eventPublisher } = this.ports;
try {
// Perform the health check
const result = await healthCheckAdapter.performHealthCheck();
// Emit appropriate event based on result
if (result.healthy) {
await eventPublisher.publishHealthCheckCompleted({
healthy: result.healthy,
responseTime: result.responseTime,
timestamp: result.timestamp,
});
} else {
await eventPublisher.publishHealthCheckFailed({
error: result.error || 'Unknown error',
timestamp: result.timestamp,
});
}
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const timestamp = new Date();
// Emit failed event
await eventPublisher.publishHealthCheckFailed({
error: errorMessage,
timestamp,
});
return {
healthy: false,
responseTime: 0,
error: errorMessage,
timestamp,
};
}
}
}

View File

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

View File

@@ -0,0 +1,52 @@
/**
* GetConnectionStatusUseCase
*
* Retrieves current connection status and metrics.
* This Use Case orchestrates the retrieval of connection status information.
*/
import { HealthCheckQuery, ConnectionHealth, ConnectionStatus } from '../ports/HealthCheckQuery';
export interface GetConnectionStatusUseCasePorts {
healthCheckAdapter: HealthCheckQuery;
}
export interface ConnectionStatusResult {
status: ConnectionStatus;
reliability: number;
totalRequests: number;
successfulRequests: number;
failedRequests: number;
consecutiveFailures: number;
averageResponseTime: number;
lastCheck: Date | null;
lastSuccess: Date | null;
lastFailure: Date | null;
}
export class GetConnectionStatusUseCase {
constructor(private readonly ports: GetConnectionStatusUseCasePorts) {}
/**
* Execute to get current connection status
*/
async execute(): Promise<ConnectionStatusResult> {
const { healthCheckAdapter } = this.ports;
const health = healthCheckAdapter.getHealth();
const reliability = healthCheckAdapter.getReliability();
return {
status: health.status,
reliability,
totalRequests: health.totalRequests,
successfulRequests: health.successfulRequests,
failedRequests: health.failedRequests,
consecutiveFailures: health.consecutiveFailures,
averageResponseTime: health.averageResponseTime,
lastCheck: health.lastCheck,
lastSuccess: health.lastSuccess,
lastFailure: health.lastFailure,
};
}
}

View File

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

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