team rating
This commit is contained in:
376
core/racing/application/queries/GetTeamRatingLedgerQuery.test.ts
Normal file
376
core/racing/application/queries/GetTeamRatingLedgerQuery.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
106
core/racing/application/queries/GetTeamRatingLedgerQuery.ts
Normal file
106
core/racing/application/queries/GetTeamRatingLedgerQuery.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
5
core/racing/application/queries/index.ts
Normal file
5
core/racing/application/queries/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user