team rating

This commit is contained in:
2025-12-30 12:25:45 +01:00
parent ccaa39c39c
commit 83371ea839
93 changed files with 10324 additions and 490 deletions

View File

@@ -0,0 +1,376 @@
/**
* Tests for GetTeamRatingLedgerQuery
*/
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetTeamRatingLedgerQuery, GetTeamRatingLedgerQueryHandler } from './GetTeamRatingLedgerQuery';
import { TeamRatingEvent } from '../../domain/entities/TeamRatingEvent';
import { TeamRatingEventId } from '../../domain/value-objects/TeamRatingEventId';
import { TeamRatingDimensionKey } from '../../domain/value-objects/TeamRatingDimensionKey';
import { TeamRatingDelta } from '../../domain/value-objects/TeamRatingDelta';
describe('GetTeamRatingLedgerQuery', () => {
let mockRatingEventRepo: any;
let handler: GetTeamRatingLedgerQueryHandler;
beforeEach(() => {
mockRatingEventRepo = {
findEventsPaginated: vi.fn(),
};
handler = new GetTeamRatingLedgerQueryHandler(mockRatingEventRepo);
});
describe('execute', () => {
it('should return paginated ledger entries', async () => {
const teamId = 'team-123';
// Mock paginated result
const event1 = TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(10),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-123' },
reason: { code: 'RACE_FINISH' },
visibility: { public: true },
version: 1,
});
const event2 = TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(-5),
occurredAt: new Date('2024-01-02T10:00:00Z'),
createdAt: new Date('2024-01-02T10:00:00Z'),
source: { type: 'penalty', id: 'penalty-456' },
reason: { code: 'LATE_JOIN' },
visibility: { public: true },
version: 1,
weight: 2,
});
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
items: [event1, event2],
total: 2,
limit: 20,
offset: 0,
hasMore: false,
nextOffset: undefined,
});
const query: GetTeamRatingLedgerQuery = { teamId };
const result = await handler.execute(query);
expect(result.entries.length).toBe(2);
const entry1 = result.entries[0];
expect(entry1).toBeDefined();
if (entry1) {
expect(entry1.teamId).toBe(teamId);
expect(entry1.dimension).toBe('driving');
expect(entry1.delta).toBe(10);
expect(entry1.source.type).toBe('race');
expect(entry1.source.id).toBe('race-123');
expect(entry1.reason.code).toBe('RACE_FINISH');
expect(entry1.visibility.public).toBe(true);
}
const entry2 = result.entries[1];
expect(entry2).toBeDefined();
if (entry2) {
expect(entry2.dimension).toBe('adminTrust');
expect(entry2.delta).toBe(-5);
expect(entry2.weight).toBe(2);
expect(entry2.source.type).toBe('penalty');
expect(entry2.source.id).toBe('penalty-456');
}
expect(result.pagination.total).toBe(2);
expect(result.pagination.limit).toBe(20);
expect(result.pagination.hasMore).toBe(false);
});
it('should apply default pagination values', async () => {
const teamId = 'team-123';
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
items: [],
total: 0,
limit: 20,
offset: 0,
hasMore: false,
});
const query: GetTeamRatingLedgerQuery = { teamId };
await handler.execute(query);
expect(mockRatingEventRepo.findEventsPaginated).toHaveBeenCalledWith(
teamId,
expect.objectContaining({
limit: 20,
offset: 0,
})
);
});
it('should apply custom pagination values', async () => {
const teamId = 'team-123';
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
items: [],
total: 0,
limit: 10,
offset: 20,
hasMore: true,
nextOffset: 30,
});
const query: GetTeamRatingLedgerQuery = { teamId, limit: 10, offset: 20 };
await handler.execute(query);
expect(mockRatingEventRepo.findEventsPaginated).toHaveBeenCalledWith(
teamId,
expect.objectContaining({
limit: 10,
offset: 20,
})
);
});
it('should apply filters when provided', async () => {
const teamId = 'team-123';
const filter = {
dimensions: ['driving'],
sourceTypes: ['race', 'penalty'] as ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[],
from: '2024-01-01T00:00:00Z',
to: '2024-01-31T23:59:59Z',
reasonCodes: ['RACE_FINISH', 'LATE_JOIN'],
};
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
items: [],
total: 0,
limit: 20,
offset: 0,
hasMore: false,
});
const query: GetTeamRatingLedgerQuery = { teamId, filter };
await handler.execute(query);
expect(mockRatingEventRepo.findEventsPaginated).toHaveBeenCalledWith(
teamId,
expect.objectContaining({
filter: expect.objectContaining({
dimensions: ['driving'],
sourceTypes: ['race', 'penalty'],
from: new Date('2024-01-01T00:00:00Z'),
to: new Date('2024-01-31T23:59:59Z'),
reasonCodes: ['RACE_FINISH', 'LATE_JOIN'],
}),
})
);
});
it('should handle events with optional weight', async () => {
const teamId = 'team-123';
const eventWithWeight = TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(15),
weight: 1.5,
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-789' },
reason: { code: 'PERFORMANCE_BONUS' },
visibility: { public: true },
version: 1,
});
const eventWithoutWeight = TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(5),
occurredAt: new Date('2024-01-02T10:00:00Z'),
createdAt: new Date('2024-01-02T10:00:00Z'),
source: { type: 'vote', id: 'vote-123' },
reason: { code: 'POSITIVE_VOTE' },
visibility: { public: true },
version: 1,
});
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
items: [eventWithWeight, eventWithoutWeight],
total: 2,
limit: 20,
offset: 0,
hasMore: false,
});
const query: GetTeamRatingLedgerQuery = { teamId };
const result = await handler.execute(query);
const entry1 = result.entries[0];
const entry2 = result.entries[1];
expect(entry1).toBeDefined();
expect(entry2).toBeDefined();
if (entry1) {
expect(entry1.weight).toBe(1.5);
}
if (entry2) {
expect(entry2.weight).toBeUndefined();
}
});
it('should handle events with optional source.id', async () => {
const teamId = 'team-123';
const eventWithId = TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(10),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-123' },
reason: { code: 'RACE_FINISH' },
visibility: { public: true },
version: 1,
});
const eventWithoutId = TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(5),
occurredAt: new Date('2024-01-02T10:00:00Z'),
createdAt: new Date('2024-01-02T10:00:00Z'),
source: { type: 'manualAdjustment' },
reason: { code: 'ADMIN_ADJUSTMENT' },
visibility: { public: true },
version: 1,
});
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
items: [eventWithId, eventWithoutId],
total: 2,
limit: 20,
offset: 0,
hasMore: false,
});
const query: GetTeamRatingLedgerQuery = { teamId };
const result = await handler.execute(query);
const entry1 = result.entries[0];
const entry2 = result.entries[1];
expect(entry1).toBeDefined();
expect(entry2).toBeDefined();
if (entry1) {
expect(entry1.source.id).toBe('race-123');
}
if (entry2) {
expect(entry2.source.id).toBeUndefined();
}
});
it('should handle events with optional reason.description', async () => {
const teamId = 'team-123';
const eventWithDescription = TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(10),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-123' },
reason: { code: 'RACE_FINISH', description: 'Finished 1st in class' },
visibility: { public: true },
version: 1,
});
const eventWithoutDescription = TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('adminTrust'),
delta: TeamRatingDelta.create(5),
occurredAt: new Date('2024-01-02T10:00:00Z'),
createdAt: new Date('2024-01-02T10:00:00Z'),
source: { type: 'vote', id: 'vote-123' },
reason: { code: 'POSITIVE_VOTE' },
visibility: { public: true },
version: 1,
});
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
items: [eventWithDescription, eventWithoutDescription],
total: 2,
limit: 20,
offset: 0,
hasMore: false,
});
const query: GetTeamRatingLedgerQuery = { teamId };
const result = await handler.execute(query);
const entry1 = result.entries[0];
const entry2 = result.entries[1];
expect(entry1).toBeDefined();
expect(entry2).toBeDefined();
if (entry1) {
expect(entry1.reason.description).toBe('Finished 1st in class');
}
if (entry2) {
expect(entry2.reason.description).toBeUndefined();
}
});
it('should return nextOffset when hasMore is true', async () => {
const teamId = 'team-123';
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
items: [],
total: 50,
limit: 20,
offset: 20,
hasMore: true,
nextOffset: 40,
});
const query: GetTeamRatingLedgerQuery = { teamId };
const result = await handler.execute(query);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.nextOffset).toBe(40);
});
it('should return null nextOffset when hasMore is false', async () => {
const teamId = 'team-123';
mockRatingEventRepo.findEventsPaginated.mockResolvedValue({
items: [],
total: 15,
limit: 20,
offset: 0,
hasMore: false,
});
const query: GetTeamRatingLedgerQuery = { teamId };
const result = await handler.execute(query);
expect(result.pagination.hasMore).toBe(false);
expect(result.pagination.nextOffset).toBeNull();
});
});
});

