This commit is contained in:
2025-12-29 22:27:33 +01:00
parent 3f610c1cb6
commit 7a853d4e43
96 changed files with 14790 additions and 111 deletions

View File

@@ -0,0 +1,111 @@
import { IExternalGameRatingRepository, PaginatedQueryOptions, PaginatedResult } from '@core/identity/domain/repositories/IExternalGameRatingRepository';
import { ExternalGameRatingProfile } from '@core/identity/domain/entities/ExternalGameRatingProfile';
/**
* In-Memory Implementation: IExternalGameRatingRepository
*
* For testing and development purposes.
*/
export class InMemoryExternalGameRatingRepository implements IExternalGameRatingRepository {
private profiles: Map<string, ExternalGameRatingProfile> = new Map();
private getKey(userId: string, gameKey: string): string {
return `${userId}|${gameKey}`;
}
async findByUserIdAndGameKey(
userId: string,
gameKey: string
): Promise<ExternalGameRatingProfile | null> {
const key = this.getKey(userId, gameKey);
return this.profiles.get(key) || null;
}
async findByUserId(userId: string): Promise<ExternalGameRatingProfile[]> {
return Array.from(this.profiles.values()).filter(
p => p.userId.toString() === userId
);
}
async findByGameKey(gameKey: string): Promise<ExternalGameRatingProfile[]> {
return Array.from(this.profiles.values()).filter(
p => p.gameKey.toString() === gameKey
);
}
async save(profile: ExternalGameRatingProfile): Promise<ExternalGameRatingProfile> {
const key = this.getKey(profile.userId.toString(), profile.gameKey.toString());
this.profiles.set(key, profile);
return profile;
}
async saveMany(profiles: ExternalGameRatingProfile[]): Promise<ExternalGameRatingProfile[]> {
for (const profile of profiles) {
await this.save(profile);
}
return profiles;
}
async delete(userId: string, gameKey: string): Promise<boolean> {
const key = this.getKey(userId, gameKey);
return this.profiles.delete(key);
}
async exists(userId: string, gameKey: string): Promise<boolean> {
const key = this.getKey(userId, gameKey);
return this.profiles.has(key);
}
async findProfilesPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<ExternalGameRatingProfile>> {
const allProfiles = await this.findByUserId(userId);
// Apply filters
let filtered = allProfiles;
if (options?.filter) {
const filter = options.filter;
if (filter.gameKeys) {
filtered = filtered.filter(p => filter.gameKeys!.includes(p.gameKey.toString()));
}
if (filter.sources) {
filtered = filtered.filter(p => filter.sources!.includes(p.provenance.source));
}
if (filter.verified !== undefined) {
filtered = filtered.filter(p => p.provenance.verified === filter.verified);
}
if (filter.lastSyncedAfter) {
filtered = filtered.filter(p => p.provenance.lastSyncedAt >= filter.lastSyncedAfter!);
}
}
const total = filtered.length;
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
const items = filtered.slice(offset, offset + limit);
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: PaginatedResult<ExternalGameRatingProfile> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
// Helper method for testing
clear(): void {
this.profiles.clear();
}
// Helper method for testing
getAll(): ExternalGameRatingProfile[] {
return Array.from(this.profiles.values());
}
}

View File

@@ -0,0 +1,39 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/**
* ORM Entity: ExternalGameRatingProfile
*
* Stores external game rating profiles per user and game.
* Uses JSONB for ratings map and provenance data.
*/
@Entity({ name: 'external_game_rating_profiles' })
@Index(['userId', 'gameKey'], { unique: true })
@Index(['userId'])
@Index(['gameKey'])
export class ExternalGameRatingProfileOrmEntity {
@PrimaryColumn({ type: 'text' })
userId!: string;
@PrimaryColumn({ type: 'text' })
gameKey!: string;
@Column({ type: 'jsonb' })
ratings!: Array<{
type: string;
gameKey: string;
value: number;
}>;
@Column({ type: 'jsonb' })
provenance!: {
source: string;
lastSyncedAt: Date;
verified: boolean;
};
@Column({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,57 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/**
* ORM Entity: RatingEvent
*
* Stores rating events in the ledger with indexes for efficient querying
* by userId and ordering by occurredAt for snapshot computation.
*/
@Entity({ name: 'rating_events' })
@Index(['userId', 'occurredAt', 'createdAt', 'id'], { unique: true })
export class RatingEventOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Index()
@Column({ type: 'text' })
userId!: string;
@Index()
@Column({ type: 'text' })
dimension!: string;
@Column({ type: 'double precision' })
delta!: number;
@Column({ type: 'double precision', nullable: true })
weight?: number;
@Index()
@Column({ type: 'timestamptz' })
occurredAt!: Date;
@Column({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'jsonb' })
source!: {
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
id: string;
};
@Column({ type: 'jsonb' })
reason!: {
code: string;
summary: string;
details: Record<string, unknown>;
};
@Column({ type: 'jsonb' })
visibility!: {
public: boolean;
redactedFields: string[];
};
@Column({ type: 'integer' })
version!: number;
}

View File

@@ -0,0 +1,71 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/**
* ORM Entity: UserRating
*
* Stores the current rating snapshot per user.
* Uses JSONB for dimension data to keep schema flexible.
*/
@Entity({ name: 'user_ratings' })
@Index(['userId'], { unique: true })
export class UserRatingOrmEntity {
@PrimaryColumn({ type: 'text' })
userId!: string;
@Column({ type: 'jsonb' })
driver!: {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
};
@Column({ type: 'jsonb' })
admin!: {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
};
@Column({ type: 'jsonb' })
steward!: {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
};
@Column({ type: 'jsonb' })
trust!: {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
};
@Column({ type: 'jsonb' })
fairness!: {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
};
@Column({ type: 'double precision' })
overallReputation!: number;
@Column({ type: 'text', nullable: true })
calculatorVersion?: string;
@Column({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,107 @@
import { ExternalGameRatingProfileOrmMapper } from './ExternalGameRatingProfileOrmMapper';
import { ExternalGameRatingProfileOrmEntity } from '../entities/ExternalGameRatingProfileOrmEntity';
import { ExternalGameRatingProfile } from '@core/identity/domain/entities/ExternalGameRatingProfile';
import { UserId } from '@core/identity/domain/value-objects/UserId';
import { GameKey } from '@core/identity/domain/value-objects/GameKey';
import { ExternalRating } from '@core/identity/domain/value-objects/ExternalRating';
import { ExternalRatingProvenance } from '@core/identity/domain/value-objects/ExternalRatingProvenance';
describe('ExternalGameRatingProfileOrmMapper', () => {
describe('toDomain', () => {
it('should convert ORM entity to domain entity', () => {
const now = new Date('2024-01-01');
const entity = new ExternalGameRatingProfileOrmEntity();
entity.userId = 'user-123';
entity.gameKey = 'iracing';
entity.ratings = [
{ type: 'safety', gameKey: 'iracing', value: 85.5 },
{ type: 'skill', gameKey: 'iracing', value: 92.0 },
];
entity.provenance = {
source: 'iracing',
lastSyncedAt: now,
verified: true,
};
entity.createdAt = now;
entity.updatedAt = now;
const domain = ExternalGameRatingProfileOrmMapper.toDomain(entity);
expect(domain.userId.toString()).toBe('user-123');
expect(domain.gameKey.toString()).toBe('iracing');
expect(domain.ratings.size).toBe(2);
expect(domain.provenance.source).toBe('iracing');
expect(domain.provenance.verified).toBe(true);
});
});
describe('toOrmEntity', () => {
it('should convert domain entity to ORM entity', () => {
const domain = createTestDomainEntity('user-123', 'iracing');
const entity = ExternalGameRatingProfileOrmMapper.toOrmEntity(domain);
expect(entity.userId).toBe('user-123');
expect(entity.gameKey).toBe('iracing');
expect(entity.ratings).toHaveLength(2);
expect(entity.provenance.source).toBe('iracing');
expect(entity.provenance.verified).toBe(false);
expect(entity.createdAt).toBeInstanceOf(Date);
expect(entity.updatedAt).toBeInstanceOf(Date);
});
});
describe('updateOrmEntity', () => {
it('should update existing ORM entity from domain', () => {
const existingEntity = new ExternalGameRatingProfileOrmEntity();
existingEntity.userId = 'user-123';
existingEntity.gameKey = 'iracing';
existingEntity.ratings = [
{ type: 'safety', gameKey: 'iracing', value: 85.5 },
];
existingEntity.provenance = {
source: 'iracing',
lastSyncedAt: new Date('2024-01-01'),
verified: false,
};
existingEntity.createdAt = new Date('2024-01-01');
existingEntity.updatedAt = new Date('2024-01-01');
const domain = createTestDomainEntity('user-123', 'iracing');
// Update domain with new data
domain.updateLastSyncedAt(new Date('2024-01-02'));
domain.markVerified();
const updated = ExternalGameRatingProfileOrmMapper.updateOrmEntity(existingEntity, domain);
expect(updated.ratings).toHaveLength(2);
expect(updated.provenance.verified).toBe(true);
expect(updated.provenance.lastSyncedAt).toEqual(new Date('2024-01-02'));
// updatedAt should be updated (may be same timestamp if test runs fast)
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(existingEntity.updatedAt.getTime());
// createdAt should be preserved from existing entity
expect(updated.createdAt).toEqual(existingEntity.createdAt);
});
});
function createTestDomainEntity(userId: string, gameKey: string): ExternalGameRatingProfile {
const user = UserId.fromString(userId);
const game = GameKey.create(gameKey);
const ratings = new Map([
['safety', ExternalRating.create(game, 'safety', 85.5)],
['skill', ExternalRating.create(game, 'skill', 92.0)],
]);
const provenance = ExternalRatingProvenance.create({
source: gameKey,
lastSyncedAt: new Date('2024-01-01'),
verified: false,
});
return ExternalGameRatingProfile.create({
userId: user,
gameKey: game,
ratings,
provenance,
});
}
});

View File

@@ -0,0 +1,85 @@
import { ExternalGameRatingProfile } from '@core/identity/domain/entities/ExternalGameRatingProfile';
import { ExternalGameRatingProfileOrmEntity } from '../entities/ExternalGameRatingProfileOrmEntity';
/**
* Mapper: ExternalGameRatingProfileOrmMapper
*
* Converts between ExternalGameRatingProfile domain entity and
* ExternalGameRatingProfileOrmEntity.
*/
export class ExternalGameRatingProfileOrmMapper {
/**
* Convert ORM entity to domain entity
*/
static toDomain(entity: ExternalGameRatingProfileOrmEntity): ExternalGameRatingProfile {
return ExternalGameRatingProfile.restore({
userId: entity.userId,
gameKey: entity.gameKey,
ratings: entity.ratings.map(r => ({
type: r.type,
gameKey: r.gameKey,
value: r.value,
})),
provenance: {
source: entity.provenance.source,
lastSyncedAt: entity.provenance.lastSyncedAt,
verified: entity.provenance.verified,
},
});
}
/**
* Convert domain entity to ORM entity
*/
static toOrmEntity(domain: ExternalGameRatingProfile): ExternalGameRatingProfileOrmEntity {
const entity = new ExternalGameRatingProfileOrmEntity();
entity.userId = domain.userId.toString();
entity.gameKey = domain.gameKey.toString();
// Convert ratings map to array
entity.ratings = Array.from(domain.ratings.entries()).map(([type, rating]) => ({
type,
gameKey: rating.gameKey.toString(),
value: rating.value,
}));
// Convert provenance
entity.provenance = {
source: domain.provenance.source,
lastSyncedAt: domain.provenance.lastSyncedAt,
verified: domain.provenance.verified,
};
// Set timestamps (use current time for new entities)
const now = new Date();
entity.createdAt = now;
entity.updatedAt = now;
return entity;
}
/**
* Update existing ORM entity from domain entity
*/
static updateOrmEntity(
entity: ExternalGameRatingProfileOrmEntity,
domain: ExternalGameRatingProfile
): ExternalGameRatingProfileOrmEntity {
entity.ratings = Array.from(domain.ratings.entries()).map(([type, rating]) => ({
type,
gameKey: rating.gameKey.toString(),
value: rating.value,
}));
entity.provenance = {
source: domain.provenance.source,
lastSyncedAt: domain.provenance.lastSyncedAt,
verified: domain.provenance.verified,
};
entity.updatedAt = new Date();
return entity;
}
}

View File

@@ -0,0 +1,145 @@
/**
* Unit tests for RatingEventOrmMapper
*/
import { RatingEvent } from '@core/identity/domain/entities/RatingEvent';
import { RatingEventId } from '@core/identity/domain/value-objects/RatingEventId';
import { RatingDimensionKey } from '@core/identity/domain/value-objects/RatingDimensionKey';
import { RatingDelta } from '@core/identity/domain/value-objects/RatingDelta';
import { RatingEventOrmEntity } from '../entities/RatingEventOrmEntity';
import { RatingEventOrmMapper } from './RatingEventOrmMapper';
describe('RatingEventOrmMapper', () => {
describe('toDomain', () => {
it('should convert ORM entity to domain entity', () => {
const entity = new RatingEventOrmEntity();
entity.id = '123e4567-e89b-12d3-a456-426614174000';
entity.userId = 'user-1';
entity.dimension = 'driving';
entity.delta = 5.5;
entity.weight = 1;
entity.occurredAt = new Date('2024-01-01T10:00:00Z');
entity.createdAt = new Date('2024-01-01T10:00:00Z');
entity.source = { type: 'race', id: 'race-1' };
entity.reason = { code: 'TEST', summary: 'Test event', details: {} };
entity.visibility = { public: true, redactedFields: [] };
entity.version = 1;
const domain = RatingEventOrmMapper.toDomain(entity);
expect(domain.id.value).toBe(entity.id);
expect(domain.userId).toBe(entity.userId);
expect(domain.dimension.value).toBe(entity.dimension);
expect(domain.delta.value).toBe(entity.delta);
expect(domain.weight).toBe(entity.weight);
expect(domain.occurredAt).toEqual(entity.occurredAt);
expect(domain.createdAt).toEqual(entity.createdAt);
expect(domain.source).toEqual(entity.source);
expect(domain.reason).toEqual(entity.reason);
expect(domain.visibility).toEqual(entity.visibility);
expect(domain.version).toBe(entity.version);
});
it('should handle optional weight', () => {
const entity = new RatingEventOrmEntity();
entity.id = '123e4567-e89b-12d3-a456-426614174000';
entity.userId = 'user-1';
entity.dimension = 'driving';
entity.delta = 5.5;
entity.occurredAt = new Date('2024-01-01T10:00:00Z');
entity.createdAt = new Date('2024-01-01T10:00:00Z');
entity.source = { type: 'race', id: 'race-1' };
entity.reason = { code: 'TEST', summary: 'Test', details: {} };
entity.visibility = { public: true, redactedFields: [] };
entity.version = 1;
const domain = RatingEventOrmMapper.toDomain(entity);
expect(domain.weight).toBeUndefined();
});
});
describe('toOrmEntity', () => {
it('should convert domain entity to ORM entity', () => {
const domain = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5.5),
weight: 1,
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test event', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const entity = RatingEventOrmMapper.toOrmEntity(domain);
expect(entity.id).toBe(domain.id.value);
expect(entity.userId).toBe(domain.userId);
expect(entity.dimension).toBe(domain.dimension.value);
expect(entity.delta).toBe(domain.delta.value);
expect(entity.weight).toBe(domain.weight);
expect(entity.occurredAt).toEqual(domain.occurredAt);
expect(entity.createdAt).toEqual(domain.createdAt);
expect(entity.source).toEqual(domain.source);
expect(entity.reason).toEqual(domain.reason);
expect(entity.visibility).toEqual(domain.visibility);
expect(entity.version).toBe(domain.version);
});
it('should handle optional weight', () => {
const domain = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5.5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const entity = RatingEventOrmMapper.toOrmEntity(domain);
expect(entity.weight).toBeUndefined();
});
});
describe('round-trip', () => {
it('should preserve data through domain -> orm -> domain conversion', () => {
const original = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(7.5),
weight: 0.5,
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'DRIVING_FINISH_STRENGTH_GAIN', summary: 'Finished 3rd', details: { position: 3 } },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const entity = RatingEventOrmMapper.toOrmEntity(original);
const restored = RatingEventOrmMapper.toDomain(entity);
expect(restored.id.equals(original.id)).toBe(true);
expect(restored.userId).toBe(original.userId);
expect(restored.dimension.value).toBe(original.dimension.value);
expect(restored.delta.value).toBe(original.delta.value);
expect(restored.weight).toBe(original.weight);
expect(restored.occurredAt).toEqual(original.occurredAt);
expect(restored.createdAt).toEqual(original.createdAt);
expect(restored.source).toEqual(original.source);
expect(restored.reason).toEqual(original.reason);
expect(restored.visibility).toEqual(original.visibility);
expect(restored.version).toBe(original.version);
});
});
});

View File

@@ -0,0 +1,57 @@
import { RatingEvent } from '@core/identity/domain/entities/RatingEvent';
import { RatingEventId } from '@core/identity/domain/value-objects/RatingEventId';
import { RatingDimensionKey } from '@core/identity/domain/value-objects/RatingDimensionKey';
import { RatingDelta } from '@core/identity/domain/value-objects/RatingDelta';
import { RatingEventOrmEntity } from '../entities/RatingEventOrmEntity';
/**
* Mapper: RatingEventOrmMapper
*
* Converts between RatingEvent domain entity and RatingEventOrmEntity.
*/
export class RatingEventOrmMapper {
/**
* Convert ORM entity to domain entity
*/
static toDomain(entity: RatingEventOrmEntity): RatingEvent {
const props: any = {
id: RatingEventId.create(entity.id),
userId: entity.userId,
dimension: RatingDimensionKey.create(entity.dimension),
delta: RatingDelta.create(entity.delta),
occurredAt: entity.occurredAt,
createdAt: entity.createdAt,
source: entity.source,
reason: entity.reason,
visibility: entity.visibility,
version: entity.version,
};
if (entity.weight !== undefined && entity.weight !== null) {
props.weight = entity.weight;
}
return RatingEvent.rehydrate(props);
}
/**
* Convert domain entity to ORM entity
*/
static toOrmEntity(domain: RatingEvent): RatingEventOrmEntity {
const entity = new RatingEventOrmEntity();
entity.id = domain.id.value;
entity.userId = domain.userId;
entity.dimension = domain.dimension.value;
entity.delta = domain.delta.value;
if (domain.weight !== undefined) {
entity.weight = domain.weight;
}
entity.occurredAt = domain.occurredAt;
entity.createdAt = domain.createdAt;
entity.source = domain.source;
entity.reason = domain.reason;
entity.visibility = domain.visibility;
entity.version = domain.version;
return entity;
}
}

View File

@@ -0,0 +1,128 @@
/**
* Unit tests for UserRatingOrmMapper
*/
import { UserRating } from '@core/identity/domain/value-objects/UserRating';
import { UserRatingOrmEntity } from '../entities/UserRatingOrmEntity';
import { UserRatingOrmMapper } from './UserRatingOrmMapper';
describe('UserRatingOrmMapper', () => {
describe('toDomain', () => {
it('should convert ORM entity to domain value object', () => {
const now = new Date('2024-01-01T10:00:00Z');
const entity = new UserRatingOrmEntity();
entity.userId = 'user-1';
entity.driver = { value: 75, confidence: 0.5, sampleSize: 10, trend: 'rising', lastUpdated: now };
entity.admin = { value: 60, confidence: 0.3, sampleSize: 5, trend: 'stable', lastUpdated: now };
entity.steward = { value: 80, confidence: 0.4, sampleSize: 8, trend: 'falling', lastUpdated: now };
entity.trust = { value: 70, confidence: 0.6, sampleSize: 12, trend: 'rising', lastUpdated: now };
entity.fairness = { value: 85, confidence: 0.7, sampleSize: 15, trend: 'stable', lastUpdated: now };
entity.overallReputation = 74;
entity.calculatorVersion = '1.0';
entity.createdAt = now;
entity.updatedAt = now;
const domain = UserRatingOrmMapper.toDomain(entity);
expect(domain.userId).toBe(entity.userId);
expect(domain.driver).toEqual(entity.driver);
expect(domain.admin).toEqual(entity.admin);
expect(domain.steward).toEqual(entity.steward);
expect(domain.trust).toEqual(entity.trust);
expect(domain.fairness).toEqual(entity.fairness);
expect(domain.overallReputation).toBe(entity.overallReputation);
expect(domain.calculatorVersion).toBe(entity.calculatorVersion);
expect(domain.createdAt).toEqual(entity.createdAt);
expect(domain.updatedAt).toEqual(entity.updatedAt);
});
it('should handle optional calculatorVersion', () => {
const now = new Date('2024-01-01T10:00:00Z');
const entity = new UserRatingOrmEntity();
entity.userId = 'user-1';
entity.driver = { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now };
entity.admin = { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now };
entity.steward = { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now };
entity.trust = { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now };
entity.fairness = { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now };
entity.overallReputation = 50;
entity.createdAt = now;
entity.updatedAt = now;
const domain = UserRatingOrmMapper.toDomain(entity);
expect(domain.calculatorVersion).toBeUndefined();
});
});
describe('toOrmEntity', () => {
it('should convert domain value object to ORM entity', () => {
const domain = UserRating.create('user-1');
const updated = domain.updateDriverRating(75);
const entity = UserRatingOrmMapper.toOrmEntity(updated);
expect(entity.userId).toBe(updated.userId);
expect(entity.driver).toEqual(updated.driver);
expect(entity.admin).toEqual(updated.admin);
expect(entity.steward).toEqual(updated.steward);
expect(entity.trust).toEqual(updated.trust);
expect(entity.fairness).toEqual(updated.fairness);
expect(entity.overallReputation).toBe(updated.overallReputation);
expect(entity.calculatorVersion).toBe(updated.calculatorVersion);
expect(entity.createdAt).toEqual(updated.createdAt);
expect(entity.updatedAt).toEqual(updated.updatedAt);
});
it('should handle optional calculatorVersion', () => {
const now = new Date('2024-01-01T10:00:00Z');
const domain = UserRating.restore({
userId: 'user-1',
driver: { value: 60, confidence: 0.5, sampleSize: 10, trend: 'rising', lastUpdated: now },
admin: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now },
steward: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now },
trust: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now },
fairness: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now },
overallReputation: 55,
createdAt: now,
updatedAt: now,
});
const entity = UserRatingOrmMapper.toOrmEntity(domain);
expect(entity.calculatorVersion).toBeUndefined();
});
});
describe('round-trip', () => {
it('should preserve data through domain -> orm -> domain conversion', () => {
const now = new Date('2024-01-01T10:00:00Z');
const original = UserRating.restore({
userId: 'user-1',
driver: { value: 75, confidence: 0.5, sampleSize: 10, trend: 'rising', lastUpdated: now },
admin: { value: 60, confidence: 0.3, sampleSize: 5, trend: 'stable', lastUpdated: now },
steward: { value: 80, confidence: 0.4, sampleSize: 8, trend: 'falling', lastUpdated: now },
trust: { value: 70, confidence: 0.6, sampleSize: 12, trend: 'rising', lastUpdated: now },
fairness: { value: 85, confidence: 0.7, sampleSize: 15, trend: 'stable', lastUpdated: now },
overallReputation: 74,
calculatorVersion: '1.0',
createdAt: now,
updatedAt: now,
});
const entity = UserRatingOrmMapper.toOrmEntity(original);
const restored = UserRatingOrmMapper.toDomain(entity);
expect(restored.userId).toBe(original.userId);
expect(restored.driver).toEqual(original.driver);
expect(restored.admin).toEqual(original.admin);
expect(restored.steward).toEqual(original.steward);
expect(restored.trust).toEqual(original.trust);
expect(restored.fairness).toEqual(original.fairness);
expect(restored.overallReputation).toBe(original.overallReputation);
expect(restored.calculatorVersion).toBe(original.calculatorVersion);
expect(restored.createdAt).toEqual(original.createdAt);
expect(restored.updatedAt).toEqual(original.updatedAt);
});
});
});

