rating
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user