View File

@@ -0,0 +1,106 @@
/**
* Query: GetTeamRatingLedgerQuery
*
* Paginated/filtered query for team rating events (ledger).
* Mirrors user slice 6 pattern but for teams.
*/
import { TeamLedgerEntryDto, TeamLedgerFilter, PaginatedTeamLedgerResult } from '../dtos/TeamLedgerEntryDto';
import { ITeamRatingEventRepository, PaginatedQueryOptions, TeamRatingEventFilter } from '../../domain/repositories/ITeamRatingEventRepository';
export interface GetTeamRatingLedgerQuery {
teamId: string;
limit?: number;
offset?: number;
filter?: TeamLedgerFilter;
}
export class GetTeamRatingLedgerQueryHandler {
constructor(
private readonly ratingEventRepo: ITeamRatingEventRepository
) {}
async execute(query: GetTeamRatingLedgerQuery): Promise<PaginatedTeamLedgerResult> {
const { teamId, limit = 20, offset = 0, filter } = query;
// Build repo options
const repoOptions: PaginatedQueryOptions = {
limit,
offset,
};
// Add filter if provided
if (filter) {
const ratingEventFilter: TeamRatingEventFilter = {};
if (filter.dimensions) {
ratingEventFilter.dimensions = filter.dimensions;
}
if (filter.sourceTypes) {
ratingEventFilter.sourceTypes = filter.sourceTypes;
}
if (filter.from) {
ratingEventFilter.from = new Date(filter.from);
}
if (filter.to) {
ratingEventFilter.to = new Date(filter.to);
}
if (filter.reasonCodes) {
ratingEventFilter.reasonCodes = filter.reasonCodes;
}
repoOptions.filter = ratingEventFilter;
}
// Query repository
const result = await this.ratingEventRepo.findEventsPaginated(teamId, repoOptions);
// Convert domain entities to DTOs
const entries: TeamLedgerEntryDto[] = result.items.map(event => {
const source: { type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment'; id?: string } = {
type: event.source.type,
};
if (event.source.id !== undefined) {
source.id = event.source.id;
}
const reason: { code: string; description?: string } = {
code: event.reason.code,
};
if (event.reason.description !== undefined) {
reason.description = event.reason.description;
}
const dto: TeamLedgerEntryDto = {
id: event.id.value,
teamId: event.teamId,
dimension: event.dimension.value,
delta: event.delta.value,
occurredAt: event.occurredAt.toISOString(),
createdAt: event.createdAt.toISOString(),
source,
reason,
visibility: {
public: event.visibility.public,
},
};
if (event.weight !== undefined) {
dto.weight = event.weight;
}
return dto;
});
const nextOffset = result.nextOffset !== undefined ? result.nextOffset : null;
return {
entries,
pagination: {
total: result.total,
limit: result.limit,
offset: result.offset,
hasMore: result.hasMore,
nextOffset,
},
};
}
}

