diff --git a/adapters/achievement/persistence/typeorm/errors/TypeOrmPersistenceSchemaAdapterError.test.ts b/adapters/achievement/persistence/typeorm/errors/TypeOrmPersistenceSchemaAdapterError.test.ts new file mode 100644 index 000000000..f65a2a708 --- /dev/null +++ b/adapters/achievement/persistence/typeorm/errors/TypeOrmPersistenceSchemaAdapterError.test.ts @@ -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'); + }); + }); +}); diff --git a/adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper.test.ts b/adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper.test.ts new file mode 100644 index 000000000..5e5fe4eaf --- /dev/null +++ b/adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper.test.ts @@ -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', + }) + ); + }); + }); +}); diff --git a/adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper.ts b/adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper.ts index 973d347e7..64da9e888 100644 --- a/adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper.ts +++ b/adapters/achievement/persistence/typeorm/mappers/AchievementOrmMapper.ts @@ -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({ diff --git a/adapters/achievement/persistence/typeorm/repositories/TypeOrmAchievementRepository.test.ts b/adapters/achievement/persistence/typeorm/repositories/TypeOrmAchievementRepository.test.ts new file mode 100644 index 000000000..d09ba088b --- /dev/null +++ b/adapters/achievement/persistence/typeorm/repositories/TypeOrmAchievementRepository.test.ts @@ -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 }; + let mockAchievementRepo: { findOne: ReturnType; find: ReturnType; save: ReturnType }; + let mockUserAchievementRepo: { findOne: ReturnType; find: ReturnType; save: ReturnType }; + let mockMapper: { toOrmEntity: ReturnType; toDomain: ReturnType; toUserAchievementOrmEntity: ReturnType; toUserAchievementDomain: ReturnType }; + 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, + }, + }); + }); + }); +}); diff --git a/adapters/achievement/persistence/typeorm/schema/AchievementSchemaGuard.test.ts b/adapters/achievement/persistence/typeorm/schema/AchievementSchemaGuard.test.ts new file mode 100644 index 000000000..21aa079c8 --- /dev/null +++ b/adapters/achievement/persistence/typeorm/schema/AchievementSchemaGuard.test.ts @@ -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', + }) + ); + }); + }); +}); diff --git a/adapters/activity/persistence/inmemory/InMemoryActivityRepository.test.ts b/adapters/activity/persistence/inmemory/InMemoryActivityRepository.test.ts new file mode 100644 index 000000000..c6de649f1 --- /dev/null +++ b/adapters/activity/persistence/inmemory/InMemoryActivityRepository.test.ts @@ -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); + }); + }); +}); diff --git a/adapters/analytics/persistence/typeorm/errors/TypeOrmAnalyticsSchemaError.test.ts b/adapters/analytics/persistence/typeorm/errors/TypeOrmAnalyticsSchemaError.test.ts new file mode 100644 index 000000000..80344bf75 --- /dev/null +++ b/adapters/analytics/persistence/typeorm/errors/TypeOrmAnalyticsSchemaError.test.ts @@ -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); + }); +}); diff --git a/adapters/analytics/persistence/typeorm/mappers/AnalyticsSnapshotOrmMapper.test.ts b/adapters/analytics/persistence/typeorm/mappers/AnalyticsSnapshotOrmMapper.test.ts new file mode 100644 index 000000000..d878123af --- /dev/null +++ b/adapters/analytics/persistence/typeorm/mappers/AnalyticsSnapshotOrmMapper.test.ts @@ -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.'); + } + }); +}); diff --git a/adapters/analytics/persistence/typeorm/mappers/EngagementEventOrmMapper.test.ts b/adapters/analytics/persistence/typeorm/mappers/EngagementEventOrmMapper.test.ts new file mode 100644 index 000000000..f6028572d --- /dev/null +++ b/adapters/analytics/persistence/typeorm/mappers/EngagementEventOrmMapper.test.ts @@ -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'); + } + }); +}); diff --git a/adapters/analytics/persistence/typeorm/repositories/TypeOrmAnalyticsSnapshotRepository.test.ts b/adapters/analytics/persistence/typeorm/repositories/TypeOrmAnalyticsSnapshotRepository.test.ts new file mode 100644 index 000000000..b9865ce3c --- /dev/null +++ b/adapters/analytics/persistence/typeorm/repositories/TypeOrmAnalyticsSnapshotRepository.test.ts @@ -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 = { + save: vi.fn().mockResolvedValue(orm), + } as unknown as Repository; + + 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 = { + findOneBy: vi.fn().mockResolvedValue(orm), + } as unknown as Repository; + + 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 = { + 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' }, + }); + }); +}); diff --git a/adapters/analytics/persistence/typeorm/repositories/TypeOrmEngagementRepository.test.ts b/adapters/analytics/persistence/typeorm/repositories/TypeOrmEngagementRepository.test.ts new file mode 100644 index 000000000..cc6795dfb --- /dev/null +++ b/adapters/analytics/persistence/typeorm/repositories/TypeOrmEngagementRepository.test.ts @@ -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 = { + save: vi.fn().mockResolvedValue(orm), + } as unknown as Repository; + + 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 = { + findOneBy: vi.fn().mockResolvedValue(orm), + } as unknown as Repository; + + 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 = { + 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(), + }), + }); + }); +}); diff --git a/adapters/analytics/persistence/typeorm/schema/TypeOrmAnalyticsSchemaGuards.test.ts b/adapters/analytics/persistence/typeorm/schema/TypeOrmAnalyticsSchemaGuards.test.ts new file mode 100644 index 000000000..6b7e9d6c9 --- /dev/null +++ b/adapters/analytics/persistence/typeorm/schema/TypeOrmAnalyticsSchemaGuards.test.ts @@ -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); + }); + }); +}); diff --git a/adapters/drivers/persistence/inmemory/InMemoryDriverRepository.test.ts b/adapters/drivers/persistence/inmemory/InMemoryDriverRepository.test.ts new file mode 100644 index 000000000..4eb52f117 --- /dev/null +++ b/adapters/drivers/persistence/inmemory/InMemoryDriverRepository.test.ts @@ -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); + }); + }); +}); diff --git a/adapters/events/InMemoryEventPublisher.test.ts b/adapters/events/InMemoryEventPublisher.test.ts new file mode 100644 index 000000000..857a1fdf3 --- /dev/null +++ b/adapters/events/InMemoryEventPublisher.test.ts @@ -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); + }); + }); +}); diff --git a/adapters/events/InMemoryHealthEventPublisher.test.ts b/adapters/events/InMemoryHealthEventPublisher.test.ts new file mode 100644 index 000000000..ed38119f2 --- /dev/null +++ b/adapters/events/InMemoryHealthEventPublisher.test.ts @@ -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); + }); + }); +}); diff --git a/adapters/health/persistence/inmemory/InMemoryHealthCheckAdapter.test.ts b/adapters/health/persistence/inmemory/InMemoryHealthCheckAdapter.test.ts new file mode 100644 index 000000000..f45366bf8 --- /dev/null +++ b/adapters/health/persistence/inmemory/InMemoryHealthCheckAdapter.test.ts @@ -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 + }); + }); +}); diff --git a/adapters/http/RequestContext.test.ts b/adapters/http/RequestContext.test.ts new file mode 100644 index 000000000..e465f4ccf --- /dev/null +++ b/adapters/http/RequestContext.test.ts @@ -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((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((resolve) => { + requestContextMiddleware(req1, res1, () => { + setTimeout(() => { + expect(getHttpRequestContext().req).toBe(req1); + resolve(); + }, 10); + }); + }); + + const p2 = new Promise((resolve) => { + requestContextMiddleware(req2, res2, () => { + setTimeout(() => { + expect(getHttpRequestContext().req).toBe(req2); + resolve(); + }, 5); + }); + }); + + return Promise.all([p1, p2]); + }); +}); diff --git a/adapters/leaderboards/persistence/inmemory/InMemoryLeaderboardsRepository.test.ts b/adapters/leaderboards/persistence/inmemory/InMemoryLeaderboardsRepository.test.ts new file mode 100644 index 000000000..3c8a8021e --- /dev/null +++ b/adapters/leaderboards/persistence/inmemory/InMemoryLeaderboardsRepository.test.ts @@ -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]); + }); + }); +}); diff --git a/adapters/leagues/persistence/inmemory/InMemoryLeagueRepository.test.ts b/adapters/leagues/persistence/inmemory/InMemoryLeagueRepository.test.ts new file mode 100644 index 000000000..5f908e12a --- /dev/null +++ b/adapters/leagues/persistence/inmemory/InMemoryLeagueRepository.test.ts @@ -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'); + }); + }); +}); diff --git a/adapters/media/persistence/inmemory/InMemoryMediaRepository.contract.test.ts b/adapters/media/persistence/inmemory/InMemoryMediaRepository.contract.test.ts new file mode 100644 index 000000000..e616f70c4 --- /dev/null +++ b/adapters/media/persistence/inmemory/InMemoryMediaRepository.contract.test.ts @@ -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(); + } + }; + }); +}); diff --git a/adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.contract.test.ts b/adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.contract.test.ts new file mode 100644 index 000000000..46b239fc2 --- /dev/null +++ b/adapters/media/persistence/typeorm/repositories/TypeOrmMediaRepository.contract.test.ts @@ -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(); + + 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(); + } + }; + }); +}); diff --git a/adapters/notifications/gateways/DiscordNotificationGateway.test.ts b/adapters/notifications/gateways/DiscordNotificationGateway.test.ts new file mode 100644 index 000000000..ec35c8a77 --- /dev/null +++ b/adapters/notifications/gateways/DiscordNotificationGateway.test.ts @@ -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); + }); + }); +}); diff --git a/adapters/notifications/gateways/EmailNotificationGateway.test.ts b/adapters/notifications/gateways/EmailNotificationGateway.test.ts new file mode 100644 index 000000000..ed780b820 --- /dev/null +++ b/adapters/notifications/gateways/EmailNotificationGateway.test.ts @@ -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); + }); + }); +}); diff --git a/adapters/notifications/gateways/InAppNotificationGateway.test.ts b/adapters/notifications/gateways/InAppNotificationGateway.test.ts new file mode 100644 index 000000000..bceca68b7 --- /dev/null +++ b/adapters/notifications/gateways/InAppNotificationGateway.test.ts @@ -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); + }); + }); +}); diff --git a/adapters/notifications/gateways/NotificationGatewayRegistry.test.ts b/adapters/notifications/gateways/NotificationGatewayRegistry.test.ts new file mode 100644 index 000000000..0004cc111 --- /dev/null +++ b/adapters/notifications/gateways/NotificationGatewayRegistry.test.ts @@ -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'); + }); + }); +}); diff --git a/adapters/races/persistence/inmemory/InMemoryRaceRepository.test.ts b/adapters/races/persistence/inmemory/InMemoryRaceRepository.test.ts new file mode 100644 index 000000000..ebbc85299 --- /dev/null +++ b/adapters/races/persistence/inmemory/InMemoryRaceRepository.test.ts @@ -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); + }); + }); +}); diff --git a/adapters/rating/persistence/inmemory/InMemoryRatingRepository.test.ts b/adapters/rating/persistence/inmemory/InMemoryRatingRepository.test.ts new file mode 100644 index 000000000..114527240 --- /dev/null +++ b/adapters/rating/persistence/inmemory/InMemoryRatingRepository.test.ts @@ -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); + }); + }); +}); diff --git a/plans/testing-concept-adapters.md b/plans/testing-concept-adapters.md new file mode 100644 index 000000000..3b4127dad --- /dev/null +++ b/plans/testing-concept-adapters.md @@ -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//.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. + diff --git a/tests/contracts/media/MediaRepository.contract.ts b/tests/contracts/media/MediaRepository.contract.ts new file mode 100644 index 000000000..af2b02109 --- /dev/null +++ b/tests/contracts/media/MediaRepository.contract.ts @@ -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; + }> +) { + describe('MediaRepository Contract', () => { + let repository: MediaRepository; + let cleanup: (() => Promise) | 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(); + }); + }); +}