rating
This commit is contained in:
@@ -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[]>;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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>>;
|
||||
}
|
||||
560
core/identity/domain/repositories/IRatingEventRepository.test.ts
Normal file
560
core/identity/domain/repositories/IRatingEventRepository.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
73
core/identity/domain/repositories/IRatingEventRepository.ts
Normal file
73
core/identity/domain/repositories/IRatingEventRepository.ts
Normal 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>>;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user