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,42 @@
import type { AdminVoteSession } from '../entities/AdminVoteSession';
/**
* Repository Interface: IAdminVoteSessionRepository
*
* Port for persisting and retrieving admin vote sessions.
* Sessions are scoped to leagues and control voting windows.
*/
export interface IAdminVoteSessionRepository {
/**
* Save a vote session
*/
save(session: AdminVoteSession): Promise<AdminVoteSession>;
/**
* Find a vote session by ID
*/
findById(id: string): Promise<AdminVoteSession | null>;
/**
* Find active vote sessions for an admin in a league
* (within voting window and not closed)
*/
findActiveForAdmin(adminId: string, leagueId: string): Promise<AdminVoteSession[]>;
/**
* Find all vote sessions for an admin in a league
*/
findByAdminAndLeague(adminId: string, leagueId: string): Promise<AdminVoteSession[]>;
/**
* Find vote sessions by league
*/
findByLeague(leagueId: string): Promise<AdminVoteSession[]>;
/**
* Find closed vote sessions ready for outcome processing
* (closed but not yet processed into rating events)
*/
findClosedUnprocessed(): Promise<AdminVoteSession[]>;
}

View File

@@ -0,0 +1,368 @@
import { IExternalGameRatingRepository } from './IExternalGameRatingRepository';
import { ExternalGameRatingProfile } from '../entities/ExternalGameRatingProfile';
import { UserId } from '../value-objects/UserId';
import { GameKey } from '../value-objects/GameKey';
import { ExternalRating } from '../value-objects/ExternalRating';
import { ExternalRatingProvenance } from '../value-objects/ExternalRatingProvenance';
/**
* Test suite for IExternalGameRatingRepository interface
* This tests the contract that all implementations must satisfy
*/
describe('IExternalGameRatingRepository', () => {
// Mock implementation for testing
class MockExternalGameRatingRepository 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?: import('./IExternalGameRatingRepository').PaginatedQueryOptions): Promise<import('./IExternalGameRatingRepository').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: import('./IExternalGameRatingRepository').PaginatedResult<ExternalGameRatingProfile> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
}
let repository: IExternalGameRatingRepository;
beforeEach(() => {
repository = new MockExternalGameRatingRepository();
});
describe('findByUserIdAndGameKey', () => {
it('should return null when profile does not exist', async () => {
const result = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(result).toBeNull();
});
it('should return profile when it exists', async () => {
const profile = createTestProfile('user-123', 'iracing');
await repository.save(profile);
const result = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(result).not.toBeNull();
expect(result?.userId.toString()).toBe('user-123');
expect(result?.gameKey.toString()).toBe('iracing');
});
});
describe('findByUserId', () => {
it('should return empty array when no profiles exist for user', async () => {
const results = await repository.findByUserId('user-123');
expect(results).toEqual([]);
});
it('should return all profiles for a user', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-123', 'assetto');
const profile3 = createTestProfile('user-456', 'iracing');
await repository.saveMany([profile1, profile2, profile3]);
const results = await repository.findByUserId('user-123');
expect(results).toHaveLength(2);
expect(results.map(p => p.gameKey.toString()).sort()).toEqual(['assetto', 'iracing']);
});
});
describe('findByGameKey', () => {
it('should return empty array when no profiles exist for game', async () => {
const results = await repository.findByGameKey('iracing');
expect(results).toEqual([]);
});
it('should return all profiles for a game', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-456', 'iracing');
const profile3 = createTestProfile('user-123', 'assetto');
await repository.saveMany([profile1, profile2, profile3]);
const results = await repository.findByGameKey('iracing');
expect(results).toHaveLength(2);
expect(results.map(p => p.userId.toString()).sort()).toEqual(['user-123', 'user-456']);
});
});
describe('save', () => {
it('should save a new profile', async () => {
const profile = createTestProfile('user-123', 'iracing');
const saved = await repository.save(profile);
expect(saved).toBe(profile);
const retrieved = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(retrieved).toBe(profile);
});
it('should update an existing profile', async () => {
const profile = createTestProfile('user-123', 'iracing');
await repository.save(profile);
// Update the profile
const updatedProvenance = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: new Date('2024-01-02'),
verified: true,
});
profile.updateLastSyncedAt(new Date('2024-01-02'));
profile.markVerified();
await repository.save(profile);
const retrieved = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(retrieved?.provenance.verified).toBe(true);
expect(retrieved?.provenance.lastSyncedAt).toEqual(new Date('2024-01-02'));
});
});
describe('saveMany', () => {
it('should save multiple profiles', async () => {
const profiles = [
createTestProfile('user-123', 'iracing'),
createTestProfile('user-456', 'assetto'),
createTestProfile('user-789', 'iracing'),
];
const saved = await repository.saveMany(profiles);
expect(saved).toHaveLength(3);
const iracingProfiles = await repository.findByGameKey('iracing');
expect(iracingProfiles).toHaveLength(2);
});
});
describe('delete', () => {
it('should delete existing profile', async () => {
const profile = createTestProfile('user-123', 'iracing');
await repository.save(profile);
const deleted = await repository.delete('user-123', 'iracing');
expect(deleted).toBe(true);
const retrieved = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(retrieved).toBeNull();
});
it('should return false when deleting non-existent profile', async () => {
const deleted = await repository.delete('user-123', 'iracing');
expect(deleted).toBe(false);
});
});
describe('exists', () => {
it('should return true when profile exists', async () => {
const profile = createTestProfile('user-123', 'iracing');
await repository.save(profile);
const exists = await repository.exists('user-123', 'iracing');
expect(exists).toBe(true);
});
it('should return false when profile does not exist', async () => {
const exists = await repository.exists('user-123', 'iracing');
expect(exists).toBe(false);
});
});
describe('findProfilesPaginated', () => {
it('should return paginated results', async () => {
// Create 15 profiles
for (let i = 0; i < 15; i++) {
const profile = createTestProfile('user-123', `game-${i}`);
await repository.save(profile);
}
const result = await repository.findProfilesPaginated('user-123', { limit: 5, offset: 0 });
expect(result.items).toHaveLength(5);
expect(result.total).toBe(15);
expect(result.limit).toBe(5);
expect(result.offset).toBe(0);
expect(result.hasMore).toBe(true);
expect(result.nextOffset).toBe(5);
});
it('should filter by game keys', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-123', 'assetto');
const profile3 = createTestProfile('user-123', 'rfactor');
await repository.saveMany([profile1, profile2, profile3]);
const result = await repository.findProfilesPaginated('user-123', {
filter: { gameKeys: ['iracing', 'rfactor'] }
});
expect(result.items).toHaveLength(2);
expect(result.items.map(p => p.gameKey.toString()).sort()).toEqual(['iracing', 'rfactor']);
});
it('should filter by sources', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-123', 'assetto');
// Manually update provenance for testing
const profile2Provenance = ExternalRatingProvenance.create({
source: 'manual',
lastSyncedAt: new Date('2024-01-01'),
verified: false,
});
profile2.updateRatings(profile2.ratings, profile2Provenance);
await repository.saveMany([profile1, profile2]);
const result = await repository.findProfilesPaginated('user-123', {
filter: { sources: ['iracing'] }
});
expect(result.items).toHaveLength(1);
expect(result.items[0].gameKey.toString()).toBe('iracing');
});
it('should filter by verified status', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-123', 'assetto');
profile1.markVerified();
await repository.saveMany([profile1, profile2]);
const result = await repository.findProfilesPaginated('user-123', {
filter: { verified: true }
});
expect(result.items).toHaveLength(1);
expect(result.items[0].gameKey.toString()).toBe('iracing');
});
it('should filter by last synced date', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-123', 'assetto');
profile1.updateLastSyncedAt(new Date('2024-01-02'));
profile2.updateLastSyncedAt(new Date('2024-01-01'));
await repository.saveMany([profile1, profile2]);
const result = await repository.findProfilesPaginated('user-123', {
filter: { lastSyncedAfter: new Date('2024-01-01T12:00:00Z') }
});
expect(result.items).toHaveLength(1);
expect(result.items[0].gameKey.toString()).toBe('iracing');
});
it('should return empty result when no profiles match', async () => {
const result = await repository.findProfilesPaginated('non-existent', {
filter: { gameKeys: ['iracing'] }
});
expect(result.items).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.hasMore).toBe(false);
});
});
// Helper function to create test profiles
function createTestProfile(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,76 @@
import { ExternalGameRatingProfile } from '../entities/ExternalGameRatingProfile';
/**
* Repository Interface: IExternalGameRatingRepository
*
* Port for persisting and retrieving external game rating profiles.
* Store/display only, no compute.
*/
export interface ExternalGameRatingFilter {
/** Filter by specific game keys */
gameKeys?: string[];
/** Filter by source */
sources?: string[];
/** Filter by verification status */
verified?: boolean;
/** Filter by last synced date */
lastSyncedAfter?: Date;
}
export interface PaginatedQueryOptions {
limit?: number;
offset?: number;
filter?: ExternalGameRatingFilter;
}
export interface PaginatedResult<T> {
items: T[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
nextOffset?: number;
}
export interface IExternalGameRatingRepository {
/**
* Find profile by user ID and game key
*/
findByUserIdAndGameKey(userId: string, gameKey: string): Promise<ExternalGameRatingProfile | null>;
/**
* Find all profiles for a user
*/
findByUserId(userId: string): Promise<ExternalGameRatingProfile[]>;
/**
* Find all profiles for a game
*/
findByGameKey(gameKey: string): Promise<ExternalGameRatingProfile[]>;
/**
* Save or update a profile
*/
save(profile: ExternalGameRatingProfile): Promise<ExternalGameRatingProfile>;
/**
* Save multiple profiles
*/
saveMany(profiles: ExternalGameRatingProfile[]): Promise<ExternalGameRatingProfile[]>;
/**
* Delete a profile
*/
delete(userId: string, gameKey: string): Promise<boolean>;
/**
* Check if profile exists
*/
exists(userId: string, gameKey: string): Promise<boolean>;
/**
* Find profiles with pagination and filtering
*/
findProfilesPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<ExternalGameRatingProfile>>;
}

View File

@@ -0,0 +1,560 @@
/**
* Unit tests for IRatingEventRepository
*/
import { RatingEvent } from '../entities/RatingEvent';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
import { IRatingEventRepository, FindByUserIdOptions, PaginatedQueryOptions, PaginatedResult } from './IRatingEventRepository';
// In-memory test implementation
class InMemoryRatingEventRepository implements IRatingEventRepository {
private events: RatingEvent[] = [];
async save(event: RatingEvent): Promise<RatingEvent> {
const existingIndex = this.events.findIndex(e => e.id.equals(event.id));
if (existingIndex >= 0) {
this.events[existingIndex] = event;
} else {
this.events.push(event);
}
return event;
}
async findByUserId(userId: string, options?: FindByUserIdOptions): Promise<RatingEvent[]> {
let filtered = this.events.filter(e => e.userId === userId);
// Sort by occurredAt, then createdAt, then id for deterministic ordering
filtered.sort((a, b) => {
const timeCompare = a.occurredAt.getTime() - b.occurredAt.getTime();
if (timeCompare !== 0) return timeCompare;
const createdCompare = a.createdAt.getTime() - b.createdAt.getTime();
if (createdCompare !== 0) return createdCompare;
return a.id.value.localeCompare(b.id.value);
});
// Apply afterId filter
if (options?.afterId) {
const afterIndex = filtered.findIndex(e => e.id.equals(options.afterId!));
if (afterIndex >= 0) {
filtered = filtered.slice(afterIndex + 1);
}
}
// Apply limit
if (options?.limit) {
filtered = filtered.slice(0, options.limit);
}
return filtered;
}
async findByIds(ids: RatingEventId[]): Promise<RatingEvent[]> {
return this.events.filter(e => ids.some(id => e.id.equals(id)));
}
async getAllByUserId(userId: string): Promise<RatingEvent[]> {
return this.findByUserId(userId);
}
async findEventsPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<RatingEvent>> {
const allEvents = await this.findByUserId(userId);
// Apply filters
let filtered = allEvents;
if (options?.filter) {
const filter = options.filter;
if (filter.dimensions) {
filtered = filtered.filter(e => filter.dimensions!.includes(e.dimension.value));
}
if (filter.sourceTypes) {
filtered = filtered.filter(e => filter.sourceTypes!.includes(e.source.type));
}
if (filter.from) {
filtered = filtered.filter(e => e.occurredAt >= filter.from!);
}
if (filter.to) {
filtered = filtered.filter(e => e.occurredAt <= filter.to!);
}
if (filter.reasonCodes) {
filtered = filtered.filter(e => filter.reasonCodes!.includes(e.reason.code));
}
if (filter.visibility) {
filtered = filtered.filter(e => e.visibility.public === (filter.visibility === 'public'));
}
}
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<RatingEvent> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
}
describe('IRatingEventRepository', () => {
let repository: InMemoryRatingEventRepository;
beforeEach(() => {
repository = new InMemoryRatingEventRepository();
});
describe('save', () => {
it('should save a new event', async () => {
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(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 event', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const saved = await repository.save(event);
expect(saved).toEqual(event);
const found = await repository.findByUserId('user-1');
expect(found).toHaveLength(1);
expect(found[0]!.id).toEqual(event.id);
});
it('should update existing event', async () => {
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(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 event', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event);
// Rehydrate with same ID (simulating update)
const updated = RatingEvent.rehydrate({
id: event.id,
userId: event.userId,
dimension: event.dimension,
delta: RatingDelta.create(10),
weight: undefined,
occurredAt: event.occurredAt,
createdAt: event.createdAt,
source: event.source,
reason: event.reason,
visibility: event.visibility,
version: event.version,
});
await repository.save(updated);
const found = await repository.findByUserId('user-1');
expect(found).toHaveLength(1);
expect(found[0]!.delta.value).toBe(10);
});
});
describe('findByUserId', () => {
it('should return events ordered by occurredAt', async () => {
const events = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T12:00:00Z'),
createdAt: new Date('2024-01-01T12:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
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,
}),
];
for (const event of events) {
await repository.save(event);
}
const found = await repository.findByUserId('user-1');
expect(found).toHaveLength(2);
expect(found[0]!.occurredAt).toEqual(new Date('2024-01-01T10:00:00Z'));
expect(found[1]!.occurredAt).toEqual(new Date('2024-01-01T12:00:00Z'));
});
it('should filter by afterId', async () => {
const events = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
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,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T12:00:00Z'),
createdAt: new Date('2024-01-01T12:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
for (const event of events) {
await repository.save(event);
}
const found = await repository.findByUserId('user-1', {
afterId: events[0]!.id,
});
expect(found).toHaveLength(1);
expect(found[0]!.id).toEqual(events[1]!.id);
});
it('should limit results', async () => {
const events = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(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', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(2),
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T12:00:00Z'),
createdAt: new Date('2024-01-01T12:00:00Z'),
source: { type: 'race', id: 'race-3' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
for (const event of events) {
await repository.save(event);
}
const found = await repository.findByUserId('user-1', { limit: 2 });
expect(found).toHaveLength(2);
expect(found[0]!.delta.value).toBe(1);
expect(found[1]!.delta.value).toBe(2);
});
it('should return empty array for non-existent user', async () => {
const found = await repository.findByUserId('non-existent');
expect(found).toEqual([]);
});
});
describe('findByIds', () => {
it('should return events by IDs', async () => {
const event1 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(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 event2 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event1);
await repository.save(event2);
const found = await repository.findByIds([event1.id, event2.id]);
expect(found).toHaveLength(2);
expect(found.map(e => e.id.value)).toContain(event1.id.value);
expect(found.map(e => e.id.value)).toContain(event2.id.value);
});
it('should return empty array for non-existent IDs', async () => {
const nonExistentId = RatingEventId.generate();
const found = await repository.findByIds([nonExistentId]);
expect(found).toEqual([]);
});
});
describe('getAllByUserId', () => {
it('should return all events for user', async () => {
const events = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(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,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-2',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
for (const event of events) {
await repository.save(event);
}
const user1Events = await repository.getAllByUserId('user-1');
expect(user1Events).toHaveLength(1);
expect(user1Events[0]!.userId).toBe('user-1');
const user2Events = await repository.getAllByUserId('user-2');
expect(user2Events).toHaveLength(1);
expect(user2Events[0]!.userId).toBe('user-2');
});
});
describe('findEventsPaginated', () => {
it('should return paginated results', async () => {
// Create 15 events
for (let i = 0; i < 15; i++) {
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(i),
occurredAt: new Date(`2024-01-01T${10 + i}:00:00Z`),
createdAt: new Date(`2024-01-01T${10 + i}:00:00Z`),
source: { type: 'race', id: `race-${i}` },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event);
}
const result = await repository.findEventsPaginated('user-1', { limit: 5, offset: 0 });
expect(result.items).toHaveLength(5);
expect(result.total).toBe(15);
expect(result.limit).toBe(5);
expect(result.offset).toBe(0);
expect(result.hasMore).toBe(true);
expect(result.nextOffset).toBe(5);
});
it('should filter by dimensions', async () => {
const event1 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(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 event2 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'vote', id: 'vote-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event1);
await repository.save(event2);
const result = await repository.findEventsPaginated('user-1', {
filter: { dimensions: ['driving'] }
});
expect(result.items).toHaveLength(1);
expect(result.items[0]!.dimension.value).toBe('driving');
});
it('should filter by source types', async () => {
const event1 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(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 event2 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'vote', id: 'vote-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event1);
await repository.save(event2);
const result = await repository.findEventsPaginated('user-1', {
filter: { sourceTypes: ['race'] }
});
expect(result.items).toHaveLength(1);
expect(result.items[0]!.source.type).toBe('race');
});
it('should filter by date range', async () => {
const event1 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(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 event2 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-02T10:00:00Z'),
createdAt: new Date('2024-01-02T10:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event1);
await repository.save(event2);
const result = await repository.findEventsPaginated('user-1', {
filter: {
from: new Date('2024-01-02T00:00:00Z'),
to: new Date('2024-01-02T23:59:59Z')
}
});
expect(result.items).toHaveLength(1);
expect(result.items[0]!.occurredAt).toEqual(new Date('2024-01-02T10:00:00Z'));
});
it('should return empty result when no events match', async () => {
const result = await repository.findEventsPaginated('non-existent', {
filter: { dimensions: ['driving'] }
});
expect(result.items).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.hasMore).toBe(false);
});
});
});

View File

@@ -0,0 +1,73 @@
/**
* Repository Interface: IRatingEventRepository
*
* Port for persisting and retrieving rating events (ledger).
* Events are immutable and ordered by occurredAt for deterministic snapshot computation.
*/
import type { RatingEvent } from '../entities/RatingEvent';
import type { RatingEventId } from '../value-objects/RatingEventId';
export interface FindByUserIdOptions {
/** Only return events after this ID (for pagination/streaming) */
afterId?: RatingEventId;
/** Maximum number of events to return */
limit?: number;
}
export interface RatingEventFilter {
/** Filter by dimension keys */
dimensions?: string[];
/** Filter by source types */
sourceTypes?: ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[];
/** Filter by date range (inclusive) */
from?: Date;
to?: Date;
/** Filter by reason codes */
reasonCodes?: string[];
/** Filter by visibility */
visibility?: 'public' | 'private';
}
export interface PaginatedQueryOptions {
limit?: number;
offset?: number;
filter?: RatingEventFilter;
}
export interface PaginatedResult<T> {
items: T[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
nextOffset?: number;
}
export interface IRatingEventRepository {
/**
* Save a rating event to the ledger
*/
save(event: RatingEvent): Promise<RatingEvent>;
/**
* Find all rating events for a user, ordered by occurredAt (ascending)
* Options allow for pagination and streaming
*/
findByUserId(userId: string, options?: FindByUserIdOptions): Promise<RatingEvent[]>;
/**
* Find multiple events by their IDs
*/
findByIds(ids: RatingEventId[]): Promise<RatingEvent[]>;
/**
* Get all events for a user (for snapshot recomputation)
*/
getAllByUserId(userId: string): Promise<RatingEvent[]>;
/**
* Find events with pagination and filtering
*/
findEventsPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<RatingEvent>>;
}

View File

@@ -0,0 +1,90 @@
/**
* Unit tests for IUserRatingRepository
*/
import { UserRating } from '../value-objects/UserRating';
import { IUserRatingRepository } from './IUserRatingRepository';
// In-memory test implementation
class InMemoryUserRatingRepository implements IUserRatingRepository {
private ratings: Map<string, UserRating> = new Map();
async findByUserId(userId: string): Promise<UserRating | null> {
return this.ratings.get(userId) || null;
}
async save(userRating: UserRating): Promise<UserRating> {
this.ratings.set(userRating.userId, userRating);
return userRating;
}
}
describe('IUserRatingRepository', () => {
let repository: InMemoryUserRatingRepository;
beforeEach(() => {
repository = new InMemoryUserRatingRepository();
});
describe('save', () => {
it('should save a new user rating', async () => {
const rating = UserRating.create('user-1');
const saved = await repository.save(rating);
expect(saved).toEqual(rating);
const found = await repository.findByUserId('user-1');
expect(found).toEqual(rating);
});
it('should update existing user rating', async () => {
const rating1 = UserRating.create('user-1');
await repository.save(rating1);
// Update the saved rating (not create a new one)
const updated = rating1.updateDriverRating(75);
await repository.save(updated);
const found = await repository.findByUserId('user-1');
expect(found).toEqual(updated);
// Value will be ~57.5 due to EMA from base 50
expect(found!.driver.value).toBeGreaterThan(50);
expect(found!.driver.value).toBeLessThan(75);
});
});
describe('findByUserId', () => {
it('should return rating for existing user', async () => {
const rating = UserRating.create('user-1');
await repository.save(rating);
const found = await repository.findByUserId('user-1');
expect(found).toEqual(rating);
expect(found!.userId).toBe('user-1');
});
it('should return null for non-existent user', async () => {
const found = await repository.findByUserId('non-existent');
expect(found).toBeNull();
});
it('should handle multiple users independently', async () => {
const rating1 = UserRating.create('user-1');
const rating2 = UserRating.create('user-2');
const updated1 = rating1.updateDriverRating(60);
const updated2 = rating2.updateDriverRating(80);
await repository.save(updated1);
await repository.save(updated2);
const found1 = await repository.findByUserId('user-1');
const found2 = await repository.findByUserId('user-2');
// Both should have different values (EMA from base 50)
expect(found1!.driver.value).not.toBe(found2!.driver.value);
expect(found1!.userId).toBe('user-1');
expect(found2!.userId).toBe('user-2');
});
});
});

View File

@@ -1,49 +1,20 @@
/**
* Repository Interface: IUserRatingRepository
*
* Defines operations for UserRating value objects
* Port for persisting and retrieving UserRating snapshots.
* Snapshots are derived from rating events for fast reads.
*/
import type { UserRating } from '../value-objects/UserRating';
export interface IUserRatingRepository {
/**
* Find rating by user ID
* Find rating snapshot by user ID
*/
findByUserId(userId: string): Promise<UserRating | null>;
/**
* Find ratings by multiple user IDs
* Save or update a user rating snapshot
*/
findByUserIds(userIds: string[]): Promise<UserRating[]>;
/**
* Save or update a user rating
*/
save(rating: UserRating): Promise<UserRating>;
/**
* Get top rated drivers
*/
getTopDrivers(limit: number): Promise<UserRating[]>;
/**
* Get top trusted users
*/
getTopTrusted(limit: number): Promise<UserRating[]>;
/**
* Get eligible stewards (based on trust and fairness thresholds)
*/
getEligibleStewards(): Promise<UserRating[]>;
/**
* Get ratings by driver tier
*/
findByDriverTier(tier: 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite'): Promise<UserRating[]>;
/**
* Delete rating by user ID
*/
delete(userId: string): Promise<void>;
save(userRating: UserRating): Promise<UserRating>;
}