304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
|
import { RatingEvent } from '../entities/RatingEvent';
|
|
import type { RatingEventRepository } from '../repositories/RatingEventRepository';
|
|
import type { UserRatingRepository } from '../repositories/UserRatingRepository';
|
|
import { RatingDelta } from '../value-objects/RatingDelta';
|
|
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
|
|
import { RatingEventId } from '../value-objects/RatingEventId';
|
|
import { UserRating } from '../value-objects/UserRating';
|
|
import { RatingUpdateService } from './RatingUpdateService';
|
|
|
|
describe('RatingUpdateService - Slice 7 Evolution', () => {
|
|
let service: RatingUpdateService;
|
|
let userRatingRepository: { findByUserId: Mock; save: Mock };
|
|
let ratingEventRepository: { save: Mock; getAllByUserId: Mock };
|
|
|
|
beforeEach(() => {
|
|
userRatingRepository = {
|
|
findByUserId: vi.fn(),
|
|
save: vi.fn(),
|
|
};
|
|
|
|
ratingEventRepository = {
|
|
save: vi.fn(),
|
|
getAllByUserId: vi.fn(),
|
|
};
|
|
|
|
service = new RatingUpdateService(
|
|
userRatingRepository as unknown as UserRatingRepository,
|
|
ratingEventRepository as unknown as RatingEventRepository
|
|
);
|
|
});
|
|
|
|
describe('recordRaceRatingEvents - Ledger-based approach', () => {
|
|
it('should record race events and update snapshots', async () => {
|
|
const raceId = 'race-123';
|
|
const raceResults = [
|
|
{
|
|
userId: 'driver-1',
|
|
startPos: 5,
|
|
finishPos: 2,
|
|
incidents: 1,
|
|
status: 'finished' as const,
|
|
},
|
|
{
|
|
userId: 'driver-2',
|
|
startPos: 3,
|
|
finishPos: 1,
|
|
incidents: 0,
|
|
status: 'finished' as const,
|
|
},
|
|
];
|
|
|
|
// Mock repositories
|
|
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
|
ratingEventRepository.getAllByUserId.mockImplementation((userId: string) => {
|
|
// Return mock events based on user
|
|
if (userId === 'driver-1') {
|
|
return Promise.resolve([
|
|
RatingEvent.create({
|
|
id: RatingEventId.generate(),
|
|
userId: 'driver-1',
|
|
dimension: RatingDimensionKey.create('driving'),
|
|
delta: RatingDelta.create(15),
|
|
weight: 1,
|
|
occurredAt: new Date(),
|
|
createdAt: new Date(),
|
|
source: { type: 'race', id: raceId },
|
|
reason: { code: 'DRIVING_FINISH_STRENGTH_GAIN', summary: 'Test', details: {} },
|
|
visibility: { public: true, redactedFields: [] },
|
|
version: 1,
|
|
}),
|
|
]);
|
|
}
|
|
return Promise.resolve([
|
|
RatingEvent.create({
|
|
id: RatingEventId.generate(),
|
|
userId: 'driver-2',
|
|
dimension: RatingDimensionKey.create('driving'),
|
|
delta: RatingDelta.create(20),
|
|
weight: 1,
|
|
occurredAt: new Date(),
|
|
createdAt: new Date(),
|
|
source: { type: 'race', id: raceId },
|
|
reason: { code: 'DRIVING_FINISH_STRENGTH_GAIN', summary: 'Test', details: {} },
|
|
visibility: { public: true, redactedFields: [] },
|
|
version: 1,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
|
|
|
const result = await service.recordRaceRatingEvents(raceId, raceResults);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.eventsCreated).toBeGreaterThan(0);
|
|
expect(result.driversUpdated).toEqual(['driver-1', 'driver-2']);
|
|
|
|
// Verify events were saved
|
|
expect(ratingEventRepository.save).toHaveBeenCalled();
|
|
|
|
// Verify snapshots were recomputed
|
|
expect(ratingEventRepository.getAllByUserId).toHaveBeenCalledWith('driver-1');
|
|
expect(ratingEventRepository.getAllByUserId).toHaveBeenCalledWith('driver-2');
|
|
expect(userRatingRepository.save).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should handle empty race results gracefully', async () => {
|
|
const result = await service.recordRaceRatingEvents('race-123', []);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.eventsCreated).toBe(0);
|
|
expect(result.driversUpdated).toEqual([]);
|
|
});
|
|
|
|
it('should return failure on repository errors', async () => {
|
|
ratingEventRepository.save.mockRejectedValue(new Error('Database error'));
|
|
|
|
const result = await service.recordRaceRatingEvents('race-123', [
|
|
{
|
|
userId: 'driver-1',
|
|
startPos: 5,
|
|
finishPos: 2,
|
|
incidents: 1,
|
|
status: 'finished' as const,
|
|
},
|
|
]);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.eventsCreated).toBe(0);
|
|
expect(result.driversUpdated).toEqual([]);
|
|
});
|
|
|
|
it('should handle DNF status correctly', async () => {
|
|
const raceResults = [
|
|
{
|
|
userId: 'driver-1',
|
|
startPos: 5,
|
|
finishPos: 10,
|
|
incidents: 2,
|
|
status: 'dnf' as const,
|
|
},
|
|
];
|
|
|
|
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
|
ratingEventRepository.getAllByUserId.mockResolvedValue([]);
|
|
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
|
|
|
const result = await service.recordRaceRatingEvents('race-123', raceResults);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.eventsCreated).toBeGreaterThan(0);
|
|
|
|
// Verify DNF penalty event was created
|
|
const savedEvents = ratingEventRepository.save.mock.calls.map(call => call[0]);
|
|
const hasDnfPenalty = savedEvents.some((event: RatingEvent) =>
|
|
event.reason.code === 'DRIVING_DNF_PENALTY'
|
|
);
|
|
expect(hasDnfPenalty).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('updateDriverRatingsAfterRace - Backward compatibility', () => {
|
|
it('should delegate to new ledger-based approach', async () => {
|
|
const driverResults = [
|
|
{
|
|
driverId: 'driver-1',
|
|
position: 2,
|
|
totalDrivers: 10,
|
|
incidents: 1,
|
|
startPosition: 5,
|
|
},
|
|
];
|
|
|
|
// Mock the new method to succeed
|
|
const recordSpy = vi.spyOn(service, 'recordRaceRatingEvents').mockResolvedValue({
|
|
success: true,
|
|
eventsCreated: 2,
|
|
driversUpdated: ['driver-1'],
|
|
});
|
|
|
|
await service.updateDriverRatingsAfterRace(driverResults);
|
|
|
|
expect(recordSpy).toHaveBeenCalled();
|
|
|
|
recordSpy.mockRestore();
|
|
});
|
|
|
|
it('should throw error when ledger approach fails', async () => {
|
|
const driverResults = [
|
|
{
|
|
driverId: 'driver-1',
|
|
position: 2,
|
|
totalDrivers: 10,
|
|
incidents: 1,
|
|
startPosition: 5,
|
|
},
|
|
];
|
|
|
|
// Mock the new method to fail
|
|
const recordSpy = vi.spyOn(service, 'recordRaceRatingEvents').mockResolvedValue({
|
|
success: false,
|
|
eventsCreated: 0,
|
|
driversUpdated: [],
|
|
});
|
|
|
|
await expect(service.updateDriverRatingsAfterRace(driverResults)).rejects.toThrow(
|
|
'Failed to update ratings via event system'
|
|
);
|
|
|
|
recordSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('updateTrustScore - Ledger-based', () => {
|
|
it('should create trust event and update snapshot', async () => {
|
|
const userId = 'user-1';
|
|
const trustChange = 10;
|
|
|
|
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
|
ratingEventRepository.getAllByUserId.mockResolvedValue([
|
|
RatingEvent.create({
|
|
id: RatingEventId.generate(),
|
|
userId: userId,
|
|
dimension: RatingDimensionKey.create('adminTrust'),
|
|
delta: RatingDelta.create(10),
|
|
weight: 1,
|
|
occurredAt: new Date(),
|
|
createdAt: new Date(),
|
|
source: { type: 'manualAdjustment', id: 'trust-test' },
|
|
reason: { code: 'TRUST_BONUS', summary: 'Test', details: {} },
|
|
visibility: { public: true, redactedFields: [] },
|
|
version: 1,
|
|
}),
|
|
]);
|
|
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
|
|
|
await service.updateTrustScore(userId, trustChange);
|
|
|
|
expect(ratingEventRepository.save).toHaveBeenCalled();
|
|
expect(userRatingRepository.save).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle negative trust changes', async () => {
|
|
const userId = 'user-1';
|
|
const trustChange = -5;
|
|
|
|
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
|
ratingEventRepository.getAllByUserId.mockResolvedValue([]);
|
|
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
|
|
|
await service.updateTrustScore(userId, trustChange);
|
|
|
|
const savedEvent = ratingEventRepository.save.mock.calls[0][0];
|
|
expect(savedEvent.reason.code).toBe('TRUST_PENALTY');
|
|
expect(savedEvent.delta.value).toBe(-5);
|
|
});
|
|
});
|
|
|
|
describe('updateStewardRating - Ledger-based', () => {
|
|
it('should create steward event and update snapshot', async () => {
|
|
const stewardId = 'steward-1';
|
|
const ratingChange = 8;
|
|
|
|
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
|
ratingEventRepository.getAllByUserId.mockResolvedValue([
|
|
RatingEvent.create({
|
|
id: RatingEventId.generate(),
|
|
userId: stewardId,
|
|
dimension: RatingDimensionKey.create('adminTrust'),
|
|
delta: RatingDelta.create(8),
|
|
weight: 1,
|
|
occurredAt: new Date(),
|
|
createdAt: new Date(),
|
|
source: { type: 'manualAdjustment', id: 'steward-test' },
|
|
reason: { code: 'STEWARD_BONUS', summary: 'Test', details: {} },
|
|
visibility: { public: true, redactedFields: [] },
|
|
version: 1,
|
|
}),
|
|
]);
|
|
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
|
|
|
await service.updateStewardRating(stewardId, ratingChange);
|
|
|
|
expect(ratingEventRepository.save).toHaveBeenCalled();
|
|
expect(userRatingRepository.save).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle negative steward rating changes', async () => {
|
|
const stewardId = 'steward-1';
|
|
const ratingChange = -3;
|
|
|
|
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
|
ratingEventRepository.getAllByUserId.mockResolvedValue([]);
|
|
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
|
|
|
await service.updateStewardRating(stewardId, ratingChange);
|
|
|
|
const savedEvent = ratingEventRepository.save.mock.calls[0][0];
|
|
expect(savedEvent.reason.code).toBe('STEWARD_PENALTY');
|
|
expect(savedEvent.delta.value).toBe(-3);
|
|
});
|
|
});
|
|
}); |