View File

@@ -0,0 +1,52 @@
import { UserRating } from '@core/identity/domain/value-objects/UserRating';
import { UserRatingOrmEntity } from '../entities/UserRatingOrmEntity';
/**
* Mapper: UserRatingOrmMapper
*
* Converts between UserRating value object and UserRatingOrmEntity.
*/
export class UserRatingOrmMapper {
/**
* Convert ORM entity to domain value object
*/
static toDomain(entity: UserRatingOrmEntity): UserRating {
const props: any = {
userId: entity.userId,
driver: entity.driver,
admin: entity.admin,
steward: entity.steward,
trust: entity.trust,
fairness: entity.fairness,
overallReputation: entity.overallReputation,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
if (entity.calculatorVersion !== undefined && entity.calculatorVersion !== null) {
props.calculatorVersion = entity.calculatorVersion;
}
return UserRating.restore(props);
}
/**
* Convert domain value object to ORM entity
*/
static toOrmEntity(domain: UserRating): UserRatingOrmEntity {
const entity = new UserRatingOrmEntity();
entity.userId = domain.userId;
entity.driver = domain.driver;
entity.admin = domain.admin;
entity.steward = domain.steward;
entity.trust = domain.trust;
entity.fairness = domain.fairness;
entity.overallReputation = domain.overallReputation;
if (domain.calculatorVersion !== undefined) {
entity.calculatorVersion = domain.calculatorVersion;
}
entity.createdAt = domain.createdAt;
entity.updatedAt = domain.updatedAt;
return entity;
}
}

View File

@@ -0,0 +1,153 @@
import { Repository } from 'typeorm';
import { IExternalGameRatingRepository, PaginatedQueryOptions, PaginatedResult } from '@core/identity/domain/repositories/IExternalGameRatingRepository';
import { ExternalGameRatingProfile } from '@core/identity/domain/entities/ExternalGameRatingProfile';
import { ExternalGameRatingProfileOrmEntity } from '../entities/ExternalGameRatingProfileOrmEntity';
import { ExternalGameRatingProfileOrmMapper } from '../mappers/ExternalGameRatingProfileOrmMapper';
/**
* TypeORM Implementation: IExternalGameRatingRepository
*
* Repository for external game rating profiles using TypeORM.
* Implements store/display operations only, no compute.
*/
export class TypeOrmExternalGameRatingRepository implements IExternalGameRatingRepository {
constructor(
private readonly repository: Repository<ExternalGameRatingProfileOrmEntity>
) {}
async findByUserIdAndGameKey(
userId: string,
gameKey: string
): Promise<ExternalGameRatingProfile | null> {
const entity = await this.repository.findOne({
where: { userId, gameKey },
});
if (!entity) {
return null;
}
return ExternalGameRatingProfileOrmMapper.toDomain(entity);
}
async findByUserId(userId: string): Promise<ExternalGameRatingProfile[]> {
const entities = await this.repository.find({
where: { userId },
order: { updatedAt: 'DESC' },
});
return entities.map(entity => ExternalGameRatingProfileOrmMapper.toDomain(entity));
}
async findByGameKey(gameKey: string): Promise<ExternalGameRatingProfile[]> {
const entities = await this.repository.find({
where: { gameKey },
order: { updatedAt: 'DESC' },
});
return entities.map(entity => ExternalGameRatingProfileOrmMapper.toDomain(entity));
}
async save(profile: ExternalGameRatingProfile): Promise<ExternalGameRatingProfile> {
const existing = await this.repository.findOne({
where: {
userId: profile.userId.toString(),
gameKey: profile.gameKey.toString(),
},
});
let entity: ExternalGameRatingProfileOrmEntity;
if (existing) {
// Update existing
entity = ExternalGameRatingProfileOrmMapper.updateOrmEntity(existing, profile);
} else {
// Create new
entity = ExternalGameRatingProfileOrmMapper.toOrmEntity(profile);
}
const saved = await this.repository.save(entity);
return ExternalGameRatingProfileOrmMapper.toDomain(saved);
}
async saveMany(profiles: ExternalGameRatingProfile[]): Promise<ExternalGameRatingProfile[]> {
const results: ExternalGameRatingProfile[] = [];
for (const profile of profiles) {
const saved = await this.save(profile);
results.push(saved);
}
return results;
}
async delete(userId: string, gameKey: string): Promise<boolean> {
const result = await this.repository.delete({ userId, gameKey });
return (result.affected ?? 0) > 0;
}
async exists(userId: string, gameKey: string): Promise<boolean> {
const count = await this.repository.count({
where: { userId, gameKey },
});
return count > 0;
}
async findProfilesPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<ExternalGameRatingProfile>> {
const query = this.repository.createQueryBuilder('profile')
.where('profile.userId = :userId', { userId });
// Apply filters
if (options?.filter) {
const filter = options.filter;
if (filter.gameKeys) {
query.andWhere('profile.gameKey IN (:...gameKeys)', { gameKeys: filter.gameKeys });
}
if (filter.sources) {
query.andWhere('profile.provenanceSource IN (:...sources)', { sources: filter.sources });
}
if (filter.verified !== undefined) {
query.andWhere('profile.provenanceVerified = :verified', { verified: filter.verified });
}
if (filter.lastSyncedAfter) {
query.andWhere('profile.provenanceLastSyncedAt >= :lastSyncedAfter', { lastSyncedAfter: filter.lastSyncedAfter });
}
}
// Get total count
const total = await query.getCount();
// Apply pagination
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
query
.orderBy('profile.updatedAt', 'DESC')
.limit(limit)
.offset(offset);
const entities = await query.getMany();
const items = entities.map(entity => ExternalGameRatingProfileOrmMapper.toDomain(entity));
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: PaginatedResult<ExternalGameRatingProfile> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
}

View File

@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmRatingEventRepository } from './TypeOrmRatingEventRepository';
describe('TypeOrmRatingEventRepository', () => {
it('constructor works with injected dependencies', () => {
const dataSource = {} as unknown as DataSource;
const repo = new TypeOrmRatingEventRepository(dataSource);
expect(repo).toBeInstanceOf(TypeOrmRatingEventRepository);
});
it('save: works with mocked TypeORM', async () => {
const save = vi.fn().mockResolvedValue({});
const dataSource = {
getRepository: vi.fn().mockReturnValue({ save }),
} as unknown as DataSource;
const repo = new TypeOrmRatingEventRepository(dataSource);
// Create a mock event (we're testing the repository wiring, not the mapper)
const mockEvent = {
id: { value: 'test-id' },
userId: 'user-1',
dimension: { value: 'driving' },
delta: { value: 5 },
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
} as any;
// Mock the mapper
vi.doMock('../mappers/RatingEventOrmMapper', () => ({
RatingEventOrmMapper: {
toOrmEntity: vi.fn().mockReturnValue({ id: 'test-id' }),
},
}));
const result = await repo.save(mockEvent);
expect(result).toBe(mockEvent);
expect(save).toHaveBeenCalled();
});
it('findByUserId: returns empty array with mocked DB', async () => {
const getMany = vi.fn().mockResolvedValue([]);
const createQueryBuilder = vi.fn().mockReturnValue({
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
addOrderBy: vi.fn().mockReturnThis(),
andWhere: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
getMany,
});
const dataSource = {
getRepository: vi.fn().mockReturnValue({ createQueryBuilder }),
} as unknown as DataSource;
const repo = new TypeOrmRatingEventRepository(dataSource);
const result = await repo.findByUserId('user-1');
expect(result).toEqual([]);
expect(createQueryBuilder).toHaveBeenCalled();
});
it('getAllByUserId: works with mocked DB', async () => {
const getMany = vi.fn().mockResolvedValue([]);
const createQueryBuilder = vi.fn().mockReturnValue({
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
addOrderBy: vi.fn().mockReturnThis(),
getMany,
});
const dataSource = {
getRepository: vi.fn().mockReturnValue({ createQueryBuilder }),
} as unknown as DataSource;
const repo = new TypeOrmRatingEventRepository(dataSource);
const result = await repo.getAllByUserId('user-1');
expect(result).toEqual([]);
expect(createQueryBuilder).toHaveBeenCalled();
});
it('findByIds: handles empty array', async () => {
const dataSource = {} as unknown as DataSource;
const repo = new TypeOrmRatingEventRepository(dataSource);
const result = await repo.findByIds([]);
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,151 @@
import type { DataSource } from 'typeorm';
import type { IRatingEventRepository, FindByUserIdOptions, PaginatedQueryOptions, PaginatedResult } from '@core/identity/domain/repositories/IRatingEventRepository';
import type { RatingEvent } from '@core/identity/domain/entities/RatingEvent';
import type { RatingEventId } from '@core/identity/domain/value-objects/RatingEventId';
import { RatingEventOrmEntity } from '../entities/RatingEventOrmEntity';
import { RatingEventOrmMapper } from '../mappers/RatingEventOrmMapper';
/**
* TypeORM Implementation: IRatingEventRepository
*
* Persists rating events in the ledger with efficient querying by userId
* and ordering for snapshot computation.
*/
export class TypeOrmRatingEventRepository implements IRatingEventRepository {
constructor(private readonly dataSource: DataSource) {}
async save(event: RatingEvent): Promise<RatingEvent> {
const repo = this.dataSource.getRepository(RatingEventOrmEntity);
const entity = RatingEventOrmMapper.toOrmEntity(event);
await repo.save(entity);
return event;
}
async findByUserId(userId: string, options?: FindByUserIdOptions): Promise<RatingEvent[]> {
const repo = this.dataSource.getRepository(RatingEventOrmEntity);
const query = repo
.createQueryBuilder('event')
.where('event.userId = :userId', { userId })
.orderBy('event.occurredAt', 'ASC')
.addOrderBy('event.createdAt', 'ASC')
.addOrderBy('event.id', 'ASC');
if (options?.afterId) {
query.andWhere('event.id > :afterId', { afterId: options.afterId.value });
}
if (options?.limit) {
query.limit(options.limit);
}
const entities = await query.getMany();
return entities.map(entity => RatingEventOrmMapper.toDomain(entity));
}
async findByIds(ids: RatingEventId[]): Promise<RatingEvent[]> {
if (ids.length === 0) {
return [];
}
const repo = this.dataSource.getRepository(RatingEventOrmEntity);
const idValues = ids.map(id => id.value);
const entities = await repo
.createQueryBuilder('event')
.where('event.id IN (:...ids)', { ids: idValues })
.orderBy('event.occurredAt', 'ASC')
.addOrderBy('event.createdAt', 'ASC')
.addOrderBy('event.id', 'ASC')
.getMany();
return entities.map(entity => RatingEventOrmMapper.toDomain(entity));
}
async getAllByUserId(userId: string): Promise<RatingEvent[]> {
const repo = this.dataSource.getRepository(RatingEventOrmEntity);
const entities = await repo
.createQueryBuilder('event')
.where('event.userId = :userId', { userId })
.orderBy('event.occurredAt', 'ASC')
.addOrderBy('event.createdAt', 'ASC')
.addOrderBy('event.id', 'ASC')
.getMany();
return entities.map(entity => RatingEventOrmMapper.toDomain(entity));
}
async findEventsPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<RatingEvent>> {
const repo = this.dataSource.getRepository(RatingEventOrmEntity);
const query = repo
.createQueryBuilder('event')
.where('event.userId = :userId', { userId });
// Apply filters
if (options?.filter) {
const filter = options.filter;
if (filter.dimensions) {
query.andWhere('event.dimension IN (:...dimensions)', { dimensions: filter.dimensions });
}
if (filter.sourceTypes) {
query.andWhere('event.sourceType IN (:...sourceTypes)', { sourceTypes: filter.sourceTypes });
}
if (filter.from) {
query.andWhere('event.occurredAt >= :from', { from: filter.from });
}
if (filter.to) {
query.andWhere('event.occurredAt <= :to', { to: filter.to });
}
if (filter.reasonCodes) {
query.andWhere('event.reasonCode IN (:...reasonCodes)', { reasonCodes: filter.reasonCodes });
}
if (filter.visibility) {
query.andWhere('event.visibility = :visibility', { visibility: filter.visibility });
}
}
// Get total count
const total = await query.getCount();
// Apply pagination
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
query
.orderBy('event.occurredAt', 'ASC')
.addOrderBy('event.createdAt', 'ASC')
.addOrderBy('event.id', 'ASC')
.limit(limit)
.offset(offset);
const entities = await query.getMany();
const items = entities.map(entity => RatingEventOrmMapper.toDomain(entity));
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: PaginatedResult<RatingEvent> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmUserRatingRepository } from './TypeOrmUserRatingRepository';
describe('TypeOrmUserRatingRepository', () => {
it('constructor works with injected dependencies', () => {
const dataSource = {} as unknown as DataSource;
const repo = new TypeOrmUserRatingRepository(dataSource);
expect(repo).toBeInstanceOf(TypeOrmUserRatingRepository);
});
it('findByUserId: returns null when not found', async () => {
const findOne = vi.fn().mockResolvedValue(null);
const dataSource = {
getRepository: vi.fn().mockReturnValue({ findOne }),
} as unknown as DataSource;
const repo = new TypeOrmUserRatingRepository(dataSource);
const result = await repo.findByUserId('user-1');
expect(result).toBeNull();
expect(findOne).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
});
it('save: works with mocked TypeORM', async () => {
const save = vi.fn().mockResolvedValue({});
const dataSource = {
getRepository: vi.fn().mockReturnValue({ save }),
} as unknown as DataSource;
const repo = new TypeOrmUserRatingRepository(dataSource);
const mockRating = {
userId: 'user-1',
driver: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: new Date() },
admin: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: new Date() },
steward: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: new Date() },
trust: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: new Date() },
fairness: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: new Date() },
overallReputation: 50,
createdAt: new Date(),
updatedAt: new Date(),
} as any;
const result = await repo.save(mockRating);
expect(result).toBe(mockRating);
expect(save).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,34 @@
import type { DataSource } from 'typeorm';
import type { IUserRatingRepository } from '@core/identity/domain/repositories/IUserRatingRepository';
import type { UserRating } from '@core/identity/domain/value-objects/UserRating';
import { UserRatingOrmEntity } from '../entities/UserRatingOrmEntity';
import { UserRatingOrmMapper } from '../mappers/UserRatingOrmMapper';
/**
* TypeORM Implementation: IUserRatingRepository
*
* Persists and retrieves UserRating snapshots for fast reads.
*/
export class TypeOrmUserRatingRepository implements IUserRatingRepository {
constructor(private readonly dataSource: DataSource) {}
async findByUserId(userId: string): Promise<UserRating | null> {
const repo = this.dataSource.getRepository(UserRatingOrmEntity);
const entity = await repo.findOne({ where: { userId } });
if (!entity) {
return null;
}
return UserRatingOrmMapper.toDomain(entity);
}
async save(userRating: UserRating): Promise<UserRating> {
const repo = this.dataSource.getRepository(UserRatingOrmEntity);
const entity = UserRatingOrmMapper.toOrmEntity(userRating);
await repo.save(entity);
return userRating;
}
}