racing typeorm
This commit is contained in:
@@ -67,6 +67,10 @@ export class InMemoryLeagueRepository implements ILeagueRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async countAll(): Promise<number> {
|
||||
return this.leagues.size;
|
||||
}
|
||||
|
||||
async create(league: League): Promise<League> {
|
||||
this.logger.debug(`Attempting to create league: ${league.id}.`);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import type { SerializedLeagueSettings } from '../serialized/RacingTypeOrmSerialized';
|
||||
|
||||
@Entity({ name: 'racing_leagues' })
|
||||
export class LeagueOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
ownerId!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
settings!: SerializedLeagueSettings;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
participantCount!: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
discordUrl!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
youtubeUrl!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
websiteUrl!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import type { SerializedChampionshipConfig } from '../mappers/ChampionshipConfigJsonMapper';
|
||||
|
||||
@Entity({ name: 'racing_league_scoring_configs' })
|
||||
export class LeagueScoringConfigOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
seasonId!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
scoringPresetId!: string | null;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
championships!: SerializedChampionshipConfig[];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'racing_races' })
|
||||
export class RaceOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
leagueId!: string;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
scheduledAt!: Date;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
track!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
trackId!: string | null;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
car!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
carId!: string | null;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
sessionType!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
status!: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
strengthOfField!: number | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
registeredCount!: number | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
maxParticipants!: number | null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import type {
|
||||
SerializedSeasonDropPolicy,
|
||||
SerializedSeasonSchedule,
|
||||
SerializedSeasonScoringConfig,
|
||||
SerializedSeasonStewardingConfig,
|
||||
} from '../serialized/RacingTypeOrmSerialized';
|
||||
|
||||
@Entity({ name: 'racing_seasons' })
|
||||
export class SeasonOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
leagueId!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
gameId!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
year!: number | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
order!: number | null;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
status!: string;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
startDate!: Date | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
endDate!: Date | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schedule!: SerializedSeasonSchedule | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
schedulePublished!: boolean;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
scoringConfig!: SerializedSeasonScoringConfig | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
dropPolicy!: SerializedSeasonDropPolicy | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
stewardingConfig!: SerializedSeasonStewardingConfig | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
maxDrivers!: number | null;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
participantCount!: number;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class InvalidLeagueScoringConfigChampionshipsSchemaError extends Error {
|
||||
override readonly name = 'InvalidLeagueScoringConfigChampionshipsSchemaError';
|
||||
|
||||
constructor(message = 'Invalid LeagueScoringConfig.championships persisted schema') {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidLeagueSettingsSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidLeagueSettingsSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidRaceSessionTypeSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidRaceSessionTypeSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidRaceStatusSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidRaceStatusSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidSeasonScheduleSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidSeasonScheduleSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidSeasonStatusSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidSeasonStatusSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { BonusRule } from '@core/racing/domain/types/BonusRule';
|
||||
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
|
||||
import type { DropScorePolicy } from '@core/racing/domain/types/DropScorePolicy';
|
||||
import type { SessionType } from '@core/racing/domain/types/SessionType';
|
||||
import { PointsTableJsonMapper, type SerializedPointsTable } from './PointsTableJsonMapper';
|
||||
|
||||
export type SerializedChampionshipConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChampionshipConfig['type'];
|
||||
sessionTypes: SessionType[];
|
||||
pointsTableBySessionType: Record<SessionType, SerializedPointsTable>;
|
||||
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
|
||||
dropScorePolicy: DropScorePolicy;
|
||||
};
|
||||
|
||||
export class ChampionshipConfigJsonMapper {
|
||||
constructor(private readonly pointsTableMapper: PointsTableJsonMapper) {}
|
||||
|
||||
toJson(config: ChampionshipConfig): SerializedChampionshipConfig {
|
||||
const pointsTableBySessionType = {} as Record<SessionType, SerializedPointsTable>;
|
||||
|
||||
for (const [sessionType, pointsTable] of Object.entries(config.pointsTableBySessionType) as Array<
|
||||
[SessionType, Parameters<PointsTableJsonMapper['toJson']>[0]]
|
||||
>) {
|
||||
pointsTableBySessionType[sessionType] = this.pointsTableMapper.toJson(pointsTable);
|
||||
}
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
type: config.type,
|
||||
sessionTypes: config.sessionTypes,
|
||||
pointsTableBySessionType,
|
||||
...(config.bonusRulesBySessionType ? { bonusRulesBySessionType: config.bonusRulesBySessionType } : {}),
|
||||
dropScorePolicy: config.dropScorePolicy,
|
||||
};
|
||||
}
|
||||
|
||||
fromJson(serialized: SerializedChampionshipConfig): ChampionshipConfig {
|
||||
const pointsTableBySessionType = {} as ChampionshipConfig['pointsTableBySessionType'];
|
||||
|
||||
for (const [sessionType, pointsTable] of Object.entries(serialized.pointsTableBySessionType) as Array<
|
||||
[SessionType, SerializedPointsTable]
|
||||
>) {
|
||||
pointsTableBySessionType[sessionType] = this.pointsTableMapper.fromJson(pointsTable);
|
||||
}
|
||||
|
||||
return {
|
||||
id: serialized.id,
|
||||
name: serialized.name,
|
||||
type: serialized.type,
|
||||
sessionTypes: serialized.sessionTypes,
|
||||
pointsTableBySessionType,
|
||||
...(serialized.bonusRulesBySessionType ? { bonusRulesBySessionType: serialized.bonusRulesBySessionType } : {}),
|
||||
dropScorePolicy: serialized.dropScorePolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
|
||||
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
|
||||
import { LeagueOrmMapper } from './LeagueOrmMapper';
|
||||
|
||||
describe('LeagueOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
|
||||
const mapper = new LeagueOrmMapper();
|
||||
|
||||
const entity = new LeagueOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.name = 'League Name';
|
||||
entity.description = 'League Description';
|
||||
entity.ownerId = '00000000-0000-4000-8000-000000000002';
|
||||
entity.settings = {
|
||||
pointsSystem: 'f1-2024',
|
||||
visibility: 'ranked',
|
||||
maxDrivers: 32,
|
||||
sessionDuration: 60,
|
||||
qualifyingFormat: 'open',
|
||||
stewarding: { decisionMode: 'admin_only' },
|
||||
};
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.participantCount = 7;
|
||||
entity.discordUrl = null;
|
||||
entity.youtubeUrl = null;
|
||||
entity.websiteUrl = null;
|
||||
|
||||
if (typeof (League as any).rehydrate !== 'function') {
|
||||
throw new Error('rehydrate-missing');
|
||||
}
|
||||
|
||||
const rehydrateSpy = vi.spyOn(League as any, 'rehydrate');
|
||||
const createSpy = vi.spyOn(League, 'create').mockImplementation(() => {
|
||||
throw new Error('create-called');
|
||||
});
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id.toString()).toBe(entity.id);
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
expect(rehydrateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates persisted settings schema and throws adapter-scoped error type', () => {
|
||||
const mapper = new LeagueOrmMapper();
|
||||
|
||||
const entity = new LeagueOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.name = 'League Name';
|
||||
entity.description = 'League Description';
|
||||
entity.ownerId = '00000000-0000-4000-8000-000000000002';
|
||||
entity.settings = 123 as unknown as never;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.participantCount = 0;
|
||||
entity.discordUrl = null;
|
||||
entity.youtubeUrl = null;
|
||||
entity.websiteUrl = null;
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ name: 'InvalidLeagueSettingsSchemaError' });
|
||||
}
|
||||
});
|
||||
});
|
||||
190
adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts
Normal file
190
adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { League, type LeagueSettings } from '@core/racing/domain/entities/League';
|
||||
|
||||
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
|
||||
import { InvalidLeagueSettingsSchemaError } from '../errors/InvalidLeagueSettingsSchemaError';
|
||||
import type { SerializedLeagueSettings } from '../serialized/RacingTypeOrmSerialized';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const VALID_POINTS_SYSTEMS = ['f1-2024', 'indycar', 'custom'] as const;
|
||||
|
||||
const VALID_VISIBILITY = ['ranked', 'unranked'] as const;
|
||||
|
||||
const VALID_DECISION_MODES = [
|
||||
'admin_only',
|
||||
'steward_decides',
|
||||
'steward_vote',
|
||||
'member_vote',
|
||||
'steward_veto',
|
||||
'member_veto',
|
||||
] as const;
|
||||
|
||||
function isOneOf<T extends string>(value: string, allowed: readonly T[]): value is T {
|
||||
return (allowed as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function assertSerializedLeagueSettings(value: unknown): asserts value is SerializedLeagueSettings {
|
||||
if (!isRecord(value)) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings (expected object)');
|
||||
}
|
||||
|
||||
if (typeof value.pointsSystem !== 'string' || !isOneOf(value.pointsSystem, VALID_POINTS_SYSTEMS)) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.pointsSystem');
|
||||
}
|
||||
|
||||
if (value.sessionDuration !== undefined && typeof value.sessionDuration !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.sessionDuration');
|
||||
}
|
||||
|
||||
if (value.qualifyingFormat !== undefined && typeof value.qualifyingFormat !== 'string') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.qualifyingFormat');
|
||||
}
|
||||
|
||||
if (value.maxDrivers !== undefined && typeof value.maxDrivers !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.maxDrivers');
|
||||
}
|
||||
|
||||
if (value.visibility !== undefined) {
|
||||
if (typeof value.visibility !== 'string' || !isOneOf(value.visibility, VALID_VISIBILITY)) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.visibility');
|
||||
}
|
||||
}
|
||||
|
||||
if (value.stewarding !== undefined) {
|
||||
if (!isRecord(value.stewarding)) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding (expected object)');
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.stewarding.decisionMode !== 'string' ||
|
||||
!isOneOf(value.stewarding.decisionMode, VALID_DECISION_MODES)
|
||||
) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.decisionMode');
|
||||
}
|
||||
|
||||
if (value.stewarding.requiredVotes !== undefined && typeof value.stewarding.requiredVotes !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.requiredVotes');
|
||||
}
|
||||
if (value.stewarding.requireDefense !== undefined && typeof value.stewarding.requireDefense !== 'boolean') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.requireDefense');
|
||||
}
|
||||
if (value.stewarding.defenseTimeLimit !== undefined && typeof value.stewarding.defenseTimeLimit !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.defenseTimeLimit');
|
||||
}
|
||||
if (value.stewarding.voteTimeLimit !== undefined && typeof value.stewarding.voteTimeLimit !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.voteTimeLimit');
|
||||
}
|
||||
if (value.stewarding.protestDeadlineHours !== undefined && typeof value.stewarding.protestDeadlineHours !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.protestDeadlineHours');
|
||||
}
|
||||
if (
|
||||
value.stewarding.stewardingClosesHours !== undefined &&
|
||||
typeof value.stewarding.stewardingClosesHours !== 'number'
|
||||
) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.stewardingClosesHours');
|
||||
}
|
||||
if (
|
||||
value.stewarding.notifyAccusedOnProtest !== undefined &&
|
||||
typeof value.stewarding.notifyAccusedOnProtest !== 'boolean'
|
||||
) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.notifyAccusedOnProtest');
|
||||
}
|
||||
if (value.stewarding.notifyOnVoteRequired !== undefined && typeof value.stewarding.notifyOnVoteRequired !== 'boolean') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.notifyOnVoteRequired');
|
||||
}
|
||||
}
|
||||
|
||||
if (value.customPoints !== undefined) {
|
||||
if (!isRecord(value.customPoints)) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected object)');
|
||||
}
|
||||
|
||||
for (const [key, points] of Object.entries(value.customPoints)) {
|
||||
if (!Number.isInteger(Number(key))) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected numeric keys)');
|
||||
}
|
||||
if (typeof points !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected number values)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseLeagueSettings(value: unknown): LeagueSettings {
|
||||
assertSerializedLeagueSettings(value);
|
||||
|
||||
const customPoints: Record<number, number> | undefined = value.customPoints
|
||||
? Object.fromEntries(Object.entries(value.customPoints).map(([position, points]) => [Number(position), points]))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
pointsSystem: value.pointsSystem,
|
||||
...(value.sessionDuration !== undefined ? { sessionDuration: value.sessionDuration } : {}),
|
||||
...(value.qualifyingFormat !== undefined ? { qualifyingFormat: value.qualifyingFormat } : {}),
|
||||
...(customPoints !== undefined ? { customPoints } : {}),
|
||||
...(value.maxDrivers !== undefined ? { maxDrivers: value.maxDrivers } : {}),
|
||||
...(value.stewarding !== undefined ? { stewarding: value.stewarding } : {}),
|
||||
...(value.visibility !== undefined ? { visibility: value.visibility } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeLeagueSettings(settings: LeagueSettings): SerializedLeagueSettings {
|
||||
const customPoints =
|
||||
settings.customPoints !== undefined
|
||||
? Object.fromEntries(
|
||||
Object.entries(settings.customPoints).map(([position, points]) => [String(position), points]),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
pointsSystem: settings.pointsSystem,
|
||||
...(settings.sessionDuration !== undefined ? { sessionDuration: settings.sessionDuration } : {}),
|
||||
...(settings.qualifyingFormat !== undefined ? { qualifyingFormat: settings.qualifyingFormat } : {}),
|
||||
...(customPoints !== undefined ? { customPoints } : {}),
|
||||
...(settings.maxDrivers !== undefined ? { maxDrivers: settings.maxDrivers } : {}),
|
||||
...(settings.stewarding !== undefined ? { stewarding: settings.stewarding } : {}),
|
||||
...(settings.visibility !== undefined ? { visibility: settings.visibility } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class LeagueOrmMapper {
|
||||
toOrmEntity(domain: League): LeagueOrmEntity {
|
||||
const entity = new LeagueOrmEntity();
|
||||
entity.id = domain.id.toString();
|
||||
entity.name = domain.name.toString();
|
||||
entity.description = domain.description.toString();
|
||||
entity.ownerId = domain.ownerId.toString();
|
||||
entity.settings = serializeLeagueSettings(domain.settings);
|
||||
entity.createdAt = domain.createdAt.toDate();
|
||||
entity.participantCount = domain.getParticipantCount();
|
||||
entity.discordUrl = domain.socialLinks?.discordUrl ?? null;
|
||||
entity.youtubeUrl = domain.socialLinks?.youtubeUrl ?? null;
|
||||
entity.websiteUrl = domain.socialLinks?.websiteUrl ?? null;
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: LeagueOrmEntity): League {
|
||||
const settings = parseLeagueSettings(entity.settings);
|
||||
|
||||
return League.rehydrate({
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
ownerId: entity.ownerId,
|
||||
settings,
|
||||
createdAt: entity.createdAt,
|
||||
participantCount: entity.participantCount,
|
||||
...(entity.discordUrl || entity.youtubeUrl || entity.websiteUrl
|
||||
? {
|
||||
socialLinks: {
|
||||
...(entity.discordUrl ? { discordUrl: entity.discordUrl } : {}),
|
||||
...(entity.youtubeUrl ? { youtubeUrl: entity.youtubeUrl } : {}),
|
||||
...(entity.websiteUrl ? { websiteUrl: entity.websiteUrl } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||
|
||||
import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity';
|
||||
import { LeagueScoringConfigOrmMapper } from './LeagueScoringConfigOrmMapper';
|
||||
import { ChampionshipConfigJsonMapper, type SerializedChampionshipConfig } from './ChampionshipConfigJsonMapper';
|
||||
import { PointsTableJsonMapper } from './PointsTableJsonMapper';
|
||||
|
||||
describe('LeagueScoringConfigOrmMapper', () => {
|
||||
it('toDomain uses rehydrate semantics (does not call create)', () => {
|
||||
const pointsTableMapper = new PointsTableJsonMapper();
|
||||
const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper);
|
||||
const mapper = new LeagueScoringConfigOrmMapper(championshipMapper);
|
||||
|
||||
const serializedChampionship: SerializedChampionshipConfig = {
|
||||
id: 'champ-1',
|
||||
name: 'Main Championship',
|
||||
type: 'driver',
|
||||
sessionTypes: ['main'],
|
||||
pointsTableBySessionType: {
|
||||
main: { pointsByPosition: { '1': 25, '2': 18 } },
|
||||
} as unknown as SerializedChampionshipConfig['pointsTableBySessionType'],
|
||||
dropScorePolicy: { strategy: 'none' },
|
||||
};
|
||||
|
||||
const entity = new LeagueScoringConfigOrmEntity();
|
||||
entity.id = 'scoring-config-season-1';
|
||||
entity.seasonId = 'season-1';
|
||||
entity.scoringPresetId = null;
|
||||
entity.championships = [serializedChampionship];
|
||||
|
||||
const createSpy = vi.spyOn(LeagueScoringConfig, 'create').mockImplementation(() => {
|
||||
throw new Error('create-called');
|
||||
});
|
||||
|
||||
expect(() => mapper.toDomain(entity)).not.toThrow();
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates schema: non-array championships yields adapter-scoped error type', () => {
|
||||
const pointsTableMapper = new PointsTableJsonMapper();
|
||||
const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper);
|
||||
const mapper = new LeagueScoringConfigOrmMapper(championshipMapper);
|
||||
|
||||
const entity = new LeagueScoringConfigOrmEntity();
|
||||
entity.id = 'scoring-config-season-1';
|
||||
entity.seasonId = 'season-1';
|
||||
entity.scoringPresetId = null;
|
||||
entity.championships = { not: 'an-array' } as unknown as never;
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ name: 'InvalidLeagueScoringConfigChampionshipsSchemaError' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||
import { ChampionshipConfigJsonMapper, type SerializedChampionshipConfig } from './ChampionshipConfigJsonMapper';
|
||||
import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity';
|
||||
import { InvalidLeagueScoringConfigChampionshipsSchemaError } from '../errors/InvalidLeagueScoringConfigChampionshipsSchemaError';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertSerializedChampionshipConfig(value: unknown, index: number): asserts value is SerializedChampionshipConfig {
|
||||
if (!isRecord(value)) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}] (expected object)`);
|
||||
}
|
||||
|
||||
if (typeof value.id !== 'string' || value.id.trim().length === 0) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].id`);
|
||||
}
|
||||
|
||||
if (typeof value.name !== 'string' || value.name.trim().length === 0) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].name`);
|
||||
}
|
||||
|
||||
if (typeof value.type !== 'string' || value.type.trim().length === 0) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].type`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.sessionTypes)) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].sessionTypes`);
|
||||
}
|
||||
|
||||
if (!isRecord(value.pointsTableBySessionType)) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(
|
||||
`Invalid championships[${index}].pointsTableBySessionType`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecord(value.dropScorePolicy) || typeof value.dropScorePolicy.strategy !== 'string') {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].dropScorePolicy`);
|
||||
}
|
||||
}
|
||||
|
||||
export class LeagueScoringConfigOrmMapper {
|
||||
constructor(private readonly championshipConfigMapper: ChampionshipConfigJsonMapper) {}
|
||||
|
||||
toOrmEntity(domain: LeagueScoringConfig): LeagueScoringConfigOrmEntity {
|
||||
const entity = new LeagueScoringConfigOrmEntity();
|
||||
entity.id = domain.id.toString();
|
||||
entity.seasonId = domain.seasonId.toString();
|
||||
entity.scoringPresetId = domain.scoringPresetId ? domain.scoringPresetId.toString() : null;
|
||||
entity.championships = domain.championships.map((c) => this.championshipConfigMapper.toJson(c));
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: LeagueScoringConfigOrmEntity): LeagueScoringConfig {
|
||||
if (!Array.isArray(entity.championships)) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError('Invalid championships (expected array)');
|
||||
}
|
||||
|
||||
const championships = entity.championships.map((candidate, index) => {
|
||||
assertSerializedChampionshipConfig(candidate, index);
|
||||
return this.championshipConfigMapper.fromJson(candidate);
|
||||
});
|
||||
|
||||
return LeagueScoringConfig.rehydrate({
|
||||
id: entity.id,
|
||||
seasonId: entity.seasonId,
|
||||
...(entity.scoringPresetId ? { scoringPresetId: entity.scoringPresetId } : {}),
|
||||
championships,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
|
||||
|
||||
export type SerializedPointsTable = {
|
||||
pointsByPosition: Record<string, number>;
|
||||
};
|
||||
|
||||
export class PointsTableJsonMapper {
|
||||
toJson(pointsTable: PointsTable): SerializedPointsTable {
|
||||
const pointsByPosition: Record<string, number> = {};
|
||||
for (const [position, points] of pointsTable.props.pointsByPosition.entries()) {
|
||||
pointsByPosition[String(position)] = points;
|
||||
}
|
||||
return { pointsByPosition };
|
||||
}
|
||||
|
||||
fromJson(serialized: SerializedPointsTable): PointsTable {
|
||||
const record: Record<number, number> = {};
|
||||
for (const [position, points] of Object.entries(serialized.pointsByPosition)) {
|
||||
record[Number(position)] = points;
|
||||
}
|
||||
return new PointsTable(record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Race } from '@core/racing/domain/entities/Race';
|
||||
|
||||
import { RaceOrmEntity } from '../entities/RaceOrmEntity';
|
||||
import { RaceOrmMapper } from './RaceOrmMapper';
|
||||
|
||||
describe('RaceOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
|
||||
const mapper = new RaceOrmMapper();
|
||||
|
||||
const entity = new RaceOrmEntity();
|
||||
entity.id = 'race-1';
|
||||
entity.leagueId = '00000000-0000-4000-8000-000000000001';
|
||||
entity.scheduledAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.track = 'Spa';
|
||||
entity.trackId = null;
|
||||
entity.car = 'Porsche';
|
||||
entity.carId = null;
|
||||
entity.sessionType = 'main';
|
||||
entity.status = 'scheduled';
|
||||
entity.strengthOfField = null;
|
||||
entity.registeredCount = null;
|
||||
entity.maxParticipants = null;
|
||||
|
||||
if (typeof (Race as any).rehydrate !== 'function') {
|
||||
throw new Error('rehydrate-missing');
|
||||
}
|
||||
|
||||
const rehydrateSpy = vi.spyOn(Race as any, 'rehydrate');
|
||||
const createSpy = vi.spyOn(Race, 'create').mockImplementation(() => {
|
||||
throw new Error('create-called');
|
||||
});
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id).toBe(entity.id);
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
expect(rehydrateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates persisted sessionType/status and throws adapter-scoped error type', () => {
|
||||
const mapper = new RaceOrmMapper();
|
||||
|
||||
const entity = new RaceOrmEntity();
|
||||
entity.id = 'race-1';
|
||||
entity.leagueId = '00000000-0000-4000-8000-000000000001';
|
||||
entity.scheduledAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.track = 'Spa';
|
||||
entity.trackId = null;
|
||||
entity.car = 'Porsche';
|
||||
entity.carId = null;
|
||||
entity.sessionType = 123 as unknown as string;
|
||||
entity.status = 'scheduled';
|
||||
entity.strengthOfField = null;
|
||||
entity.registeredCount = null;
|
||||
entity.maxParticipants = null;
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ name: 'InvalidRaceSessionTypeSchemaError' });
|
||||
}
|
||||
});
|
||||
});
|
||||
79
adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts
Normal file
79
adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Race } from '@core/racing/domain/entities/Race';
|
||||
import { RaceStatus, type RaceStatusValue } from '@core/racing/domain/value-objects/RaceStatus';
|
||||
import { SessionType, type SessionTypeValue } from '@core/racing/domain/value-objects/SessionType';
|
||||
|
||||
import { RaceOrmEntity } from '../entities/RaceOrmEntity';
|
||||
import { InvalidRaceSessionTypeSchemaError } from '../errors/InvalidRaceSessionTypeSchemaError';
|
||||
import { InvalidRaceStatusSchemaError } from '../errors/InvalidRaceStatusSchemaError';
|
||||
|
||||
const VALID_SESSION_TYPES: SessionTypeValue[] = [
|
||||
'practice',
|
||||
'qualifying',
|
||||
'q1',
|
||||
'q2',
|
||||
'q3',
|
||||
'sprint',
|
||||
'main',
|
||||
'timeTrial',
|
||||
];
|
||||
|
||||
const VALID_RACE_STATUSES: RaceStatusValue[] = [
|
||||
'scheduled',
|
||||
'running',
|
||||
'completed',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
function assertSessionTypeValue(value: unknown): asserts value is SessionTypeValue {
|
||||
if (typeof value !== 'string' || !VALID_SESSION_TYPES.includes(value as any)) {
|
||||
throw new InvalidRaceSessionTypeSchemaError('Invalid sessionType');
|
||||
}
|
||||
}
|
||||
|
||||
function assertRaceStatusValue(value: unknown): asserts value is RaceStatusValue {
|
||||
if (typeof value !== 'string' || !VALID_RACE_STATUSES.includes(value as any)) {
|
||||
throw new InvalidRaceStatusSchemaError('Invalid status');
|
||||
}
|
||||
}
|
||||
|
||||
export class RaceOrmMapper {
|
||||
toOrmEntity(domain: Race): RaceOrmEntity {
|
||||
const entity = new RaceOrmEntity();
|
||||
entity.id = domain.id;
|
||||
entity.leagueId = domain.leagueId;
|
||||
entity.scheduledAt = domain.scheduledAt;
|
||||
entity.track = domain.track;
|
||||
entity.trackId = domain.trackId ?? null;
|
||||
entity.car = domain.car;
|
||||
entity.carId = domain.carId ?? null;
|
||||
entity.sessionType = domain.sessionType.props;
|
||||
entity.status = domain.status.toString();
|
||||
entity.strengthOfField = domain.getStrengthOfField() ?? null;
|
||||
entity.registeredCount = domain.registeredCountNumber ?? null;
|
||||
entity.maxParticipants = domain.getMaxParticipants() ?? null;
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: RaceOrmEntity): Race {
|
||||
assertSessionTypeValue(entity.sessionType);
|
||||
assertRaceStatusValue(entity.status);
|
||||
|
||||
const sessionType = new SessionType(entity.sessionType);
|
||||
const status = RaceStatus.create(entity.status);
|
||||
|
||||
return Race.rehydrate({
|
||||
id: entity.id,
|
||||
leagueId: entity.leagueId,
|
||||
scheduledAt: entity.scheduledAt,
|
||||
track: entity.track,
|
||||
...(entity.trackId !== null ? { trackId: entity.trackId } : {}),
|
||||
car: entity.car,
|
||||
...(entity.carId !== null ? { carId: entity.carId } : {}),
|
||||
sessionType,
|
||||
status,
|
||||
...(entity.strengthOfField !== null ? { strengthOfField: entity.strengthOfField } : {}),
|
||||
...(entity.registeredCount !== null ? { registeredCount: entity.registeredCount } : {}),
|
||||
...(entity.maxParticipants !== null ? { maxParticipants: entity.maxParticipants } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Season } from '@core/racing/domain/entities/season/Season';
|
||||
|
||||
import { SeasonOrmEntity } from '../entities/SeasonOrmEntity';
|
||||
import { SeasonOrmMapper } from './SeasonOrmMapper';
|
||||
|
||||
describe('SeasonOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
|
||||
const mapper = new SeasonOrmMapper();
|
||||
|
||||
const entity = new SeasonOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.leagueId = '00000000-0000-4000-8000-000000000002';
|
||||
entity.gameId = 'iracing';
|
||||
entity.name = 'Season 2025';
|
||||
entity.year = 2025;
|
||||
entity.order = 1;
|
||||
entity.status = 'planned';
|
||||
entity.startDate = null;
|
||||
entity.endDate = null;
|
||||
entity.schedule = null;
|
||||
entity.schedulePublished = false;
|
||||
entity.scoringConfig = null;
|
||||
entity.dropPolicy = null;
|
||||
entity.stewardingConfig = null;
|
||||
entity.maxDrivers = null;
|
||||
entity.participantCount = 3;
|
||||
|
||||
if (typeof (Season as any).rehydrate !== 'function') {
|
||||
throw new Error('rehydrate-missing');
|
||||
}
|
||||
|
||||
const rehydrateSpy = vi.spyOn(Season as any, 'rehydrate');
|
||||
const createSpy = vi.spyOn(Season, 'create').mockImplementation(() => {
|
||||
throw new Error('create-called');
|
||||
});
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id).toBe(entity.id);
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
expect(rehydrateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates persisted schedule schema and throws adapter-scoped error type', () => {
|
||||
const mapper = new SeasonOrmMapper();
|
||||
|
||||
const entity = new SeasonOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.leagueId = '00000000-0000-4000-8000-000000000002';
|
||||
entity.gameId = 'iracing';
|
||||
entity.name = 'Season 2025';
|
||||
entity.year = null;
|
||||
entity.order = null;
|
||||
entity.status = 'planned';
|
||||
entity.startDate = null;
|
||||
entity.endDate = null;
|
||||
entity.schedule = 123 as unknown as never;
|
||||
entity.schedulePublished = false;
|
||||
entity.scoringConfig = null;
|
||||
entity.dropPolicy = null;
|
||||
entity.stewardingConfig = null;
|
||||
entity.maxDrivers = null;
|
||||
entity.participantCount = 0;
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ name: 'InvalidSeasonScheduleSchemaError' });
|
||||
}
|
||||
});
|
||||
});
|
||||
316
adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts
Normal file
316
adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { Season } from '@core/racing/domain/entities/season/Season';
|
||||
import { ALL_WEEKDAYS, type Weekday } from '@core/racing/domain/types/Weekday';
|
||||
import { LeagueTimezone } from '@core/racing/domain/value-objects/LeagueTimezone';
|
||||
import { MonthlyRecurrencePattern } from '@core/racing/domain/value-objects/MonthlyRecurrencePattern';
|
||||
import { RaceTimeOfDay } from '@core/racing/domain/value-objects/RaceTimeOfDay';
|
||||
import { RecurrenceStrategy } from '@core/racing/domain/value-objects/RecurrenceStrategy';
|
||||
import { RecurrenceStrategyFactory } from '@core/racing/domain/value-objects/RecurrenceStrategyFactory';
|
||||
import { SeasonDropPolicy } from '@core/racing/domain/value-objects/SeasonDropPolicy';
|
||||
import { SeasonSchedule } from '@core/racing/domain/value-objects/SeasonSchedule';
|
||||
import { SeasonScoringConfig } from '@core/racing/domain/value-objects/SeasonScoringConfig';
|
||||
import { SeasonStatus, type SeasonStatusValue } from '@core/racing/domain/value-objects/SeasonStatus';
|
||||
import { SeasonStewardingConfig } from '@core/racing/domain/value-objects/SeasonStewardingConfig';
|
||||
import { WeekdaySet } from '@core/racing/domain/value-objects/WeekdaySet';
|
||||
|
||||
import { SeasonOrmEntity } from '../entities/SeasonOrmEntity';
|
||||
import { InvalidSeasonScheduleSchemaError } from '../errors/InvalidSeasonScheduleSchemaError';
|
||||
|
||||
import { InvalidSeasonStatusSchemaError } from '@adapters/racing/persistence/typeorm/errors/InvalidSeasonStatusSchemaError';
|
||||
import type {
|
||||
SerializedSeasonDropPolicy,
|
||||
SerializedSeasonEveryNWeeksRecurrence,
|
||||
SerializedSeasonMonthlyNthWeekdayRecurrence,
|
||||
SerializedSeasonRecurrence,
|
||||
SerializedSeasonSchedule,
|
||||
SerializedSeasonScoringConfig,
|
||||
SerializedSeasonStewardingConfig,
|
||||
SerializedSeasonWeeklyRecurrence,
|
||||
} from '../serialized/RacingTypeOrmSerialized';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const VALID_SEASON_STATUSES: SeasonStatusValue[] = [
|
||||
'planned',
|
||||
'active',
|
||||
'completed',
|
||||
'archived',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
function assertSeasonStatusValue(value: unknown): asserts value is SeasonStatusValue {
|
||||
if (typeof value !== 'string' || !VALID_SEASON_STATUSES.includes(value as SeasonStatusValue)) {
|
||||
throw new InvalidSeasonStatusSchemaError('Invalid status');
|
||||
}
|
||||
}
|
||||
|
||||
function assertSerializedSeasonRecurrence(value: unknown): asserts value is SerializedSeasonRecurrence {
|
||||
if (!isRecord(value) || typeof value.kind !== 'string') {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence');
|
||||
}
|
||||
|
||||
if (value.kind === 'weekly') {
|
||||
const candidate = value as SerializedSeasonWeeklyRecurrence;
|
||||
if (!Array.isArray(candidate.weekdays)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays');
|
||||
}
|
||||
for (const day of candidate.weekdays) {
|
||||
if (typeof day !== 'string' || !ALL_WEEKDAYS.includes(day as Weekday)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays[]');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.kind === 'everyNWeeks') {
|
||||
const candidate = value as SerializedSeasonEveryNWeeksRecurrence;
|
||||
if (!Number.isInteger(candidate.intervalWeeks) || candidate.intervalWeeks <= 0) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.intervalWeeks');
|
||||
}
|
||||
if (!Array.isArray(candidate.weekdays)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays');
|
||||
}
|
||||
for (const day of candidate.weekdays) {
|
||||
if (typeof day !== 'string' || !ALL_WEEKDAYS.includes(day as Weekday)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays[]');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.kind === 'monthlyNthWeekday') {
|
||||
const candidate = value as SerializedSeasonMonthlyNthWeekdayRecurrence;
|
||||
if (![1, 2, 3, 4].includes(candidate.ordinal)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.ordinal');
|
||||
}
|
||||
if (typeof candidate.weekday !== 'string' || !ALL_WEEKDAYS.includes(candidate.weekday as Weekday)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekday');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.kind');
|
||||
}
|
||||
|
||||
function parseSeasonSchedule(value: unknown): SeasonSchedule {
|
||||
if (!isRecord(value)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule (expected object)');
|
||||
}
|
||||
|
||||
if (typeof value.startDate !== 'string') {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.startDate');
|
||||
}
|
||||
const startDate = new Date(value.startDate);
|
||||
if (Number.isNaN(startDate.getTime())) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.startDate (invalid date)');
|
||||
}
|
||||
|
||||
if (typeof value.timeOfDay !== 'string') {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timeOfDay');
|
||||
}
|
||||
|
||||
if (typeof value.timezoneId !== 'string') {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timezoneId');
|
||||
}
|
||||
|
||||
const plannedRoundsCandidate = value.plannedRounds;
|
||||
if (typeof plannedRoundsCandidate !== 'number' || !Number.isInteger(plannedRoundsCandidate) || plannedRoundsCandidate <= 0) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.plannedRounds');
|
||||
}
|
||||
|
||||
const plannedRounds = plannedRoundsCandidate;
|
||||
|
||||
assertSerializedSeasonRecurrence(value.recurrence);
|
||||
|
||||
let timeOfDay: RaceTimeOfDay;
|
||||
try {
|
||||
timeOfDay = RaceTimeOfDay.fromString(value.timeOfDay);
|
||||
} catch {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timeOfDay (expected HH:MM)');
|
||||
}
|
||||
|
||||
let timezone: LeagueTimezone;
|
||||
try {
|
||||
timezone = LeagueTimezone.create(value.timezoneId);
|
||||
} catch {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timezoneId');
|
||||
}
|
||||
|
||||
let recurrence: RecurrenceStrategy;
|
||||
try {
|
||||
switch (value.recurrence.kind) {
|
||||
case 'weekly': {
|
||||
const weekdays = WeekdaySet.fromArray(value.recurrence.weekdays);
|
||||
recurrence = RecurrenceStrategyFactory.weekly(weekdays);
|
||||
break;
|
||||
}
|
||||
case 'everyNWeeks': {
|
||||
const weekdays = WeekdaySet.fromArray(value.recurrence.weekdays);
|
||||
recurrence = RecurrenceStrategyFactory.everyNWeeks(value.recurrence.intervalWeeks, weekdays);
|
||||
break;
|
||||
}
|
||||
case 'monthlyNthWeekday': {
|
||||
const pattern = MonthlyRecurrencePattern.create(value.recurrence.ordinal, value.recurrence.weekday);
|
||||
recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.kind');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidSeasonScheduleSchemaError) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence');
|
||||
}
|
||||
|
||||
return new SeasonSchedule({
|
||||
startDate,
|
||||
timeOfDay,
|
||||
timezone,
|
||||
recurrence,
|
||||
plannedRounds,
|
||||
});
|
||||
}
|
||||
|
||||
function serializeSeasonSchedule(schedule: SeasonSchedule): SerializedSeasonSchedule {
|
||||
const recurrence = (() => {
|
||||
const props = schedule.recurrence.props;
|
||||
switch (props.kind) {
|
||||
case 'weekly':
|
||||
return {
|
||||
kind: 'weekly',
|
||||
weekdays: props.weekdays.getAll(),
|
||||
} satisfies SerializedSeasonWeeklyRecurrence;
|
||||
case 'everyNWeeks':
|
||||
return {
|
||||
kind: 'everyNWeeks',
|
||||
intervalWeeks: props.intervalWeeks,
|
||||
weekdays: props.weekdays.getAll(),
|
||||
} satisfies SerializedSeasonEveryNWeeksRecurrence;
|
||||
case 'monthlyNthWeekday':
|
||||
return {
|
||||
kind: 'monthlyNthWeekday',
|
||||
ordinal: props.monthlyPattern.ordinal,
|
||||
weekday: props.monthlyPattern.weekday,
|
||||
} satisfies SerializedSeasonMonthlyNthWeekdayRecurrence;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
startDate: schedule.startDate.toISOString(),
|
||||
timeOfDay: schedule.timeOfDay.toString(),
|
||||
timezoneId: schedule.timezone.toString(),
|
||||
recurrence,
|
||||
plannedRounds: schedule.plannedRounds,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSeasonScoringConfig(config: SeasonScoringConfig): SerializedSeasonScoringConfig {
|
||||
const props = config.props;
|
||||
return {
|
||||
scoringPresetId: props.scoringPresetId,
|
||||
...(props.customScoringEnabled ? { customScoringEnabled: props.customScoringEnabled } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSeasonDropPolicy(policy: SeasonDropPolicy): SerializedSeasonDropPolicy {
|
||||
const props = policy.props;
|
||||
return {
|
||||
strategy: props.strategy,
|
||||
...(props.n !== undefined ? { n: props.n } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSeasonStewardingConfig(config: SeasonStewardingConfig): SerializedSeasonStewardingConfig {
|
||||
const props = config.props;
|
||||
return {
|
||||
decisionMode: props.decisionMode,
|
||||
...(props.requiredVotes !== undefined ? { requiredVotes: props.requiredVotes } : {}),
|
||||
requireDefense: props.requireDefense,
|
||||
defenseTimeLimit: props.defenseTimeLimit,
|
||||
voteTimeLimit: props.voteTimeLimit,
|
||||
protestDeadlineHours: props.protestDeadlineHours,
|
||||
stewardingClosesHours: props.stewardingClosesHours,
|
||||
notifyAccusedOnProtest: props.notifyAccusedOnProtest,
|
||||
notifyOnVoteRequired: props.notifyOnVoteRequired,
|
||||
};
|
||||
}
|
||||
|
||||
export class SeasonOrmMapper {
|
||||
toOrmEntity(domain: Season): SeasonOrmEntity {
|
||||
const entity = new SeasonOrmEntity();
|
||||
entity.id = domain.id;
|
||||
entity.leagueId = domain.leagueId;
|
||||
entity.gameId = domain.gameId;
|
||||
entity.name = domain.name;
|
||||
entity.year = domain.year ?? null;
|
||||
entity.order = domain.order ?? null;
|
||||
entity.status = domain.status.toString();
|
||||
entity.startDate = domain.startDate ?? null;
|
||||
entity.endDate = domain.endDate ?? null;
|
||||
entity.schedule = domain.schedule ? serializeSeasonSchedule(domain.schedule) : null;
|
||||
entity.schedulePublished = domain.schedulePublished;
|
||||
entity.scoringConfig = domain.scoringConfig ? serializeSeasonScoringConfig(domain.scoringConfig) : null;
|
||||
entity.dropPolicy = domain.dropPolicy ? serializeSeasonDropPolicy(domain.dropPolicy) : null;
|
||||
entity.stewardingConfig = domain.stewardingConfig ? serializeSeasonStewardingConfig(domain.stewardingConfig) : null;
|
||||
entity.maxDrivers = domain.maxDrivers ?? null;
|
||||
entity.participantCount = domain.getParticipantCount();
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: SeasonOrmEntity): Season {
|
||||
assertSeasonStatusValue(entity.status);
|
||||
|
||||
const status = SeasonStatus.create(entity.status);
|
||||
|
||||
const schedule = entity.schedule !== null ? parseSeasonSchedule(entity.schedule) : undefined;
|
||||
|
||||
let scoringConfig: SeasonScoringConfig | undefined;
|
||||
if (entity.scoringConfig !== null) {
|
||||
try {
|
||||
scoringConfig = new SeasonScoringConfig(entity.scoringConfig);
|
||||
} catch {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid scoringConfig');
|
||||
}
|
||||
}
|
||||
|
||||
let dropPolicy: SeasonDropPolicy | undefined;
|
||||
if (entity.dropPolicy !== null) {
|
||||
try {
|
||||
dropPolicy = new SeasonDropPolicy(entity.dropPolicy);
|
||||
} catch {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid dropPolicy');
|
||||
}
|
||||
}
|
||||
|
||||
let stewardingConfig: SeasonStewardingConfig | undefined;
|
||||
if (entity.stewardingConfig !== null) {
|
||||
try {
|
||||
stewardingConfig = new SeasonStewardingConfig(entity.stewardingConfig);
|
||||
} catch {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid stewardingConfig');
|
||||
}
|
||||
}
|
||||
|
||||
return Season.rehydrate({
|
||||
id: entity.id,
|
||||
leagueId: entity.leagueId,
|
||||
gameId: entity.gameId,
|
||||
name: entity.name,
|
||||
...(entity.year !== null ? { year: entity.year } : {}),
|
||||
...(entity.order !== null ? { order: entity.order } : {}),
|
||||
status,
|
||||
...(entity.startDate !== null ? { startDate: entity.startDate } : {}),
|
||||
...(entity.endDate !== null ? { endDate: entity.endDate } : {}),
|
||||
...(schedule !== undefined ? { schedule } : {}),
|
||||
schedulePublished: entity.schedulePublished,
|
||||
...(scoringConfig !== undefined ? { scoringConfig } : {}),
|
||||
...(dropPolicy !== undefined ? { dropPolicy } : {}),
|
||||
...(stewardingConfig !== undefined ? { stewardingConfig } : {}),
|
||||
...(entity.maxDrivers !== null ? { maxDrivers: entity.maxDrivers } : {}),
|
||||
participantCount: entity.participantCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { TypeOrmLeagueRepository } from './TypeOrmLeagueRepository';
|
||||
|
||||
describe('TypeOrmLeagueRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
const sourcePath = path.resolve(__dirname, 'TypeOrmLeagueRepository.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+LeagueOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+LeagueOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmLeagueRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'l1' }),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain' }),
|
||||
toOrmEntity: vi.fn(),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmLeagueRepository(dataSource as any, mapper as any);
|
||||
|
||||
const league = await repo.findById('l1');
|
||||
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(league).toEqual({ id: 'domain' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
|
||||
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
|
||||
import { LeagueOrmMapper } from '../mappers/LeagueOrmMapper';
|
||||
|
||||
export class TypeOrmLeagueRepository implements ILeagueRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: LeagueOrmMapper,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<League | null> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<League[]> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entities = await repo.find();
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async countAll(): Promise<number> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
return repo.count();
|
||||
}
|
||||
|
||||
async findByOwnerId(ownerId: string): Promise<League[]> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entities = await repo.find({ where: { ownerId } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async create(league: League): Promise<League> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(league);
|
||||
await repo.save(entity);
|
||||
return league;
|
||||
}
|
||||
|
||||
async update(league: League): Promise<League> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(league);
|
||||
await repo.save(entity);
|
||||
return league;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
await repo.delete({ id });
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const count = await repo.count({ where: { id } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async searchByName(query: string): Promise<League[]> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entities = await repo
|
||||
.createQueryBuilder('league')
|
||||
.where('league.name ILIKE :q', { q: `%${query}%` })
|
||||
.getMany();
|
||||
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Hoisted mocks: if the repository constructor still "news" these classes internally,
|
||||
// the mocked constructors will be called and this test will fail.
|
||||
vi.mock('../mappers/PointsTableJsonMapper', () => ({
|
||||
PointsTableJsonMapper: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../mappers/ChampionshipConfigJsonMapper', () => ({
|
||||
ChampionshipConfigJsonMapper: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../mappers/LeagueScoringConfigOrmMapper', () => ({
|
||||
LeagueScoringConfigOrmMapper: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('TypeOrmLeagueScoringConfigRepository', () => {
|
||||
it('constructor must not new sub-mappers internally (mapper graph must be injected)', async () => {
|
||||
const { PointsTableJsonMapper } = await import('../mappers/PointsTableJsonMapper');
|
||||
const { ChampionshipConfigJsonMapper } = await import('../mappers/ChampionshipConfigJsonMapper');
|
||||
const { LeagueScoringConfigOrmMapper } = await import('../mappers/LeagueScoringConfigOrmMapper');
|
||||
const { TypeOrmLeagueScoringConfigRepository } = await import('./TypeOrmLeagueScoringConfigRepository');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dataSourceStub: any = { getRepository: vi.fn() };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const injectedMapper: any = { toDomain: vi.fn(), toOrmEntity: vi.fn() };
|
||||
|
||||
// If repo is still doing `new PointsTableJsonMapper()` etc. internally, the mocked constructors would be called.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
new (TypeOrmLeagueScoringConfigRepository as unknown as new (...args: unknown[]) => unknown)(
|
||||
dataSourceStub,
|
||||
injectedMapper,
|
||||
);
|
||||
|
||||
expect(PointsTableJsonMapper).not.toHaveBeenCalled();
|
||||
expect(ChampionshipConfigJsonMapper).not.toHaveBeenCalled();
|
||||
expect(LeagueScoringConfigOrmMapper).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository';
|
||||
import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||
|
||||
import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity';
|
||||
import { LeagueScoringConfigOrmMapper } from '../mappers/LeagueScoringConfigOrmMapper';
|
||||
|
||||
export class TypeOrmLeagueScoringConfigRepository implements ILeagueScoringConfigRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: LeagueScoringConfigOrmMapper,
|
||||
) {}
|
||||
|
||||
async findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null> {
|
||||
const repo = this.dataSource.getRepository(LeagueScoringConfigOrmEntity);
|
||||
const entity = await repo.findOne({ where: { seasonId } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async save(config: LeagueScoringConfig): Promise<LeagueScoringConfig> {
|
||||
const repo = this.dataSource.getRepository(LeagueScoringConfigOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(config));
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { TypeOrmRaceRepository } from './TypeOrmRaceRepository';
|
||||
|
||||
describe('TypeOrmRaceRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
const sourcePath = path.resolve(__dirname, 'TypeOrmRaceRepository.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+RaceOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+RaceOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmRaceRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'r1' }),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain' }),
|
||||
toOrmEntity: vi.fn(),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmRaceRepository(dataSource as any, mapper as any);
|
||||
|
||||
const race = await repo.findById('r1');
|
||||
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(race).toEqual({ id: 'domain' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
|
||||
import { Race, type RaceStatusValue } from '@core/racing/domain/entities/Race';
|
||||
|
||||
import { RaceOrmEntity } from '../entities/RaceOrmEntity';
|
||||
import { RaceOrmMapper } from '../mappers/RaceOrmMapper';
|
||||
|
||||
export class TypeOrmRaceRepository implements IRaceRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: RaceOrmMapper,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<Race | null> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo.find();
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo.find({ where: { leagueId } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findUpcomingByLeagueId(leagueId: string): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo
|
||||
.createQueryBuilder('race')
|
||||
.where('race.leagueId = :leagueId', { leagueId })
|
||||
.andWhere('race.status = :status', { status: 'scheduled' })
|
||||
.andWhere('race.scheduledAt >= NOW()')
|
||||
.getMany();
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findCompletedByLeagueId(leagueId: string): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo.find({ where: { leagueId, status: 'completed' } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findByStatus(status: RaceStatusValue): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo.find({ where: { status } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo
|
||||
.createQueryBuilder('race')
|
||||
.where('race.scheduledAt >= :startDate', { startDate })
|
||||
.andWhere('race.scheduledAt <= :endDate', { endDate })
|
||||
.getMany();
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async create(race: Race): Promise<Race> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(race));
|
||||
return race;
|
||||
}
|
||||
|
||||
async update(race: Race): Promise<Race> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(race));
|
||||
return race;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
await repo.delete({ id });
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const count = await repo.count({ where: { id } });
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { TypeOrmSeasonRepository } from './TypeOrmSeasonRepository';
|
||||
|
||||
describe('TypeOrmSeasonRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
const sourcePath = path.resolve(__dirname, 'TypeOrmSeasonRepository.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+SeasonOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+SeasonOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmSeasonRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 's1' }),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain' }),
|
||||
toOrmEntity: vi.fn(),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmSeasonRepository(dataSource as any, mapper as any);
|
||||
|
||||
const season = await repo.findById('s1');
|
||||
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(season).toEqual({ id: 'domain' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
|
||||
import { Season } from '@core/racing/domain/entities/season/Season';
|
||||
|
||||
import { SeasonOrmEntity } from '../entities/SeasonOrmEntity';
|
||||
import { SeasonOrmMapper } from '../mappers/SeasonOrmMapper';
|
||||
|
||||
export class TypeOrmSeasonRepository implements ISeasonRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: SeasonOrmMapper,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<Season | null> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Season[]> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
const entities = await repo.find({ where: { leagueId } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async create(season: Season): Promise<Season> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(season));
|
||||
return season;
|
||||
}
|
||||
|
||||
async add(season: Season): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(season));
|
||||
}
|
||||
|
||||
async update(season: Season): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(season));
|
||||
}
|
||||
|
||||
async listByLeague(leagueId: string): Promise<Season[]> {
|
||||
return this.findByLeagueId(leagueId);
|
||||
}
|
||||
|
||||
async listActiveByLeague(leagueId: string): Promise<Season[]> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
const entities = await repo.find({ where: { leagueId, status: 'active' } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Weekday } from '@core/racing/domain/types/Weekday';
|
||||
|
||||
export type SerializedLeagueVisibility = 'ranked' | 'unranked';
|
||||
|
||||
export type SerializedLeagueStewardingDecisionMode =
|
||||
| 'admin_only'
|
||||
| 'steward_decides'
|
||||
| 'steward_vote'
|
||||
| 'member_vote'
|
||||
| 'steward_veto'
|
||||
| 'member_veto';
|
||||
|
||||
export type SerializedLeagueStewardingSettings = {
|
||||
decisionMode: SerializedLeagueStewardingDecisionMode;
|
||||
requiredVotes?: number;
|
||||
requireDefense?: boolean;
|
||||
defenseTimeLimit?: number;
|
||||
voteTimeLimit?: number;
|
||||
protestDeadlineHours?: number;
|
||||
stewardingClosesHours?: number;
|
||||
notifyAccusedOnProtest?: boolean;
|
||||
notifyOnVoteRequired?: boolean;
|
||||
};
|
||||
|
||||
export type SerializedLeagueCustomPoints = Record<string, number>;
|
||||
|
||||
export type SerializedLeagueSettings = {
|
||||
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
|
||||
sessionDuration?: number;
|
||||
qualifyingFormat?: 'single-lap' | 'open';
|
||||
customPoints?: SerializedLeagueCustomPoints;
|
||||
maxDrivers?: number;
|
||||
stewarding?: SerializedLeagueStewardingSettings;
|
||||
visibility?: SerializedLeagueVisibility;
|
||||
};
|
||||
|
||||
export type SerializedSeasonWeeklyRecurrence = {
|
||||
kind: 'weekly';
|
||||
weekdays: Weekday[];
|
||||
};
|
||||
|
||||
export type SerializedSeasonEveryNWeeksRecurrence = {
|
||||
kind: 'everyNWeeks';
|
||||
intervalWeeks: number;
|
||||
weekdays: Weekday[];
|
||||
};
|
||||
|
||||
export type SerializedSeasonMonthlyNthWeekdayRecurrence = {
|
||||
kind: 'monthlyNthWeekday';
|
||||
ordinal: 1 | 2 | 3 | 4;
|
||||
weekday: Weekday;
|
||||
};
|
||||
|
||||
export type SerializedSeasonRecurrence =
|
||||
| SerializedSeasonWeeklyRecurrence
|
||||
| SerializedSeasonEveryNWeeksRecurrence
|
||||
| SerializedSeasonMonthlyNthWeekdayRecurrence;
|
||||
|
||||
export type SerializedSeasonSchedule = {
|
||||
startDate: string;
|
||||
timeOfDay: string;
|
||||
timezoneId: string;
|
||||
recurrence: SerializedSeasonRecurrence;
|
||||
plannedRounds: number;
|
||||
};
|
||||
|
||||
export type SerializedSeasonScoringConfig = {
|
||||
scoringPresetId: string;
|
||||
customScoringEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type SerializedSeasonDropPolicy = {
|
||||
strategy: 'none' | 'bestNResults' | 'dropWorstN';
|
||||
n?: number;
|
||||
};
|
||||
|
||||
export type SerializedSeasonStewardingConfig = {
|
||||
decisionMode: SerializedLeagueStewardingDecisionMode;
|
||||
requiredVotes?: number;
|
||||
requireDefense: boolean;
|
||||
defenseTimeLimit: number;
|
||||
voteTimeLimit: number;
|
||||
protestDeadlineHours: number;
|
||||
stewardingClosesHours: number;
|
||||
notifyAccusedOnProtest: boolean;
|
||||
notifyOnVoteRequired: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user