View File

@@ -0,0 +1,184 @@
/**
* Tests for GetTeamRatingsSummaryQuery
*/
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetTeamRatingsSummaryQuery, GetTeamRatingsSummaryQueryHandler } from './GetTeamRatingsSummaryQuery';
import { TeamRatingSnapshot } from '../../domain/services/TeamRatingSnapshotCalculator';
import { TeamRatingValue } from '../../domain/value-objects/TeamRatingValue';
import { TeamRatingEvent } from '../../domain/entities/TeamRatingEvent';
import { TeamRatingEventId } from '../../domain/value-objects/TeamRatingEventId';
import { TeamRatingDimensionKey } from '../../domain/value-objects/TeamRatingDimensionKey';
import { TeamRatingDelta } from '../../domain/value-objects/TeamRatingDelta';
describe('GetTeamRatingsSummaryQuery', () => {
let mockTeamRatingRepo: any;
let mockRatingEventRepo: any;
let handler: GetTeamRatingsSummaryQueryHandler;
beforeEach(() => {
mockTeamRatingRepo = {
findByTeamId: vi.fn(),
};
mockRatingEventRepo = {
getAllByTeamId: vi.fn(),
};
handler = new GetTeamRatingsSummaryQueryHandler(
mockTeamRatingRepo,
mockRatingEventRepo
);
});
describe('execute', () => {
it('should return summary with platform ratings', async () => {
const teamId = 'team-123';
// Mock team rating snapshot
const snapshot: TeamRatingSnapshot = {
teamId,
driving: TeamRatingValue.create(75),
adminTrust: TeamRatingValue.create(60),
overall: 70.5,
lastUpdated: new Date('2024-01-01T10:00:00Z'),
eventCount: 5,
};
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
// Mock rating events
const event = TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(10),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-123' },
reason: { code: 'RACE_FINISH' },
visibility: { public: true },
version: 1,
});
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([event]);
const query: GetTeamRatingsSummaryQuery = { teamId };
const result = await handler.execute(query);
expect(result.teamId).toBe(teamId);
expect(result.platform.driving.value).toBe(75);
expect(result.platform.adminTrust.value).toBe(60);
expect(result.platform.overall).toBe(70.5);
expect(result.lastRatingEventAt).toBe('2024-01-01T10:00:00.000Z');
});
it('should handle missing team rating gracefully', async () => {
const teamId = 'team-123';
mockTeamRatingRepo.findByTeamId.mockResolvedValue(null);
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([]);
const query: GetTeamRatingsSummaryQuery = { teamId };
const result = await handler.execute(query);
expect(result.teamId).toBe(teamId);
expect(result.platform.driving.value).toBe(0);
expect(result.platform.adminTrust.value).toBe(0);
expect(result.platform.overall).toBe(0);
expect(result.lastRatingEventAt).toBeUndefined();
});
it('should handle multiple events and find latest', async () => {
const teamId = 'team-123';
const snapshot: TeamRatingSnapshot = {
teamId,
driving: TeamRatingValue.create(80),
adminTrust: TeamRatingValue.create(70),
overall: 77,
lastUpdated: new Date('2024-01-02T10:00:00Z'),
eventCount: 10,
};
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
// Multiple events with different timestamps
const events = [
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(5),
occurredAt: new Date('2024-01-01T08:00:00Z'),
createdAt: new Date('2024-01-01T08:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'RACE_FINISH' },
visibility: { public: true },
version: 1,
}),
TeamRatingEvent.create({
id: TeamRatingEventId.generate(),
teamId,
dimension: TeamRatingDimensionKey.create('driving'),
delta: TeamRatingDelta.create(10),
occurredAt: new Date('2024-01-02T10:00:00Z'),
createdAt: new Date('2024-01-02T10:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'RACE_FINISH' },
visibility: { public: true },
version: 1,
}),
];
mockRatingEventRepo.getAllByTeamId.mockResolvedValue(events);
const query: GetTeamRatingsSummaryQuery = { teamId };
const result = await handler.execute(query);
expect(result.lastRatingEventAt).toBe('2024-01-02T10:00:00.000Z');
});
it('should calculate confidence and sampleSize from event count', async () => {
const teamId = 'team-123';
const snapshot: TeamRatingSnapshot = {
teamId,
driving: TeamRatingValue.create(65),
adminTrust: TeamRatingValue.create(55),
overall: 62,
lastUpdated: new Date('2024-01-01T10:00:00Z'),
eventCount: 8,
};
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([]);
const query: GetTeamRatingsSummaryQuery = { teamId };
const result = await handler.execute(query);
// Confidence should be min(1, eventCount/10) = 0.8
expect(result.platform.driving.confidence).toBe(0.8);
expect(result.platform.driving.sampleSize).toBe(8);
expect(result.platform.adminTrust.confidence).toBe(0.8);
expect(result.platform.adminTrust.sampleSize).toBe(8);
});
it('should handle empty events array', async () => {
const teamId = 'team-123';
const snapshot: TeamRatingSnapshot = {
teamId,
driving: TeamRatingValue.create(50),
adminTrust: TeamRatingValue.create(50),
overall: 50,
lastUpdated: new Date('2024-01-01T10:00:00Z'),
eventCount: 0,
};
mockTeamRatingRepo.findByTeamId.mockResolvedValue(snapshot);
mockRatingEventRepo.getAllByTeamId.mockResolvedValue([]);
const query: GetTeamRatingsSummaryQuery = { teamId };
const result = await handler.execute(query);
expect(result.platform.driving.confidence).toBe(0);
expect(result.platform.driving.sampleSize).toBe(0);
expect(result.platform.driving.trend).toBe('stable');
expect(result.lastRatingEventAt).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,82 @@
/**
* Query: GetTeamRatingsSummaryQuery
*
* Fast read query for team rating summary.
* Mirrors user slice 6 pattern but for teams.
*/
import { TeamRatingSummaryDto } from '../dtos/TeamRatingSummaryDto';
import { ITeamRatingRepository } from '../../domain/repositories/ITeamRatingRepository';
import { ITeamRatingEventRepository } from '../../domain/repositories/ITeamRatingEventRepository';
export interface GetTeamRatingsSummaryQuery {
teamId: string;
}
export class GetTeamRatingsSummaryQueryHandler {
constructor(
private readonly teamRatingRepo: ITeamRatingRepository,
private readonly ratingEventRepo: ITeamRatingEventRepository
) {}
async execute(query: GetTeamRatingsSummaryQuery): Promise<TeamRatingSummaryDto> {
const { teamId } = query;
// Fetch platform rating snapshot
const teamRating = await this.teamRatingRepo.findByTeamId(teamId);
// Get last event timestamp if available
let lastRatingEventAt: string | undefined;
if (teamRating) {
// Get all events to find the most recent one
const events = await this.ratingEventRepo.getAllByTeamId(teamId);
if (events.length > 0) {
const lastEvent = events[events.length - 1];
if (lastEvent) {
lastRatingEventAt = lastEvent.occurredAt.toISOString();
}
}
}
// Build platform rating dimensions
// For team ratings, we don't have confidence/sampleSize/trend per dimension
// We'll derive these from event count and recent activity
const eventCount = teamRating?.eventCount || 0;
const lastUpdated = teamRating?.lastUpdated || new Date(0);
const platform = {
driving: {
value: teamRating?.driving.value || 0,
confidence: Math.min(1, eventCount / 10), // Simple confidence based on event count
sampleSize: eventCount,
trend: 'stable' as const, // Could be calculated from recent events
lastUpdated: lastUpdated.toISOString(),
},
adminTrust: {
value: teamRating?.adminTrust.value || 0,
confidence: Math.min(1, eventCount / 10),
sampleSize: eventCount,
trend: 'stable' as const,
lastUpdated: lastUpdated.toISOString(),
},
overall: teamRating?.overall || 0,
};
// Get timestamps
const createdAt = lastUpdated.toISOString();
const updatedAt = lastUpdated.toISOString();
const result: TeamRatingSummaryDto = {
teamId,
platform,
createdAt,
updatedAt,
};
if (lastRatingEventAt) {
result.lastRatingEventAt = lastRatingEventAt;
}
return result;
}
}

View File

@@ -0,0 +1,5 @@
// Team Rating Queries
export type { GetTeamRatingsSummaryQuery } from './GetTeamRatingsSummaryQuery';
export { GetTeamRatingsSummaryQueryHandler } from './GetTeamRatingsSummaryQuery';
export type { GetTeamRatingLedgerQuery } from './GetTeamRatingLedgerQuery';
export { GetTeamRatingLedgerQueryHandler } from './GetTeamRatingLedgerQuery';