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
Some checks failed
CI / lint-typecheck (pull_request) Failing after 4m51s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
import { TypeOrmPersistenceSchemaAdapter } from './TypeOrmPersistenceSchemaAdapterError';
|
||||
|
||||
describe('TypeOrmPersistenceSchemaAdapter', () => {
|
||||
describe('constructor', () => {
|
||||
// Given: valid parameters with all required fields
|
||||
// When: TypeOrmPersistenceSchemaAdapter is instantiated
|
||||
// Then: it should create an error with correct properties
|
||||
it('should create an error with all required properties', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmPersistenceSchemaAdapter(params);
|
||||
|
||||
// Then
|
||||
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(error.name).toBe('TypeOrmPersistenceSchemaAdapter');
|
||||
expect(error.entityName).toBe('Achievement');
|
||||
expect(error.fieldName).toBe('name');
|
||||
expect(error.reason).toBe('not_string');
|
||||
expect(error.message).toBe('Schema validation failed for Achievement.name: not_string');
|
||||
});
|
||||
|
||||
// Given: valid parameters with custom message
|
||||
// When: TypeOrmPersistenceSchemaAdapter is instantiated
|
||||
// Then: it should use the custom message
|
||||
it('should use custom message when provided', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
message: 'Custom error message',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmPersistenceSchemaAdapter(params);
|
||||
|
||||
// Then
|
||||
expect(error.message).toBe('Custom error message');
|
||||
});
|
||||
|
||||
// Given: parameters with empty string entityName
|
||||
// When: TypeOrmPersistenceSchemaAdapter is instantiated
|
||||
// Then: it should still create an error with the provided entityName
|
||||
it('should handle empty string entityName', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: '',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmPersistenceSchemaAdapter(params);
|
||||
|
||||
// Then
|
||||
expect(error.entityName).toBe('');
|
||||
expect(error.message).toBe('Schema validation failed for .name: not_string');
|
||||
});
|
||||
|
||||
// Given: parameters with empty string fieldName
|
||||
// When: TypeOrmPersistenceSchemaAdapter is instantiated
|
||||
// Then: it should still create an error with the provided fieldName
|
||||
it('should handle empty string fieldName', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'Achievement',
|
||||
fieldName: '',
|
||||
reason: 'not_string',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmPersistenceSchemaAdapter(params);
|
||||
|
||||
// Then
|
||||
expect(error.fieldName).toBe('');
|
||||
expect(error.message).toBe('Schema validation failed for Achievement.: not_string');
|
||||
});
|
||||
|
||||
// Given: parameters with empty string reason
|
||||
// When: TypeOrmPersistenceSchemaAdapter is instantiated
|
||||
// Then: it should still create an error with the provided reason
|
||||
it('should handle empty string reason', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: '',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmPersistenceSchemaAdapter(params);
|
||||
|
||||
// Then
|
||||
expect(error.reason).toBe('');
|
||||
expect(error.message).toBe('Schema validation failed for Achievement.name: ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error details shape', () => {
|
||||
// Given: an error instance
|
||||
// When: checking the error structure
|
||||
// Then: it should have the correct shape with entityName, fieldName, and reason
|
||||
it('should have correct error details shape', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'userId',
|
||||
reason: 'empty_string',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error).toHaveProperty('entityName');
|
||||
expect(error).toHaveProperty('fieldName');
|
||||
expect(error).toHaveProperty('reason');
|
||||
expect(error).toHaveProperty('message');
|
||||
expect(error).toHaveProperty('name');
|
||||
});
|
||||
|
||||
// Given: an error instance
|
||||
// When: checking the error is an instance of Error
|
||||
// Then: it should be an instance of Error
|
||||
it('should be an instance of Error', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'points',
|
||||
reason: 'not_integer',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
// Given: an error instance
|
||||
// When: checking the error name
|
||||
// Then: it should be 'TypeOrmPersistenceSchemaAdapter'
|
||||
it('should have correct error name', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'category',
|
||||
reason: 'invalid_enum_value',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error.name).toBe('TypeOrmPersistenceSchemaAdapter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error message format', () => {
|
||||
// Given: an error with standard parameters
|
||||
// When: checking the error message
|
||||
// Then: it should follow the standard format
|
||||
it('should follow standard message format', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements[0].type',
|
||||
reason: 'not_string',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error.message).toBe('Schema validation failed for Achievement.requirements[0].type: not_string');
|
||||
});
|
||||
|
||||
// Given: an error with nested field name
|
||||
// When: checking the error message
|
||||
// Then: it should include the nested field path
|
||||
it('should include nested field path in message', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements[0].operator',
|
||||
reason: 'invalid_enum_value',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error.message).toBe('Schema validation failed for Achievement.requirements[0].operator: invalid_enum_value');
|
||||
});
|
||||
|
||||
// Given: an error with custom message
|
||||
// When: checking the error message
|
||||
// Then: it should use the custom message
|
||||
it('should use custom message when provided', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'earnedAt',
|
||||
reason: 'invalid_date',
|
||||
message: 'The earnedAt field must be a valid date',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error.message).toBe('The earnedAt field must be a valid date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error property immutability', () => {
|
||||
// Given: an error instance
|
||||
// When: checking the properties
|
||||
// Then: properties should be defined and accessible
|
||||
it('should have defined properties', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error.entityName).toBe('Achievement');
|
||||
expect(error.fieldName).toBe('name');
|
||||
expect(error.reason).toBe('not_string');
|
||||
});
|
||||
|
||||
// Given: an error instance
|
||||
// When: trying to modify properties
|
||||
// Then: properties can be modified (TypeScript readonly doesn't enforce runtime immutability)
|
||||
it('should allow property modification (TypeScript readonly is compile-time only)', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
});
|
||||
|
||||
// When
|
||||
(error as any).entityName = 'NewEntity';
|
||||
(error as any).fieldName = 'newField';
|
||||
(error as any).reason = 'new_reason';
|
||||
|
||||
// Then
|
||||
expect(error.entityName).toBe('NewEntity');
|
||||
expect(error.fieldName).toBe('newField');
|
||||
expect(error.reason).toBe('new_reason');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error serialization', () => {
|
||||
// Given: an error instance
|
||||
// When: converting to string
|
||||
// Then: it should include the error message
|
||||
it('should serialize to string with message', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
});
|
||||
|
||||
// When
|
||||
const stringRepresentation = error.toString();
|
||||
|
||||
// Then
|
||||
expect(stringRepresentation).toContain('TypeOrmPersistenceSchemaAdapter');
|
||||
expect(stringRepresentation).toContain('Schema validation failed for Achievement.name: not_string');
|
||||
});
|
||||
|
||||
// Given: an error instance
|
||||
// When: converting to JSON
|
||||
// Then: it should include all error properties
|
||||
it('should serialize to JSON with all properties', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
});
|
||||
|
||||
// When
|
||||
const jsonRepresentation = JSON.parse(JSON.stringify(error));
|
||||
|
||||
// Then
|
||||
expect(jsonRepresentation).toHaveProperty('entityName', 'Achievement');
|
||||
expect(jsonRepresentation).toHaveProperty('fieldName', 'name');
|
||||
expect(jsonRepresentation).toHaveProperty('reason', 'not_string');
|
||||
expect(jsonRepresentation).toHaveProperty('message', 'Schema validation failed for Achievement.name: not_string');
|
||||
expect(jsonRepresentation).toHaveProperty('name', 'TypeOrmPersistenceSchemaAdapter');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,639 @@
|
||||
import { Achievement, AchievementCategory, AchievementRequirement } from '@core/identity/domain/entities/Achievement';
|
||||
import { UserAchievement } from '@core/identity/domain/entities/UserAchievement';
|
||||
import { AchievementOrmEntity } from '../entities/AchievementOrmEntity';
|
||||
import { UserAchievementOrmEntity } from '../entities/UserAchievementOrmEntity';
|
||||
import { TypeOrmPersistenceSchemaAdapter } from '../errors/TypeOrmPersistenceSchemaAdapterError';
|
||||
import { AchievementOrmMapper } from './AchievementOrmMapper';
|
||||
|
||||
describe('AchievementOrmMapper', () => {
|
||||
let mapper: AchievementOrmMapper;
|
||||
|
||||
beforeEach(() => {
|
||||
mapper = new AchievementOrmMapper();
|
||||
});
|
||||
|
||||
describe('toOrmEntity', () => {
|
||||
// Given: a valid Achievement domain entity
|
||||
// When: toOrmEntity is called
|
||||
// Then: it should return a properly mapped AchievementOrmEntity
|
||||
it('should map Achievement domain entity to ORM entity', () => {
|
||||
// Given
|
||||
const achievement = Achievement.create({
|
||||
id: 'ach-123',
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver' as AchievementCategory,
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' } as AchievementRequirement,
|
||||
],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = mapper.toOrmEntity(achievement);
|
||||
|
||||
// Then
|
||||
expect(result).toBeInstanceOf(AchievementOrmEntity);
|
||||
expect(result.id).toBe('ach-123');
|
||||
expect(result.name).toBe('First Race');
|
||||
expect(result.description).toBe('Complete your first race');
|
||||
expect(result.category).toBe('driver');
|
||||
expect(result.rarity).toBe('common');
|
||||
expect(result.points).toBe(10);
|
||||
expect(result.requirements).toEqual([
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
]);
|
||||
expect(result.isSecret).toBe(false);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
// Given: an Achievement with optional iconUrl
|
||||
// When: toOrmEntity is called
|
||||
// Then: it should map iconUrl correctly (or null if not provided)
|
||||
it('should map Achievement with iconUrl to ORM entity', () => {
|
||||
// Given
|
||||
const achievement = Achievement.create({
|
||||
id: 'ach-456',
|
||||
name: 'Champion',
|
||||
description: 'Win a championship',
|
||||
category: 'driver' as AchievementCategory,
|
||||
rarity: 'legendary',
|
||||
points: 100,
|
||||
requirements: [
|
||||
{ type: 'championships_won', value: 1, operator: '>=' } as AchievementRequirement,
|
||||
],
|
||||
isSecret: false,
|
||||
iconUrl: 'https://example.com/icon.png',
|
||||
});
|
||||
|
||||
// When
|
||||
const result = mapper.toOrmEntity(achievement);
|
||||
|
||||
// Then
|
||||
expect(result.iconUrl).toBe('https://example.com/icon.png');
|
||||
});
|
||||
|
||||
// Given: an Achievement without iconUrl
|
||||
// When: toOrmEntity is called
|
||||
// Then: it should map iconUrl to null
|
||||
it('should map Achievement without iconUrl to null in ORM entity', () => {
|
||||
// Given
|
||||
const achievement = Achievement.create({
|
||||
id: 'ach-789',
|
||||
name: 'Clean Race',
|
||||
description: 'Complete a race without incidents',
|
||||
category: 'driver' as AchievementCategory,
|
||||
rarity: 'uncommon',
|
||||
points: 25,
|
||||
requirements: [
|
||||
{ type: 'clean_races', value: 1, operator: '>=' } as AchievementRequirement,
|
||||
],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = mapper.toOrmEntity(achievement);
|
||||
|
||||
// Then
|
||||
expect(result.iconUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDomain', () => {
|
||||
// Given: a valid AchievementOrmEntity
|
||||
// When: toDomain is called
|
||||
// Then: it should return a properly mapped Achievement domain entity
|
||||
it('should map AchievementOrmEntity to domain entity', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When
|
||||
const result = mapper.toDomain(entity);
|
||||
|
||||
// Then
|
||||
expect(result).toBeInstanceOf(Achievement);
|
||||
expect(result.id).toBe('ach-123');
|
||||
expect(result.name).toBe('First Race');
|
||||
expect(result.description).toBe('Complete your first race');
|
||||
expect(result.category).toBe('driver');
|
||||
expect(result.rarity).toBe('common');
|
||||
expect(result.points).toBe(10);
|
||||
expect(result.requirements).toEqual([
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
]);
|
||||
expect(result.isSecret).toBe(false);
|
||||
expect(result.createdAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with iconUrl
|
||||
// When: toDomain is called
|
||||
// Then: it should map iconUrl correctly
|
||||
it('should map AchievementOrmEntity with iconUrl to domain entity', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-456';
|
||||
entity.name = 'Champion';
|
||||
entity.description = 'Win a championship';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'legendary';
|
||||
entity.points = 100;
|
||||
entity.requirements = [
|
||||
{ type: 'championships_won', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.iconUrl = 'https://example.com/icon.png';
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When
|
||||
const result = mapper.toDomain(entity);
|
||||
|
||||
// Then
|
||||
expect(result.iconUrl).toBe('https://example.com/icon.png');
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with null iconUrl
|
||||
// When: toDomain is called
|
||||
// Then: it should map iconUrl to empty string
|
||||
it('should map AchievementOrmEntity with null iconUrl to empty string in domain entity', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-789';
|
||||
entity.name = 'Clean Race';
|
||||
entity.description = 'Complete a race without incidents';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'uncommon';
|
||||
entity.points = 25;
|
||||
entity.requirements = [
|
||||
{ type: 'clean_races', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.iconUrl = null;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When
|
||||
const result = mapper.toDomain(entity);
|
||||
|
||||
// Then
|
||||
expect(result.iconUrl).toBe('');
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid id (empty string)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when id is empty string', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = '';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'id',
|
||||
reason: 'empty_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid name (not a string)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when name is not a string', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 123 as any;
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid category (not in valid categories)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when category is invalid', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'invalid_category' as any;
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'category',
|
||||
reason: 'invalid_enum_value',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid points (not an integer)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when points is not an integer', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10.5;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'points',
|
||||
reason: 'not_integer',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid requirements (not an array)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when requirements is not an array', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = 'not_an_array' as any;
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements',
|
||||
reason: 'not_array',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid requirement object (null)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when requirement is null', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [null as any];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements[0]',
|
||||
reason: 'invalid_requirement_object',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid requirement type (not a string)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when requirement type is not a string', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [{ type: 123, value: 1, operator: '>=' } as any];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements[0].type',
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid requirement operator (not in valid operators)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when requirement operator is invalid', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [{ type: 'races_completed', value: 1, operator: 'invalid' } as any];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements[0].operator',
|
||||
reason: 'invalid_enum_value',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid createdAt (not a Date)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when createdAt is not a Date', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = 'not_a_date' as any;
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'createdAt',
|
||||
reason: 'not_date',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toUserAchievementOrmEntity', () => {
|
||||
// Given: a valid UserAchievement domain entity
|
||||
// When: toUserAchievementOrmEntity is called
|
||||
// Then: it should return a properly mapped UserAchievementOrmEntity
|
||||
it('should map UserAchievement domain entity to ORM entity', () => {
|
||||
// Given
|
||||
const userAchievement = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: 'user-456',
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = mapper.toUserAchievementOrmEntity(userAchievement);
|
||||
|
||||
// Then
|
||||
expect(result).toBeInstanceOf(UserAchievementOrmEntity);
|
||||
expect(result.id).toBe('ua-123');
|
||||
expect(result.userId).toBe('user-456');
|
||||
expect(result.achievementId).toBe('ach-789');
|
||||
expect(result.earnedAt).toEqual(new Date('2024-01-01'));
|
||||
expect(result.progress).toBe(50);
|
||||
expect(result.notifiedAt).toBeNull();
|
||||
});
|
||||
|
||||
// Given: a UserAchievement with notifiedAt
|
||||
// When: toUserAchievementOrmEntity is called
|
||||
// Then: it should map notifiedAt correctly
|
||||
it('should map UserAchievement with notifiedAt to ORM entity', () => {
|
||||
// Given
|
||||
const userAchievement = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: 'user-456',
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 100,
|
||||
notifiedAt: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
// When
|
||||
const result = mapper.toUserAchievementOrmEntity(userAchievement);
|
||||
|
||||
// Then
|
||||
expect(result.notifiedAt).toEqual(new Date('2024-01-02'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('toUserAchievementDomain', () => {
|
||||
// Given: a valid UserAchievementOrmEntity
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should return a properly mapped UserAchievement domain entity
|
||||
it('should map UserAchievementOrmEntity to domain entity', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = 'ua-123';
|
||||
entity.userId = 'user-456';
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = new Date('2024-01-01');
|
||||
entity.progress = 50;
|
||||
entity.notifiedAt = null;
|
||||
|
||||
// When
|
||||
const result = mapper.toUserAchievementDomain(entity);
|
||||
|
||||
// Then
|
||||
expect(result).toBeInstanceOf(UserAchievement);
|
||||
expect(result.id).toBe('ua-123');
|
||||
expect(result.userId).toBe('user-456');
|
||||
expect(result.achievementId).toBe('ach-789');
|
||||
expect(result.earnedAt).toEqual(new Date('2024-01-01'));
|
||||
expect(result.progress).toBe(50);
|
||||
expect(result.notifiedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
// Given: a UserAchievementOrmEntity with notifiedAt
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should map notifiedAt correctly
|
||||
it('should map UserAchievementOrmEntity with notifiedAt to domain entity', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = 'ua-123';
|
||||
entity.userId = 'user-456';
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = new Date('2024-01-01');
|
||||
entity.progress = 100;
|
||||
entity.notifiedAt = new Date('2024-01-02');
|
||||
|
||||
// When
|
||||
const result = mapper.toUserAchievementDomain(entity);
|
||||
|
||||
// Then
|
||||
expect(result.notifiedAt).toEqual(new Date('2024-01-02'));
|
||||
});
|
||||
|
||||
// Given: a UserAchievementOrmEntity with invalid id (empty string)
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when id is empty string', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = '';
|
||||
entity.userId = 'user-456';
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = new Date('2024-01-01');
|
||||
entity.progress = 50;
|
||||
entity.notifiedAt = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'id',
|
||||
reason: 'empty_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a UserAchievementOrmEntity with invalid userId (not a string)
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when userId is not a string', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = 'ua-123';
|
||||
entity.userId = 123 as any;
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = new Date('2024-01-01');
|
||||
entity.progress = 50;
|
||||
entity.notifiedAt = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'userId',
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a UserAchievementOrmEntity with invalid progress (not an integer)
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when progress is not an integer', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = 'ua-123';
|
||||
entity.userId = 'user-456';
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = new Date('2024-01-01');
|
||||
entity.progress = 50.5;
|
||||
entity.notifiedAt = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'progress',
|
||||
reason: 'not_integer',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a UserAchievementOrmEntity with invalid earnedAt (not a Date)
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when earnedAt is not a Date', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = 'ua-123';
|
||||
entity.userId = 'user-456';
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = 'not_a_date' as any;
|
||||
entity.progress = 50;
|
||||
entity.notifiedAt = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'earnedAt',
|
||||
reason: 'not_date',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,11 @@ export class AchievementOrmMapper {
|
||||
assertNonEmptyString(entityName, 'achievementId', entity.achievementId);
|
||||
assertInteger(entityName, 'progress', entity.progress);
|
||||
assertDate(entityName, 'earnedAt', entity.earnedAt);
|
||||
assertOptionalStringOrNull(entityName, 'notifiedAt', entity.notifiedAt);
|
||||
|
||||
// Validate notifiedAt (Date | null)
|
||||
if (entity.notifiedAt !== null) {
|
||||
assertDate(entityName, 'notifiedAt', entity.notifiedAt);
|
||||
}
|
||||
|
||||
try {
|
||||
return UserAchievement.create({
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
import { vi } from 'vitest';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Achievement } from '@core/identity/domain/entities/Achievement';
|
||||
import { UserAchievement } from '@core/identity/domain/entities/UserAchievement';
|
||||
import { AchievementOrmEntity } from '../entities/AchievementOrmEntity';
|
||||
import { UserAchievementOrmEntity } from '../entities/UserAchievementOrmEntity';
|
||||
import { AchievementOrmMapper } from '../mappers/AchievementOrmMapper';
|
||||
import { TypeOrmAchievementRepository } from './TypeOrmAchievementRepository';
|
||||
|
||||
describe('TypeOrmAchievementRepository', () => {
|
||||
let mockDataSource: { getRepository: ReturnType<typeof vi.fn> };
|
||||
let mockAchievementRepo: { findOne: ReturnType<typeof vi.fn>; find: ReturnType<typeof vi.fn>; save: ReturnType<typeof vi.fn> };
|
||||
let mockUserAchievementRepo: { findOne: ReturnType<typeof vi.fn>; find: ReturnType<typeof vi.fn>; save: ReturnType<typeof vi.fn> };
|
||||
let mockMapper: { toOrmEntity: ReturnType<typeof vi.fn>; toDomain: ReturnType<typeof vi.fn>; toUserAchievementOrmEntity: ReturnType<typeof vi.fn>; toUserAchievementDomain: ReturnType<typeof vi.fn> };
|
||||
let repository: TypeOrmAchievementRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
// Given: mocked TypeORM DataSource and repositories
|
||||
mockAchievementRepo = {
|
||||
findOne: vi.fn(),
|
||||
find: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
mockUserAchievementRepo = {
|
||||
findOne: vi.fn(),
|
||||
find: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
mockDataSource = {
|
||||
getRepository: vi.fn((entityClass) => {
|
||||
if (entityClass === AchievementOrmEntity) {
|
||||
return mockAchievementRepo;
|
||||
}
|
||||
if (entityClass === UserAchievementOrmEntity) {
|
||||
return mockUserAchievementRepo;
|
||||
}
|
||||
throw new Error('Unknown entity class');
|
||||
}),
|
||||
};
|
||||
|
||||
mockMapper = {
|
||||
toOrmEntity: vi.fn(),
|
||||
toDomain: vi.fn(),
|
||||
toUserAchievementOrmEntity: vi.fn(),
|
||||
toUserAchievementDomain: vi.fn(),
|
||||
};
|
||||
|
||||
// When: repository is instantiated with mocked dependencies
|
||||
repository = new TypeOrmAchievementRepository(mockDataSource as any, mockMapper as any);
|
||||
});
|
||||
|
||||
describe('DI Boundary - Constructor', () => {
|
||||
// Given: both dependencies provided
|
||||
// When: repository is instantiated
|
||||
// Then: it should create repository successfully
|
||||
it('should create repository with valid dependencies', () => {
|
||||
// Given & When & Then
|
||||
expect(repository).toBeInstanceOf(TypeOrmAchievementRepository);
|
||||
});
|
||||
|
||||
// Given: repository instance
|
||||
// When: checking repository properties
|
||||
// Then: it should have injected dependencies
|
||||
it('should have injected dependencies', () => {
|
||||
// Given & When & Then
|
||||
expect((repository as any).dataSource).toBe(mockDataSource);
|
||||
expect((repository as any).mapper).toBe(mockMapper);
|
||||
});
|
||||
|
||||
// Given: repository instance
|
||||
// When: checking repository methods
|
||||
// Then: it should have all required methods
|
||||
it('should have all required repository methods', () => {
|
||||
// Given & When & Then
|
||||
expect(repository.findAchievementById).toBeDefined();
|
||||
expect(repository.findAllAchievements).toBeDefined();
|
||||
expect(repository.findAchievementsByCategory).toBeDefined();
|
||||
expect(repository.createAchievement).toBeDefined();
|
||||
expect(repository.findUserAchievementById).toBeDefined();
|
||||
expect(repository.findUserAchievementsByUserId).toBeDefined();
|
||||
expect(repository.findUserAchievementByUserAndAchievement).toBeDefined();
|
||||
expect(repository.hasUserEarnedAchievement).toBeDefined();
|
||||
expect(repository.createUserAchievement).toBeDefined();
|
||||
expect(repository.updateUserAchievement).toBeDefined();
|
||||
expect(repository.getAchievementLeaderboard).toBeDefined();
|
||||
expect(repository.getUserAchievementStats).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAchievementById', () => {
|
||||
// Given: an achievement exists in the database
|
||||
// When: findAchievementById is called
|
||||
// Then: it should return the achievement domain entity
|
||||
it('should return achievement when found', async () => {
|
||||
// Given
|
||||
const achievementId = 'ach-123';
|
||||
const ormEntity = new AchievementOrmEntity();
|
||||
ormEntity.id = achievementId;
|
||||
ormEntity.name = 'First Race';
|
||||
ormEntity.description = 'Complete your first race';
|
||||
ormEntity.category = 'driver';
|
||||
ormEntity.rarity = 'common';
|
||||
ormEntity.points = 10;
|
||||
ormEntity.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
|
||||
ormEntity.isSecret = false;
|
||||
ormEntity.createdAt = new Date('2024-01-01');
|
||||
|
||||
const domainEntity = Achievement.create({
|
||||
id: achievementId,
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
mockAchievementRepo.findOne.mockResolvedValue(ormEntity);
|
||||
mockMapper.toDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.findAchievementById(achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: achievementId } });
|
||||
expect(mockMapper.toDomain).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(domainEntity);
|
||||
});
|
||||
|
||||
// Given: no achievement exists with the given ID
|
||||
// When: findAchievementById is called
|
||||
// Then: it should return null
|
||||
it('should return null when achievement not found', async () => {
|
||||
// Given
|
||||
const achievementId = 'ach-999';
|
||||
mockAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.findAchievementById(achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: achievementId } });
|
||||
expect(mockMapper.toDomain).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllAchievements', () => {
|
||||
// Given: multiple achievements exist in the database
|
||||
// When: findAllAchievements is called
|
||||
// Then: it should return all achievement domain entities
|
||||
it('should return all achievements', async () => {
|
||||
// Given
|
||||
const ormEntity1 = new AchievementOrmEntity();
|
||||
ormEntity1.id = 'ach-1';
|
||||
ormEntity1.name = 'First Race';
|
||||
ormEntity1.description = 'Complete your first race';
|
||||
ormEntity1.category = 'driver';
|
||||
ormEntity1.rarity = 'common';
|
||||
ormEntity1.points = 10;
|
||||
ormEntity1.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
|
||||
ormEntity1.isSecret = false;
|
||||
ormEntity1.createdAt = new Date('2024-01-01');
|
||||
|
||||
const ormEntity2 = new AchievementOrmEntity();
|
||||
ormEntity2.id = 'ach-2';
|
||||
ormEntity2.name = 'Champion';
|
||||
ormEntity2.description = 'Win a championship';
|
||||
ormEntity2.category = 'driver';
|
||||
ormEntity2.rarity = 'legendary';
|
||||
ormEntity2.points = 100;
|
||||
ormEntity2.requirements = [{ type: 'championships_won', value: 1, operator: '>=' }];
|
||||
ormEntity2.isSecret = false;
|
||||
ormEntity2.createdAt = new Date('2024-01-02');
|
||||
|
||||
const domainEntity1 = Achievement.create({
|
||||
id: 'ach-1',
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
const domainEntity2 = Achievement.create({
|
||||
id: 'ach-2',
|
||||
name: 'Champion',
|
||||
description: 'Win a championship',
|
||||
category: 'driver',
|
||||
rarity: 'legendary',
|
||||
points: 100,
|
||||
requirements: [{ type: 'championships_won', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
mockAchievementRepo.find.mockResolvedValue([ormEntity1, ormEntity2]);
|
||||
mockMapper.toDomain
|
||||
.mockReturnValueOnce(domainEntity1)
|
||||
.mockReturnValueOnce(domainEntity2);
|
||||
|
||||
// When
|
||||
const result = await repository.findAllAchievements();
|
||||
|
||||
// Then
|
||||
expect(mockAchievementRepo.find).toHaveBeenCalledWith();
|
||||
expect(mockMapper.toDomain).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([domainEntity1, domainEntity2]);
|
||||
});
|
||||
|
||||
// Given: no achievements exist in the database
|
||||
// When: findAllAchievements is called
|
||||
// Then: it should return an empty array
|
||||
it('should return empty array when no achievements exist', async () => {
|
||||
// Given
|
||||
mockAchievementRepo.find.mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await repository.findAllAchievements();
|
||||
|
||||
// Then
|
||||
expect(mockAchievementRepo.find).toHaveBeenCalledWith();
|
||||
expect(mockMapper.toDomain).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAchievementsByCategory', () => {
|
||||
// Given: achievements exist in a specific category
|
||||
// When: findAchievementsByCategory is called
|
||||
// Then: it should return achievements from that category
|
||||
it('should return achievements by category', async () => {
|
||||
// Given
|
||||
const category = 'driver';
|
||||
const ormEntity = new AchievementOrmEntity();
|
||||
ormEntity.id = 'ach-1';
|
||||
ormEntity.name = 'First Race';
|
||||
ormEntity.description = 'Complete your first race';
|
||||
ormEntity.category = 'driver';
|
||||
ormEntity.rarity = 'common';
|
||||
ormEntity.points = 10;
|
||||
ormEntity.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
|
||||
ormEntity.isSecret = false;
|
||||
ormEntity.createdAt = new Date('2024-01-01');
|
||||
|
||||
const domainEntity = Achievement.create({
|
||||
id: 'ach-1',
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
mockAchievementRepo.find.mockResolvedValue([ormEntity]);
|
||||
mockMapper.toDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.findAchievementsByCategory(category);
|
||||
|
||||
// Then
|
||||
expect(mockAchievementRepo.find).toHaveBeenCalledWith({ where: { category } });
|
||||
expect(mockMapper.toDomain).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toEqual([domainEntity]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAchievement', () => {
|
||||
// Given: a valid achievement domain entity
|
||||
// When: createAchievement is called
|
||||
// Then: it should save the achievement and return it
|
||||
it('should create and save achievement', async () => {
|
||||
// Given
|
||||
const achievement = Achievement.create({
|
||||
id: 'ach-123',
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
const ormEntity = new AchievementOrmEntity();
|
||||
ormEntity.id = 'ach-123';
|
||||
ormEntity.name = 'First Race';
|
||||
ormEntity.description = 'Complete your first race';
|
||||
ormEntity.category = 'driver';
|
||||
ormEntity.rarity = 'common';
|
||||
ormEntity.points = 10;
|
||||
ormEntity.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
|
||||
ormEntity.isSecret = false;
|
||||
ormEntity.createdAt = new Date('2024-01-01');
|
||||
|
||||
mockMapper.toOrmEntity.mockReturnValue(ormEntity);
|
||||
mockAchievementRepo.save.mockResolvedValue(ormEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.createAchievement(achievement);
|
||||
|
||||
// Then
|
||||
expect(mockMapper.toOrmEntity).toHaveBeenCalledWith(achievement);
|
||||
expect(mockAchievementRepo.save).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(achievement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserAchievementById', () => {
|
||||
// Given: a user achievement exists in the database
|
||||
// When: findUserAchievementById is called
|
||||
// Then: it should return the user achievement domain entity
|
||||
it('should return user achievement when found', async () => {
|
||||
// Given
|
||||
const userAchievementId = 'ua-123';
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = userAchievementId;
|
||||
ormEntity.userId = 'user-456';
|
||||
ormEntity.achievementId = 'ach-789';
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 50;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
const domainEntity = UserAchievement.create({
|
||||
id: userAchievementId,
|
||||
userId: 'user-456',
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
|
||||
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.findUserAchievementById(userAchievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: userAchievementId } });
|
||||
expect(mockMapper.toUserAchievementDomain).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(domainEntity);
|
||||
});
|
||||
|
||||
// Given: no user achievement exists with the given ID
|
||||
// When: findUserAchievementById is called
|
||||
// Then: it should return null
|
||||
it('should return null when user achievement not found', async () => {
|
||||
// Given
|
||||
const userAchievementId = 'ua-999';
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.findUserAchievementById(userAchievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: userAchievementId } });
|
||||
expect(mockMapper.toUserAchievementDomain).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserAchievementsByUserId', () => {
|
||||
// Given: user achievements exist for a specific user
|
||||
// When: findUserAchievementsByUserId is called
|
||||
// Then: it should return user achievements for that user
|
||||
it('should return user achievements by user ID', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = userId;
|
||||
ormEntity.achievementId = 'ach-789';
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 50;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
const domainEntity = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: userId,
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
mockUserAchievementRepo.find.mockResolvedValue([ormEntity]);
|
||||
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.findUserAchievementsByUserId(userId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId } });
|
||||
expect(mockMapper.toUserAchievementDomain).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toEqual([domainEntity]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserAchievementByUserAndAchievement', () => {
|
||||
// Given: a user achievement exists for a specific user and achievement
|
||||
// When: findUserAchievementByUserAndAchievement is called
|
||||
// Then: it should return the user achievement
|
||||
it('should return user achievement by user and achievement IDs', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const achievementId = 'ach-789';
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = userId;
|
||||
ormEntity.achievementId = achievementId;
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 50;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
const domainEntity = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: userId,
|
||||
achievementId: achievementId,
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
|
||||
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.findUserAchievementByUserAndAchievement(userId, achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
|
||||
expect(mockMapper.toUserAchievementDomain).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(domainEntity);
|
||||
});
|
||||
|
||||
// Given: no user achievement exists for the given user and achievement
|
||||
// When: findUserAchievementByUserAndAchievement is called
|
||||
// Then: it should return null
|
||||
it('should return null when user achievement not found', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const achievementId = 'ach-999';
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.findUserAchievementByUserAndAchievement(userId, achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
|
||||
expect(mockMapper.toUserAchievementDomain).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasUserEarnedAchievement', () => {
|
||||
// Given: a user has earned an achievement (progress = 100)
|
||||
// When: hasUserEarnedAchievement is called
|
||||
// Then: it should return true
|
||||
it('should return true when user has earned achievement', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const achievementId = 'ach-789';
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = userId;
|
||||
ormEntity.achievementId = achievementId;
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 100;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
const domainEntity = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: userId,
|
||||
achievementId: achievementId,
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 100,
|
||||
});
|
||||
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
|
||||
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.hasUserEarnedAchievement(userId, achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
// Given: a user has not earned an achievement (progress < 100)
|
||||
// When: hasUserEarnedAchievement is called
|
||||
// Then: it should return false
|
||||
it('should return false when user has not earned achievement', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const achievementId = 'ach-789';
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = userId;
|
||||
ormEntity.achievementId = achievementId;
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 50;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
const domainEntity = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: userId,
|
||||
achievementId: achievementId,
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
|
||||
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.hasUserEarnedAchievement(userId, achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
// Given: no user achievement exists
|
||||
// When: hasUserEarnedAchievement is called
|
||||
// Then: it should return false
|
||||
it('should return false when user achievement not found', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const achievementId = 'ach-999';
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.hasUserEarnedAchievement(userId, achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUserAchievement', () => {
|
||||
// Given: a valid user achievement domain entity
|
||||
// When: createUserAchievement is called
|
||||
// Then: it should save the user achievement and return it
|
||||
it('should create and save user achievement', async () => {
|
||||
// Given
|
||||
const userAchievement = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: 'user-456',
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = 'user-456';
|
||||
ormEntity.achievementId = 'ach-789';
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 50;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
mockMapper.toUserAchievementOrmEntity.mockReturnValue(ormEntity);
|
||||
mockUserAchievementRepo.save.mockResolvedValue(ormEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.createUserAchievement(userAchievement);
|
||||
|
||||
// Then
|
||||
expect(mockMapper.toUserAchievementOrmEntity).toHaveBeenCalledWith(userAchievement);
|
||||
expect(mockUserAchievementRepo.save).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(userAchievement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserAchievement', () => {
|
||||
// Given: an existing user achievement to update
|
||||
// When: updateUserAchievement is called
|
||||
// Then: it should update the user achievement and return it
|
||||
it('should update and save user achievement', async () => {
|
||||
// Given
|
||||
const userAchievement = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: 'user-456',
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 75,
|
||||
});
|
||||
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = 'user-456';
|
||||
ormEntity.achievementId = 'ach-789';
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 75;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
mockMapper.toUserAchievementOrmEntity.mockReturnValue(ormEntity);
|
||||
mockUserAchievementRepo.save.mockResolvedValue(ormEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.updateUserAchievement(userAchievement);
|
||||
|
||||
// Then
|
||||
expect(mockMapper.toUserAchievementOrmEntity).toHaveBeenCalledWith(userAchievement);
|
||||
expect(mockUserAchievementRepo.save).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(userAchievement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAchievementLeaderboard', () => {
|
||||
// Given: multiple users have completed achievements
|
||||
// When: getAchievementLeaderboard is called
|
||||
// Then: it should return sorted leaderboard
|
||||
it('should return achievement leaderboard', async () => {
|
||||
// Given
|
||||
const userAchievement1 = new UserAchievementOrmEntity();
|
||||
userAchievement1.id = 'ua-1';
|
||||
userAchievement1.userId = 'user-1';
|
||||
userAchievement1.achievementId = 'ach-1';
|
||||
userAchievement1.progress = 100;
|
||||
|
||||
const userAchievement2 = new UserAchievementOrmEntity();
|
||||
userAchievement2.id = 'ua-2';
|
||||
userAchievement2.userId = 'user-2';
|
||||
userAchievement2.achievementId = 'ach-2';
|
||||
userAchievement2.progress = 100;
|
||||
|
||||
const achievement1 = new AchievementOrmEntity();
|
||||
achievement1.id = 'ach-1';
|
||||
achievement1.points = 10;
|
||||
|
||||
const achievement2 = new AchievementOrmEntity();
|
||||
achievement2.id = 'ach-2';
|
||||
achievement2.points = 20;
|
||||
|
||||
mockUserAchievementRepo.find.mockResolvedValue([userAchievement1, userAchievement2]);
|
||||
mockAchievementRepo.findOne
|
||||
.mockResolvedValueOnce(achievement1)
|
||||
.mockResolvedValueOnce(achievement2);
|
||||
|
||||
// When
|
||||
const result = await repository.getAchievementLeaderboard(10);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { progress: 100 } });
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([
|
||||
{ userId: 'user-2', points: 20, count: 1 },
|
||||
{ userId: 'user-1', points: 10, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
// Given: no completed user achievements exist
|
||||
// When: getAchievementLeaderboard is called
|
||||
// Then: it should return empty array
|
||||
it('should return empty array when no completed achievements', async () => {
|
||||
// Given
|
||||
mockUserAchievementRepo.find.mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await repository.getAchievementLeaderboard(10);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { progress: 100 } });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
// Given: user achievements exist but achievement not found
|
||||
// When: getAchievementLeaderboard is called
|
||||
// Then: it should skip those achievements
|
||||
it('should skip achievements that cannot be found', async () => {
|
||||
// Given
|
||||
const userAchievement = new UserAchievementOrmEntity();
|
||||
userAchievement.id = 'ua-1';
|
||||
userAchievement.userId = 'user-1';
|
||||
userAchievement.achievementId = 'ach-999';
|
||||
userAchievement.progress = 100;
|
||||
|
||||
mockUserAchievementRepo.find.mockResolvedValue([userAchievement]);
|
||||
mockAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.getAchievementLeaderboard(10);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { progress: 100 } });
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: 'ach-999' } });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserAchievementStats', () => {
|
||||
// Given: a user has completed achievements
|
||||
// When: getUserAchievementStats is called
|
||||
// Then: it should return user statistics
|
||||
it('should return user achievement statistics', async () => {
|
||||
// Given
|
||||
const userId = 'user-1';
|
||||
const userAchievement1 = new UserAchievementOrmEntity();
|
||||
userAchievement1.id = 'ua-1';
|
||||
userAchievement1.userId = userId;
|
||||
userAchievement1.achievementId = 'ach-1';
|
||||
userAchievement1.progress = 100;
|
||||
|
||||
const userAchievement2 = new UserAchievementOrmEntity();
|
||||
userAchievement2.id = 'ua-2';
|
||||
userAchievement2.userId = userId;
|
||||
userAchievement2.achievementId = 'ach-2';
|
||||
userAchievement2.progress = 100;
|
||||
|
||||
const achievement1 = new AchievementOrmEntity();
|
||||
achievement1.id = 'ach-1';
|
||||
achievement1.category = 'driver';
|
||||
achievement1.points = 10;
|
||||
|
||||
const achievement2 = new AchievementOrmEntity();
|
||||
achievement2.id = 'ach-2';
|
||||
achievement2.category = 'steward';
|
||||
achievement2.points = 20;
|
||||
|
||||
mockUserAchievementRepo.find.mockResolvedValue([userAchievement1, userAchievement2]);
|
||||
mockAchievementRepo.findOne
|
||||
.mockResolvedValueOnce(achievement1)
|
||||
.mockResolvedValueOnce(achievement2);
|
||||
|
||||
// When
|
||||
const result = await repository.getUserAchievementStats(userId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId, progress: 100 } });
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({
|
||||
total: 2,
|
||||
points: 30,
|
||||
byCategory: {
|
||||
driver: 1,
|
||||
steward: 1,
|
||||
admin: 0,
|
||||
community: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Given: a user has no completed achievements
|
||||
// When: getUserAchievementStats is called
|
||||
// Then: it should return zero statistics
|
||||
it('should return zero statistics when no completed achievements', async () => {
|
||||
// Given
|
||||
const userId = 'user-1';
|
||||
mockUserAchievementRepo.find.mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await repository.getUserAchievementStats(userId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId, progress: 100 } });
|
||||
expect(result).toEqual({
|
||||
total: 0,
|
||||
points: 0,
|
||||
byCategory: {
|
||||
driver: 0,
|
||||
steward: 0,
|
||||
admin: 0,
|
||||
community: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Given: a user has completed achievements but achievement not found
|
||||
// When: getUserAchievementStats is called
|
||||
// Then: it should skip those achievements
|
||||
it('should skip achievements that cannot be found', async () => {
|
||||
// Given
|
||||
const userId = 'user-1';
|
||||
const userAchievement = new UserAchievementOrmEntity();
|
||||
userAchievement.id = 'ua-1';
|
||||
userAchievement.userId = userId;
|
||||
userAchievement.achievementId = 'ach-999';
|
||||
userAchievement.progress = 100;
|
||||
|
||||
mockUserAchievementRepo.find.mockResolvedValue([userAchievement]);
|
||||
mockAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.getUserAchievementStats(userId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId, progress: 100 } });
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: 'ach-999' } });
|
||||
expect(result).toEqual({
|
||||
total: 1,
|
||||
points: 0,
|
||||
byCategory: {
|
||||
driver: 0,
|
||||
steward: 0,
|
||||
admin: 0,
|
||||
community: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import { TypeOrmPersistenceSchemaAdapter } from '../errors/TypeOrmPersistenceSchemaAdapterError';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertDate,
|
||||
assertEnumValue,
|
||||
assertArray,
|
||||
assertNumber,
|
||||
assertInteger,
|
||||
assertBoolean,
|
||||
assertOptionalStringOrNull,
|
||||
assertRecord,
|
||||
} from './AchievementSchemaGuard';
|
||||
|
||||
describe('AchievementSchemaGuard', () => {
|
||||
describe('assertNonEmptyString', () => {
|
||||
// Given: a valid non-empty string
|
||||
// When: assertNonEmptyString is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid non-empty string', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'valid string';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a string
|
||||
// When: assertNonEmptyString is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_string'
|
||||
it('should reject a non-string value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 123;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an empty string
|
||||
// When: assertNonEmptyString is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'empty_string'
|
||||
it('should reject an empty string', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = '';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'empty_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a string with only whitespace
|
||||
// When: assertNonEmptyString is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'empty_string'
|
||||
it('should reject a string with only whitespace', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = ' ';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'empty_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertDate', () => {
|
||||
// Given: a valid Date object
|
||||
// When: assertDate is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid Date object', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = new Date();
|
||||
|
||||
// When & Then
|
||||
expect(() => assertDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a Date
|
||||
// When: assertDate is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_date'
|
||||
it('should reject a non-Date value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = '2024-01-01';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_date',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an invalid Date object (NaN)
|
||||
// When: assertDate is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'invalid_date'
|
||||
it('should reject an invalid Date object', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = new Date('invalid');
|
||||
|
||||
// When & Then
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_date',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertEnumValue', () => {
|
||||
const VALID_VALUES = ['option1', 'option2', 'option3'] as const;
|
||||
|
||||
// Given: a valid enum value
|
||||
// When: assertEnumValue is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid enum value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'option1';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a string
|
||||
// When: assertEnumValue is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_string'
|
||||
it('should reject a non-string value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 123;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an invalid enum value
|
||||
// When: assertEnumValue is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'invalid_enum_value'
|
||||
it('should reject an invalid enum value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'invalid_option';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_enum_value',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertArray', () => {
|
||||
// Given: a valid array
|
||||
// When: assertArray is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid array', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = [1, 2, 3];
|
||||
|
||||
// When & Then
|
||||
expect(() => assertArray(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not an array
|
||||
// When: assertArray is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_array'
|
||||
it('should reject a non-array value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = { key: 'value' };
|
||||
|
||||
// When & Then
|
||||
expect(() => assertArray(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertArray(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_array',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: null value
|
||||
// When: assertArray is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_array'
|
||||
it('should reject null value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertArray(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertArray(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_array',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertNumber', () => {
|
||||
// Given: a valid number
|
||||
// When: assertNumber is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid number', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 42;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNumber(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a number
|
||||
// When: assertNumber is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_number'
|
||||
it('should reject a non-number value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = '42';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNumber(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertNumber(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_number',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: NaN value
|
||||
// When: assertNumber is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_number'
|
||||
it('should reject NaN value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = NaN;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNumber(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertNumber(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_number',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertInteger', () => {
|
||||
// Given: a valid integer
|
||||
// When: assertInteger is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid integer', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 42;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertInteger(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not an integer (float)
|
||||
// When: assertInteger is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_integer'
|
||||
it('should reject a float value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 42.5;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertInteger(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertInteger(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_integer',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a value that is not a number
|
||||
// When: assertInteger is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_integer'
|
||||
it('should reject a non-number value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = '42';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertInteger(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertInteger(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_integer',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertBoolean', () => {
|
||||
// Given: a valid boolean (true)
|
||||
// When: assertBoolean is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept true', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = true;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertBoolean(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a valid boolean (false)
|
||||
// When: assertBoolean is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept false', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = false;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertBoolean(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a boolean
|
||||
// When: assertBoolean is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_boolean'
|
||||
it('should reject a non-boolean value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'true';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertBoolean(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertBoolean(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_boolean',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalStringOrNull', () => {
|
||||
// Given: a valid string
|
||||
// When: assertOptionalStringOrNull is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid string', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'valid string';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: null value
|
||||
// When: assertOptionalStringOrNull is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept null value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: undefined value
|
||||
// When: assertOptionalStringOrNull is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept undefined value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = undefined;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a string, null, or undefined
|
||||
// When: assertOptionalStringOrNull is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_string'
|
||||
it('should reject a non-string value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 123;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertRecord', () => {
|
||||
// Given: a valid record (object)
|
||||
// When: assertRecord is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid record', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = { key: 'value' };
|
||||
|
||||
// When & Then
|
||||
expect(() => assertRecord(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not an object (null)
|
||||
// When: assertRecord is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_object'
|
||||
it('should reject null value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_object',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a value that is an array
|
||||
// When: assertRecord is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_object'
|
||||
it('should reject array value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = [1, 2, 3];
|
||||
|
||||
// When & Then
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_object',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a value that is a primitive (string)
|
||||
// When: assertRecord is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_object'
|
||||
it('should reject string value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'not an object';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_object',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { InMemoryActivityRepository } from './InMemoryActivityRepository';
|
||||
import { DriverData } from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||
|
||||
describe('InMemoryActivityRepository', () => {
|
||||
let repository: InMemoryActivityRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryActivityRepository();
|
||||
});
|
||||
|
||||
describe('findDriverById', () => {
|
||||
it('should return null when driver does not exist', async () => {
|
||||
// Given
|
||||
const driverId = 'non-existent';
|
||||
|
||||
// When
|
||||
const result = await repository.findDriverById(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return driver when it exists', async () => {
|
||||
// Given
|
||||
const driver: DriverData = {
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 100,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
leagues: 5,
|
||||
};
|
||||
repository.addDriver(driver);
|
||||
|
||||
// When
|
||||
const result = await repository.findDriverById(driver.id);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(driver);
|
||||
});
|
||||
|
||||
it('should overwrite driver with same id (idempotency/uniqueness)', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const driver1: DriverData = {
|
||||
id: driverId,
|
||||
name: 'John Doe',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 100,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
leagues: 5,
|
||||
};
|
||||
const driver2: DriverData = {
|
||||
id: driverId,
|
||||
name: 'John Updated',
|
||||
rating: 1600,
|
||||
rank: 5,
|
||||
starts: 101,
|
||||
wins: 11,
|
||||
podiums: 31,
|
||||
leagues: 5,
|
||||
};
|
||||
|
||||
// When
|
||||
repository.addDriver(driver1);
|
||||
repository.addDriver(driver2);
|
||||
const result = await repository.findDriverById(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(driver2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upcomingRaces', () => {
|
||||
it('should return empty array when no races for driver', async () => {
|
||||
// When
|
||||
const result = await repository.getUpcomingRaces('driver-1');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return races when they exist', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const races = [{ id: 'race-1', name: 'Grand Prix', date: new Date().toISOString() }];
|
||||
repository.addUpcomingRaces(driverId, races);
|
||||
|
||||
// When
|
||||
const result = await repository.getUpcomingRaces(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(races);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TypeOrmAnalyticsSchemaError } from './TypeOrmAnalyticsSchemaError';
|
||||
|
||||
describe('TypeOrmAnalyticsSchemaError', () => {
|
||||
it('contains entity, field, and reason', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'AnalyticsSnapshot',
|
||||
fieldName: 'metrics.pageViews',
|
||||
reason: 'not_number' as const,
|
||||
message: 'Custom message',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmAnalyticsSchemaError(params);
|
||||
|
||||
// Then
|
||||
expect(error.name).toBe('TypeOrmAnalyticsSchemaError');
|
||||
expect(error.entityName).toBe(params.entityName);
|
||||
expect(error.fieldName).toBe(params.fieldName);
|
||||
expect(error.reason).toBe(params.reason);
|
||||
expect(error.message).toBe(params.message);
|
||||
});
|
||||
|
||||
it('works without optional message', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'EngagementEvent',
|
||||
fieldName: 'id',
|
||||
reason: 'missing' as const,
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmAnalyticsSchemaError(params);
|
||||
|
||||
// Then
|
||||
expect(error.message).toBe('');
|
||||
expect(error.entityName).toBe(params.entityName);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
|
||||
|
||||
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
|
||||
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
|
||||
import { AnalyticsSnapshotOrmMapper } from './AnalyticsSnapshotOrmMapper';
|
||||
|
||||
describe('AnalyticsSnapshotOrmMapper', () => {
|
||||
const mapper = new AnalyticsSnapshotOrmMapper();
|
||||
|
||||
it('maps domain -> orm -> domain (round-trip)', () => {
|
||||
// Given
|
||||
const domain = AnalyticsSnapshot.create({
|
||||
id: 'snap_1',
|
||||
entityType: 'league',
|
||||
entityId: 'league-1',
|
||||
period: 'daily',
|
||||
startDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||
endDate: new Date('2025-01-01T23:59:59.999Z'),
|
||||
metrics: {
|
||||
pageViews: 100,
|
||||
uniqueVisitors: 50,
|
||||
avgSessionDuration: 120,
|
||||
bounceRate: 0.4,
|
||||
engagementScore: 75,
|
||||
sponsorClicks: 10,
|
||||
sponsorUrlClicks: 5,
|
||||
socialShares: 2,
|
||||
leagueJoins: 1,
|
||||
raceRegistrations: 3,
|
||||
exposureValue: 150.5,
|
||||
},
|
||||
createdAt: new Date('2025-01-02T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
// When
|
||||
const orm = mapper.toOrmEntity(domain);
|
||||
const rehydrated = mapper.toDomain(orm);
|
||||
|
||||
// Then
|
||||
expect(orm).toBeInstanceOf(AnalyticsSnapshotOrmEntity);
|
||||
expect(orm.id).toBe(domain.id);
|
||||
expect(rehydrated.id).toBe(domain.id);
|
||||
expect(rehydrated.entityType).toBe(domain.entityType);
|
||||
expect(rehydrated.entityId).toBe(domain.entityId);
|
||||
expect(rehydrated.period).toBe(domain.period);
|
||||
expect(rehydrated.startDate.toISOString()).toBe(domain.startDate.toISOString());
|
||||
expect(rehydrated.endDate.toISOString()).toBe(domain.endDate.toISOString());
|
||||
expect(rehydrated.metrics).toEqual(domain.metrics);
|
||||
expect(rehydrated.createdAt.toISOString()).toBe(domain.createdAt.toISOString());
|
||||
});
|
||||
|
||||
it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => {
|
||||
// Given
|
||||
const orm = new AnalyticsSnapshotOrmEntity();
|
||||
orm.id = ''; // Invalid: empty
|
||||
orm.entityType = 'league' as any;
|
||||
orm.entityId = 'league-1';
|
||||
orm.period = 'daily' as any;
|
||||
orm.startDate = new Date();
|
||||
orm.endDate = new Date();
|
||||
orm.metrics = {} as any; // Invalid: missing fields
|
||||
orm.createdAt = new Date();
|
||||
|
||||
// When / Then
|
||||
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('throws TypeOrmAnalyticsSchemaError when metrics are missing required fields', () => {
|
||||
// Given
|
||||
const orm = new AnalyticsSnapshotOrmEntity();
|
||||
orm.id = 'snap_1';
|
||||
orm.entityType = 'league' as any;
|
||||
orm.entityId = 'league-1';
|
||||
orm.period = 'daily' as any;
|
||||
orm.startDate = new Date();
|
||||
orm.endDate = new Date();
|
||||
orm.metrics = { pageViews: 100 } as any; // Missing other metrics
|
||||
orm.createdAt = new Date();
|
||||
|
||||
// When / Then
|
||||
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
try {
|
||||
mapper.toDomain(orm);
|
||||
} catch (e: any) {
|
||||
expect(e.fieldName).toContain('metrics.');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
|
||||
|
||||
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
|
||||
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
|
||||
import { EngagementEventOrmMapper } from './EngagementEventOrmMapper';
|
||||
|
||||
describe('EngagementEventOrmMapper', () => {
|
||||
const mapper = new EngagementEventOrmMapper();
|
||||
|
||||
it('maps domain -> orm -> domain (round-trip)', () => {
|
||||
// Given
|
||||
const domain = EngagementEvent.create({
|
||||
id: 'eng_1',
|
||||
action: 'click_sponsor_logo',
|
||||
entityType: 'sponsor',
|
||||
entityId: 'sponsor-1',
|
||||
actorType: 'driver',
|
||||
actorId: 'driver-1',
|
||||
sessionId: 'sess-1',
|
||||
metadata: { key: 'value', num: 123, bool: true },
|
||||
timestamp: new Date('2025-01-01T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
// When
|
||||
const orm = mapper.toOrmEntity(domain);
|
||||
const rehydrated = mapper.toDomain(orm);
|
||||
|
||||
// Then
|
||||
expect(orm).toBeInstanceOf(EngagementEventOrmEntity);
|
||||
expect(orm.id).toBe(domain.id);
|
||||
expect(rehydrated.id).toBe(domain.id);
|
||||
expect(rehydrated.action).toBe(domain.action);
|
||||
expect(rehydrated.entityType).toBe(domain.entityType);
|
||||
expect(rehydrated.entityId).toBe(domain.entityId);
|
||||
expect(rehydrated.actorType).toBe(domain.actorType);
|
||||
expect(rehydrated.actorId).toBe(domain.actorId);
|
||||
expect(rehydrated.sessionId).toBe(domain.sessionId);
|
||||
expect(rehydrated.metadata).toEqual(domain.metadata);
|
||||
expect(rehydrated.timestamp.toISOString()).toBe(domain.timestamp.toISOString());
|
||||
});
|
||||
|
||||
it('maps domain -> orm -> domain with nulls', () => {
|
||||
// Given
|
||||
const domain = EngagementEvent.create({
|
||||
id: 'eng_2',
|
||||
action: 'view_standings',
|
||||
entityType: 'league',
|
||||
entityId: 'league-1',
|
||||
actorType: 'anonymous',
|
||||
sessionId: 'sess-2',
|
||||
timestamp: new Date('2025-01-01T11:00:00.000Z'),
|
||||
});
|
||||
|
||||
// When
|
||||
const orm = mapper.toOrmEntity(domain);
|
||||
const rehydrated = mapper.toDomain(orm);
|
||||
|
||||
// Then
|
||||
expect(orm.actorId).toBeNull();
|
||||
expect(orm.metadata).toBeNull();
|
||||
expect(rehydrated.actorId).toBeUndefined();
|
||||
expect(rehydrated.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => {
|
||||
// Given
|
||||
const orm = new EngagementEventOrmEntity();
|
||||
orm.id = ''; // Invalid
|
||||
orm.action = 'invalid_action' as any;
|
||||
orm.entityType = 'league' as any;
|
||||
orm.entityId = 'league-1';
|
||||
orm.actorType = 'anonymous' as any;
|
||||
orm.sessionId = 'sess-1';
|
||||
orm.timestamp = new Date();
|
||||
|
||||
// When / Then
|
||||
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('throws TypeOrmAnalyticsSchemaError for invalid metadata values', () => {
|
||||
// Given
|
||||
const orm = new EngagementEventOrmEntity();
|
||||
orm.id = 'eng_1';
|
||||
orm.action = 'click_sponsor_logo' as any;
|
||||
orm.entityType = 'sponsor' as any;
|
||||
orm.entityId = 'sponsor-1';
|
||||
orm.actorType = 'driver' as any;
|
||||
orm.sessionId = 'sess-1';
|
||||
orm.timestamp = new Date();
|
||||
orm.metadata = { invalid: { nested: 'object' } } as any;
|
||||
|
||||
// When / Then
|
||||
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
try {
|
||||
mapper.toDomain(orm);
|
||||
} catch (e: any) {
|
||||
expect(e.reason).toBe('invalid_shape');
|
||||
expect(e.fieldName).toBe('metadata');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
|
||||
|
||||
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
|
||||
import { AnalyticsSnapshotOrmMapper } from '../mappers/AnalyticsSnapshotOrmMapper';
|
||||
import { TypeOrmAnalyticsSnapshotRepository } from './TypeOrmAnalyticsSnapshotRepository';
|
||||
|
||||
describe('TypeOrmAnalyticsSnapshotRepository', () => {
|
||||
it('saves mapped entities via injected mapper', async () => {
|
||||
// Given
|
||||
const orm = new AnalyticsSnapshotOrmEntity();
|
||||
orm.id = 'snap_1';
|
||||
|
||||
const mapper: AnalyticsSnapshotOrmMapper = {
|
||||
toOrmEntity: vi.fn().mockReturnValue(orm),
|
||||
toDomain: vi.fn(),
|
||||
} as unknown as AnalyticsSnapshotOrmMapper;
|
||||
|
||||
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
|
||||
save: vi.fn().mockResolvedValue(orm),
|
||||
} as unknown as Repository<AnalyticsSnapshotOrmEntity>;
|
||||
|
||||
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
|
||||
|
||||
const domain = AnalyticsSnapshot.create({
|
||||
id: 'snap_1',
|
||||
entityType: 'league',
|
||||
entityId: 'league-1',
|
||||
period: 'daily',
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
metrics: {} as any,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
// When
|
||||
await sut.save(domain);
|
||||
|
||||
// Then
|
||||
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domain);
|
||||
expect(repo.save).toHaveBeenCalledWith(orm);
|
||||
});
|
||||
|
||||
it('findById maps entity -> domain', async () => {
|
||||
// Given
|
||||
const orm = new AnalyticsSnapshotOrmEntity();
|
||||
orm.id = 'snap_1';
|
||||
|
||||
const domain = AnalyticsSnapshot.create({
|
||||
id: 'snap_1',
|
||||
entityType: 'league',
|
||||
entityId: 'league-1',
|
||||
period: 'daily',
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
metrics: {} as any,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const mapper: AnalyticsSnapshotOrmMapper = {
|
||||
toOrmEntity: vi.fn(),
|
||||
toDomain: vi.fn().mockReturnValue(domain),
|
||||
} as unknown as AnalyticsSnapshotOrmMapper;
|
||||
|
||||
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
|
||||
findOneBy: vi.fn().mockResolvedValue(orm),
|
||||
} as unknown as Repository<AnalyticsSnapshotOrmEntity>;
|
||||
|
||||
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
|
||||
|
||||
// When
|
||||
const result = await sut.findById('snap_1');
|
||||
|
||||
// Then
|
||||
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'snap_1' });
|
||||
expect(mapper.toDomain).toHaveBeenCalledWith(orm);
|
||||
expect(result?.id).toBe('snap_1');
|
||||
});
|
||||
|
||||
it('findLatest uses correct query options', async () => {
|
||||
// Given
|
||||
const orm = new AnalyticsSnapshotOrmEntity();
|
||||
const mapper: AnalyticsSnapshotOrmMapper = {
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'snap_1' } as any),
|
||||
} as any;
|
||||
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
|
||||
findOne: vi.fn().mockResolvedValue(orm),
|
||||
} as any;
|
||||
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
|
||||
|
||||
// When
|
||||
await sut.findLatest('league', 'league-1', 'daily');
|
||||
|
||||
// Then
|
||||
expect(repo.findOne).toHaveBeenCalledWith({
|
||||
where: { entityType: 'league', entityId: 'league-1', period: 'daily' },
|
||||
order: { endDate: 'DESC' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
|
||||
|
||||
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
|
||||
import { EngagementEventOrmMapper } from '../mappers/EngagementEventOrmMapper';
|
||||
import { TypeOrmEngagementRepository } from './TypeOrmEngagementRepository';
|
||||
|
||||
describe('TypeOrmEngagementRepository', () => {
|
||||
it('saves mapped entities via injected mapper', async () => {
|
||||
// Given
|
||||
const orm = new EngagementEventOrmEntity();
|
||||
orm.id = 'eng_1';
|
||||
|
||||
const mapper: EngagementEventOrmMapper = {
|
||||
toOrmEntity: vi.fn().mockReturnValue(orm),
|
||||
toDomain: vi.fn(),
|
||||
} as unknown as EngagementEventOrmMapper;
|
||||
|
||||
const repo: Repository<EngagementEventOrmEntity> = {
|
||||
save: vi.fn().mockResolvedValue(orm),
|
||||
} as unknown as Repository<EngagementEventOrmEntity>;
|
||||
|
||||
const sut = new TypeOrmEngagementRepository(repo, mapper);
|
||||
|
||||
const domain = EngagementEvent.create({
|
||||
id: 'eng_1',
|
||||
action: 'click_sponsor_logo',
|
||||
entityType: 'sponsor',
|
||||
entityId: 'sponsor-1',
|
||||
actorType: 'anonymous',
|
||||
sessionId: 'sess-1',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
// When
|
||||
await sut.save(domain);
|
||||
|
||||
// Then
|
||||
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domain);
|
||||
expect(repo.save).toHaveBeenCalledWith(orm);
|
||||
});
|
||||
|
||||
it('findById maps entity -> domain', async () => {
|
||||
// Given
|
||||
const orm = new EngagementEventOrmEntity();
|
||||
orm.id = 'eng_1';
|
||||
|
||||
const domain = EngagementEvent.create({
|
||||
id: 'eng_1',
|
||||
action: 'click_sponsor_logo',
|
||||
entityType: 'sponsor',
|
||||
entityId: 'sponsor-1',
|
||||
actorType: 'anonymous',
|
||||
sessionId: 'sess-1',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const mapper: EngagementEventOrmMapper = {
|
||||
toOrmEntity: vi.fn(),
|
||||
toDomain: vi.fn().mockReturnValue(domain),
|
||||
} as unknown as EngagementEventOrmMapper;
|
||||
|
||||
const repo: Repository<EngagementEventOrmEntity> = {
|
||||
findOneBy: vi.fn().mockResolvedValue(orm),
|
||||
} as unknown as Repository<EngagementEventOrmEntity>;
|
||||
|
||||
const sut = new TypeOrmEngagementRepository(repo, mapper);
|
||||
|
||||
// When
|
||||
const result = await sut.findById('eng_1');
|
||||
|
||||
// Then
|
||||
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'eng_1' });
|
||||
expect(mapper.toDomain).toHaveBeenCalledWith(orm);
|
||||
expect(result?.id).toBe('eng_1');
|
||||
});
|
||||
|
||||
it('countByAction uses correct where clause', async () => {
|
||||
// Given
|
||||
const repo: Repository<EngagementEventOrmEntity> = {
|
||||
count: vi.fn().mockResolvedValue(5),
|
||||
} as any;
|
||||
const sut = new TypeOrmEngagementRepository(repo, {} as any);
|
||||
const since = new Date();
|
||||
|
||||
// When
|
||||
await sut.countByAction('click_sponsor_logo', 'sponsor-1', since);
|
||||
|
||||
// Then
|
||||
expect(repo.count).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
action: 'click_sponsor_logo',
|
||||
entityId: 'sponsor-1',
|
||||
timestamp: expect.anything(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
|
||||
import {
|
||||
assertBoolean,
|
||||
assertDate,
|
||||
assertEnumValue,
|
||||
assertInteger,
|
||||
assertNonEmptyString,
|
||||
assertNumber,
|
||||
assertOptionalIntegerOrNull,
|
||||
assertOptionalNumberOrNull,
|
||||
assertOptionalStringOrNull,
|
||||
assertRecord,
|
||||
} from './TypeOrmAnalyticsSchemaGuards';
|
||||
|
||||
describe('TypeOrmAnalyticsSchemaGuards', () => {
|
||||
const entity = 'TestEntity';
|
||||
|
||||
describe('assertNonEmptyString', () => {
|
||||
it('accepts valid string', () => {
|
||||
expect(() => assertNonEmptyString(entity, 'field', 'valid')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects null/undefined', () => {
|
||||
expect(() => assertNonEmptyString(entity, 'field', null)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
expect(() => assertNonEmptyString(entity, 'field', undefined)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('rejects empty/whitespace string', () => {
|
||||
expect(() => assertNonEmptyString(entity, 'field', '')).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
expect(() => assertNonEmptyString(entity, 'field', ' ')).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('rejects non-string', () => {
|
||||
expect(() => assertNonEmptyString(entity, 'field', 123)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalStringOrNull', () => {
|
||||
it('accepts valid string, null, or undefined', () => {
|
||||
expect(() => assertOptionalStringOrNull(entity, 'field', 'valid')).not.toThrow();
|
||||
expect(() => assertOptionalStringOrNull(entity, 'field', null)).not.toThrow();
|
||||
expect(() => assertOptionalStringOrNull(entity, 'field', undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-string', () => {
|
||||
expect(() => assertOptionalStringOrNull(entity, 'field', 123)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertNumber', () => {
|
||||
it('accepts valid number', () => {
|
||||
expect(() => assertNumber(entity, 'field', 123.45)).not.toThrow();
|
||||
expect(() => assertNumber(entity, 'field', 0)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects NaN', () => {
|
||||
expect(() => assertNumber(entity, 'field', NaN)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('rejects non-number', () => {
|
||||
expect(() => assertNumber(entity, 'field', '123')).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalNumberOrNull', () => {
|
||||
it('accepts valid number, null, or undefined', () => {
|
||||
expect(() => assertOptionalNumberOrNull(entity, 'field', 123)).not.toThrow();
|
||||
expect(() => assertOptionalNumberOrNull(entity, 'field', null)).not.toThrow();
|
||||
expect(() => assertOptionalNumberOrNull(entity, 'field', undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertInteger', () => {
|
||||
it('accepts valid integer', () => {
|
||||
expect(() => assertInteger(entity, 'field', 123)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects float', () => {
|
||||
expect(() => assertInteger(entity, 'field', 123.45)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalIntegerOrNull', () => {
|
||||
it('accepts valid integer, null, or undefined', () => {
|
||||
expect(() => assertOptionalIntegerOrNull(entity, 'field', 123)).not.toThrow();
|
||||
expect(() => assertOptionalIntegerOrNull(entity, 'field', null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertBoolean', () => {
|
||||
it('accepts boolean', () => {
|
||||
expect(() => assertBoolean(entity, 'field', true)).not.toThrow();
|
||||
expect(() => assertBoolean(entity, 'field', false)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-boolean', () => {
|
||||
expect(() => assertBoolean(entity, 'field', 'true')).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertDate', () => {
|
||||
it('accepts valid Date', () => {
|
||||
expect(() => assertDate(entity, 'field', new Date())).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid Date', () => {
|
||||
expect(() => assertDate(entity, 'field', new Date('invalid'))).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('rejects non-Date', () => {
|
||||
expect(() => assertDate(entity, 'field', '2025-01-01')).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertEnumValue', () => {
|
||||
const allowed = ['a', 'b'] as const;
|
||||
it('accepts allowed value', () => {
|
||||
expect(() => assertEnumValue(entity, 'field', 'a', allowed)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects disallowed value', () => {
|
||||
expect(() => assertEnumValue(entity, 'field', 'c', allowed)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertRecord', () => {
|
||||
it('accepts object', () => {
|
||||
expect(() => assertRecord(entity, 'field', { a: 1 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects array', () => {
|
||||
expect(() => assertRecord(entity, 'field', [])).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('rejects null', () => {
|
||||
expect(() => assertRecord(entity, 'field', null)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { InMemoryDriverRepository } from './InMemoryDriverRepository';
|
||||
import { DriverData } from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||
|
||||
describe('InMemoryDriverRepository', () => {
|
||||
let repository: InMemoryDriverRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryDriverRepository();
|
||||
});
|
||||
|
||||
describe('findDriverById', () => {
|
||||
it('should return null when driver does not exist', async () => {
|
||||
// Given
|
||||
const driverId = 'non-existent';
|
||||
|
||||
// When
|
||||
const result = await repository.findDriverById(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return driver when it exists', async () => {
|
||||
// Given
|
||||
const driver: DriverData = {
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 100,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
leagues: 5,
|
||||
};
|
||||
repository.addDriver(driver);
|
||||
|
||||
// When
|
||||
const result = await repository.findDriverById(driver.id);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(driver);
|
||||
});
|
||||
|
||||
it('should overwrite driver with same id (idempotency)', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const driver1: DriverData = {
|
||||
id: driverId,
|
||||
name: 'John Doe',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 100,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
leagues: 5,
|
||||
};
|
||||
const driver2: DriverData = {
|
||||
id: driverId,
|
||||
name: 'John Updated',
|
||||
rating: 1600,
|
||||
rank: 5,
|
||||
starts: 101,
|
||||
wins: 11,
|
||||
podiums: 31,
|
||||
leagues: 5,
|
||||
};
|
||||
|
||||
// When
|
||||
repository.addDriver(driver1);
|
||||
repository.addDriver(driver2);
|
||||
const result = await repository.findDriverById(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(driver2);
|
||||
});
|
||||
});
|
||||
});
|
||||
77
adapters/events/InMemoryEventPublisher.test.ts
Normal file
77
adapters/events/InMemoryEventPublisher.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { InMemoryEventPublisher } from './InMemoryEventPublisher';
|
||||
import { DashboardAccessedEvent } from '../../core/dashboard/application/ports/DashboardEventPublisher';
|
||||
import { LeagueCreatedEvent } from '../../core/leagues/application/ports/LeagueEventPublisher';
|
||||
|
||||
describe('InMemoryEventPublisher', () => {
|
||||
let publisher: InMemoryEventPublisher;
|
||||
|
||||
beforeEach(() => {
|
||||
publisher = new InMemoryEventPublisher();
|
||||
});
|
||||
|
||||
describe('Dashboard Events', () => {
|
||||
it('should publish and track dashboard accessed events', async () => {
|
||||
// Given
|
||||
const event: DashboardAccessedEvent = { userId: 'user-1', timestamp: new Date() };
|
||||
|
||||
// When
|
||||
await publisher.publishDashboardAccessed(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getDashboardAccessedEventCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw error when configured to fail', async () => {
|
||||
// Given
|
||||
publisher.setShouldFail(true);
|
||||
const event: DashboardAccessedEvent = { userId: 'user-1', timestamp: new Date() };
|
||||
|
||||
// When & Then
|
||||
await expect(publisher.publishDashboardAccessed(event)).rejects.toThrow('Event publisher failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('League Events', () => {
|
||||
it('should publish and track league created events', async () => {
|
||||
// Given
|
||||
const event: LeagueCreatedEvent = { leagueId: 'league-1', name: 'Test League', timestamp: new Date() };
|
||||
|
||||
// When
|
||||
await publisher.emitLeagueCreated(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getLeagueCreatedEventCount()).toBe(1);
|
||||
expect(publisher.getLeagueCreatedEvents()).toContainEqual(event);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generic Domain Events', () => {
|
||||
it('should publish and track generic domain events', async () => {
|
||||
// Given
|
||||
const event = { type: 'TestEvent', timestamp: new Date() };
|
||||
|
||||
// When
|
||||
await publisher.publish(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getEvents()).toContainEqual(event);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maintenance', () => {
|
||||
it('should clear all events', async () => {
|
||||
// Given
|
||||
await publisher.publishDashboardAccessed({ userId: 'u1', timestamp: new Date() });
|
||||
await publisher.emitLeagueCreated({ leagueId: 'l1', name: 'L1', timestamp: new Date() });
|
||||
await publisher.publish({ type: 'Generic', timestamp: new Date() });
|
||||
|
||||
// When
|
||||
publisher.clear();
|
||||
|
||||
// Then
|
||||
expect(publisher.getDashboardAccessedEventCount()).toBe(0);
|
||||
expect(publisher.getLeagueCreatedEventCount()).toBe(0);
|
||||
expect(publisher.getEvents().length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
103
adapters/events/InMemoryHealthEventPublisher.test.ts
Normal file
103
adapters/events/InMemoryHealthEventPublisher.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { InMemoryHealthEventPublisher } from './InMemoryHealthEventPublisher';
|
||||
|
||||
describe('InMemoryHealthEventPublisher', () => {
|
||||
let publisher: InMemoryHealthEventPublisher;
|
||||
|
||||
beforeEach(() => {
|
||||
publisher = new InMemoryHealthEventPublisher();
|
||||
});
|
||||
|
||||
describe('Health Check Events', () => {
|
||||
it('should publish and track health check completed events', async () => {
|
||||
// Given
|
||||
const event = {
|
||||
healthy: true,
|
||||
responseTime: 100,
|
||||
timestamp: new Date(),
|
||||
endpoint: 'http://api.test/health',
|
||||
};
|
||||
|
||||
// When
|
||||
await publisher.publishHealthCheckCompleted(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getEventCount()).toBe(1);
|
||||
expect(publisher.getEventCountByType('HealthCheckCompleted')).toBe(1);
|
||||
const events = publisher.getEventsByType('HealthCheckCompleted');
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'HealthCheckCompleted',
|
||||
...event,
|
||||
});
|
||||
});
|
||||
|
||||
it('should publish and track health check failed events', async () => {
|
||||
// Given
|
||||
const event = {
|
||||
error: 'Connection refused',
|
||||
timestamp: new Date(),
|
||||
endpoint: 'http://api.test/health',
|
||||
};
|
||||
|
||||
// When
|
||||
await publisher.publishHealthCheckFailed(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getEventCountByType('HealthCheckFailed')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Status Events', () => {
|
||||
it('should publish and track connected events', async () => {
|
||||
// Given
|
||||
const event = {
|
||||
timestamp: new Date(),
|
||||
responseTime: 50,
|
||||
};
|
||||
|
||||
// When
|
||||
await publisher.publishConnected(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getEventCountByType('Connected')).toBe(1);
|
||||
});
|
||||
|
||||
it('should publish and track disconnected events', async () => {
|
||||
// Given
|
||||
const event = {
|
||||
timestamp: new Date(),
|
||||
consecutiveFailures: 3,
|
||||
};
|
||||
|
||||
// When
|
||||
await publisher.publishDisconnected(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getEventCountByType('Disconnected')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should throw error when configured to fail', async () => {
|
||||
// Given
|
||||
publisher.setShouldFail(true);
|
||||
const event = { timestamp: new Date() };
|
||||
|
||||
// When & Then
|
||||
await expect(publisher.publishChecking(event)).rejects.toThrow('Event publisher failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maintenance', () => {
|
||||
it('should clear all events', async () => {
|
||||
// Given
|
||||
await publisher.publishChecking({ timestamp: new Date() });
|
||||
await publisher.publishConnected({ timestamp: new Date(), responseTime: 10 });
|
||||
|
||||
// When
|
||||
publisher.clear();
|
||||
|
||||
// Then
|
||||
expect(publisher.getEventCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { InMemoryHealthCheckAdapter } from './InMemoryHealthCheckAdapter';
|
||||
|
||||
describe('InMemoryHealthCheckAdapter', () => {
|
||||
let adapter: InMemoryHealthCheckAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new InMemoryHealthCheckAdapter();
|
||||
adapter.setResponseTime(0); // Speed up tests
|
||||
});
|
||||
|
||||
describe('Health Checks', () => {
|
||||
it('should return healthy by default', async () => {
|
||||
// When
|
||||
const result = await adapter.performHealthCheck();
|
||||
|
||||
// Then
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(adapter.getStatus()).toBe('connected');
|
||||
});
|
||||
|
||||
it('should return unhealthy when configured to fail', async () => {
|
||||
// Given
|
||||
adapter.setShouldFail(true, 'Custom error');
|
||||
|
||||
// When
|
||||
const result = await adapter.performHealthCheck();
|
||||
|
||||
// Then
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(result.error).toBe('Custom error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Transitions', () => {
|
||||
it('should transition to disconnected after 3 consecutive failures', async () => {
|
||||
// Given
|
||||
adapter.setShouldFail(true);
|
||||
|
||||
// When
|
||||
await adapter.performHealthCheck(); // 1
|
||||
expect(adapter.getStatus()).toBe('checking'); // Initial state is disconnected, first failure keeps it checking/disconnected
|
||||
|
||||
await adapter.performHealthCheck(); // 2
|
||||
await adapter.performHealthCheck(); // 3
|
||||
|
||||
// Then
|
||||
expect(adapter.getStatus()).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('should transition to degraded if reliability is low', async () => {
|
||||
// Given
|
||||
// We need 5 requests total, and reliability < 0.7
|
||||
// 1 success, 4 failures (not consecutive)
|
||||
|
||||
await adapter.performHealthCheck(); // Success 1
|
||||
|
||||
adapter.setShouldFail(true);
|
||||
await adapter.performHealthCheck(); // Failure 1
|
||||
adapter.setShouldFail(false);
|
||||
await adapter.performHealthCheck(); // Success 2 (resets consecutive)
|
||||
adapter.setShouldFail(true);
|
||||
await adapter.performHealthCheck(); // Failure 2
|
||||
await adapter.performHealthCheck(); // Failure 3
|
||||
adapter.setShouldFail(false);
|
||||
await adapter.performHealthCheck(); // Success 3 (resets consecutive)
|
||||
adapter.setShouldFail(true);
|
||||
await adapter.performHealthCheck(); // Failure 4
|
||||
await adapter.performHealthCheck(); // Failure 5
|
||||
|
||||
// Then
|
||||
expect(adapter.getStatus()).toBe('degraded');
|
||||
expect(adapter.getReliability()).toBeLessThan(70);
|
||||
});
|
||||
|
||||
it('should recover status after a success', async () => {
|
||||
// Given
|
||||
adapter.setShouldFail(true);
|
||||
await adapter.performHealthCheck();
|
||||
await adapter.performHealthCheck();
|
||||
await adapter.performHealthCheck();
|
||||
expect(adapter.getStatus()).toBe('disconnected');
|
||||
|
||||
// When
|
||||
adapter.setShouldFail(false);
|
||||
await adapter.performHealthCheck();
|
||||
|
||||
// Then
|
||||
expect(adapter.getStatus()).toBe('connected');
|
||||
expect(adapter.isAvailable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metrics', () => {
|
||||
it('should track average response time', async () => {
|
||||
// Given
|
||||
adapter.setResponseTime(10);
|
||||
await adapter.performHealthCheck();
|
||||
|
||||
adapter.setResponseTime(20);
|
||||
await adapter.performHealthCheck();
|
||||
|
||||
// Then
|
||||
const health = adapter.getHealth();
|
||||
expect(health.averageResponseTime).toBe(15);
|
||||
expect(health.totalRequests).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maintenance', () => {
|
||||
it('should clear state', async () => {
|
||||
// Given
|
||||
await adapter.performHealthCheck();
|
||||
expect(adapter.getHealth().totalRequests).toBe(1);
|
||||
|
||||
// When
|
||||
adapter.clear();
|
||||
|
||||
// Then
|
||||
expect(adapter.getHealth().totalRequests).toBe(0);
|
||||
expect(adapter.getStatus()).toBe('disconnected'); // Initial state
|
||||
});
|
||||
});
|
||||
});
|
||||
63
adapters/http/RequestContext.test.ts
Normal file
63
adapters/http/RequestContext.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getHttpRequestContext, requestContextMiddleware, tryGetHttpRequestContext } from './RequestContext';
|
||||
|
||||
describe('RequestContext', () => {
|
||||
it('should return null when accessed outside of middleware', () => {
|
||||
// When
|
||||
const ctx = tryGetHttpRequestContext();
|
||||
|
||||
// Then
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error when getHttpRequestContext is called outside of middleware', () => {
|
||||
// When & Then
|
||||
expect(() => getHttpRequestContext()).toThrow('HttpRequestContext is not available');
|
||||
});
|
||||
|
||||
it('should provide request and response within middleware scope', () => {
|
||||
// Given
|
||||
const mockReq = { id: 'req-1' } as unknown as Request;
|
||||
const mockRes = { id: 'res-1' } as unknown as Response;
|
||||
|
||||
// When
|
||||
return new Promise<void>((resolve) => {
|
||||
requestContextMiddleware(mockReq, mockRes, () => {
|
||||
// Then
|
||||
const ctx = getHttpRequestContext();
|
||||
expect(ctx.req).toBe(mockReq);
|
||||
expect(ctx.res).toBe(mockRes);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain separate contexts for concurrent requests', () => {
|
||||
// Given
|
||||
const req1 = { id: '1' } as unknown as Request;
|
||||
const res1 = { id: '1' } as unknown as Response;
|
||||
const req2 = { id: '2' } as unknown as Request;
|
||||
const res2 = { id: '2' } as unknown as Response;
|
||||
|
||||
// When
|
||||
const p1 = new Promise<void>((resolve) => {
|
||||
requestContextMiddleware(req1, res1, () => {
|
||||
setTimeout(() => {
|
||||
expect(getHttpRequestContext().req).toBe(req1);
|
||||
resolve();
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
const p2 = new Promise<void>((resolve) => {
|
||||
requestContextMiddleware(req2, res2, () => {
|
||||
setTimeout(() => {
|
||||
expect(getHttpRequestContext().req).toBe(req2);
|
||||
resolve();
|
||||
}, 5);
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all([p1, p2]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { InMemoryLeaderboardsRepository } from './InMemoryLeaderboardsRepository';
|
||||
import { LeaderboardDriverData, LeaderboardTeamData } from '../../../../core/leaderboards/application/ports/LeaderboardsRepository';
|
||||
|
||||
describe('InMemoryLeaderboardsRepository', () => {
|
||||
let repository: InMemoryLeaderboardsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryLeaderboardsRepository();
|
||||
});
|
||||
|
||||
describe('drivers', () => {
|
||||
it('should return empty array when no drivers exist', async () => {
|
||||
// When
|
||||
const result = await repository.findAllDrivers();
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should add and find all drivers', async () => {
|
||||
// Given
|
||||
const driver: LeaderboardDriverData = {
|
||||
id: 'd1',
|
||||
name: 'Driver 1',
|
||||
rating: 1500,
|
||||
raceCount: 10,
|
||||
teamId: 't1',
|
||||
teamName: 'Team 1',
|
||||
};
|
||||
repository.addDriver(driver);
|
||||
|
||||
// When
|
||||
const result = await repository.findAllDrivers();
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([driver]);
|
||||
});
|
||||
|
||||
it('should find drivers by team id', async () => {
|
||||
// Given
|
||||
const d1: LeaderboardDriverData = { id: 'd1', name: 'D1', rating: 1500, raceCount: 10, teamId: 't1', teamName: 'T1' };
|
||||
const d2: LeaderboardDriverData = { id: 'd2', name: 'D2', rating: 1400, raceCount: 5, teamId: 't2', teamName: 'T2' };
|
||||
repository.addDriver(d1);
|
||||
repository.addDriver(d2);
|
||||
|
||||
// When
|
||||
const result = await repository.findDriversByTeamId('t1');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([d1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('teams', () => {
|
||||
it('should add and find all teams', async () => {
|
||||
// Given
|
||||
const team: LeaderboardTeamData = {
|
||||
id: 't1',
|
||||
name: 'Team 1',
|
||||
rating: 3000,
|
||||
memberCount: 2,
|
||||
raceCount: 20,
|
||||
};
|
||||
repository.addTeam(team);
|
||||
|
||||
// When
|
||||
const result = await repository.findAllTeams();
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([team]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { InMemoryLeagueRepository } from './InMemoryLeagueRepository';
|
||||
import { LeagueData } from '../../../../core/leagues/application/ports/LeagueRepository';
|
||||
|
||||
describe('InMemoryLeagueRepository', () => {
|
||||
let repository: InMemoryLeagueRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryLeagueRepository();
|
||||
});
|
||||
|
||||
const createLeague = (id: string, name: string, ownerId: string): LeagueData => ({
|
||||
id,
|
||||
name,
|
||||
ownerId,
|
||||
description: `Description for ${name}`,
|
||||
visibility: 'public',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
maxDrivers: 100,
|
||||
approvalRequired: false,
|
||||
lateJoinAllowed: true,
|
||||
raceFrequency: 'weekly',
|
||||
raceDay: 'Monday',
|
||||
raceTime: '20:00',
|
||||
tracks: ['Spa'],
|
||||
scoringSystem: null,
|
||||
bonusPointsEnabled: true,
|
||||
penaltiesEnabled: true,
|
||||
protestsEnabled: true,
|
||||
appealsEnabled: true,
|
||||
stewardTeam: [],
|
||||
gameType: 'iRacing',
|
||||
skillLevel: 'Intermediate',
|
||||
category: 'Road',
|
||||
tags: [],
|
||||
});
|
||||
|
||||
describe('create and findById', () => {
|
||||
it('should return null when league does not exist', async () => {
|
||||
// When
|
||||
const result = await repository.findById('non-existent');
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should create and retrieve a league', async () => {
|
||||
// Given
|
||||
const league = createLeague('l1', 'League 1', 'o1');
|
||||
|
||||
// When
|
||||
await repository.create(league);
|
||||
const result = await repository.findById('l1');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(league);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find a league by name', async () => {
|
||||
// Given
|
||||
const league = createLeague('l1', 'Unique Name', 'o1');
|
||||
await repository.create(league);
|
||||
|
||||
// When
|
||||
const result = await repository.findByName('Unique Name');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(league);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing league', async () => {
|
||||
// Given
|
||||
const league = createLeague('l1', 'Original Name', 'o1');
|
||||
await repository.create(league);
|
||||
|
||||
// When
|
||||
const updated = await repository.update('l1', { name: 'Updated Name' });
|
||||
|
||||
// Then
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
const result = await repository.findById('l1');
|
||||
expect(result?.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent league', async () => {
|
||||
// When & Then
|
||||
await expect(repository.update('non-existent', { name: 'New' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a league', async () => {
|
||||
// Given
|
||||
const league = createLeague('l1', 'To Delete', 'o1');
|
||||
await repository.create(league);
|
||||
|
||||
// When
|
||||
await repository.delete('l1');
|
||||
|
||||
// Then
|
||||
const result = await repository.findById('l1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should find leagues by name or description', async () => {
|
||||
// Given
|
||||
const l1 = createLeague('l1', 'Formula 1', 'o1');
|
||||
const l2 = createLeague('l2', 'GT3 Masters', 'o1');
|
||||
await repository.create(l1);
|
||||
await repository.create(l2);
|
||||
|
||||
// When
|
||||
const results = await repository.search('Formula');
|
||||
|
||||
// Then
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('l1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, vi } from 'vitest';
|
||||
import { InMemoryMediaRepository } from './InMemoryMediaRepository';
|
||||
import { runMediaRepositoryContract } from '../../../../tests/contracts/media/MediaRepository.contract';
|
||||
|
||||
describe('InMemoryMediaRepository Contract Compliance', () => {
|
||||
runMediaRepositoryContract(async () => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const repository = new InMemoryMediaRepository(logger as any);
|
||||
|
||||
return {
|
||||
repository,
|
||||
cleanup: async () => {
|
||||
repository.clear();
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, vi } from 'vitest';
|
||||
import { TypeOrmMediaRepository } from './TypeOrmMediaRepository';
|
||||
import { MediaOrmMapper } from '../mappers/MediaOrmMapper';
|
||||
import { runMediaRepositoryContract } from '../../../../../tests/contracts/media/MediaRepository.contract';
|
||||
|
||||
describe('TypeOrmMediaRepository Contract Compliance', () => {
|
||||
runMediaRepositoryContract(async () => {
|
||||
// Mocking TypeORM DataSource and Repository for a DB-free contract test
|
||||
// In a real scenario, this might use an in-memory SQLite database
|
||||
const ormEntities = new Map<string, any>();
|
||||
|
||||
const ormRepo = {
|
||||
save: vi.fn().mockImplementation(async (entity) => {
|
||||
ormEntities.set(entity.id, entity);
|
||||
return entity;
|
||||
}),
|
||||
findOne: vi.fn().mockImplementation(async ({ where: { id } }) => {
|
||||
return ormEntities.get(id) || null;
|
||||
}),
|
||||
find: vi.fn().mockImplementation(async ({ where: { uploadedBy } }) => {
|
||||
return Array.from(ormEntities.values()).filter(e => e.uploadedBy === uploadedBy);
|
||||
}),
|
||||
delete: vi.fn().mockImplementation(async ({ id }) => {
|
||||
ormEntities.delete(id);
|
||||
}),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = new MediaOrmMapper();
|
||||
const repository = new TypeOrmMediaRepository(dataSource as any, mapper);
|
||||
|
||||
return {
|
||||
repository,
|
||||
cleanup: async () => {
|
||||
ormEntities.clear();
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { DiscordNotificationAdapter } from './DiscordNotificationGateway';
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
|
||||
describe('DiscordNotificationAdapter', () => {
|
||||
const webhookUrl = 'https://discord.com/api/webhooks/123/abc';
|
||||
let adapter: DiscordNotificationAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new DiscordNotificationAdapter({ webhookUrl });
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
const createNotification = (overrides: any = {}) => {
|
||||
return Notification.create({
|
||||
id: 'notif-123',
|
||||
recipientId: 'driver-456',
|
||||
type: 'protest_filed',
|
||||
title: 'New Protest',
|
||||
body: 'A new protest has been filed against you.',
|
||||
channel: 'discord',
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
describe('send', () => {
|
||||
it('should return success when configured', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
|
||||
// When
|
||||
const result = await adapter.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe('discord');
|
||||
expect(result.externalId).toContain('discord-stub-');
|
||||
expect(result.attemptedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should return failure when not configured', async () => {
|
||||
// Given
|
||||
const unconfiguredAdapter = new DiscordNotificationAdapter();
|
||||
const notification = createNotification();
|
||||
|
||||
// When
|
||||
const result = await unconfiguredAdapter.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Discord webhook URL not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsChannel', () => {
|
||||
it('should return true for discord channel', () => {
|
||||
expect(adapter.supportsChannel('discord')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other channels', () => {
|
||||
expect(adapter.supportsChannel('email' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
it('should return true when webhookUrl is set', () => {
|
||||
expect(adapter.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when webhookUrl is missing', () => {
|
||||
const unconfigured = new DiscordNotificationAdapter();
|
||||
expect(unconfigured.isConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setWebhookUrl', () => {
|
||||
it('should update the webhook URL', () => {
|
||||
const unconfigured = new DiscordNotificationAdapter();
|
||||
unconfigured.setWebhookUrl(webhookUrl);
|
||||
expect(unconfigured.isConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { EmailNotificationAdapter } from './EmailNotificationGateway';
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
|
||||
describe('EmailNotificationAdapter', () => {
|
||||
const config = {
|
||||
smtpHost: 'smtp.example.com',
|
||||
fromAddress: 'noreply@gridpilot.com',
|
||||
};
|
||||
let adapter: EmailNotificationAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new EmailNotificationAdapter(config);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
const createNotification = (overrides: any = {}) => {
|
||||
return Notification.create({
|
||||
id: 'notif-123',
|
||||
recipientId: 'driver-456',
|
||||
type: 'protest_filed',
|
||||
title: 'New Protest',
|
||||
body: 'A new protest has been filed against you.',
|
||||
channel: 'email',
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
describe('send', () => {
|
||||
it('should return success when configured', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
|
||||
// When
|
||||
const result = await adapter.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe('email');
|
||||
expect(result.externalId).toContain('email-stub-');
|
||||
expect(result.attemptedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should return failure when not configured', async () => {
|
||||
// Given
|
||||
const unconfiguredAdapter = new EmailNotificationAdapter();
|
||||
const notification = createNotification();
|
||||
|
||||
// When
|
||||
const result = await unconfiguredAdapter.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Email SMTP not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsChannel', () => {
|
||||
it('should return true for email channel', () => {
|
||||
expect(adapter.supportsChannel('email')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other channels', () => {
|
||||
expect(adapter.supportsChannel('discord' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
it('should return true when smtpHost and fromAddress are set', () => {
|
||||
expect(adapter.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when config is missing', () => {
|
||||
const unconfigured = new EmailNotificationAdapter();
|
||||
expect(unconfigured.isConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configure', () => {
|
||||
it('should update the configuration', () => {
|
||||
const unconfigured = new EmailNotificationAdapter();
|
||||
unconfigured.configure(config);
|
||||
expect(unconfigured.isConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { InAppNotificationAdapter } from './InAppNotificationGateway';
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
|
||||
describe('InAppNotificationAdapter', () => {
|
||||
let adapter: InAppNotificationAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new InAppNotificationAdapter();
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
const createNotification = (overrides: any = {}) => {
|
||||
return Notification.create({
|
||||
id: 'notif-123',
|
||||
recipientId: 'driver-456',
|
||||
type: 'protest_filed',
|
||||
title: 'New Protest',
|
||||
body: 'A new protest has been filed against you.',
|
||||
channel: 'in_app',
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
describe('send', () => {
|
||||
it('should return success', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
|
||||
// When
|
||||
const result = await adapter.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe('in_app');
|
||||
expect(result.externalId).toBe('notif-123');
|
||||
expect(result.attemptedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsChannel', () => {
|
||||
it('should return true for in_app channel', () => {
|
||||
expect(adapter.supportsChannel('in_app')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other channels', () => {
|
||||
expect(adapter.supportsChannel('email' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
it('should always return true', () => {
|
||||
expect(adapter.isConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { NotificationGatewayRegistry } from './NotificationGatewayRegistry';
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
import type { NotificationGateway, NotificationDeliveryResult } from '@core/notifications/application/ports/NotificationGateway';
|
||||
import type { NotificationChannel } from '@core/notifications/domain/types/NotificationTypes';
|
||||
|
||||
describe('NotificationGatewayRegistry', () => {
|
||||
let registry: NotificationGatewayRegistry;
|
||||
let mockGateway: NotificationGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGateway = {
|
||||
send: vi.fn(),
|
||||
supportsChannel: vi.fn().mockReturnValue(true),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
getChannel: vi.fn().mockReturnValue('email'),
|
||||
};
|
||||
registry = new NotificationGatewayRegistry([mockGateway]);
|
||||
});
|
||||
|
||||
const createNotification = (overrides: any = {}) => {
|
||||
return Notification.create({
|
||||
id: 'notif-123',
|
||||
recipientId: 'driver-456',
|
||||
type: 'protest_filed',
|
||||
title: 'New Protest',
|
||||
body: 'A new protest has been filed against you.',
|
||||
channel: 'email',
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
describe('register and get', () => {
|
||||
it('should register and retrieve a gateway', () => {
|
||||
const discordGateway = {
|
||||
...mockGateway,
|
||||
getChannel: vi.fn().mockReturnValue('discord'),
|
||||
} as any;
|
||||
|
||||
registry.register(discordGateway);
|
||||
expect(registry.getGateway('discord')).toBe(discordGateway);
|
||||
});
|
||||
|
||||
it('should return null for unregistered channel', () => {
|
||||
expect(registry.getGateway('discord')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return all registered gateways', () => {
|
||||
expect(registry.getAllGateways()).toHaveLength(1);
|
||||
expect(registry.getAllGateways()[0]).toBe(mockGateway);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should route notification to the correct gateway', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
const expectedResult: NotificationDeliveryResult = {
|
||||
success: true,
|
||||
channel: 'email',
|
||||
externalId: 'ext-123',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
vi.mocked(mockGateway.send).mockResolvedValue(expectedResult);
|
||||
|
||||
// When
|
||||
const result = await registry.send(notification);
|
||||
|
||||
// Then
|
||||
expect(mockGateway.send).toHaveBeenCalledWith(notification);
|
||||
expect(result).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should return failure if no gateway is registered for channel', async () => {
|
||||
// Given
|
||||
const notification = createNotification({ channel: 'discord' });
|
||||
|
||||
// When
|
||||
const result = await registry.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No gateway registered for channel: discord');
|
||||
});
|
||||
|
||||
it('should return failure if gateway is not configured', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
vi.mocked(mockGateway.isConfigured).mockReturnValue(false);
|
||||
|
||||
// When
|
||||
const result = await registry.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Gateway for channel email is not configured');
|
||||
});
|
||||
|
||||
it('should catch and return errors from gateway.send', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
vi.mocked(mockGateway.send).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
// When
|
||||
const result = await registry.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Network error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { InMemoryRaceRepository } from './InMemoryRaceRepository';
|
||||
import { RaceData } from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||
|
||||
describe('InMemoryRaceRepository', () => {
|
||||
let repository: InMemoryRaceRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryRaceRepository();
|
||||
});
|
||||
|
||||
describe('getUpcomingRaces', () => {
|
||||
it('should return empty array when no races for driver', async () => {
|
||||
// When
|
||||
const result = await repository.getUpcomingRaces('driver-1');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return races when they exist', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const races: RaceData[] = [
|
||||
{
|
||||
id: 'race-1',
|
||||
trackName: 'Spa-Francorchamps',
|
||||
carType: 'GT3',
|
||||
scheduledDate: new Date(),
|
||||
},
|
||||
];
|
||||
repository.addUpcomingRaces(driverId, races);
|
||||
|
||||
// When
|
||||
const result = await repository.getUpcomingRaces(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(races);
|
||||
});
|
||||
|
||||
it('should overwrite races for same driver (idempotency)', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const races1: RaceData[] = [{ id: 'r1', trackName: 'T1', carType: 'C1', scheduledDate: new Date() }];
|
||||
const races2: RaceData[] = [{ id: 'r2', trackName: 'T2', carType: 'C2', scheduledDate: new Date() }];
|
||||
|
||||
// When
|
||||
repository.addUpcomingRaces(driverId, races1);
|
||||
repository.addUpcomingRaces(driverId, races2);
|
||||
const result = await repository.getUpcomingRaces(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(races2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { InMemoryRatingRepository } from './InMemoryRatingRepository';
|
||||
import { Rating } from '../../../../core/rating/domain/Rating';
|
||||
import { DriverId } from '../../../../core/racing/domain/entities/DriverId';
|
||||
import { RaceId } from '../../../../core/racing/domain/entities/RaceId';
|
||||
|
||||
describe('InMemoryRatingRepository', () => {
|
||||
let repository: InMemoryRatingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryRatingRepository();
|
||||
});
|
||||
|
||||
const createRating = (driverId: string, raceId: string, ratingValue: number) => {
|
||||
return Rating.create({
|
||||
driverId: DriverId.create(driverId),
|
||||
raceId: RaceId.create(raceId),
|
||||
rating: ratingValue,
|
||||
components: {
|
||||
resultsStrength: ratingValue,
|
||||
consistency: 0,
|
||||
cleanDriving: 0,
|
||||
racecraft: 0,
|
||||
reliability: 0,
|
||||
teamContribution: 0,
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
describe('save and findByDriverAndRace', () => {
|
||||
it('should return null when rating does not exist', async () => {
|
||||
// When
|
||||
const result = await repository.findByDriverAndRace('d1', 'r1');
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should save and retrieve a rating', async () => {
|
||||
// Given
|
||||
const rating = createRating('d1', 'r1', 1500);
|
||||
|
||||
// When
|
||||
await repository.save(rating);
|
||||
const result = await repository.findByDriverAndRace('d1', 'r1');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(rating);
|
||||
});
|
||||
|
||||
it('should overwrite rating for same driver and race (idempotency)', async () => {
|
||||
// Given
|
||||
const r1 = createRating('d1', 'r1', 1500);
|
||||
const r2 = createRating('d1', 'r1', 1600);
|
||||
|
||||
// When
|
||||
await repository.save(r1);
|
||||
await repository.save(r2);
|
||||
const result = await repository.findByDriverAndRace('d1', 'r1');
|
||||
|
||||
// Then
|
||||
expect(result?.rating).toBe(1600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByDriver', () => {
|
||||
it('should return all ratings for a driver', async () => {
|
||||
// Given
|
||||
const r1 = createRating('d1', 'r1', 1500);
|
||||
const r2 = createRating('d1', 'r2', 1600);
|
||||
const r3 = createRating('d2', 'r1', 1400);
|
||||
|
||||
await repository.save(r1);
|
||||
await repository.save(r2);
|
||||
await repository.save(r3);
|
||||
|
||||
// When
|
||||
const result = await repository.findByDriver('d1');
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual(r1);
|
||||
expect(result).toContainEqual(r2);
|
||||
});
|
||||
});
|
||||
});
|
||||
248
plans/testing-concept-adapters.md
Normal file
248
plans/testing-concept-adapters.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# Testing concept: fully testing [`adapters/`](adapters/:1)
|
||||
|
||||
This is a Clean Architecture-aligned testing concept for completely testing the code under [`adapters/`](adapters/:1), using:
|
||||
|
||||
- [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md:1) (where test types belong)
|
||||
- [`docs/architecture/shared/ADAPTERS.md`](docs/architecture/shared/ADAPTERS.md:1) (what adapters are)
|
||||
- [`docs/architecture/shared/REPOSITORY_STRUCTURE.md`](docs/architecture/shared/REPOSITORY_STRUCTURE.md:1) (where things live)
|
||||
- [`docs/architecture/shared/DATA_FLOW.md`](docs/architecture/shared/DATA_FLOW.md:1) (dependency rule)
|
||||
- [`docs/TESTS.md`](docs/TESTS.md:1) (current repo testing practices)
|
||||
|
||||
---
|
||||
|
||||
## 1) Goal + constraints
|
||||
|
||||
### 1.1 Goal
|
||||
Make [`adapters/`](adapters/:1) **safe to change** by covering:
|
||||
|
||||
1. Correct port behavior (adapters implement Core ports correctly)
|
||||
2. Correct mapping across boundaries (domain ⇄ persistence, domain ⇄ external system)
|
||||
3. Correct error shaping at boundaries (adapter-scoped schema errors)
|
||||
4. Correct composition (small clusters like composite resolvers)
|
||||
5. Correct wiring assumptions (DI boundaries: repositories don’t construct their own mappers)
|
||||
|
||||
### 1.2 Constraints / non-negotiables
|
||||
|
||||
- Dependencies point inward: delivery apps → adapters → core per [`docs/architecture/shared/DATA_FLOW.md`](docs/architecture/shared/DATA_FLOW.md:13)
|
||||
- Adapters are reusable infrastructure implementations (no delivery concerns) per [`docs/architecture/shared/REPOSITORY_STRUCTURE.md`](docs/architecture/shared/REPOSITORY_STRUCTURE.md:25)
|
||||
- Tests live as close as possible to the code they verify per [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md:6)
|
||||
|
||||
---
|
||||
|
||||
## 2) Test taxonomy for adapters (mapped to repo locations)
|
||||
|
||||
This section translates [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md:1) into concrete rules for adapter code.
|
||||
|
||||
### 2.1 Local tests (live inside [`adapters/`](adapters/:1))
|
||||
|
||||
These are the default for adapter correctness.
|
||||
|
||||
#### A) Unit tests (file-adjacent)
|
||||
**Use for:**
|
||||
|
||||
- schema guards (validate persisted/remote shapes)
|
||||
- error types (message formatting, details)
|
||||
- pure mappers (domain ⇄ orm/DTO)
|
||||
- in-memory repositories and deterministic services
|
||||
|
||||
**Location:** next to implementation, e.g. [`adapters/logging/ConsoleLogger.test.ts`](adapters/logging/ConsoleLogger.test.ts:1)
|
||||
|
||||
**Style:** behavior-focused with BDD structure from [`docs/TESTS.md`](docs/TESTS.md:23). Use simple `Given/When/Then` comments; do not assert internal calls unless that’s the observable contract.
|
||||
|
||||
Reference anchor: [`typescript.describe()`](adapters/logging/ConsoleLogger.test.ts:4)
|
||||
|
||||
#### B) Sociable unit tests (small collaborating cluster)
|
||||
**Use for:**
|
||||
|
||||
- a repository using an injected mapper (repository + mapper + schema guard)
|
||||
- composite adapters (delegation and resolution order)
|
||||
|
||||
**Location:** still adjacent to the “root” of the cluster, not necessarily to each file.
|
||||
|
||||
Reference anchor: [`adapters/media/MediaResolverAdapter.test.ts`](adapters/media/MediaResolverAdapter.test.ts:1)
|
||||
|
||||
#### C) Component / module tests (module invariants without infrastructure)
|
||||
**Use for:**
|
||||
|
||||
- “module-level” adapter compositions that should behave consistently as a unit (e.g. a group of in-memory repos that are expected to work together)
|
||||
|
||||
**Location:** adjacent to the module root.
|
||||
|
||||
Reference anchor: [`adapters/racing/persistence/inmemory/InMemoryScoringRepositories.test.ts`](adapters/racing/persistence/inmemory/InMemoryScoringRepositories.test.ts:1)
|
||||
|
||||
### 2.2 Global tests (live outside adapters)
|
||||
|
||||
#### D) Contract tests (boundary tests)
|
||||
Contract tests belong at system boundaries per [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md:88).
|
||||
|
||||
For this repo there are two contract categories:
|
||||
|
||||
1. **External system contracts** (API ↔ website) already documented in [`docs/CONTRACT_TESTING.md`](docs/CONTRACT_TESTING.md:1)
|
||||
2. **Internal port contracts** (core port interface ↔ adapter implementation)
|
||||
|
||||
Internal port contracts are still valuable, but they are not “between systems”. Treat them as **shared executable specifications** for a port.
|
||||
|
||||
**Proposed location:** [`tests/contracts/`](tests/:1)
|
||||
|
||||
Principle: the contract suite imports the port interface from core and runs the same assertions against multiple adapter implementations (in-memory and TypeORM-DB-free where possible).
|
||||
|
||||
#### E) Integration / E2E (system-level)
|
||||
Per [`docs/TESTS.md`](docs/TESTS.md:106):
|
||||
|
||||
- Integration tests live in [`tests/integration/`](tests/:1) and use in-memory adapters.
|
||||
- E2E tests live in [`tests/e2e/`](tests/:1) and can use TypeORM/Postgres.
|
||||
|
||||
Adapter code should *enable* these tests, but adapter *unit correctness* should not depend on these tests.
|
||||
|
||||
---
|
||||
|
||||
## 3) Canonical adapter test recipes (what to test, not how)
|
||||
|
||||
These are reusable patterns to standardize how we test adapters.
|
||||
|
||||
### 3.1 In-memory repositories (pure adapter behavior)
|
||||
|
||||
**Minimum spec for an in-memory repository implementation:**
|
||||
|
||||
- persists and retrieves the aggregate/value (happy path)
|
||||
- supports negative paths (not found returns null / empty)
|
||||
- enforces invariants that the real implementation must also enforce (uniqueness, idempotency)
|
||||
- does not leak references if immutability is expected (optional; depends on domain semantics)
|
||||
|
||||
Examples:
|
||||
|
||||
- [`adapters/identity/persistence/inmemory/InMemoryUserRepository.test.ts`](adapters/identity/persistence/inmemory/InMemoryUserRepository.test.ts:1)
|
||||
- [`adapters/racing/persistence/inmemory/InMemorySessionRepository.test.ts`](adapters/racing/persistence/inmemory/InMemorySessionRepository.test.ts:1)
|
||||
|
||||
### 3.2 TypeORM mappers (mapping + validation)
|
||||
|
||||
**Minimum spec for a mapper:**
|
||||
|
||||
- domain → orm mapping produces a persistable shape
|
||||
- orm → domain mapping reconstitutes without calling “create” semantics (i.e., preserves persisted identity)
|
||||
- invalid persisted shape throws adapter-scoped schema error type
|
||||
|
||||
Examples:
|
||||
|
||||
- [`adapters/media/persistence/typeorm/mappers/MediaOrmMapper.test.ts`](adapters/media/persistence/typeorm/mappers/MediaOrmMapper.test.ts:1)
|
||||
- [`adapters/racing/persistence/typeorm/mappers/DriverOrmMapper.test.ts`](adapters/racing/persistence/typeorm/mappers/DriverOrmMapper.test.ts:1)
|
||||
|
||||
### 3.3 TypeORM repositories (DB-free correctness + DI boundaries)
|
||||
|
||||
**We split repository tests into 2 categories:**
|
||||
|
||||
1. **DB-free repository behavior tests**: verify mapping is applied and correct ORM repository methods are called with expected shapes (using a stubbed TypeORM repository).
|
||||
2. **DI boundary tests**: verify no internal instantiation of mappers and that constructor requires injected dependencies.
|
||||
|
||||
Examples:
|
||||
|
||||
- [`adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.test.ts`](adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.test.ts:1)
|
||||
- [`adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository.test.ts`](adapters/payments/persistence/typeorm/repositories/TypeOrmPaymentRepository.test.ts:1)
|
||||
|
||||
### 3.4 Schema guards + schema errors (adapter boundary hardening)
|
||||
|
||||
**Minimum spec:**
|
||||
|
||||
- guard accepts valid shapes
|
||||
- guard rejects invalid shapes with deterministic error messages
|
||||
- schema error contains enough details to debug (entity, field, reason)
|
||||
|
||||
Examples:
|
||||
|
||||
- [`adapters/admin/persistence/typeorm/schema/TypeOrmAdminSchemaGuards.test.ts`](adapters/admin/persistence/typeorm/schema/TypeOrmAdminSchemaGuards.test.ts:1)
|
||||
- [`adapters/admin/persistence/typeorm/errors/TypeOrmAdminSchemaError.test.ts`](adapters/admin/persistence/typeorm/errors/TypeOrmAdminSchemaError.test.ts:1)
|
||||
|
||||
### 3.5 Gateways (external side effects)
|
||||
|
||||
**Minimum spec:**
|
||||
|
||||
- correct request construction (mapping domain intent → external API payload)
|
||||
- error handling and retries (if present)
|
||||
- logging behavior (only observable outputs)
|
||||
|
||||
These tests should stub the external client; no real network.
|
||||
|
||||
---
|
||||
|
||||
## 4) Gap matrix (folder-level)
|
||||
|
||||
Legend:
|
||||
|
||||
- ✅ = present (at least one meaningful test exists)
|
||||
- ⚠️ = partially covered
|
||||
- ❌ = missing
|
||||
|
||||
> Important: this matrix is based on the current directory contents under [`adapters/`](adapters/:1). It’s folder-level, not per-class.
|
||||
|
||||
| Adapter folder | What exists | Local tests status | Missing tests (minimum) |
|
||||
|---|---|---:|---|
|
||||
| [`adapters/achievement/`](adapters/achievement/:1) | TypeORM entities/mappers/repository/schema guard | ❌ | Mapper tests, schema guard tests, repo DI boundary tests, schema error tests |
|
||||
| [`adapters/activity/`](adapters/activity/:1) | In-memory repository | ❌ | In-memory repo behavior test suite |
|
||||
| [`adapters/admin/`](adapters/admin/:1) | In-memory repo + TypeORM layer | ✅ | Consider adding DB-free repo tests consistency patterns for TypeORM (if not already), ensure schema guard coverage is complete |
|
||||
| [`adapters/analytics/`](adapters/analytics/:1) | In-memory repos + TypeORM layer | ⚠️ | Tests for TypeORM repos without tests, tests for non-tested mappers (`AnalyticsSnapshotOrmMapper`, `EngagementEventOrmMapper`), schema guard tests, schema error tests |
|
||||
| [`adapters/automation/`](adapters/automation/:1) | Config objects | ❌ | Unit tests for config parsing/merging defaults (if behavior exists); otherwise explicitly accept no tests |
|
||||
| [`adapters/bootstrap/`](adapters/bootstrap/:1) | Seeders + many config modules + factories | ⚠️ | Add unit tests for critical deterministic configs/factories not yet covered; establish module tests for seeding workflows (DB-free) |
|
||||
| [`adapters/drivers/`](adapters/drivers/:1) | In-memory repository | ❌ | In-memory repo behavior tests |
|
||||
| [`adapters/events/`](adapters/events/:1) | In-memory event publishers | ❌ | Behavior tests: publishes expected events to subscribers/collectors; ensure “no-op” safety |
|
||||
| [`adapters/health/`](adapters/health/:1) | In-memory health check adapter | ❌ | Behavior tests: healthy/unhealthy reporting, edge cases |
|
||||
| [`adapters/http/`](adapters/http/:1) | Request context module | ❌ | Unit tests for any parsing/propagation logic; otherwise explicitly accept no tests |
|
||||
| [`adapters/identity/`](adapters/identity/:1) | In-memory repos + TypeORM repos/mappers + services + session adapter | ⚠️ | Add tests for in-memory files without tests (company/external game rating), tests for TypeORM repos without tests, schema guards tests, cookie session adapter tests |
|
||||
| [`adapters/leaderboards/`](adapters/leaderboards/:1) | In-memory repo + event publisher | ❌ | Repo tests + publisher tests |
|
||||
| [`adapters/leagues/`](adapters/leagues/:1) | In-memory repo + event publisher | ❌ | Repo tests + publisher tests |
|
||||
| [`adapters/logging/`](adapters/logging/:1) | Console logger + error reporter | ⚠️ | Add tests for error reporter behavior; keep logger tests |
|
||||
| [`adapters/media/`](adapters/media/:1) | Resolvers + in-memory repos + TypeORM layer + ports | ⚠️ | Add tests for in-memory repos without tests, file-system storage adapter tests, gateway/event publisher tests if behavior exists |
|
||||
| [`adapters/notifications/`](adapters/notifications/:1) | Gateways + persistence + ports | ⚠️ | Add gateway tests, registry tests, port adapter tests; schema guard tests for TypeORM |
|
||||
| [`adapters/payments/`](adapters/payments/:1) | In-memory repos + TypeORM layer | ⚠️ | Add tests for non-tested mappers, non-tested repos, schema guard tests |
|
||||
| [`adapters/persistence/`](adapters/persistence/:1) | In-memory achievement repo + migration script | ⚠️ | Decide whether migrations are tested (usually via E2E/integration). If treated as code, add smoke test for migration shape |
|
||||
| [`adapters/races/`](adapters/races/:1) | In-memory repository | ❌ | In-memory repo behavior tests |
|
||||
| [`adapters/racing/`](adapters/racing/:1) | Large in-memory + TypeORM layer; many tests | ✅ | Add tests for remaining untested files (notably some in-memory repos and TypeORM repos/mappers without tests) |
|
||||
| [`adapters/rating/`](adapters/rating/:1) | In-memory repository | ❌ | In-memory repo behavior tests |
|
||||
| [`adapters/social/`](adapters/social/:1) | In-memory + TypeORM; some tests | ⚠️ | Add tests for TypeORM social graph repository, schema guards, and any missing in-memory invariants |
|
||||
| [`adapters/eslint-rules/`](adapters/eslint-rules/:1) | ESLint rules | ⚠️ | Optional: rule tests (if the project values rule stability); otherwise accept manual verification |
|
||||
|
||||
---
|
||||
|
||||
## 5) Priority order (risk-first)
|
||||
|
||||
If “completely tested” is the goal, this is the order I’d implement missing tests.
|
||||
|
||||
1. Persistence adapters that can corrupt or misread data (TypeORM mappers + schema guards) under [`adapters/racing/persistence/typeorm/`](adapters/racing/persistence/typeorm/:1), [`adapters/identity/persistence/typeorm/`](adapters/identity/persistence/typeorm/:1), [`adapters/payments/persistence/typeorm/`](adapters/payments/persistence/typeorm/:1)
|
||||
2. Un-tested persistence folders with real production impact: [`adapters/achievement/`](adapters/achievement/:1), [`adapters/analytics/`](adapters/analytics/:1)
|
||||
3. External side-effect gateways: [`adapters/notifications/gateways/`](adapters/notifications/gateways/:1)
|
||||
4. Small but foundational shared utilities (request context, health, event publishers): [`adapters/http/`](adapters/http/:1), [`adapters/health/`](adapters/health/:1), [`adapters/events/`](adapters/events/:1)
|
||||
5. Remaining in-memory repos to keep integration tests trustworthy: [`adapters/activity/`](adapters/activity/:1), [`adapters/drivers/`](adapters/drivers/:1), [`adapters/races/`](adapters/races/:1), [`adapters/rating/`](adapters/rating/:1), [`adapters/leaderboards/`](adapters/leaderboards/:1), [`adapters/leagues/`](adapters/leagues/:1)
|
||||
|
||||
---
|
||||
|
||||
## 6) Definition of done (what “completely tested adapters” means)
|
||||
|
||||
For each adapter module under [`adapters/`](adapters/:1):
|
||||
|
||||
1. Every in-memory repository has a behavior test (happy path + at least one negative path).
|
||||
2. Every TypeORM mapper has a mapping test and an invalid-shape test.
|
||||
3. Every TypeORM repository has at least a DB-free test proving:
|
||||
- dependencies are injected (no internal `new Mapper()` patterns)
|
||||
- mapping is applied on save/load
|
||||
4. Every schema guard and schema error class is tested.
|
||||
5. Every external gateway has a stubbed-client unit test verifying payload mapping and error shaping.
|
||||
6. At least one module-level test exists for any composite adapter (delegation order + null-handling).
|
||||
7. Anything that is intentionally “not worth unit-testing” is explicitly declared and justified in the gap matrix (to avoid silent omissions).
|
||||
|
||||
---
|
||||
|
||||
## 7) Optional: internal port-contract test harness (shared executable specs)
|
||||
|
||||
If we want the same behavioral contract applied across multiple adapter implementations, add a tiny harness under [`tests/contracts/`](tests/:1):
|
||||
|
||||
- `tests/contracts/<feature>/<PortName>.contract.ts`
|
||||
- exports a function that takes a factory creating an implementation
|
||||
- Each adapter test imports that contract and runs it
|
||||
|
||||
This keeps contracts central **without** moving tests away from the code (the adapter still owns the “run this contract for my implementation” test file).
|
||||
|
||||
---
|
||||
|
||||
## 8) Mode switch intent
|
||||
|
||||
After you approve this concept, the implementation phase is to add the missing tests adjacent to the adapter files and (optionally) introduce `tests/contracts/` without breaking dependency rules.
|
||||
|
||||
118
tests/contracts/media/MediaRepository.contract.ts
Normal file
118
tests/contracts/media/MediaRepository.contract.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { Media } from '../../../core/media/domain/entities/Media';
|
||||
import { MediaRepository } from '../../../core/media/domain/repositories/MediaRepository';
|
||||
|
||||
export function runMediaRepositoryContract(
|
||||
factory: () => Promise<{
|
||||
repository: MediaRepository;
|
||||
cleanup?: () => Promise<void>;
|
||||
}>
|
||||
) {
|
||||
describe('MediaRepository Contract', () => {
|
||||
let repository: MediaRepository;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
const result = await factory();
|
||||
repository = result.repository;
|
||||
cleanup = result.cleanup;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (cleanup) {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('should save and find a media entity by ID', async () => {
|
||||
const media = Media.create({
|
||||
id: 'media-1',
|
||||
filename: 'test.jpg',
|
||||
originalName: 'test.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1024,
|
||||
url: 'https://example.com/test.jpg',
|
||||
type: 'image',
|
||||
uploadedBy: 'user-1',
|
||||
});
|
||||
|
||||
await repository.save(media);
|
||||
const found = await repository.findById('media-1');
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.id).toBe(media.id);
|
||||
expect(found?.filename).toBe(media.filename);
|
||||
});
|
||||
|
||||
it('should return null when finding a non-existent media entity', async () => {
|
||||
const found = await repository.findById('non-existent');
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should find all media entities uploaded by a specific user', async () => {
|
||||
const user1 = 'user-1';
|
||||
const user2 = 'user-2';
|
||||
|
||||
const media1 = Media.create({
|
||||
id: 'm1',
|
||||
filename: 'f1.jpg',
|
||||
originalName: 'f1.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 100,
|
||||
url: 'https://example.com/url1',
|
||||
type: 'image',
|
||||
uploadedBy: user1,
|
||||
});
|
||||
|
||||
const media2 = Media.create({
|
||||
id: 'm2',
|
||||
filename: 'f2.jpg',
|
||||
originalName: 'f2.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 200,
|
||||
url: 'https://example.com/url2',
|
||||
type: 'image',
|
||||
uploadedBy: user1,
|
||||
});
|
||||
|
||||
const media3 = Media.create({
|
||||
id: 'm3',
|
||||
filename: 'f3.jpg',
|
||||
originalName: 'f3.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 300,
|
||||
url: 'https://example.com/url3',
|
||||
type: 'image',
|
||||
uploadedBy: user2,
|
||||
});
|
||||
|
||||
await repository.save(media1);
|
||||
await repository.save(media2);
|
||||
await repository.save(media3);
|
||||
|
||||
const user1Media = await repository.findByUploadedBy(user1);
|
||||
expect(user1Media).toHaveLength(2);
|
||||
expect(user1Media.map(m => m.id)).toContain('m1');
|
||||
expect(user1Media.map(m => m.id)).toContain('m2');
|
||||
});
|
||||
|
||||
it('should delete a media entity', async () => {
|
||||
const media = Media.create({
|
||||
id: 'to-delete',
|
||||
filename: 'del.jpg',
|
||||
originalName: 'del.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 100,
|
||||
url: 'https://example.com/url',
|
||||
type: 'image',
|
||||
uploadedBy: 'user',
|
||||
});
|
||||
|
||||
await repository.save(media);
|
||||
await repository.delete('to-delete');
|
||||
|
||||
const found = await repository.findById('to-delete');
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user