inmemory to postgres

This commit is contained in:
2025-12-29 18:34:12 +01:00
parent 9e17d0752a
commit f5639a367f
176 changed files with 10175 additions and 468 deletions

View File

@@ -0,0 +1,22 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'racing_drivers' })
export class DriverOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'text' })
iracingId!: string;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text' })
country!: string;
@Column({ type: 'text', nullable: true })
bio!: string | null;
@Column({ type: 'timestamptz' })
joinedAt!: Date;
}

View File

@@ -0,0 +1,22 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'racing_league_memberships' })
export class LeagueMembershipOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'uuid' })
leagueId!: string;
@Column({ type: 'uuid' })
driverId!: string;
@Column({ type: 'text' })
role!: string;
@Column({ type: 'text' })
status!: string;
@Column({ type: 'timestamptz' })
joinedAt!: Date;
}

View File

@@ -0,0 +1,272 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'racing_penalties' })
export class PenaltyOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'uuid' })
leagueId!: string;
@Column({ type: 'text' })
raceId!: string;
@Column({ type: 'uuid' })
driverId!: string;
@Column({ type: 'text' })
type!: string;
@Column({ type: 'int', nullable: true })
value!: number | null;
@Column({ type: 'text' })
reason!: string;
@Column({ type: 'text', nullable: true })
protestId!: string | null;
@Column({ type: 'uuid' })
issuedBy!: string;
@Column({ type: 'text' })
status!: string;
@Column({ type: 'timestamptz' })
issuedAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
appliedAt!: Date | null;
@Column({ type: 'text', nullable: true })
notes!: string | null;
}
@Entity({ name: 'racing_protests' })
export class ProtestOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'text' })
raceId!: string;
@Column({ type: 'uuid' })
protestingDriverId!: string;
@Column({ type: 'uuid' })
accusedDriverId!: string;
@Column({ type: 'jsonb' })
incident!: unknown;
@Column({ type: 'text', nullable: true })
comment!: string | null;
@Column({ type: 'text', nullable: true })
proofVideoUrl!: string | null;
@Column({ type: 'text' })
status!: string;
@Column({ type: 'uuid', nullable: true })
reviewedBy!: string | null;
@Column({ type: 'text', nullable: true })
decisionNotes!: string | null;
@Column({ type: 'timestamptz' })
filedAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
reviewedAt!: Date | null;
@Column({ type: 'jsonb', nullable: true })
defense!: unknown | null;
@Column({ type: 'timestamptz', nullable: true })
defenseRequestedAt!: Date | null;
@Column({ type: 'uuid', nullable: true })
defenseRequestedBy!: string | null;
}
export type OrmMoney = { amount: number; currency: string };
@Entity({ name: 'racing_league_wallets' })
export class LeagueWalletOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'uuid' })
leagueId!: string;
@Column({ type: 'jsonb' })
balance!: OrmMoney;
@Column({ type: 'text', array: true, default: () => 'ARRAY[]::text[]' })
transactionIds!: string[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
@Entity({ name: 'racing_transactions' })
export class TransactionOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'uuid' })
walletId!: string;
@Column({ type: 'text' })
type!: string;
@Column({ type: 'jsonb' })
amount!: OrmMoney;
@Column({ type: 'jsonb' })
platformFee!: OrmMoney;
@Column({ type: 'jsonb' })
netAmount!: OrmMoney;
@Column({ type: 'text' })
status!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
completedAt!: Date | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'jsonb', nullable: true })
metadata!: Record<string, unknown> | null;
}
@Entity({ name: 'racing_sponsors' })
export class SponsorOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text' })
contactEmail!: string;
@Column({ type: 'text', nullable: true })
logoUrl!: string | null;
@Column({ type: 'text', nullable: true })
websiteUrl!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
export type OrmSponsorshipPricing = unknown;
@Entity({ name: 'racing_sponsorship_pricings' })
export class SponsorshipPricingOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
entityType!: string;
@Column({ type: 'text' })
entityId!: string;
@Column({ type: 'jsonb' })
pricing!: OrmSponsorshipPricing;
}
@Entity({ name: 'racing_sponsorship_requests' })
export class SponsorshipRequestOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'uuid' })
sponsorId!: string;
@Column({ type: 'text' })
entityType!: string;
@Column({ type: 'text' })
entityId!: string;
@Column({ type: 'text' })
tier!: string;
@Column({ type: 'jsonb' })
offeredAmount!: OrmMoney;
@Column({ type: 'text', nullable: true })
message!: string | null;
@Column({ type: 'text' })
status!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
respondedAt!: Date | null;
@Column({ type: 'uuid', nullable: true })
respondedBy!: string | null;
@Column({ type: 'text', nullable: true })
rejectionReason!: string | null;
}
@Entity({ name: 'racing_season_sponsorships' })
export class SeasonSponsorshipOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'uuid' })
seasonId!: string;
@Column({ type: 'uuid', nullable: true })
leagueId!: string | null;
@Column({ type: 'uuid' })
sponsorId!: string;
@Column({ type: 'text' })
tier!: string;
@Column({ type: 'jsonb' })
pricing!: OrmMoney;
@Column({ type: 'text' })
status!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
activatedAt!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
endedAt!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
cancelledAt!: Date | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
}
@Entity({ name: 'racing_games' })
export class GameOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
name!: string;
}

View File

@@ -0,0 +1,16 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'racing_race_registrations' })
export class RaceRegistrationOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
raceId!: string;
@Column({ type: 'text' })
driverId!: string;
@Column({ type: 'timestamptz' })
registeredAt!: Date;
}

View File

@@ -0,0 +1,25 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'racing_results' })
export class ResultOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
raceId!: string;
@Column({ type: 'text' })
driverId!: string;
@Column({ type: 'int' })
position!: number;
@Column({ type: 'int' })
fastestLap!: number;
@Column({ type: 'int' })
incidents!: number;
@Column({ type: 'int' })
startPosition!: number;
}

View File

@@ -0,0 +1,25 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'racing_standings' })
export class StandingOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
leagueId!: string;
@Column({ type: 'text' })
driverId!: string;
@Column({ type: 'int' })
points!: number;
@Column({ type: 'int' })
wins!: number;
@Column({ type: 'int' })
position!: number;
@Column({ type: 'int' })
racesCompleted!: number;
}

View File

@@ -0,0 +1,61 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity({ name: 'racing_teams' })
export class TeamOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'text' })
tag!: string;
@Column({ type: 'text' })
description!: string;
@Column({ type: 'uuid' })
ownerId!: string;
@Column({ type: 'uuid', array: true })
leagues!: string[];
@Column({ type: 'timestamptz' })
createdAt!: Date;
}
@Entity({ name: 'racing_team_memberships' })
export class TeamMembershipOrmEntity {
@PrimaryColumn({ type: 'uuid' })
teamId!: string;
@PrimaryColumn({ type: 'uuid' })
driverId!: string;
@Column({ type: 'text' })
role!: string;
@Column({ type: 'text' })
status!: string;
@Column({ type: 'timestamptz' })
joinedAt!: Date;
}
@Entity({ name: 'racing_team_join_requests' })
export class TeamJoinRequestOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'uuid' })
teamId!: string;
@Column({ type: 'uuid' })
driverId!: string;
@Column({ type: 'timestamptz' })
requestedAt!: Date;
@Column({ type: 'text', nullable: true })
message!: string | null;
}

View File

@@ -0,0 +1,32 @@
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
type InvalidDriverSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidDriverSchemaError extends TypeOrmPersistenceSchemaError {
override readonly name: string = 'InvalidDriverSchemaError';
constructor(params: InvalidDriverSchemaErrorParams);
constructor(message: string);
constructor(paramsOrMessage: InvalidDriverSchemaErrorParams | string) {
if (typeof paramsOrMessage === 'string') {
super({
entityName: 'Driver',
fieldName: 'unknown',
reason: 'invalid_shape',
message: paramsOrMessage,
});
return;
}
super({
entityName: 'Driver',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
});
}
}

View File

@@ -0,0 +1,32 @@
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
type InvalidLeagueMembershipSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidLeagueMembershipSchemaError extends TypeOrmPersistenceSchemaError {
override readonly name: string = 'InvalidLeagueMembershipSchemaError';
constructor(params: InvalidLeagueMembershipSchemaErrorParams);
constructor(message: string);
constructor(paramsOrMessage: InvalidLeagueMembershipSchemaErrorParams | string) {
super(
typeof paramsOrMessage === 'string'
? {
entityName: 'LeagueMembership',
fieldName: 'unknown',
reason: 'invalid_shape',
message: paramsOrMessage,
}
: {
entityName: 'LeagueMembership',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
},
);
this.name = 'InvalidLeagueMembershipSchemaError';
}
}

View File

@@ -1,7 +1,32 @@
export class InvalidLeagueScoringConfigChampionshipsSchemaError extends Error {
override readonly name = 'InvalidLeagueScoringConfigChampionshipsSchemaError';
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
constructor(message = 'Invalid LeagueScoringConfig.championships persisted schema') {
super(message);
type InvalidLeagueScoringConfigChampionshipsSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidLeagueScoringConfigChampionshipsSchemaError extends TypeOrmPersistenceSchemaError {
override readonly name: string = 'InvalidLeagueScoringConfigChampionshipsSchemaError';
constructor(params: InvalidLeagueScoringConfigChampionshipsSchemaErrorParams);
constructor(message?: string);
constructor(paramsOrMessage: InvalidLeagueScoringConfigChampionshipsSchemaErrorParams | string | undefined) {
if (typeof paramsOrMessage === 'string' || paramsOrMessage === undefined) {
super({
entityName: 'LeagueScoringConfig',
fieldName: 'championships',
reason: 'invalid_shape',
...(paramsOrMessage ? { message: paramsOrMessage } : {}),
});
return;
}
super({
entityName: 'LeagueScoringConfig',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
});
}
}

View File

@@ -1,6 +1,32 @@
export class InvalidLeagueSettingsSchemaError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidLeagueSettingsSchemaError';
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
type InvalidLeagueSettingsSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidLeagueSettingsSchemaError extends TypeOrmPersistenceSchemaError {
override readonly name: string = 'InvalidLeagueSettingsSchemaError';
constructor(params: InvalidLeagueSettingsSchemaErrorParams);
constructor(message: string);
constructor(paramsOrMessage: InvalidLeagueSettingsSchemaErrorParams | string) {
if (typeof paramsOrMessage === 'string') {
super({
entityName: 'League',
fieldName: 'settings',
reason: 'invalid_shape',
message: paramsOrMessage,
});
return;
}
super({
entityName: 'League',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
});
}
}

View File

@@ -0,0 +1,32 @@
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
type InvalidRaceRegistrationSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidRaceRegistrationSchemaError extends TypeOrmPersistenceSchemaError {
override readonly name: string = 'InvalidRaceRegistrationSchemaError';
constructor(params: InvalidRaceRegistrationSchemaErrorParams);
constructor(message: string);
constructor(paramsOrMessage: InvalidRaceRegistrationSchemaErrorParams | string) {
super(
typeof paramsOrMessage === 'string'
? {
entityName: 'RaceRegistration',
fieldName: 'unknown',
reason: 'invalid_shape',
message: paramsOrMessage,
}
: {
entityName: 'RaceRegistration',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
},
);
this.name = 'InvalidRaceRegistrationSchemaError';
}
}

View File

@@ -1,6 +1,32 @@
export class InvalidRaceSessionTypeSchemaError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidRaceSessionTypeSchemaError';
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
type InvalidRaceSessionTypeSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidRaceSessionTypeSchemaError extends TypeOrmPersistenceSchemaError {
override readonly name: string = 'InvalidRaceSessionTypeSchemaError';
constructor(params: InvalidRaceSessionTypeSchemaErrorParams);
constructor(message: string);
constructor(paramsOrMessage: InvalidRaceSessionTypeSchemaErrorParams | string) {
if (typeof paramsOrMessage === 'string') {
super({
entityName: 'Race',
fieldName: 'sessionType',
reason: 'invalid_shape',
message: paramsOrMessage,
});
return;
}
super({
entityName: 'Race',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
});
}
}

View File

@@ -1,6 +1,32 @@
export class InvalidRaceStatusSchemaError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidRaceStatusSchemaError';
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
type InvalidRaceStatusSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidRaceStatusSchemaError extends TypeOrmPersistenceSchemaError {
override readonly name: string = 'InvalidRaceStatusSchemaError';
constructor(params: InvalidRaceStatusSchemaErrorParams);
constructor(message: string);
constructor(paramsOrMessage: InvalidRaceStatusSchemaErrorParams | string) {
if (typeof paramsOrMessage === 'string') {
super({
entityName: 'Race',
fieldName: 'status',
reason: 'invalid_shape',
message: paramsOrMessage,
});
return;
}
super({
entityName: 'Race',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
});
}
}

View File

@@ -0,0 +1,31 @@
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
type InvalidResultSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidResultSchemaError extends TypeOrmPersistenceSchemaError {
constructor(params: InvalidResultSchemaErrorParams);
constructor(message: string);
constructor(paramsOrMessage: InvalidResultSchemaErrorParams | string) {
const params =
typeof paramsOrMessage === 'string'
? {
entityName: 'Result',
fieldName: 'unknown',
reason: 'invalid_shape' as const,
message: paramsOrMessage,
}
: {
entityName: 'Result',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
};
super(params);
this.name = 'InvalidResultSchemaError';
}
}

View File

@@ -1,6 +1,32 @@
export class InvalidSeasonScheduleSchemaError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidSeasonScheduleSchemaError';
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
type InvalidSeasonScheduleSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidSeasonScheduleSchemaError extends TypeOrmPersistenceSchemaError {
override readonly name: string = 'InvalidSeasonScheduleSchemaError';
constructor(params: InvalidSeasonScheduleSchemaErrorParams);
constructor(message: string);
constructor(paramsOrMessage: InvalidSeasonScheduleSchemaErrorParams | string) {
if (typeof paramsOrMessage === 'string') {
super({
entityName: 'Season',
fieldName: 'schedule',
reason: 'invalid_shape',
message: paramsOrMessage,
});
return;
}
super({
entityName: 'Season',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
});
}
}

View File

@@ -1,6 +1,32 @@
export class InvalidSeasonStatusSchemaError extends Error {
constructor(message: string) {
super(message);
this.name = 'InvalidSeasonStatusSchemaError';
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
type InvalidSeasonStatusSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidSeasonStatusSchemaError extends TypeOrmPersistenceSchemaError {
override readonly name: string = 'InvalidSeasonStatusSchemaError';
constructor(params: InvalidSeasonStatusSchemaErrorParams);
constructor(message: string);
constructor(paramsOrMessage: InvalidSeasonStatusSchemaErrorParams | string) {
if (typeof paramsOrMessage === 'string') {
super({
entityName: 'Season',
fieldName: 'status',
reason: 'invalid_shape',
message: paramsOrMessage,
});
return;
}
super({
entityName: 'Season',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
});
}
}

View File

@@ -0,0 +1,31 @@
import { TypeOrmPersistenceSchemaError } from './TypeOrmPersistenceSchemaError';
type InvalidStandingSchemaErrorParams = {
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
};
export class InvalidStandingSchemaError extends TypeOrmPersistenceSchemaError {
constructor(params: InvalidStandingSchemaErrorParams);
constructor(message: string);
constructor(paramsOrMessage: InvalidStandingSchemaErrorParams | string) {
const params =
typeof paramsOrMessage === 'string'
? {
entityName: 'Standing',
fieldName: 'unknown',
reason: 'invalid_shape' as const,
message: paramsOrMessage,
}
: {
entityName: 'Standing',
fieldName: paramsOrMessage.fieldName,
reason: paramsOrMessage.reason,
...(paramsOrMessage.message ? { message: paramsOrMessage.message } : {}),
};
super(params);
this.name = 'InvalidStandingSchemaError';
}
}

View File

@@ -0,0 +1,34 @@
export type TypeOrmPersistenceSchemaErrorReason =
| 'missing'
| 'not_string'
| 'empty_string'
| 'not_number'
| 'not_integer'
| 'not_boolean'
| 'not_date'
| 'invalid_date'
| 'not_iso_date'
| 'not_array'
| 'not_object'
| 'invalid_enum_value'
| 'invalid_shape';
export class TypeOrmPersistenceSchemaError extends Error {
readonly entityName: string;
readonly fieldName: string;
readonly reason: TypeOrmPersistenceSchemaErrorReason | (string & {});
constructor(params: {
entityName: string;
fieldName: string;
reason: TypeOrmPersistenceSchemaError['reason'];
message?: string;
}) {
const message = params.message ?? `Invalid persisted ${params.entityName}.${params.fieldName}: ${params.reason}`;
super(message);
this.name = 'TypeOrmPersistenceSchemaError';
this.entityName = params.entityName;
this.fieldName = params.fieldName;
this.reason = params.reason;
}
}

View File

@@ -0,0 +1,310 @@
import { describe, expect, it, vi } from 'vitest';
import { Game } from '@core/racing/domain/entities/Game';
import { LeagueWallet } from '@core/racing/domain/entities/league-wallet/LeagueWallet';
import { Transaction } from '@core/racing/domain/entities/league-wallet/Transaction';
import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship';
import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor';
import { SponsorshipRequest } from '@core/racing/domain/entities/SponsorshipRequest';
import { Money } from '@core/racing/domain/value-objects/Money';
import { SponsorshipPricing } from '@core/racing/domain/value-objects/SponsorshipPricing';
import {
GameOrmEntity,
LeagueWalletOrmEntity,
SeasonSponsorshipOrmEntity,
SponsorOrmEntity,
SponsorshipPricingOrmEntity,
SponsorshipRequestOrmEntity,
TransactionOrmEntity,
} from '../entities/MissingRacingOrmEntities';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { MoneyOrmMapper } from './MoneyOrmMapper';
import {
GameOrmMapper,
LeagueWalletOrmMapper,
SeasonSponsorshipOrmMapper,
SponsorOrmMapper,
SponsorshipPricingOrmMapper,
SponsorshipRequestOrmMapper,
TransactionOrmMapper,
} from './CommerceOrmMappers';
describe('GameOrmMapper', () => {
it('toDomain uses rehydrate semantics (does not call create)', () => {
const mapper = new GameOrmMapper();
const entity = new GameOrmEntity();
entity.id = 'iracing';
entity.name = 'iRacing';
const rehydrateSpy = vi.spyOn(Game, 'rehydrate');
const createSpy = vi.spyOn(Game, 'create').mockImplementation(() => {
throw new Error('create-called');
});
const domain = mapper.toDomain(entity);
expect(domain.id.toString()).toBe('iracing');
expect(createSpy).not.toHaveBeenCalled();
expect(rehydrateSpy).toHaveBeenCalled();
});
});
describe('SponsorOrmMapper', () => {
it('toDomain uses rehydrate semantics (does not call create)', () => {
const mapper = new SponsorOrmMapper();
const entity = new SponsorOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.name = 'Sponsor One';
entity.contactEmail = 'a@example.com';
entity.logoUrl = null;
entity.websiteUrl = null;
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
const rehydrateSpy = vi.spyOn(Sponsor, 'rehydrate');
const createSpy = vi.spyOn(Sponsor, '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 createdAt is a Date', () => {
const mapper = new SponsorOrmMapper();
const entity = new SponsorOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.name = 'Sponsor One';
entity.contactEmail = 'a@example.com';
entity.logoUrl = null;
entity.websiteUrl = null;
entity.createdAt = 'not-a-date' as unknown as Date;
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Sponsor',
fieldName: 'createdAt',
reason: 'not_date',
});
}
});
});
describe('LeagueWalletOrmMapper', () => {
it('toDomain uses rehydrate semantics', () => {
const moneyMapper = new MoneyOrmMapper();
const mapper = new LeagueWalletOrmMapper(moneyMapper);
const entity = new LeagueWalletOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.leagueId = '00000000-0000-4000-8000-000000000002';
entity.balance = { amount: 10, currency: 'USD' };
entity.transactionIds = [];
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
const rehydrateSpy = vi.spyOn(LeagueWallet, 'rehydrate');
const domain = mapper.toDomain(entity);
expect(domain.id.toString()).toBe(entity.id);
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates createdAt is a Date', () => {
const moneyMapper = new MoneyOrmMapper();
const mapper = new LeagueWalletOrmMapper(moneyMapper);
const entity = new LeagueWalletOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.leagueId = '00000000-0000-4000-8000-000000000002';
entity.balance = { amount: 10, currency: 'USD' };
entity.transactionIds = [];
entity.createdAt = 'not-a-date' as unknown as Date;
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'LeagueWallet',
fieldName: 'createdAt',
reason: 'not_date',
});
}
});
});
describe('TransactionOrmMapper', () => {
it('toDomain uses rehydrate semantics', () => {
const moneyMapper = new MoneyOrmMapper();
const mapper = new TransactionOrmMapper(moneyMapper);
const entity = new TransactionOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.walletId = '00000000-0000-4000-8000-000000000002';
entity.type = 'refund';
entity.amount = { amount: 10, currency: 'USD' };
entity.platformFee = { amount: 1, currency: 'USD' };
entity.netAmount = { amount: 9, currency: 'USD' };
entity.status = 'completed';
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
entity.completedAt = null;
entity.description = null;
entity.metadata = null;
const rehydrateSpy = vi.spyOn(Transaction, 'rehydrate');
const domain = mapper.toDomain(entity);
expect(domain.id.toString()).toBe(entity.id);
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates createdAt is a Date', () => {
const moneyMapper = new MoneyOrmMapper();
const mapper = new TransactionOrmMapper(moneyMapper);
const entity = new TransactionOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.walletId = '00000000-0000-4000-8000-000000000002';
entity.type = 'refund';
entity.amount = { amount: 10, currency: 'USD' };
entity.platformFee = { amount: 1, currency: 'USD' };
entity.netAmount = { amount: 9, currency: 'USD' };
entity.status = 'completed';
entity.createdAt = 'not-a-date' as unknown as Date;
entity.completedAt = null;
entity.description = null;
entity.metadata = null;
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Transaction',
fieldName: 'createdAt',
reason: 'not_date',
});
}
});
});
describe('SponsorshipPricingOrmMapper', () => {
it('round-trips value object via JSON', () => {
const moneyMapper = new MoneyOrmMapper();
const mapper = new SponsorshipPricingOrmMapper(moneyMapper);
const pricing = SponsorshipPricing.create({
acceptingApplications: true,
mainSlot: {
tier: 'main',
price: Money.create(100, 'USD'),
benefits: ['Logo on car'],
available: true,
maxSlots: 1,
},
});
const orm = mapper.toOrmEntity('team', 'team-1', pricing);
const rehydrated = mapper.toDomain(orm);
expect(rehydrated.equals(pricing)).toBe(true);
});
it('toDomain validates nested currency enums', () => {
const mapper = new SponsorshipPricingOrmMapper(new MoneyOrmMapper());
const entity = new SponsorshipPricingOrmEntity();
entity.id = 'team:team-1';
entity.entityType = 'team';
entity.entityId = 'team-1';
entity.pricing = {
acceptingApplications: true,
mainSlot: {
tier: 'main',
price: { amount: 10, currency: 'JPY' },
benefits: [],
available: true,
maxSlots: 1,
},
};
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'SponsorshipPricing',
reason: 'invalid_enum_value',
});
}
});
});
describe('SponsorshipRequestOrmMapper', () => {
it('toDomain uses rehydrate semantics', () => {
const mapper = new SponsorshipRequestOrmMapper(new MoneyOrmMapper());
const entity = new SponsorshipRequestOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.sponsorId = '00000000-0000-4000-8000-000000000002';
entity.entityType = 'team';
entity.entityId = 'team-1';
entity.tier = 'main';
entity.offeredAmount = { amount: 10, currency: 'USD' };
entity.message = null;
entity.status = 'pending';
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
entity.respondedAt = null;
entity.respondedBy = null;
entity.rejectionReason = null;
const rehydrateSpy = vi.spyOn(SponsorshipRequest, 'rehydrate');
const domain = mapper.toDomain(entity);
expect(domain.id).toBe(entity.id);
expect(rehydrateSpy).toHaveBeenCalled();
});
});
describe('SeasonSponsorshipOrmMapper', () => {
it('toDomain uses rehydrate semantics', () => {
const mapper = new SeasonSponsorshipOrmMapper(new MoneyOrmMapper());
const entity = new SeasonSponsorshipOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.seasonId = '00000000-0000-4000-8000-000000000002';
entity.leagueId = null;
entity.sponsorId = '00000000-0000-4000-8000-000000000003';
entity.tier = 'main';
entity.pricing = { amount: 100, currency: 'USD' };
entity.status = 'pending';
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
entity.activatedAt = null;
entity.endedAt = null;
entity.cancelledAt = null;
entity.description = null;
const rehydrateSpy = vi.spyOn(SeasonSponsorship, 'rehydrate');
const domain = mapper.toDomain(entity);
expect(domain.id).toBe(entity.id);
expect(rehydrateSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,506 @@
import { Game } from '@core/racing/domain/entities/Game';
import { LeagueWallet } from '@core/racing/domain/entities/league-wallet/LeagueWallet';
import { Transaction, type TransactionStatus, type TransactionType } from '@core/racing/domain/entities/league-wallet/Transaction';
import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship';
import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor';
import { SponsorshipRequest, type SponsorableEntityType, type SponsorshipRequestStatus } from '@core/racing/domain/entities/SponsorshipRequest';
import { SponsorshipPricing } from '@core/racing/domain/value-objects/SponsorshipPricing';
import {
GameOrmEntity,
LeagueWalletOrmEntity,
SeasonSponsorshipOrmEntity,
SponsorOrmEntity,
SponsorshipPricingOrmEntity,
SponsorshipRequestOrmEntity,
TransactionOrmEntity,
} from '../entities/MissingRacingOrmEntities';
import {
assertArray,
assertBoolean,
assertDate,
assertEnumValue,
assertNonEmptyString,
assertOptionalStringOrNull,
assertRecord,
} from '../schema/TypeOrmSchemaGuards';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { MoneyOrmMapper } from './MoneyOrmMapper';
const VALID_CURRENCIES = ['USD', 'EUR', 'GBP'] as const;
const VALID_SPONSORABLE_ENTITY_TYPES = ['driver', 'team', 'race', 'season'] as const;
const VALID_SPONSORSHIP_REQUEST_STATUSES = ['pending', 'accepted', 'rejected', 'withdrawn'] as const;
const VALID_SPONSORSHIP_TIERS = ['main', 'secondary'] as const;
const VALID_SPONSORSHIP_STATUSES = ['pending', 'active', 'ended', 'cancelled'] as const;
const VALID_TRANSACTION_TYPES = [
'sponsorship_payment',
'membership_payment',
'prize_payout',
'withdrawal',
'refund',
] as const;
const VALID_TRANSACTION_STATUSES = ['pending', 'completed', 'failed', 'cancelled'] as const;
type SerializedSponsorshipSlotConfig = {
tier: 'main' | 'secondary';
price: { amount: number; currency: (typeof VALID_CURRENCIES)[number] };
benefits: string[];
available: boolean;
maxSlots: number;
};
type SerializedSponsorshipPricing = {
mainSlot?: SerializedSponsorshipSlotConfig;
secondarySlots?: SerializedSponsorshipSlotConfig;
acceptingApplications: boolean;
customRequirements?: string;
};
function assertSerializedSlotConfig(value: unknown): asserts value is SerializedSponsorshipSlotConfig {
const entityName = 'SponsorshipPricing';
const fieldName = 'pricing.slot';
assertRecord(entityName, fieldName, value);
assertEnumValue(entityName, `${fieldName}.tier`, value.tier, VALID_SPONSORSHIP_TIERS);
assertRecord(entityName, `${fieldName}.price`, value.price);
const amount = (value.price as Record<string, unknown>).amount;
const currency = (value.price as Record<string, unknown>).currency;
if (typeof amount !== 'number' || Number.isNaN(amount)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.price.amount`, reason: 'not_number' });
}
if (typeof currency !== 'string') {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.price.currency`, reason: 'not_string' });
}
if (!(VALID_CURRENCIES as readonly string[]).includes(currency)) {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.price.currency`,
reason: 'invalid_enum_value',
});
}
assertArray(entityName, `${fieldName}.benefits`, value.benefits);
for (const benefit of value.benefits) {
if (typeof benefit !== 'string') {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.benefits`,
reason: 'not_string',
});
}
}
assertBoolean(entityName, `${fieldName}.available`, value.available);
if (typeof value.maxSlots !== 'number' || !Number.isInteger(value.maxSlots)) {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.maxSlots`,
reason: 'not_integer',
});
}
}
function assertSerializedSponsorshipPricing(value: unknown): asserts value is SerializedSponsorshipPricing {
const entityName = 'SponsorshipPricing';
const fieldName = 'pricing';
assertRecord(entityName, fieldName, value);
assertBoolean(entityName, `${fieldName}.acceptingApplications`, value.acceptingApplications);
if (value.customRequirements !== undefined && typeof value.customRequirements !== 'string') {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.customRequirements`,
reason: 'not_string',
});
}
if (value.mainSlot !== undefined) {
assertSerializedSlotConfig(value.mainSlot);
}
if (value.secondarySlots !== undefined) {
assertSerializedSlotConfig(value.secondarySlots);
}
}
export class GameOrmMapper {
toOrmEntity(domain: Game): GameOrmEntity {
const entity = new GameOrmEntity();
entity.id = domain.id.toString();
entity.name = domain.name.toString();
return entity;
}
toDomain(entity: GameOrmEntity): Game {
const entityName = 'Game';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'name', entity.name);
try {
return Game.rehydrate({ id: entity.id, name: entity.name });
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
}
}
}
export class SponsorOrmMapper {
toOrmEntity(domain: Sponsor): SponsorOrmEntity {
const entity = new SponsorOrmEntity();
entity.id = domain.id.toString();
entity.name = domain.name.toString();
entity.contactEmail = domain.contactEmail.toString();
entity.logoUrl = domain.logoUrl?.toString() ?? null;
entity.websiteUrl = domain.websiteUrl?.toString() ?? null;
entity.createdAt = domain.createdAt.toDate();
return entity;
}
toDomain(entity: SponsorOrmEntity): Sponsor {
const entityName = 'Sponsor';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'name', entity.name);
assertNonEmptyString(entityName, 'contactEmail', entity.contactEmail);
assertOptionalStringOrNull(entityName, 'logoUrl', entity.logoUrl);
assertOptionalStringOrNull(entityName, 'websiteUrl', entity.websiteUrl);
assertDate(entityName, 'createdAt', entity.createdAt);
try {
return Sponsor.rehydrate({
id: entity.id,
name: entity.name,
contactEmail: entity.contactEmail,
...(entity.logoUrl !== null && entity.logoUrl !== undefined ? { logoUrl: entity.logoUrl } : {}),
...(entity.websiteUrl !== null && entity.websiteUrl !== undefined ? { websiteUrl: entity.websiteUrl } : {}),
createdAt: entity.createdAt,
});
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
}
}
}
export class LeagueWalletOrmMapper {
constructor(
private readonly moneyMapper: MoneyOrmMapper,
) {}
toOrmEntity(domain: LeagueWallet): LeagueWalletOrmEntity {
const entity = new LeagueWalletOrmEntity();
entity.id = domain.id.toString();
entity.leagueId = domain.leagueId.toString();
entity.balance = this.moneyMapper.toOrm(domain.balance);
entity.transactionIds = domain.transactionIds.map((t) => t.toString());
entity.createdAt = domain.createdAt;
return entity;
}
toDomain(entity: LeagueWalletOrmEntity): LeagueWallet {
const entityName = 'LeagueWallet';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'leagueId', entity.leagueId);
assertArray(entityName, 'transactionIds', entity.transactionIds);
for (const tid of entity.transactionIds) {
assertNonEmptyString(entityName, 'transactionIds', tid);
}
assertRecord(entityName, 'balance', entity.balance);
assertDate(entityName, 'createdAt', entity.createdAt);
const balance = this.moneyMapper.toDomain(entityName, 'balance', entity.balance);
try {
return LeagueWallet.rehydrate({
id: entity.id,
leagueId: entity.leagueId,
balance,
transactionIds: entity.transactionIds,
createdAt: entity.createdAt,
});
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
}
}
}
export class TransactionOrmMapper {
constructor(
private readonly moneyMapper: MoneyOrmMapper,
) {}
toOrmEntity(domain: Transaction): TransactionOrmEntity {
const entity = new TransactionOrmEntity();
entity.id = domain.id.toString();
entity.walletId = domain.walletId.toString();
entity.type = domain.type;
entity.amount = this.moneyMapper.toOrm(domain.amount);
entity.platformFee = this.moneyMapper.toOrm(domain.platformFee);
entity.netAmount = this.moneyMapper.toOrm(domain.netAmount);
entity.status = domain.status;
entity.createdAt = domain.createdAt;
entity.completedAt = domain.completedAt ?? null;
entity.description = domain.description ?? null;
entity.metadata = domain.metadata ?? null;
return entity;
}
toDomain(entity: TransactionOrmEntity): Transaction {
const entityName = 'Transaction';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'walletId', entity.walletId);
assertEnumValue(entityName, 'type', entity.type, VALID_TRANSACTION_TYPES);
assertEnumValue(entityName, 'status', entity.status, VALID_TRANSACTION_STATUSES);
assertRecord(entityName, 'amount', entity.amount);
assertRecord(entityName, 'platformFee', entity.platformFee);
assertRecord(entityName, 'netAmount', entity.netAmount);
assertDate(entityName, 'createdAt', entity.createdAt);
const amount = this.moneyMapper.toDomain(entityName, 'amount', entity.amount);
const platformFee = this.moneyMapper.toDomain(entityName, 'platformFee', entity.platformFee);
const netAmount = this.moneyMapper.toDomain(entityName, 'netAmount', entity.netAmount);
if (entity.completedAt !== null && entity.completedAt !== undefined && !(entity.completedAt instanceof Date)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'completedAt', reason: 'not_date' });
}
assertOptionalStringOrNull(entityName, 'description', entity.description);
if (entity.metadata !== null && entity.metadata !== undefined) {
assertRecord(entityName, 'metadata', entity.metadata);
}
try {
return Transaction.rehydrate({
id: entity.id,
walletId: entity.walletId,
type: entity.type as TransactionType,
amount,
platformFee,
netAmount,
status: entity.status as TransactionStatus,
createdAt: entity.createdAt,
...(entity.completedAt !== null && entity.completedAt !== undefined ? { completedAt: entity.completedAt } : {}),
...(entity.description !== null && entity.description !== undefined ? { description: entity.description } : {}),
...(entity.metadata !== null && entity.metadata !== undefined ? { metadata: entity.metadata } : {}),
});
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
}
}
}
export class SponsorshipPricingOrmMapper {
constructor(
private readonly moneyMapper: MoneyOrmMapper,
) {}
makeId(entityType: SponsorableEntityType, entityId: string): string {
return `${entityType}:${entityId}`;
}
toOrmEntity(entityType: SponsorableEntityType, entityId: string, pricing: SponsorshipPricing): SponsorshipPricingOrmEntity {
const entity = new SponsorshipPricingOrmEntity();
entity.id = this.makeId(entityType, entityId);
entity.entityType = entityType;
entity.entityId = entityId;
const serializeSlot = (slot: SponsorshipPricing['mainSlot']): SerializedSponsorshipSlotConfig | undefined => {
if (!slot) return undefined;
return {
tier: slot.tier,
price: this.moneyMapper.toOrm(slot.price),
benefits: slot.benefits,
available: slot.available,
maxSlots: slot.maxSlots,
};
};
entity.pricing = {
acceptingApplications: pricing.acceptingApplications,
...(pricing.customRequirements !== undefined ? { customRequirements: pricing.customRequirements } : {}),
...(pricing.mainSlot !== undefined ? { mainSlot: serializeSlot(pricing.mainSlot)! } : {}),
...(pricing.secondarySlots !== undefined ? { secondarySlots: serializeSlot(pricing.secondarySlots)! } : {}),
} satisfies SerializedSponsorshipPricing;
return entity;
}
toDomain(entity: SponsorshipPricingOrmEntity): SponsorshipPricing {
const entityName = 'SponsorshipPricing';
assertNonEmptyString(entityName, 'id', entity.id);
assertEnumValue(entityName, 'entityType', entity.entityType, VALID_SPONSORABLE_ENTITY_TYPES);
assertNonEmptyString(entityName, 'entityId', entity.entityId);
assertSerializedSponsorshipPricing(entity.pricing);
const parsed = entity.pricing as SerializedSponsorshipPricing;
const parseSlot = (slot: SerializedSponsorshipSlotConfig | undefined): SponsorshipPricing['mainSlot'] => {
if (!slot) return undefined;
return {
tier: slot.tier,
price: this.moneyMapper.toDomain('SponsorshipPricing', 'pricing.slot.price', slot.price),
benefits: slot.benefits,
available: slot.available,
maxSlots: slot.maxSlots,
};
};
try {
return SponsorshipPricing.create({
acceptingApplications: parsed.acceptingApplications,
...(parsed.customRequirements !== undefined ? { customRequirements: parsed.customRequirements } : {}),
...(parsed.mainSlot !== undefined ? { mainSlot: parseSlot(parsed.mainSlot) } : {}),
...(parsed.secondarySlots !== undefined ? { secondarySlots: parseSlot(parsed.secondarySlots) } : {}),
});
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
}
}
}
export class SponsorshipRequestOrmMapper {
constructor(
private readonly moneyMapper: MoneyOrmMapper,
) {}
toOrmEntity(domain: SponsorshipRequest): SponsorshipRequestOrmEntity {
const entity = new SponsorshipRequestOrmEntity();
entity.id = domain.id;
entity.sponsorId = domain.sponsorId;
entity.entityType = domain.entityType;
entity.entityId = domain.entityId;
entity.tier = domain.tier;
entity.offeredAmount = this.moneyMapper.toOrm(domain.offeredAmount);
entity.message = domain.message ?? null;
entity.status = domain.status;
entity.createdAt = domain.createdAt;
entity.respondedAt = domain.respondedAt ?? null;
entity.respondedBy = domain.respondedBy ?? null;
entity.rejectionReason = domain.rejectionReason ?? null;
return entity;
}
toDomain(entity: SponsorshipRequestOrmEntity): SponsorshipRequest {
const entityName = 'SponsorshipRequest';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'sponsorId', entity.sponsorId);
assertEnumValue(entityName, 'entityType', entity.entityType, VALID_SPONSORABLE_ENTITY_TYPES);
assertNonEmptyString(entityName, 'entityId', entity.entityId);
assertEnumValue(entityName, 'tier', entity.tier, VALID_SPONSORSHIP_TIERS);
assertRecord(entityName, 'offeredAmount', entity.offeredAmount);
assertEnumValue(entityName, 'status', entity.status, VALID_SPONSORSHIP_REQUEST_STATUSES);
assertOptionalStringOrNull(entityName, 'message', entity.message);
assertOptionalStringOrNull(entityName, 'rejectionReason', entity.rejectionReason);
assertDate(entityName, 'createdAt', entity.createdAt);
const offeredAmount = this.moneyMapper.toDomain(entityName, 'offeredAmount', entity.offeredAmount);
if (entity.respondedAt !== null && entity.respondedAt !== undefined && !(entity.respondedAt instanceof Date)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: 'respondedAt', reason: 'not_date' });
}
if (entity.respondedBy !== null && entity.respondedBy !== undefined) {
assertNonEmptyString(entityName, 'respondedBy', entity.respondedBy);
}
try {
return SponsorshipRequest.rehydrate({
id: entity.id,
sponsorId: entity.sponsorId,
entityType: entity.entityType as SponsorableEntityType,
entityId: entity.entityId,
tier: entity.tier as any,
offeredAmount,
...(entity.message !== null && entity.message !== undefined ? { message: entity.message } : {}),
status: entity.status as SponsorshipRequestStatus,
createdAt: entity.createdAt,
...(entity.respondedAt !== null && entity.respondedAt !== undefined ? { respondedAt: entity.respondedAt } : {}),
...(entity.respondedBy !== null && entity.respondedBy !== undefined ? { respondedBy: entity.respondedBy } : {}),
...(entity.rejectionReason !== null && entity.rejectionReason !== undefined ? { rejectionReason: entity.rejectionReason } : {}),
});
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
}
}
}
export class SeasonSponsorshipOrmMapper {
constructor(
private readonly moneyMapper: MoneyOrmMapper,
) {}
toOrmEntity(domain: SeasonSponsorship): SeasonSponsorshipOrmEntity {
const entity = new SeasonSponsorshipOrmEntity();
entity.id = domain.id;
entity.seasonId = domain.seasonId;
entity.leagueId = domain.leagueId ?? null;
entity.sponsorId = domain.sponsorId;
entity.tier = domain.tier;
entity.pricing = this.moneyMapper.toOrm(domain.pricing);
entity.status = domain.status;
entity.createdAt = domain.createdAt;
entity.activatedAt = domain.activatedAt ?? null;
entity.endedAt = domain.endedAt ?? null;
entity.cancelledAt = domain.cancelledAt ?? null;
entity.description = domain.description ?? null;
return entity;
}
toDomain(entity: SeasonSponsorshipOrmEntity): SeasonSponsorship {
const entityName = 'SeasonSponsorship';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'seasonId', entity.seasonId);
assertOptionalStringOrNull(entityName, 'leagueId', entity.leagueId);
assertNonEmptyString(entityName, 'sponsorId', entity.sponsorId);
assertEnumValue(entityName, 'tier', entity.tier, VALID_SPONSORSHIP_TIERS);
assertRecord(entityName, 'pricing', entity.pricing);
assertEnumValue(entityName, 'status', entity.status, VALID_SPONSORSHIP_STATUSES);
assertOptionalStringOrNull(entityName, 'description', entity.description);
assertDate(entityName, 'createdAt', entity.createdAt);
const pricing = this.moneyMapper.toDomain(entityName, 'pricing', entity.pricing);
const dateOrNull = (fieldName: string, d: Date | null) => {
if (d === null) return undefined;
if (!(d instanceof Date)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_date' });
}
return d;
};
try {
return SeasonSponsorship.rehydrate({
id: entity.id,
seasonId: entity.seasonId,
...(entity.leagueId !== null && entity.leagueId !== undefined ? { leagueId: entity.leagueId } : {}),
sponsorId: entity.sponsorId,
tier: entity.tier as any,
pricing,
status: entity.status as any,
createdAt: entity.createdAt,
...(dateOrNull('activatedAt', entity.activatedAt) ? { activatedAt: entity.activatedAt! } : {}),
...(dateOrNull('endedAt', entity.endedAt) ? { endedAt: entity.endedAt! } : {}),
...(dateOrNull('cancelledAt', entity.cancelledAt) ? { cancelledAt: entity.cancelledAt! } : {}),
...(entity.description !== null && entity.description !== undefined ? { description: entity.description } : {}),
});
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
}
}
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import { Driver } from '@core/racing/domain/entities/Driver';
import { DriverOrmEntity } from '../entities/DriverOrmEntity';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { DriverOrmMapper } from './DriverOrmMapper';
describe('DriverOrmMapper', () => {
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
const mapper = new DriverOrmMapper();
const entity = new DriverOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.iracingId = '12345';
entity.name = 'Max Verstappen';
entity.country = 'DE';
entity.bio = 'Bio';
entity.joinedAt = new Date('2025-01-01T00:00:00.000Z');
if (typeof (Driver as unknown as { rehydrate?: unknown }).rehydrate !== 'function') {
throw new Error('rehydrate-missing');
}
const rehydrateSpy = vi.spyOn(Driver as unknown as { rehydrate: (...args: unknown[]) => unknown }, 'rehydrate');
const createSpy = vi.spyOn(Driver, 'create').mockImplementation(() => {
throw new Error('create-called');
});
const domain = mapper.toDomain(entity);
expect(domain.id).toBe(entity.id);
expect(domain.iracingId.toString()).toBe(entity.iracingId);
expect(createSpy).not.toHaveBeenCalled();
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates persisted shape and throws adapter-scoped base schema error type', () => {
const mapper = new DriverOrmMapper();
const entity = new DriverOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.iracingId = 123 as unknown as string;
entity.name = 'Name';
entity.country = 'DE';
entity.bio = null;
entity.joinedAt = new Date('2025-01-01T00:00:00.000Z');
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Driver',
fieldName: 'iracingId',
reason: 'not_string',
});
}
});
});

View File

@@ -0,0 +1,37 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { DriverOrmEntity } from '../entities/DriverOrmEntity';
import { assertDate, assertNonEmptyString, assertOptionalStringOrNull } from '../schema/TypeOrmSchemaGuards';
export class DriverOrmMapper {
toOrmEntity(domain: Driver): DriverOrmEntity {
const entity = new DriverOrmEntity();
entity.id = domain.id;
entity.iracingId = domain.iracingId.toString();
entity.name = domain.name.toString();
entity.country = domain.country.toString();
entity.bio = domain.bio?.toString() ?? null;
entity.joinedAt = domain.joinedAt.toDate();
return entity;
}
toDomain(entity: DriverOrmEntity): Driver {
const entityName = 'Driver';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'iracingId', entity.iracingId);
assertNonEmptyString(entityName, 'name', entity.name);
assertNonEmptyString(entityName, 'country', entity.country);
assertDate(entityName, 'joinedAt', entity.joinedAt);
assertOptionalStringOrNull(entityName, 'bio', entity.bio);
return Driver.rehydrate({
id: entity.id,
iracingId: entity.iracingId,
name: entity.name,
country: entity.country,
...(entity.bio !== null && entity.bio !== undefined ? { bio: entity.bio } : {}),
joinedAt: entity.joinedAt,
});
}
}

View File

@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { LeagueMembershipOrmEntity } from '../entities/LeagueMembershipOrmEntity';
import { LeagueMembershipOrmMapper } from './LeagueMembershipOrmMapper';
describe('LeagueMembershipOrmMapper', () => {
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
const mapper = new LeagueMembershipOrmMapper();
const entity = new LeagueMembershipOrmEntity();
entity.id = 'membership-1';
entity.leagueId = '00000000-0000-4000-8000-000000000001';
entity.driverId = '00000000-0000-4000-8000-000000000002';
entity.role = 'member';
entity.status = 'active';
entity.joinedAt = new Date('2025-01-01T00:00:00.000Z');
if (typeof (LeagueMembership as unknown as { rehydrate?: unknown }).rehydrate !== 'function') {
throw new Error('rehydrate-missing');
}
const rehydrateSpy = vi.spyOn(
LeagueMembership as unknown as { rehydrate: (...args: unknown[]) => unknown },
'rehydrate',
);
const createSpy = vi.spyOn(LeagueMembership, 'create').mockImplementation(() => {
throw new Error('create-called');
});
const domain = mapper.toDomain(entity);
expect(domain.id).toBe(entity.id);
expect(domain.leagueId.toString()).toBe(entity.leagueId);
expect(domain.driverId.toString()).toBe(entity.driverId);
expect(createSpy).not.toHaveBeenCalled();
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates persisted shape and throws adapter-scoped error type', () => {
const mapper = new LeagueMembershipOrmMapper();
const entity = new LeagueMembershipOrmEntity();
entity.id = 'membership-1';
entity.leagueId = '00000000-0000-4000-8000-000000000001';
entity.driverId = '00000000-0000-4000-8000-000000000002';
entity.role = 123 as unknown as string;
entity.status = 'active';
entity.joinedAt = new Date('2025-01-01T00:00:00.000Z');
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toMatchObject({ name: 'InvalidLeagueMembershipSchemaError' });
}
});
});

View File

@@ -0,0 +1,61 @@
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import type { MembershipRoleValue } from '@core/racing/domain/entities/MembershipRole';
import type { MembershipStatusValue } from '@core/racing/domain/entities/MembershipStatus';
import { LeagueMembershipOrmEntity } from '../entities/LeagueMembershipOrmEntity';
import { InvalidLeagueMembershipSchemaError } from '../errors/InvalidLeagueMembershipSchemaError';
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
const VALID_ROLES: readonly MembershipRoleValue[] = ['owner', 'admin', 'steward', 'member'] as const;
const VALID_STATUSES: readonly MembershipStatusValue[] = ['active', 'inactive', 'pending'] as const;
function isOneOf<T extends string>(value: string, allowed: readonly T[]): value is T {
return (allowed as readonly string[]).includes(value);
}
export class LeagueMembershipOrmMapper {
toOrmEntity(domain: LeagueMembership): LeagueMembershipOrmEntity {
const entity = new LeagueMembershipOrmEntity();
entity.id = domain.id;
entity.leagueId = domain.leagueId.toString();
entity.driverId = domain.driverId.toString();
entity.role = domain.role.toString();
entity.status = domain.status.toString();
entity.joinedAt = domain.joinedAt.toDate();
return entity;
}
toDomain(entity: LeagueMembershipOrmEntity): LeagueMembership {
if (!isNonEmptyString(entity.id)) {
throw new InvalidLeagueMembershipSchemaError('Invalid id');
}
if (!isNonEmptyString(entity.leagueId)) {
throw new InvalidLeagueMembershipSchemaError('Invalid leagueId');
}
if (!isNonEmptyString(entity.driverId)) {
throw new InvalidLeagueMembershipSchemaError('Invalid driverId');
}
if (!isNonEmptyString(entity.role) || !isOneOf(entity.role, VALID_ROLES)) {
throw new InvalidLeagueMembershipSchemaError('Invalid role');
}
if (!isNonEmptyString(entity.status) || !isOneOf(entity.status, VALID_STATUSES)) {
throw new InvalidLeagueMembershipSchemaError('Invalid status');
}
if (!(entity.joinedAt instanceof Date) || Number.isNaN(entity.joinedAt.getTime())) {
throw new InvalidLeagueMembershipSchemaError('Invalid joinedAt');
}
return LeagueMembership.rehydrate({
id: entity.id,
leagueId: entity.leagueId,
driverId: entity.driverId,
role: entity.role,
status: entity.status,
joinedAt: entity.joinedAt,
});
}
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { League } from '@core/racing/domain/entities/League';
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { LeagueOrmMapper } from './LeagueOrmMapper';
describe('LeagueOrmMapper', () => {
@@ -44,7 +45,7 @@ describe('LeagueOrmMapper', () => {
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates persisted settings schema and throws adapter-scoped error type', () => {
it('toDomain validates persisted settings schema and throws adapter-scoped base schema error type', () => {
const mapper = new LeagueOrmMapper();
const entity = new LeagueOrmEntity();
@@ -63,7 +64,12 @@ describe('LeagueOrmMapper', () => {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toMatchObject({ name: 'InvalidLeagueSettingsSchemaError' });
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'League',
fieldName: 'settings',
reason: 'not_object',
});
}
});
});

View File

@@ -1,12 +1,9 @@
import { League, type LeagueSettings } from '@core/racing/domain/entities/League';
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
import { InvalidLeagueSettingsSchemaError } from '../errors/InvalidLeagueSettingsSchemaError';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import type { SerializedLeagueSettings } from '../serialized/RacingTypeOrmSerialized';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
import { assertEnumValue, assertNumber, assertRecord } from '../schema/TypeOrmSchemaGuards';
const VALID_POINTS_SYSTEMS = ['f1-2024', 'indycar', 'custom'] as const;
@@ -21,93 +18,94 @@ const VALID_DECISION_MODES = [
'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)');
}
const entityName = 'League';
if (typeof value.pointsSystem !== 'string' || !isOneOf(value.pointsSystem, VALID_POINTS_SYSTEMS)) {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.pointsSystem');
}
assertRecord(entityName, 'settings', value);
if (value.sessionDuration !== undefined && typeof value.sessionDuration !== 'number') {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.sessionDuration');
const pointsSystem = value.pointsSystem;
assertEnumValue(entityName, 'settings.pointsSystem', pointsSystem, VALID_POINTS_SYSTEMS);
if (value.sessionDuration !== undefined) {
assertNumber(entityName, 'settings.sessionDuration', value.sessionDuration);
}
if (value.qualifyingFormat !== undefined && typeof value.qualifyingFormat !== 'string') {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.qualifyingFormat');
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'settings.qualifyingFormat',
reason: 'not_string',
});
}
if (value.maxDrivers !== undefined && typeof value.maxDrivers !== 'number') {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.maxDrivers');
if (value.maxDrivers !== undefined) {
assertNumber(entityName, 'settings.maxDrivers', value.maxDrivers);
}
if (value.visibility !== undefined) {
if (typeof value.visibility !== 'string' || !isOneOf(value.visibility, VALID_VISIBILITY)) {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.visibility');
}
const visibility = value.visibility;
assertEnumValue(entityName, 'settings.visibility', visibility, VALID_VISIBILITY);
}
if (value.stewarding !== undefined) {
if (!isRecord(value.stewarding)) {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding (expected object)');
}
const stewarding = value.stewarding;
assertRecord(entityName, 'settings.stewarding', stewarding);
if (
typeof value.stewarding.decisionMode !== 'string' ||
!isOneOf(value.stewarding.decisionMode, VALID_DECISION_MODES)
) {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.decisionMode');
}
const decisionMode = stewarding.decisionMode;
assertEnumValue(entityName, 'settings.stewarding.decisionMode', decisionMode, VALID_DECISION_MODES);
if (value.stewarding.requiredVotes !== undefined && typeof value.stewarding.requiredVotes !== 'number') {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.requiredVotes');
if (stewarding.requiredVotes !== undefined) {
assertNumber(entityName, 'settings.stewarding.requiredVotes', stewarding.requiredVotes);
}
if (value.stewarding.requireDefense !== undefined && typeof value.stewarding.requireDefense !== 'boolean') {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.requireDefense');
if (stewarding.requireDefense !== undefined && typeof stewarding.requireDefense !== 'boolean') {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'settings.stewarding.requireDefense',
reason: 'not_boolean',
});
}
if (value.stewarding.defenseTimeLimit !== undefined && typeof value.stewarding.defenseTimeLimit !== 'number') {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.defenseTimeLimit');
if (stewarding.defenseTimeLimit !== undefined) {
assertNumber(entityName, 'settings.stewarding.defenseTimeLimit', stewarding.defenseTimeLimit);
}
if (value.stewarding.voteTimeLimit !== undefined && typeof value.stewarding.voteTimeLimit !== 'number') {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.voteTimeLimit');
if (stewarding.voteTimeLimit !== undefined) {
assertNumber(entityName, 'settings.stewarding.voteTimeLimit', stewarding.voteTimeLimit);
}
if (value.stewarding.protestDeadlineHours !== undefined && typeof value.stewarding.protestDeadlineHours !== 'number') {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.protestDeadlineHours');
if (stewarding.protestDeadlineHours !== undefined) {
assertNumber(entityName, 'settings.stewarding.protestDeadlineHours', stewarding.protestDeadlineHours);
}
if (
value.stewarding.stewardingClosesHours !== undefined &&
typeof value.stewarding.stewardingClosesHours !== 'number'
) {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.stewardingClosesHours');
if (stewarding.stewardingClosesHours !== undefined) {
assertNumber(entityName, 'settings.stewarding.stewardingClosesHours', stewarding.stewardingClosesHours);
}
if (
value.stewarding.notifyAccusedOnProtest !== undefined &&
typeof value.stewarding.notifyAccusedOnProtest !== 'boolean'
) {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.notifyAccusedOnProtest');
if (stewarding.notifyAccusedOnProtest !== undefined && typeof stewarding.notifyAccusedOnProtest !== 'boolean') {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'settings.stewarding.notifyAccusedOnProtest',
reason: 'not_boolean',
});
}
if (value.stewarding.notifyOnVoteRequired !== undefined && typeof value.stewarding.notifyOnVoteRequired !== 'boolean') {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.notifyOnVoteRequired');
if (stewarding.notifyOnVoteRequired !== undefined && typeof stewarding.notifyOnVoteRequired !== 'boolean') {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'settings.stewarding.notifyOnVoteRequired',
reason: 'not_boolean',
});
}
}
if (value.customPoints !== undefined) {
if (!isRecord(value.customPoints)) {
throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected object)');
}
const customPoints = value.customPoints;
assertRecord(entityName, 'settings.customPoints', customPoints);
for (const [key, points] of Object.entries(value.customPoints)) {
for (const [key, points] of Object.entries(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)');
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'settings.customPoints',
reason: 'invalid_shape',
message: 'Invalid settings.customPoints (expected numeric keys)',
});
}
assertNumber(entityName, `settings.customPoints.${key}`, points);
}
}
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { LeagueScoringConfigOrmMapper } from './LeagueScoringConfigOrmMapper';
import { ChampionshipConfigJsonMapper, type SerializedChampionshipConfig } from './ChampionshipConfigJsonMapper';
import { PointsTableJsonMapper } from './PointsTableJsonMapper';
@@ -38,7 +39,7 @@ describe('LeagueScoringConfigOrmMapper', () => {
expect(createSpy).not.toHaveBeenCalled();
});
it('toDomain validates schema: non-array championships yields adapter-scoped error type', () => {
it('toDomain validates schema: non-array championships yields adapter-scoped base schema error type', () => {
const pointsTableMapper = new PointsTableJsonMapper();
const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper);
const mapper = new LeagueScoringConfigOrmMapper(championshipMapper);
@@ -53,7 +54,12 @@ describe('LeagueScoringConfigOrmMapper', () => {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toMatchObject({ name: 'InvalidLeagueScoringConfigChampionshipsSchemaError' });
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'LeagueScoringConfig',
fieldName: 'championships',
reason: 'not_array',
});
}
});
});

View File

@@ -1,42 +1,24 @@
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);
}
import { assertArray, assertNonEmptyString, assertRecord } from '../schema/TypeOrmSchemaGuards';
function assertSerializedChampionshipConfig(value: unknown, index: number): asserts value is SerializedChampionshipConfig {
if (!isRecord(value)) {
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}] (expected object)`);
}
const entityName = 'LeagueScoringConfig';
const fieldBase = `championships[${index}]`;
if (typeof value.id !== 'string' || value.id.trim().length === 0) {
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].id`);
}
assertRecord(entityName, fieldBase, value);
if (typeof value.name !== 'string' || value.name.trim().length === 0) {
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].name`);
}
assertNonEmptyString(entityName, `${fieldBase}.id`, value.id);
assertNonEmptyString(entityName, `${fieldBase}.name`, value.name);
assertNonEmptyString(entityName, `${fieldBase}.type`, value.type);
if (typeof value.type !== 'string' || value.type.trim().length === 0) {
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].type`);
}
assertArray(entityName, `${fieldBase}.sessionTypes`, value.sessionTypes);
assertRecord(entityName, `${fieldBase}.pointsTableBySessionType`, value.pointsTableBySessionType);
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`);
}
assertRecord(entityName, `${fieldBase}.dropScorePolicy`, value.dropScorePolicy);
assertNonEmptyString(entityName, `${fieldBase}.dropScorePolicy.strategy`, value.dropScorePolicy.strategy);
}
export class LeagueScoringConfigOrmMapper {
@@ -52,9 +34,9 @@ export class LeagueScoringConfigOrmMapper {
}
toDomain(entity: LeagueScoringConfigOrmEntity): LeagueScoringConfig {
if (!Array.isArray(entity.championships)) {
throw new InvalidLeagueScoringConfigChampionshipsSchemaError('Invalid championships (expected array)');
}
const entityName = 'LeagueScoringConfig';
assertArray(entityName, 'championships', entity.championships);
const championships = entity.championships.map((candidate, index) => {
assertSerializedChampionshipConfig(candidate, index);

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { Money } from '@core/racing/domain/value-objects/Money';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { MoneyOrmMapper } from './MoneyOrmMapper';
describe('MoneyOrmMapper', () => {
it('toOrm maps Money to plain object', () => {
const mapper = new MoneyOrmMapper();
const money = Money.create(12.5, 'EUR');
expect(mapper.toOrm(money)).toEqual({ amount: 12.5, currency: 'EUR' });
});
it('toDomain validates schema and throws adapter-scoped base schema error type', () => {
const mapper = new MoneyOrmMapper();
try {
mapper.toDomain('LeagueWallet', 'balance', { amount: 10, currency: 'JPY' });
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'LeagueWallet',
fieldName: 'balance.currency',
reason: 'invalid_enum_value',
});
}
});
it('toDomain wraps domain validation failures into adapter-scoped schema error type', () => {
const mapper = new MoneyOrmMapper();
try {
mapper.toDomain('LeagueWallet', 'balance', { amount: -1, currency: 'USD' });
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'LeagueWallet',
fieldName: 'balance',
reason: 'invalid_shape',
});
}
});
});

View File

@@ -0,0 +1,46 @@
import { Money, type Currency, isCurrency } from '@core/racing/domain/value-objects/Money';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { assertNumber, assertRecord } from '../schema/TypeOrmSchemaGuards';
export type OrmMoney = { amount: number; currency: Currency };
export class MoneyOrmMapper {
toOrm(money: Money): OrmMoney {
return {
amount: money.amount,
currency: money.currency,
};
}
toDomain(entityName: string, fieldName: string, value: unknown): Money {
assertRecord(entityName, fieldName, value);
const maybeAmount = (value as Record<string, unknown>).amount;
const maybeCurrency = (value as Record<string, unknown>).currency;
assertNumber(entityName, `${fieldName}.amount`, maybeAmount);
if (typeof maybeCurrency !== 'string') {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.currency`,
reason: 'not_string',
});
}
if (!isCurrency(maybeCurrency)) {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.currency`,
reason: 'invalid_enum_value',
});
}
try {
return Money.create(maybeAmount, maybeCurrency as Currency);
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'invalid_shape' });
}
}
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { Race } from '@core/racing/domain/entities/Race';
import { RaceOrmEntity } from '../entities/RaceOrmEntity';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { RaceOrmMapper } from './RaceOrmMapper';
describe('RaceOrmMapper', () => {
@@ -39,7 +40,7 @@ describe('RaceOrmMapper', () => {
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates persisted sessionType/status and throws adapter-scoped error type', () => {
it('toDomain validates persisted sessionType/status and throws adapter-scoped base schema error type', () => {
const mapper = new RaceOrmMapper();
const entity = new RaceOrmEntity();
@@ -60,7 +61,12 @@ describe('RaceOrmMapper', () => {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toMatchObject({ name: 'InvalidRaceSessionTypeSchemaError' });
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Race',
fieldName: 'sessionType',
reason: 'not_string',
});
}
});
});

View File

@@ -3,10 +3,9 @@ import { RaceStatus, type RaceStatusValue } from '@core/racing/domain/value-obje
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';
import { assertEnumValue } from '../schema/TypeOrmSchemaGuards';
const VALID_SESSION_TYPES: SessionTypeValue[] = [
const VALID_SESSION_TYPES = [
'practice',
'qualifying',
'q1',
@@ -15,26 +14,14 @@ const VALID_SESSION_TYPES: SessionTypeValue[] = [
'sprint',
'main',
'timeTrial',
];
] as const satisfies readonly SessionTypeValue[];
const VALID_RACE_STATUSES: RaceStatusValue[] = [
const VALID_RACE_STATUSES = [
'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');
}
}
] as const satisfies readonly RaceStatusValue[];
export class RaceOrmMapper {
toOrmEntity(domain: Race): RaceOrmEntity {
@@ -55,8 +42,10 @@ export class RaceOrmMapper {
}
toDomain(entity: RaceOrmEntity): Race {
assertSessionTypeValue(entity.sessionType);
assertRaceStatusValue(entity.status);
const entityName = 'Race';
assertEnumValue(entityName, 'sessionType', entity.sessionType, VALID_SESSION_TYPES);
assertEnumValue(entityName, 'status', entity.status, VALID_RACE_STATUSES);
const sessionType = new SessionType(entity.sessionType);
const status = RaceStatus.create(entity.status);

View File

@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import { RaceRegistrationOrmEntity } from '../entities/RaceRegistrationOrmEntity';
import { RaceRegistrationOrmMapper } from './RaceRegistrationOrmMapper';
describe('RaceRegistrationOrmMapper', () => {
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
const mapper = new RaceRegistrationOrmMapper();
const entity = new RaceRegistrationOrmEntity();
entity.id = 'race-1:driver-1';
entity.raceId = 'race-1';
entity.driverId = 'driver-1';
entity.registeredAt = new Date('2025-01-01T00:00:00.000Z');
if (typeof (RaceRegistration as unknown as { rehydrate?: unknown }).rehydrate !== 'function') {
throw new Error('rehydrate-missing');
}
const rehydrateSpy = vi.spyOn(
RaceRegistration as unknown as { rehydrate: (...args: unknown[]) => unknown },
'rehydrate',
);
const createSpy = vi.spyOn(RaceRegistration, 'create').mockImplementation(() => {
throw new Error('create-called');
});
const domain = mapper.toDomain(entity);
expect(domain.id).toBe(entity.id);
expect(domain.raceId.toString()).toBe(entity.raceId);
expect(domain.driverId.toString()).toBe(entity.driverId);
expect(createSpy).not.toHaveBeenCalled();
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates persisted shape and throws adapter-scoped error type', () => {
const mapper = new RaceRegistrationOrmMapper();
const entity = new RaceRegistrationOrmEntity();
entity.id = 'race-1:driver-1';
entity.raceId = 123 as unknown as string;
entity.driverId = 'driver-1';
entity.registeredAt = new Date('2025-01-01T00:00:00.000Z');
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toMatchObject({ name: 'InvalidRaceRegistrationSchemaError' });
}
});
});

View File

@@ -0,0 +1,41 @@
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import { RaceRegistrationOrmEntity } from '../entities/RaceRegistrationOrmEntity';
import { InvalidRaceRegistrationSchemaError } from '../errors/InvalidRaceRegistrationSchemaError';
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
export class RaceRegistrationOrmMapper {
toOrmEntity(domain: RaceRegistration): RaceRegistrationOrmEntity {
const entity = new RaceRegistrationOrmEntity();
entity.id = domain.id;
entity.raceId = domain.raceId.toString();
entity.driverId = domain.driverId.toString();
entity.registeredAt = domain.registeredAt.toDate();
return entity;
}
toDomain(entity: RaceRegistrationOrmEntity): RaceRegistration {
if (!isNonEmptyString(entity.id)) {
throw new InvalidRaceRegistrationSchemaError('Invalid id');
}
if (!isNonEmptyString(entity.raceId)) {
throw new InvalidRaceRegistrationSchemaError('Invalid raceId');
}
if (!isNonEmptyString(entity.driverId)) {
throw new InvalidRaceRegistrationSchemaError('Invalid driverId');
}
if (!(entity.registeredAt instanceof Date) || Number.isNaN(entity.registeredAt.getTime())) {
throw new InvalidRaceRegistrationSchemaError('Invalid registeredAt');
}
return RaceRegistration.rehydrate({
id: entity.id,
raceId: entity.raceId,
driverId: entity.driverId,
registeredAt: entity.registeredAt,
});
}
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { ResultOrmMapper } from './ResultOrmMapper';
import { ResultOrmEntity } from '../entities/ResultOrmEntity';
import { InvalidResultSchemaError } from '../errors/InvalidResultSchemaError';
describe('ResultOrmMapper', () => {
it('maps persisted schema guard failures into InvalidResultSchemaError', () => {
const mapper = new ResultOrmMapper();
const entity = new ResultOrmEntity();
entity.id = '';
entity.raceId = 'race-1';
entity.driverId = 'driver-1';
entity.position = 1;
entity.fastestLap = 0;
entity.incidents = 0;
entity.startPosition = 1;
expect(() => mapper.toDomain(entity)).toThrow(InvalidResultSchemaError);
try {
mapper.toDomain(entity);
} catch (error) {
expect(error).toBeInstanceOf(InvalidResultSchemaError);
const schemaError = error as InvalidResultSchemaError;
expect(schemaError.entityName).toBe('Result');
expect(schemaError.fieldName).toBe('id');
expect(schemaError.reason).toBe('empty_string');
}
});
it('wraps domain rehydrate failures into InvalidResultSchemaError (no mapper-level business validation)', () => {
const mapper = new ResultOrmMapper();
const entity = new ResultOrmEntity();
entity.id = 'result-1';
entity.raceId = 'race-1';
entity.driverId = 'driver-1';
entity.position = 0; // schema ok (integer), domain invalid (Position)
entity.fastestLap = 0;
entity.incidents = 0;
entity.startPosition = 1;
try {
mapper.toDomain(entity);
throw new Error('Expected mapper.toDomain to throw');
} catch (error) {
expect(error).toBeInstanceOf(InvalidResultSchemaError);
const schemaError = error as InvalidResultSchemaError;
expect(schemaError.entityName).toBe('Result');
expect(schemaError.reason).toBe('invalid_shape');
}
});
});

View File

@@ -0,0 +1,62 @@
import { Result } from '@core/racing/domain/entities/result/Result';
import { ResultOrmEntity } from '../entities/ResultOrmEntity';
import { InvalidResultSchemaError } from '../errors/InvalidResultSchemaError';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { assertInteger, assertNonEmptyString } from '../schema/TypeOrmSchemaGuards';
export class ResultOrmMapper {
toOrmEntity(domain: Result): ResultOrmEntity {
const entity = new ResultOrmEntity();
entity.id = domain.id;
entity.raceId = domain.raceId.toString();
entity.driverId = domain.driverId.toString();
entity.position = domain.position.toNumber();
entity.fastestLap = domain.fastestLap.toNumber();
entity.incidents = domain.incidents.toNumber();
entity.startPosition = domain.startPosition.toNumber();
return entity;
}
toDomain(entity: ResultOrmEntity): Result {
const entityName = 'Result';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'raceId', entity.raceId);
assertNonEmptyString(entityName, 'driverId', entity.driverId);
assertInteger(entityName, 'position', entity.position);
assertInteger(entityName, 'fastestLap', entity.fastestLap);
assertInteger(entityName, 'incidents', entity.incidents);
assertInteger(entityName, 'startPosition', entity.startPosition);
} catch (error) {
if (error instanceof TypeOrmPersistenceSchemaError) {
throw new InvalidResultSchemaError({
fieldName: error.fieldName,
reason: error.reason,
message: error.message,
});
}
throw error;
}
try {
return Result.rehydrate({
id: entity.id,
raceId: entity.raceId,
driverId: entity.driverId,
position: entity.position,
fastestLap: entity.fastestLap,
incidents: entity.incidents,
startPosition: entity.startPosition,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid persisted Result';
throw new InvalidResultSchemaError({
fieldName: 'unknown',
reason: 'invalid_shape',
message,
});
}
}
}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { Season } from '@core/racing/domain/entities/season/Season';
import { SeasonOrmEntity } from '../entities/SeasonOrmEntity';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { SeasonOrmMapper } from './SeasonOrmMapper';
describe('SeasonOrmMapper', () => {
@@ -43,7 +44,7 @@ describe('SeasonOrmMapper', () => {
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates persisted schedule schema and throws adapter-scoped error type', () => {
it('toDomain validates persisted schedule schema and throws adapter-scoped base schema error type', () => {
const mapper = new SeasonOrmMapper();
const entity = new SeasonOrmEntity();
@@ -68,7 +69,12 @@ describe('SeasonOrmMapper', () => {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toMatchObject({ name: 'InvalidSeasonScheduleSchemaError' });
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Season',
fieldName: 'schedule',
reason: 'not_object',
});
}
});
});

View File

@@ -3,7 +3,7 @@ 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 type { 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';
@@ -13,157 +13,181 @@ import { SeasonStewardingConfig } from '@core/racing/domain/value-objects/Season
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 { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import type {
SerializedSeasonDropPolicy,
SerializedSeasonEveryNWeeksRecurrence,
SerializedSeasonMonthlyNthWeekdayRecurrence,
SerializedSeasonRecurrence,
SerializedSeasonSchedule,
SerializedSeasonScoringConfig,
SerializedSeasonStewardingConfig,
SerializedSeasonWeeklyRecurrence,
} from '../serialized/RacingTypeOrmSerialized';
import {
assertArray,
assertEnumValue,
assertInteger,
assertIsoDate,
assertNonEmptyString,
assertRecord,
} from '../schema/TypeOrmSchemaGuards';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
const VALID_SEASON_STATUSES: SeasonStatusValue[] = [
const VALID_SEASON_STATUSES = [
'planned',
'active',
'completed',
'archived',
'cancelled',
];
] as const satisfies readonly SeasonStatusValue[];
function assertSeasonStatusValue(value: unknown): asserts value is SeasonStatusValue {
if (typeof value !== 'string' || !VALID_SEASON_STATUSES.includes(value as SeasonStatusValue)) {
throw new InvalidSeasonStatusSchemaError('Invalid status');
}
}
const VALID_RECURRENCE_KINDS = ['weekly', 'everyNWeeks', 'monthlyNthWeekday'] as const;
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 isMonthlyOrdinal(value: number): value is 1 | 2 | 3 | 4 {
return value === 1 || value === 2 || value === 3 || value === 4;
}
function parseSeasonSchedule(value: unknown): SeasonSchedule {
if (!isRecord(value)) {
throw new InvalidSeasonScheduleSchemaError('Invalid schedule (expected object)');
}
const entityName = 'Season';
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)');
}
assertRecord(entityName, 'schedule', value);
if (typeof value.timeOfDay !== 'string') {
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timeOfDay');
}
const startDateCandidate = value.startDate;
assertIsoDate(entityName, 'schedule.startDate', startDateCandidate);
const startDate = new Date(startDateCandidate);
if (typeof value.timezoneId !== 'string') {
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timezoneId');
}
const timeOfDayCandidate = value.timeOfDay;
assertNonEmptyString(entityName, 'schedule.timeOfDay', timeOfDayCandidate);
const timezoneIdCandidate = value.timezoneId;
assertNonEmptyString(entityName, 'schedule.timezoneId', timezoneIdCandidate);
const plannedRoundsCandidate = value.plannedRounds;
if (typeof plannedRoundsCandidate !== 'number' || !Number.isInteger(plannedRoundsCandidate) || plannedRoundsCandidate <= 0) {
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.plannedRounds');
assertInteger(entityName, 'schedule.plannedRounds', plannedRoundsCandidate);
if (plannedRoundsCandidate <= 0) {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'schedule.plannedRounds',
reason: 'invalid_shape',
message: 'Invalid schedule.plannedRounds (expected positive integer)',
});
}
const plannedRounds = plannedRoundsCandidate;
assertSerializedSeasonRecurrence(value.recurrence);
const recurrenceCandidate = value.recurrence;
assertRecord(entityName, 'schedule.recurrence', recurrenceCandidate);
const recurrenceKindCandidate = recurrenceCandidate.kind;
assertEnumValue(entityName, 'schedule.recurrence.kind', recurrenceKindCandidate, VALID_RECURRENCE_KINDS);
const recurrenceKind = recurrenceKindCandidate;
let timeOfDay: RaceTimeOfDay;
try {
timeOfDay = RaceTimeOfDay.fromString(value.timeOfDay);
timeOfDay = RaceTimeOfDay.fromString(timeOfDayCandidate);
} catch {
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timeOfDay (expected HH:MM)');
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'schedule.timeOfDay',
reason: 'invalid_shape',
});
}
let timezone: LeagueTimezone;
try {
timezone = LeagueTimezone.create(value.timezoneId);
timezone = LeagueTimezone.create(timezoneIdCandidate);
} catch {
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timezoneId');
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'schedule.timezoneId',
reason: 'invalid_shape',
});
}
let recurrence: RecurrenceStrategy;
try {
switch (value.recurrence.kind) {
const recurrenceRecord = recurrenceCandidate;
const recurrence: RecurrenceStrategy = (() => {
switch (recurrenceKind) {
case 'weekly': {
const weekdays = WeekdaySet.fromArray(value.recurrence.weekdays);
recurrence = RecurrenceStrategyFactory.weekly(weekdays);
break;
const weekdaysCandidate = recurrenceRecord.weekdays;
assertArray(entityName, 'schedule.recurrence.weekdays', weekdaysCandidate);
const typedWeekdays: Weekday[] = [];
for (let index = 0; index < weekdaysCandidate.length; index += 1) {
const dayCandidate: unknown = weekdaysCandidate[index];
assertEnumValue(entityName, `schedule.recurrence.weekdays[${index}]`, dayCandidate, ALL_WEEKDAYS);
typedWeekdays.push(dayCandidate);
}
try {
return RecurrenceStrategyFactory.weekly(WeekdaySet.fromArray(typedWeekdays));
} catch {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'schedule.recurrence',
reason: 'invalid_shape',
});
}
}
case 'everyNWeeks': {
const weekdays = WeekdaySet.fromArray(value.recurrence.weekdays);
recurrence = RecurrenceStrategyFactory.everyNWeeks(value.recurrence.intervalWeeks, weekdays);
break;
const intervalWeeksCandidate = recurrenceRecord.intervalWeeks;
assertInteger(entityName, 'schedule.recurrence.intervalWeeks', intervalWeeksCandidate);
if (intervalWeeksCandidate <= 0) {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'schedule.recurrence.intervalWeeks',
reason: 'invalid_shape',
message: 'Invalid schedule.recurrence.intervalWeeks (expected positive integer)',
});
}
const weekdaysCandidate = recurrenceRecord.weekdays;
assertArray(entityName, 'schedule.recurrence.weekdays', weekdaysCandidate);
const typedWeekdays: Weekday[] = [];
for (let index = 0; index < weekdaysCandidate.length; index += 1) {
const dayCandidate: unknown = weekdaysCandidate[index];
assertEnumValue(entityName, `schedule.recurrence.weekdays[${index}]`, dayCandidate, ALL_WEEKDAYS);
typedWeekdays.push(dayCandidate);
}
try {
return RecurrenceStrategyFactory.everyNWeeks(intervalWeeksCandidate, WeekdaySet.fromArray(typedWeekdays));
} catch {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'schedule.recurrence',
reason: 'invalid_shape',
});
}
}
case 'monthlyNthWeekday': {
const pattern = MonthlyRecurrencePattern.create(value.recurrence.ordinal, value.recurrence.weekday);
recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
break;
const ordinalCandidate = recurrenceRecord.ordinal;
assertInteger(entityName, 'schedule.recurrence.ordinal', ordinalCandidate);
if (!isMonthlyOrdinal(ordinalCandidate)) {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'schedule.recurrence.ordinal',
reason: 'invalid_enum_value',
});
}
const weekdayCandidate = recurrenceRecord.weekday;
assertEnumValue(entityName, 'schedule.recurrence.weekday', weekdayCandidate, ALL_WEEKDAYS);
try {
const pattern = MonthlyRecurrencePattern.create(ordinalCandidate, weekdayCandidate);
return RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
} catch {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'schedule.recurrence',
reason: 'invalid_shape',
});
}
}
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,
@@ -261,36 +285,63 @@ export class SeasonOrmMapper {
}
toDomain(entity: SeasonOrmEntity): Season {
assertSeasonStatusValue(entity.status);
const entityName = 'Season';
const status = SeasonStatus.create(entity.status);
const statusValue = entity.status;
assertEnumValue(entityName, 'status', statusValue, VALID_SEASON_STATUSES);
let status: SeasonStatus;
try {
status = SeasonStatus.create(statusValue);
} catch {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'status',
reason: 'invalid_enum_value',
});
}
const schedule = entity.schedule !== null ? parseSeasonSchedule(entity.schedule) : undefined;
let scoringConfig: SeasonScoringConfig | undefined;
if (entity.scoringConfig !== null) {
assertRecord(entityName, 'scoringConfig', entity.scoringConfig);
try {
scoringConfig = new SeasonScoringConfig(entity.scoringConfig);
} catch {
throw new InvalidSeasonScheduleSchemaError('Invalid scoringConfig');
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'scoringConfig',
reason: 'invalid_shape',
});
}
}
let dropPolicy: SeasonDropPolicy | undefined;
if (entity.dropPolicy !== null) {
assertRecord(entityName, 'dropPolicy', entity.dropPolicy);
try {
dropPolicy = new SeasonDropPolicy(entity.dropPolicy);
} catch {
throw new InvalidSeasonScheduleSchemaError('Invalid dropPolicy');
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'dropPolicy',
reason: 'invalid_shape',
});
}
}
let stewardingConfig: SeasonStewardingConfig | undefined;
if (entity.stewardingConfig !== null) {
assertRecord(entityName, 'stewardingConfig', entity.stewardingConfig);
try {
stewardingConfig = new SeasonStewardingConfig(entity.stewardingConfig);
} catch {
throw new InvalidSeasonScheduleSchemaError('Invalid stewardingConfig');
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: 'stewardingConfig',
reason: 'invalid_shape',
});
}
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { StandingOrmMapper } from './StandingOrmMapper';
import { StandingOrmEntity } from '../entities/StandingOrmEntity';
import { InvalidStandingSchemaError } from '../errors/InvalidStandingSchemaError';
describe('StandingOrmMapper', () => {
it('maps persisted schema guard failures into InvalidStandingSchemaError', () => {
const mapper = new StandingOrmMapper();
const entity = new StandingOrmEntity();
entity.id = 'league-1:driver-1';
entity.leagueId = '';
entity.driverId = 'driver-1';
entity.points = 0;
entity.wins = 0;
entity.position = 1;
entity.racesCompleted = 0;
expect(() => mapper.toDomain(entity)).toThrow(InvalidStandingSchemaError);
try {
mapper.toDomain(entity);
} catch (error) {
expect(error).toBeInstanceOf(InvalidStandingSchemaError);
const schemaError = error as InvalidStandingSchemaError;
expect(schemaError.entityName).toBe('Standing');
expect(schemaError.fieldName).toBe('leagueId');
expect(schemaError.reason).toBe('empty_string');
}
});
it('wraps domain rehydrate failures into InvalidStandingSchemaError (no mapper-level business validation)', () => {
const mapper = new StandingOrmMapper();
const entity = new StandingOrmEntity();
entity.id = 'league-1:driver-1';
entity.leagueId = 'league-1';
entity.driverId = 'driver-1';
entity.points = 0;
entity.wins = 0;
entity.position = 0; // schema ok (integer), domain invalid (Position)
entity.racesCompleted = 0;
try {
mapper.toDomain(entity);
throw new Error('Expected mapper.toDomain to throw');
} catch (error) {
expect(error).toBeInstanceOf(InvalidStandingSchemaError);
const schemaError = error as InvalidStandingSchemaError;
expect(schemaError.entityName).toBe('Standing');
expect(schemaError.reason).toBe('invalid_shape');
}
});
});

View File

@@ -0,0 +1,62 @@
import { Standing } from '@core/racing/domain/entities/Standing';
import { StandingOrmEntity } from '../entities/StandingOrmEntity';
import { InvalidStandingSchemaError } from '../errors/InvalidStandingSchemaError';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { assertInteger, assertNonEmptyString } from '../schema/TypeOrmSchemaGuards';
export class StandingOrmMapper {
toOrmEntity(domain: Standing): StandingOrmEntity {
const entity = new StandingOrmEntity();
entity.id = domain.id;
entity.leagueId = domain.leagueId.toString();
entity.driverId = domain.driverId.toString();
entity.points = domain.points.toNumber();
entity.wins = domain.wins;
entity.position = domain.position.toNumber();
entity.racesCompleted = domain.racesCompleted;
return entity;
}
toDomain(entity: StandingOrmEntity): Standing {
const entityName = 'Standing';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'leagueId', entity.leagueId);
assertNonEmptyString(entityName, 'driverId', entity.driverId);
assertInteger(entityName, 'points', entity.points);
assertInteger(entityName, 'wins', entity.wins);
assertInteger(entityName, 'position', entity.position);
assertInteger(entityName, 'racesCompleted', entity.racesCompleted);
} catch (error) {
if (error instanceof TypeOrmPersistenceSchemaError) {
throw new InvalidStandingSchemaError({
fieldName: error.fieldName,
reason: error.reason,
message: error.message,
});
}
throw error;
}
try {
return Standing.rehydrate({
id: entity.id,
leagueId: entity.leagueId,
driverId: entity.driverId,
points: entity.points,
wins: entity.wins,
position: entity.position,
racesCompleted: entity.racesCompleted,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid persisted Standing';
throw new InvalidStandingSchemaError({
fieldName: 'unknown',
reason: 'invalid_shape',
message,
});
}
}
}

View File

@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from 'vitest';
import { Penalty } from '@core/racing/domain/entities/penalty/Penalty';
import { Protest } from '@core/racing/domain/entities/Protest';
import { PenaltyOrmEntity, ProtestOrmEntity } from '../entities/MissingRacingOrmEntities';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { PenaltyOrmMapper, ProtestOrmMapper } from './StewardingOrmMappers';
describe('PenaltyOrmMapper', () => {
it('toDomain uses rehydrate semantics (does not call create)', () => {
const mapper = new PenaltyOrmMapper();
const entity = new PenaltyOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.leagueId = '00000000-0000-4000-8000-000000000002';
entity.raceId = 'race-1';
entity.driverId = '00000000-0000-4000-8000-000000000003';
entity.type = 'warning';
entity.value = null;
entity.reason = 'Unsafe rejoin';
entity.protestId = null;
entity.issuedBy = '00000000-0000-4000-8000-000000000004';
entity.status = 'pending';
entity.issuedAt = new Date('2025-01-01T00:00:00.000Z');
entity.appliedAt = null;
entity.notes = null;
const rehydrateSpy = vi.spyOn(Penalty, 'rehydrate');
const createSpy = vi.spyOn(Penalty, '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 enum values and throws adapter-scoped base schema error type', () => {
const mapper = new PenaltyOrmMapper();
const entity = new PenaltyOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.leagueId = '00000000-0000-4000-8000-000000000002';
entity.raceId = 'race-1';
entity.driverId = '00000000-0000-4000-8000-000000000003';
entity.type = 'not-a-penalty-type';
entity.value = null;
entity.reason = 'Reason';
entity.protestId = null;
entity.issuedBy = '00000000-0000-4000-8000-000000000004';
entity.status = 'pending';
entity.issuedAt = new Date('2025-01-01T00:00:00.000Z');
entity.appliedAt = null;
entity.notes = null;
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Penalty',
fieldName: 'type',
reason: 'invalid_enum_value',
});
}
});
});
describe('ProtestOrmMapper', () => {
it('toDomain uses rehydrate semantics (does not call create)', () => {
const mapper = new ProtestOrmMapper();
const entity = new ProtestOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.raceId = 'race-1';
entity.protestingDriverId = '00000000-0000-4000-8000-000000000002';
entity.accusedDriverId = '00000000-0000-4000-8000-000000000003';
entity.incident = { lap: 1, description: 'Contact', timeInRace: 12.3 };
entity.comment = null;
entity.proofVideoUrl = null;
entity.status = 'pending';
entity.reviewedBy = null;
entity.decisionNotes = null;
entity.filedAt = new Date('2025-01-01T00:00:00.000Z');
entity.reviewedAt = null;
entity.defense = null;
entity.defenseRequestedAt = null;
entity.defenseRequestedBy = null;
const rehydrateSpy = vi.spyOn(Protest, 'rehydrate');
const createSpy = vi.spyOn(Protest, '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 defense schema and throws adapter-scoped base schema error type', () => {
const mapper = new ProtestOrmMapper();
const entity = new ProtestOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.raceId = 'race-1';
entity.protestingDriverId = '00000000-0000-4000-8000-000000000002';
entity.accusedDriverId = '00000000-0000-4000-8000-000000000003';
entity.incident = { lap: 1, description: 'Contact' };
entity.comment = null;
entity.proofVideoUrl = null;
entity.status = 'pending';
entity.reviewedBy = null;
entity.decisionNotes = null;
entity.filedAt = new Date('2025-01-01T00:00:00.000Z');
entity.reviewedAt = null;
entity.defense = { statement: 'hi', submittedAt: 'not-iso' };
entity.defenseRequestedAt = null;
entity.defenseRequestedBy = null;
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Protest',
fieldName: 'defense.submittedAt',
reason: 'not_iso_date',
});
}
});
});

View File

@@ -0,0 +1,295 @@
import { Penalty } from '@core/racing/domain/entities/penalty/Penalty';
import { Protest } from '@core/racing/domain/entities/Protest';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { PenaltyOrmEntity, ProtestOrmEntity } from '../entities/MissingRacingOrmEntities';
import {
assertDate,
assertEnumValue,
assertNonEmptyString,
assertOptionalInteger,
assertOptionalStringOrNull,
assertRecord,
} from '../schema/TypeOrmSchemaGuards';
const VALID_PROTEST_STATUSES = [
'pending',
'awaiting_defense',
'under_review',
'upheld',
'dismissed',
'withdrawn',
] as const;
const VALID_PENALTY_STATUSES = ['pending', 'applied', 'appealed', 'overturned'] as const;
const VALID_PENALTY_TYPES = [
'time_penalty',
'grid_penalty',
'points_deduction',
'disqualification',
'warning',
'license_points',
'probation',
'fine',
'race_ban',
] as const;
type SerializedProtestIncident = {
lap: number;
description: string;
timeInRace?: number;
};
type SerializedProtestDefense = {
statement: string;
submittedAt: string;
videoUrl?: string;
};
function assertSerializedProtestIncident(value: unknown): asserts value is SerializedProtestIncident {
const entityName = 'Protest';
const fieldName = 'incident';
assertRecord(entityName, fieldName, value);
const lap = value.lap;
if (typeof lap !== 'number' || !Number.isFinite(lap)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.lap`, reason: 'not_number' });
}
const description = value.description;
if (typeof description !== 'string') {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.description`, reason: 'not_string' });
}
if (description.trim().length === 0) {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.description`,
reason: 'empty_string',
});
}
if (value.timeInRace !== undefined) {
const timeInRace = value.timeInRace;
if (typeof timeInRace !== 'number' || !Number.isFinite(timeInRace)) {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.timeInRace`,
reason: 'not_number',
});
}
}
}
function assertSerializedProtestDefense(value: unknown): asserts value is SerializedProtestDefense {
const entityName = 'Protest';
const fieldName = 'defense';
assertRecord(entityName, fieldName, value);
const statement = value.statement;
if (typeof statement !== 'string') {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.statement`, reason: 'not_string' });
}
if (statement.trim().length === 0) {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.statement`,
reason: 'empty_string',
});
}
const submittedAt = value.submittedAt;
if (typeof submittedAt !== 'string') {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.submittedAt`,
reason: 'not_string',
});
}
const submittedAtDate = new Date(submittedAt);
if (Number.isNaN(submittedAtDate.getTime()) || submittedAtDate.toISOString() !== submittedAt) {
throw new TypeOrmPersistenceSchemaError({
entityName,
fieldName: `${fieldName}.submittedAt`,
reason: 'not_iso_date',
});
}
if (value.videoUrl !== undefined && typeof value.videoUrl !== 'string') {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: `${fieldName}.videoUrl`, reason: 'not_string' });
}
}
function serializeProtestDefense(defense: Protest['defense']): SerializedProtestDefense | undefined {
if (!defense) return undefined;
return {
statement: defense.statement.toString(),
submittedAt: defense.submittedAt.toDate().toISOString(),
...(defense.videoUrl !== undefined ? { videoUrl: defense.videoUrl.toString() } : {}),
};
}
export class PenaltyOrmMapper {
toOrmEntity(domain: Penalty): PenaltyOrmEntity {
const entity = new PenaltyOrmEntity();
entity.id = domain.id;
entity.leagueId = domain.leagueId;
entity.raceId = domain.raceId;
entity.driverId = domain.driverId;
entity.type = domain.type;
entity.value = domain.value ?? null;
entity.reason = domain.reason;
entity.protestId = domain.protestId ?? null;
entity.issuedBy = domain.issuedBy;
entity.status = domain.status;
entity.issuedAt = domain.issuedAt;
entity.appliedAt = domain.appliedAt ?? null;
entity.notes = domain.notes ?? null;
return entity;
}
toDomain(entity: PenaltyOrmEntity): Penalty {
const entityName = 'Penalty';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'leagueId', entity.leagueId);
assertNonEmptyString(entityName, 'raceId', entity.raceId);
assertNonEmptyString(entityName, 'driverId', entity.driverId);
assertEnumValue(entityName, 'type', entity.type, VALID_PENALTY_TYPES);
assertOptionalInteger(entityName, 'value', entity.value);
assertNonEmptyString(entityName, 'reason', entity.reason);
assertOptionalStringOrNull(entityName, 'protestId', entity.protestId);
assertNonEmptyString(entityName, 'issuedBy', entity.issuedBy);
assertEnumValue(entityName, 'status', entity.status, VALID_PENALTY_STATUSES);
assertDate(entityName, 'issuedAt', entity.issuedAt);
assertOptionalStringOrNull(entityName, 'notes', entity.notes);
if (entity.appliedAt !== null && entity.appliedAt !== undefined) {
assertDate(entityName, 'appliedAt', entity.appliedAt);
}
try {
return Penalty.rehydrate({
id: entity.id,
leagueId: entity.leagueId,
raceId: entity.raceId,
driverId: entity.driverId,
type: entity.type,
...(entity.value !== null && entity.value !== undefined ? { value: entity.value } : {}),
reason: entity.reason,
...(entity.protestId !== null && entity.protestId !== undefined ? { protestId: entity.protestId } : {}),
issuedBy: entity.issuedBy,
status: entity.status,
issuedAt: entity.issuedAt,
...(entity.appliedAt !== null && entity.appliedAt !== undefined ? { appliedAt: entity.appliedAt } : {}),
...(entity.notes !== null && entity.notes !== undefined ? { notes: entity.notes } : {}),
});
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
}
}
}
export class ProtestOrmMapper {
toOrmEntity(domain: Protest): ProtestOrmEntity {
const entity = new ProtestOrmEntity();
entity.id = domain.id;
entity.raceId = domain.raceId;
entity.protestingDriverId = domain.protestingDriverId;
entity.accusedDriverId = domain.accusedDriverId;
entity.incident = {
lap: domain.incident.lap.toNumber(),
description: domain.incident.description.toString(),
...(domain.incident.timeInRace !== undefined ? { timeInRace: domain.incident.timeInRace.toNumber() } : {}),
};
entity.comment = domain.comment ?? null;
entity.proofVideoUrl = domain.proofVideoUrl ?? null;
entity.status = domain.status.toString();
entity.reviewedBy = domain.reviewedBy ?? null;
entity.decisionNotes = domain.decisionNotes ?? null;
entity.filedAt = domain.filedAt;
entity.reviewedAt = domain.reviewedAt ?? null;
entity.defense = serializeProtestDefense(domain.defense) ?? null;
entity.defenseRequestedAt = domain.defenseRequestedAt ?? null;
entity.defenseRequestedBy = domain.defenseRequestedBy ?? null;
return entity;
}
toDomain(entity: ProtestOrmEntity): Protest {
const entityName = 'Protest';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'raceId', entity.raceId);
assertNonEmptyString(entityName, 'protestingDriverId', entity.protestingDriverId);
assertNonEmptyString(entityName, 'accusedDriverId', entity.accusedDriverId);
assertEnumValue(entityName, 'status', entity.status, VALID_PROTEST_STATUSES);
assertDate(entityName, 'filedAt', entity.filedAt);
assertSerializedProtestIncident(entity.incident);
assertOptionalStringOrNull(entityName, 'comment', entity.comment);
assertOptionalStringOrNull(entityName, 'proofVideoUrl', entity.proofVideoUrl);
assertOptionalStringOrNull(entityName, 'decisionNotes', entity.decisionNotes);
if (entity.reviewedAt !== null && entity.reviewedAt !== undefined) {
assertDate(entityName, 'reviewedAt', entity.reviewedAt);
}
if (entity.defenseRequestedAt !== null && entity.defenseRequestedAt !== undefined) {
assertDate(entityName, 'defenseRequestedAt', entity.defenseRequestedAt);
}
if (entity.reviewedBy !== null && entity.reviewedBy !== undefined) {
assertNonEmptyString(entityName, 'reviewedBy', entity.reviewedBy);
}
if (entity.defenseRequestedBy !== null && entity.defenseRequestedBy !== undefined) {
assertNonEmptyString(entityName, 'defenseRequestedBy', entity.defenseRequestedBy);
}
let defense: { statement: string; submittedAt: Date; videoUrl?: string } | undefined;
if (entity.defense !== null && entity.defense !== undefined) {
assertSerializedProtestDefense(entity.defense);
const parsed = entity.defense as SerializedProtestDefense;
defense = {
statement: parsed.statement,
submittedAt: new Date(parsed.submittedAt),
...(parsed.videoUrl !== undefined ? { videoUrl: parsed.videoUrl } : {}),
};
}
try {
return Protest.rehydrate({
id: entity.id,
raceId: entity.raceId,
protestingDriverId: entity.protestingDriverId,
accusedDriverId: entity.accusedDriverId,
incident: {
lap: (entity.incident as SerializedProtestIncident).lap,
description: (entity.incident as SerializedProtestIncident).description,
...((entity.incident as SerializedProtestIncident).timeInRace !== undefined
? { timeInRace: (entity.incident as SerializedProtestIncident).timeInRace }
: {}),
},
...(entity.comment !== null && entity.comment !== undefined ? { comment: entity.comment } : {}),
...(entity.proofVideoUrl !== null && entity.proofVideoUrl !== undefined ? { proofVideoUrl: entity.proofVideoUrl } : {}),
status: entity.status,
...(entity.reviewedBy !== null && entity.reviewedBy !== undefined ? { reviewedBy: entity.reviewedBy } : {}),
...(entity.decisionNotes !== null && entity.decisionNotes !== undefined ? { decisionNotes: entity.decisionNotes } : {}),
filedAt: entity.filedAt,
...(entity.reviewedAt !== null && entity.reviewedAt !== undefined ? { reviewedAt: entity.reviewedAt } : {}),
...(defense !== undefined ? { defense } : {}),
...(entity.defenseRequestedAt !== null && entity.defenseRequestedAt !== undefined
? { defenseRequestedAt: entity.defenseRequestedAt }
: {}),
...(entity.defenseRequestedBy !== null && entity.defenseRequestedBy !== undefined
? { defenseRequestedBy: entity.defenseRequestedBy }
: {}),
});
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
}
}
}

View File

@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import { Team } from '@core/racing/domain/entities/Team';
import { TeamMembershipOrmEntity, TeamOrmEntity } from '../entities/TeamOrmEntities';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import { TeamMembershipOrmMapper, TeamOrmMapper } from './TeamOrmMappers';
describe('TeamOrmMapper', () => {
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
const mapper = new TeamOrmMapper();
const entity = new TeamOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.name = 'Team One';
entity.tag = 'T1';
entity.description = 'Desc';
entity.ownerId = '00000000-0000-4000-8000-000000000002';
entity.leagues = ['00000000-0000-4000-8000-000000000003'];
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
const rehydrateSpy = vi.spyOn(Team, 'rehydrate');
const createSpy = vi.spyOn(Team, 'create').mockImplementation(() => {
throw new Error('create-called');
});
const domain = mapper.toDomain(entity);
expect(domain.id).toBe(entity.id);
expect(domain.ownerId.toString()).toBe(entity.ownerId);
expect(createSpy).not.toHaveBeenCalled();
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates persisted shape and throws adapter-scoped base schema error type', () => {
const mapper = new TeamOrmMapper();
const entity = new TeamOrmEntity();
entity.id = '';
entity.name = 'Team One';
entity.tag = 'T1';
entity.description = 'Desc';
entity.ownerId = '00000000-0000-4000-8000-000000000002';
entity.leagues = [];
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Team',
fieldName: 'id',
reason: 'empty_string',
});
}
});
});
describe('TeamMembershipOrmMapper', () => {
it('toDomainMembership validates enum role/status and throws adapter-scoped base schema error type', () => {
const mapper = new TeamMembershipOrmMapper();
const entity = new TeamMembershipOrmEntity();
entity.teamId = '00000000-0000-4000-8000-000000000001';
entity.driverId = '00000000-0000-4000-8000-000000000002';
entity.role = 'invalid';
entity.status = 'active';
entity.joinedAt = new Date('2025-01-01T00:00:00.000Z');
try {
mapper.toDomainMembership(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'TeamMembership',
fieldName: 'role',
reason: 'invalid_enum_value',
});
}
});
it('round-trips membership toOrmMembership <-> toDomainMembership', () => {
const mapper = new TeamMembershipOrmMapper();
const domain = {
teamId: '00000000-0000-4000-8000-000000000001',
driverId: '00000000-0000-4000-8000-000000000002',
role: 'driver' as const,
status: 'active' as const,
joinedAt: new Date('2025-01-01T00:00:00.000Z'),
};
const orm = mapper.toOrmMembership(domain);
const rehydrated = mapper.toDomainMembership(orm);
expect(rehydrated).toEqual(domain);
});
});

View File

@@ -0,0 +1,119 @@
import { Team } from '@core/racing/domain/entities/Team';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership';
import { TeamJoinRequestOrmEntity, TeamMembershipOrmEntity, TeamOrmEntity } from '../entities/TeamOrmEntities';
import {
assertArray,
assertDate,
assertEnumValue,
assertNonEmptyString,
assertOptionalStringOrNull,
} from '../schema/TypeOrmSchemaGuards';
const TEAM_ROLES = ['owner', 'manager', 'driver'] as const;
const TEAM_MEMBERSHIP_STATUSES = ['active', 'pending', 'none'] as const;
export class TeamOrmMapper {
toOrmEntity(domain: Team): TeamOrmEntity {
const entity = new TeamOrmEntity();
entity.id = domain.id;
entity.name = domain.name.toString();
entity.tag = domain.tag.toString();
entity.description = domain.description.toString();
entity.ownerId = domain.ownerId.toString();
entity.leagues = domain.leagues.map((l) => l.toString());
entity.createdAt = domain.createdAt.toDate();
return entity;
}
toDomain(entity: TeamOrmEntity): Team {
const entityName = 'Team';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'name', entity.name);
assertNonEmptyString(entityName, 'tag', entity.tag);
assertNonEmptyString(entityName, 'description', entity.description);
assertNonEmptyString(entityName, 'ownerId', entity.ownerId);
assertDate(entityName, 'createdAt', entity.createdAt);
assertArray(entityName, 'leagues', entity.leagues);
for (const leagueId of entity.leagues) {
assertNonEmptyString(entityName, 'leagues', leagueId);
}
try {
return Team.rehydrate({
id: entity.id,
name: entity.name,
tag: entity.tag,
description: entity.description,
ownerId: entity.ownerId,
leagues: entity.leagues,
createdAt: entity.createdAt,
});
} catch {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
}
}
}
export class TeamMembershipOrmMapper {
toOrmMembership(membership: TeamMembership): TeamMembershipOrmEntity {
const entity = new TeamMembershipOrmEntity();
entity.teamId = membership.teamId;
entity.driverId = membership.driverId;
entity.role = membership.role;
entity.status = membership.status;
entity.joinedAt = membership.joinedAt;
return entity;
}
toDomainMembership(entity: TeamMembershipOrmEntity): TeamMembership {
const entityName = 'TeamMembership';
assertNonEmptyString(entityName, 'teamId', entity.teamId);
assertNonEmptyString(entityName, 'driverId', entity.driverId);
assertEnumValue(entityName, 'role', entity.role, TEAM_ROLES);
assertEnumValue(entityName, 'status', entity.status, TEAM_MEMBERSHIP_STATUSES);
assertDate(entityName, 'joinedAt', entity.joinedAt);
return {
teamId: entity.teamId,
driverId: entity.driverId,
role: entity.role,
status: entity.status,
joinedAt: entity.joinedAt,
};
}
toOrmJoinRequest(request: TeamJoinRequest): TeamJoinRequestOrmEntity {
const entity = new TeamJoinRequestOrmEntity();
entity.id = request.id;
entity.teamId = request.teamId;
entity.driverId = request.driverId;
entity.requestedAt = request.requestedAt;
entity.message = request.message ?? null;
return entity;
}
toDomainJoinRequest(entity: TeamJoinRequestOrmEntity): TeamJoinRequest {
const entityName = 'TeamJoinRequest';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'teamId', entity.teamId);
assertNonEmptyString(entityName, 'driverId', entity.driverId);
assertDate(entityName, 'requestedAt', entity.requestedAt);
assertOptionalStringOrNull(entityName, 'message', entity.message);
return {
id: entity.id,
teamId: entity.teamId,
driverId: entity.driverId,
requestedAt: entity.requestedAt,
...(entity.message !== null && entity.message !== undefined ? { message: entity.message } : {}),
};
}
}

View File

@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from 'vitest';
import {
TypeOrmGameRepository,
TypeOrmLeagueWalletRepository,
TypeOrmSponsorRepository,
TypeOrmTransactionRepository,
} from './CommerceTypeOrmRepositories';
describe('TypeOrmGameRepository', () => {
it('findAll maps entities to domain using injected mapper (DB-free)', async () => {
const entities = [{ id: 'g1' }, { id: 'g2' }];
const repo = {
find: vi.fn().mockResolvedValue(entities),
findOne: vi.fn(),
};
const mapper = {
toDomain: vi.fn().mockImplementation((e: any) => ({ id: `domain-${e.id}` })),
};
const gameRepo = new TypeOrmGameRepository(repo as any, mapper as any);
const games = await gameRepo.findAll();
expect(repo.find).toHaveBeenCalledTimes(1);
expect(mapper.toDomain).toHaveBeenCalledTimes(2);
expect(games).toEqual([{ id: 'domain-g1' }, { id: 'domain-g2' }]);
});
});
describe('TypeOrmLeagueWalletRepository', () => {
it('exists returns true when count > 0 (DB-free)', async () => {
const repo = {
count: vi.fn().mockResolvedValue(1),
findOne: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
};
const mapper = {
toDomain: vi.fn(),
toOrmEntity: vi.fn(),
};
const walletRepo = new TypeOrmLeagueWalletRepository(repo as any, mapper as any);
await expect(walletRepo.exists('w1')).resolves.toBe(true);
expect(repo.count).toHaveBeenCalledWith({ where: { id: 'w1' } });
});
});
describe('TypeOrmTransactionRepository', () => {
it('findByWalletId maps entities to domain (DB-free)', async () => {
const entities = [{ id: 't1' }, { id: 't2' }];
const repo = {
find: vi.fn().mockResolvedValue(entities),
findOne: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
};
const mapper = {
toDomain: vi.fn().mockImplementation((e: any) => ({ id: `domain-${e.id}` })),
toOrmEntity: vi.fn(),
};
const txRepo = new TypeOrmTransactionRepository(repo as any, mapper as any);
const txs = await txRepo.findByWalletId('wallet-1');
expect(repo.find).toHaveBeenCalledWith({ where: { walletId: 'wallet-1' } });
expect(mapper.toDomain).toHaveBeenCalledTimes(2);
expect(txs).toEqual([{ id: 'domain-t1' }, { id: 'domain-t2' }]);
});
});
describe('TypeOrmSponsorRepository', () => {
it('findByEmail queries by contactEmail and maps (DB-free)', async () => {
const entity = { id: 's1' };
const repo = {
findOne: vi.fn().mockResolvedValue(entity),
find: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
};
const mapper = {
toDomain: vi.fn().mockReturnValue({ id: 'domain-s1' }),
toOrmEntity: vi.fn(),
};
const sponsorRepo = new TypeOrmSponsorRepository(repo as any, mapper as any);
const sponsor = await sponsorRepo.findByEmail('a@example.com');
expect(repo.findOne).toHaveBeenCalledWith({ where: { contactEmail: 'a@example.com' } });
expect(mapper.toDomain).toHaveBeenCalledWith(entity);
expect(sponsor).toEqual({ id: 'domain-s1' });
});
});

View File

@@ -0,0 +1,320 @@
import type { Repository } from 'typeorm';
import type { IGameRepository } from '@core/racing/domain/repositories/IGameRepository';
import type { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository';
import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository';
import type { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository';
import type { ISponsorshipPricingRepository } from '@core/racing/domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository';
import type { ITransactionRepository } from '@core/racing/domain/repositories/ITransactionRepository';
import type { Game } from '@core/racing/domain/entities/Game';
import type { LeagueWallet } from '@core/racing/domain/entities/league-wallet/LeagueWallet';
import type { Transaction, TransactionType } from '@core/racing/domain/entities/league-wallet/Transaction';
import type { SeasonSponsorship, SponsorshipTier } from '@core/racing/domain/entities/season/SeasonSponsorship';
import type { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor';
import type { SponsorshipPricing } from '@core/racing/domain/value-objects/SponsorshipPricing';
import type { SponsorshipRequest, SponsorableEntityType, SponsorshipRequestStatus } from '@core/racing/domain/entities/SponsorshipRequest';
import {
GameOrmEntity,
LeagueWalletOrmEntity,
SeasonSponsorshipOrmEntity,
SponsorOrmEntity,
SponsorshipPricingOrmEntity,
SponsorshipRequestOrmEntity,
TransactionOrmEntity,
} from '../entities/MissingRacingOrmEntities';
import {
GameOrmMapper,
LeagueWalletOrmMapper,
SeasonSponsorshipOrmMapper,
SponsorOrmMapper,
SponsorshipPricingOrmMapper,
SponsorshipRequestOrmMapper,
TransactionOrmMapper,
} from '../mappers/CommerceOrmMappers';
export class TypeOrmGameRepository implements IGameRepository {
constructor(
private readonly repo: Repository<GameOrmEntity>,
private readonly mapper: GameOrmMapper,
) {}
async findById(id: string): Promise<Game | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findAll(): Promise<Game[]> {
const entities = await this.repo.find();
return entities.map((e) => this.mapper.toDomain(e));
}
}
export class TypeOrmLeagueWalletRepository implements ILeagueWalletRepository {
constructor(
private readonly repo: Repository<LeagueWalletOrmEntity>,
private readonly mapper: LeagueWalletOrmMapper,
) {}
async findById(id: string): Promise<LeagueWallet | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByLeagueId(leagueId: string): Promise<LeagueWallet | null> {
const entity = await this.repo.findOne({ where: { leagueId } });
return entity ? this.mapper.toDomain(entity) : null;
}
async create(wallet: LeagueWallet): Promise<LeagueWallet> {
await this.repo.save(this.mapper.toOrmEntity(wallet));
return wallet;
}
async update(wallet: LeagueWallet): Promise<LeagueWallet> {
await this.repo.save(this.mapper.toOrmEntity(wallet));
return wallet;
}
async delete(id: string): Promise<void> {
await this.repo.delete({ id });
}
async exists(id: string): Promise<boolean> {
const count = await this.repo.count({ where: { id } });
return count > 0;
}
}
export class TypeOrmTransactionRepository implements ITransactionRepository {
constructor(
private readonly repo: Repository<TransactionOrmEntity>,
private readonly mapper: TransactionOrmMapper,
) {}
async findById(id: string): Promise<Transaction | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByWalletId(walletId: string): Promise<Transaction[]> {
const entities = await this.repo.find({ where: { walletId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByType(type: TransactionType): Promise<Transaction[]> {
const entities = await this.repo.find({ where: { type } });
return entities.map((e) => this.mapper.toDomain(e));
}
async create(transaction: Transaction): Promise<Transaction> {
await this.repo.save(this.mapper.toOrmEntity(transaction));
return transaction;
}
async update(transaction: Transaction): Promise<Transaction> {
await this.repo.save(this.mapper.toOrmEntity(transaction));
return transaction;
}
async delete(id: string): Promise<void> {
await this.repo.delete({ id });
}
async exists(id: string): Promise<boolean> {
const count = await this.repo.count({ where: { id } });
return count > 0;
}
}
export class TypeOrmSponsorRepository implements ISponsorRepository {
constructor(
private readonly repo: Repository<SponsorOrmEntity>,
private readonly mapper: SponsorOrmMapper,
) {}
async findById(id: string): Promise<Sponsor | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findAll(): Promise<Sponsor[]> {
const entities = await this.repo.find();
return entities.map((e) => this.mapper.toDomain(e));
}
async findByEmail(email: string): Promise<Sponsor | null> {
const entity = await this.repo.findOne({ where: { contactEmail: email } });
return entity ? this.mapper.toDomain(entity) : null;
}
async create(sponsor: Sponsor): Promise<Sponsor> {
await this.repo.save(this.mapper.toOrmEntity(sponsor));
return sponsor;
}
async update(sponsor: Sponsor): Promise<Sponsor> {
await this.repo.save(this.mapper.toOrmEntity(sponsor));
return sponsor;
}
async delete(id: string): Promise<void> {
await this.repo.delete({ id });
}
async exists(id: string): Promise<boolean> {
const count = await this.repo.count({ where: { id } });
return count > 0;
}
}
export class TypeOrmSponsorshipPricingRepository implements ISponsorshipPricingRepository {
constructor(
private readonly repo: Repository<SponsorshipPricingOrmEntity>,
private readonly mapper: SponsorshipPricingOrmMapper,
) {}
async findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipPricing | null> {
const id = this.mapper.makeId(entityType, entityId);
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async save(entityType: SponsorableEntityType, entityId: string, pricing: SponsorshipPricing): Promise<void> {
await this.repo.save(this.mapper.toOrmEntity(entityType, entityId, pricing));
}
async delete(entityType: SponsorableEntityType, entityId: string): Promise<void> {
const id = this.mapper.makeId(entityType, entityId);
await this.repo.delete({ id });
}
async exists(entityType: SponsorableEntityType, entityId: string): Promise<boolean> {
const id = this.mapper.makeId(entityType, entityId);
const count = await this.repo.count({ where: { id } });
return count > 0;
}
async findAcceptingApplications(entityType: SponsorableEntityType): Promise<Array<{ entityId: string; pricing: SponsorshipPricing }>> {
const entities = await this.repo.find({ where: { entityType, pricing: { acceptingApplications: true } } as any });
return entities.map((e) => ({ entityId: e.entityId, pricing: this.mapper.toDomain(e) }));
}
}
export class TypeOrmSponsorshipRequestRepository implements ISponsorshipRequestRepository {
constructor(
private readonly repo: Repository<SponsorshipRequestOrmEntity>,
private readonly mapper: SponsorshipRequestOrmMapper,
) {}
async findById(id: string): Promise<SponsorshipRequest | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]> {
const entities = await this.repo.find({ where: { entityType, entityId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]> {
const entities = await this.repo.find({ where: { entityType, entityId, status: 'pending' } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findBySponsorId(sponsorId: string): Promise<SponsorshipRequest[]> {
const entities = await this.repo.find({ where: { sponsorId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByStatus(status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]> {
const entities = await this.repo.find({ where: { status } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findBySponsorIdAndStatus(sponsorId: string, status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]> {
const entities = await this.repo.find({ where: { sponsorId, status } });
return entities.map((e) => this.mapper.toDomain(e));
}
async hasPendingRequest(sponsorId: string, entityType: SponsorableEntityType, entityId: string): Promise<boolean> {
const count = await this.repo.count({ where: { sponsorId, entityType, entityId, status: 'pending' } });
return count > 0;
}
async countPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<number> {
return this.repo.count({ where: { entityType, entityId, status: 'pending' } });
}
async create(request: SponsorshipRequest): Promise<SponsorshipRequest> {
await this.repo.save(this.mapper.toOrmEntity(request));
return request;
}
async update(request: SponsorshipRequest): Promise<SponsorshipRequest> {
await this.repo.save(this.mapper.toOrmEntity(request));
return request;
}
async delete(id: string): Promise<void> {
await this.repo.delete({ id });
}
async exists(id: string): Promise<boolean> {
const count = await this.repo.count({ where: { id } });
return count > 0;
}
}
export class TypeOrmSeasonSponsorshipRepository implements ISeasonSponsorshipRepository {
constructor(
private readonly repo: Repository<SeasonSponsorshipOrmEntity>,
private readonly mapper: SeasonSponsorshipOrmMapper,
) {}
async findById(id: string): Promise<SeasonSponsorship | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findBySeasonId(seasonId: string): Promise<SeasonSponsorship[]> {
const entities = await this.repo.find({ where: { seasonId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByLeagueId(leagueId: string): Promise<SeasonSponsorship[]> {
const entities = await this.repo.find({ where: { leagueId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findBySponsorId(sponsorId: string): Promise<SeasonSponsorship[]> {
const entities = await this.repo.find({ where: { sponsorId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findBySeasonAndTier(seasonId: string, tier: SponsorshipTier): Promise<SeasonSponsorship[]> {
const entities = await this.repo.find({ where: { seasonId, tier } });
return entities.map((e) => this.mapper.toDomain(e));
}
async create(sponsorship: SeasonSponsorship): Promise<SeasonSponsorship> {
await this.repo.save(this.mapper.toOrmEntity(sponsorship));
return sponsorship;
}
async update(sponsorship: SeasonSponsorship): Promise<SeasonSponsorship> {
await this.repo.save(this.mapper.toOrmEntity(sponsorship));
return sponsorship;
}
async delete(id: string): Promise<void> {
await this.repo.delete({ id });
}
async exists(id: string): Promise<boolean> {
const count = await this.repo.count({ where: { id } });
return count > 0;
}
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import { TypeOrmPenaltyRepository, TypeOrmProtestRepository } from './StewardingTypeOrmRepositories';
describe('TypeOrmPenaltyRepository', () => {
it('findById returns mapped domain when found (DB-free)', async () => {
const ormEntity = { id: 'p1' };
const repo = {
findOne: vi.fn().mockResolvedValue(ormEntity),
find: vi.fn(),
save: vi.fn(),
count: vi.fn(),
delete: vi.fn(),
};
const mapper = {
toDomain: vi.fn().mockReturnValue({ id: 'domain' }),
toOrmEntity: vi.fn(),
};
const penaltyRepo = new TypeOrmPenaltyRepository(repo as any, mapper as any);
const result = await penaltyRepo.findById('p1');
expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 'p1' } });
expect(mapper.toDomain).toHaveBeenCalledWith(ormEntity);
expect(result).toEqual({ id: 'domain' });
});
it('create uses mapper.toOrmEntity + repo.save', async () => {
const repo = {
save: vi.fn().mockResolvedValue(undefined),
};
const mapper = {
toOrmEntity: vi.fn().mockReturnValue({ id: 'p1-orm' }),
};
const penaltyRepo = new TypeOrmPenaltyRepository(repo as any, mapper as any);
await penaltyRepo.create({ id: 'p1' } as any);
expect(mapper.toOrmEntity).toHaveBeenCalled();
expect(repo.save).toHaveBeenCalledWith({ id: 'p1-orm' });
});
});
describe('TypeOrmProtestRepository', () => {
it('findPending uses injected repo + mapper (DB-free)', async () => {
const entities = [{ id: 'r1' }, { id: 'r2' }];
const repo = {
find: vi.fn().mockResolvedValue(entities),
findOne: vi.fn(),
save: vi.fn(),
count: vi.fn(),
};
const mapper = {
toDomain: vi.fn().mockImplementation((e: any) => ({ id: `domain-${e.id}` })),
toOrmEntity: vi.fn(),
};
const protestRepo = new TypeOrmProtestRepository(repo as any, mapper as any);
const results = await protestRepo.findPending();
expect(repo.find).toHaveBeenCalledWith({ where: { status: 'pending' } });
expect(mapper.toDomain).toHaveBeenCalledTimes(2);
expect(results).toEqual([{ id: 'domain-r1' }, { id: 'domain-r2' }]);
});
});

View File

@@ -0,0 +1,109 @@
import type { Repository } from 'typeorm';
import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository';
import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository';
import type { Penalty } from '@core/racing/domain/entities/penalty/Penalty';
import type { Protest } from '@core/racing/domain/entities/Protest';
import { PenaltyOrmEntity, ProtestOrmEntity } from '../entities/MissingRacingOrmEntities';
import { PenaltyOrmMapper, ProtestOrmMapper } from '../mappers/StewardingOrmMappers';
export class TypeOrmPenaltyRepository implements IPenaltyRepository {
constructor(
private readonly repo: Repository<PenaltyOrmEntity>,
private readonly mapper: PenaltyOrmMapper,
) {}
async findById(id: string): Promise<Penalty | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByRaceId(raceId: string): Promise<Penalty[]> {
const entities = await this.repo.find({ where: { raceId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByDriverId(driverId: string): Promise<Penalty[]> {
const entities = await this.repo.find({ where: { driverId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByProtestId(protestId: string): Promise<Penalty[]> {
const entities = await this.repo.find({ where: { protestId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findPending(): Promise<Penalty[]> {
const entities = await this.repo.find({ where: { status: 'pending' } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findIssuedBy(stewardId: string): Promise<Penalty[]> {
const entities = await this.repo.find({ where: { issuedBy: stewardId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async create(penalty: Penalty): Promise<void> {
await this.repo.save(this.mapper.toOrmEntity(penalty));
}
async update(penalty: Penalty): Promise<void> {
await this.repo.save(this.mapper.toOrmEntity(penalty));
}
async exists(id: string): Promise<boolean> {
const count = await this.repo.count({ where: { id } });
return count > 0;
}
}
export class TypeOrmProtestRepository implements IProtestRepository {
constructor(
private readonly repo: Repository<ProtestOrmEntity>,
private readonly mapper: ProtestOrmMapper,
) {}
async findById(id: string): Promise<Protest | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByRaceId(raceId: string): Promise<Protest[]> {
const entities = await this.repo.find({ where: { raceId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByProtestingDriverId(driverId: string): Promise<Protest[]> {
const entities = await this.repo.find({ where: { protestingDriverId: driverId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByAccusedDriverId(driverId: string): Promise<Protest[]> {
const entities = await this.repo.find({ where: { accusedDriverId: driverId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findPending(): Promise<Protest[]> {
const entities = await this.repo.find({ where: { status: 'pending' } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findUnderReviewBy(stewardId: string): Promise<Protest[]> {
const entities = await this.repo.find({ where: { reviewedBy: stewardId, status: 'under_review' } });
return entities.map((e) => this.mapper.toDomain(e));
}
async create(protest: Protest): Promise<void> {
await this.repo.save(this.mapper.toOrmEntity(protest));
}
async update(protest: Protest): Promise<void> {
await this.repo.save(this.mapper.toOrmEntity(protest));
}
async exists(id: string): Promise<boolean> {
const count = await this.repo.count({ where: { id } });
return count > 0;
}
}

View File

@@ -0,0 +1,120 @@
import { describe, expect, it, vi } from 'vitest';
import { TypeOrmTeamMembershipRepository, TypeOrmTeamRepository } from './TeamTypeOrmRepositories';
describe('TypeOrmTeamRepository', () => {
it('uses injected repo + mapper (DB-free)', async () => {
const ormEntity = { id: 'team-1' };
const repo = {
findOne: vi.fn().mockResolvedValue(ormEntity),
find: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
createQueryBuilder: vi.fn(),
};
const mapper = {
toDomain: vi.fn().mockReturnValue({ id: 'domain-team' }),
toOrmEntity: vi.fn().mockReturnValue({ id: 'team-1-orm' }),
};
const teamRepo = new TypeOrmTeamRepository(repo as any, mapper as any);
const team = await teamRepo.findById('team-1');
expect(repo.findOne).toHaveBeenCalledWith({ where: { id: 'team-1' } });
expect(mapper.toDomain).toHaveBeenCalledWith(ormEntity);
expect(team).toEqual({ id: 'domain-team' });
});
it('create uses mapper.toOrmEntity + repo.save', async () => {
const repo = {
save: vi.fn().mockResolvedValue(undefined),
};
const mapper = {
toOrmEntity: vi.fn().mockReturnValue({ id: 'team-1-orm' }),
};
const teamRepo = new TypeOrmTeamRepository(repo as any, mapper as any);
const domainTeam = { id: 'team-1' };
await expect(teamRepo.create(domainTeam as any)).resolves.toBe(domainTeam);
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainTeam);
expect(repo.save).toHaveBeenCalledWith({ id: 'team-1-orm' });
});
});
describe('TypeOrmTeamMembershipRepository', () => {
it('getMembership returns null when TypeORM returns null (DB-free)', async () => {
const membershipRepo = {
findOne: vi.fn().mockResolvedValue(null),
find: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
};
const joinRequestRepo = {
find: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
};
const mapper = {
toDomainMembership: vi.fn(),
toOrmMembership: vi.fn(),
toDomainJoinRequest: vi.fn(),
toOrmJoinRequest: vi.fn(),
};
const repo = new TypeOrmTeamMembershipRepository(
membershipRepo as any,
joinRequestRepo as any,
mapper as any,
);
await expect(repo.getMembership('team-1', 'driver-1')).resolves.toBeNull();
expect(membershipRepo.findOne).toHaveBeenCalledWith({ where: { teamId: 'team-1', driverId: 'driver-1' } });
expect(mapper.toDomainMembership).not.toHaveBeenCalled();
});
it('saveMembership uses mapper.toOrmMembership + repo.save', async () => {
const membershipRepo = {
save: vi.fn().mockResolvedValue(undefined),
};
const joinRequestRepo = {
find: vi.fn(),
save: vi.fn(),
delete: vi.fn(),
};
const mapper = {
toOrmMembership: vi.fn().mockReturnValue({ teamId: 'team-1', driverId: 'driver-1' }),
};
const repo = new TypeOrmTeamMembershipRepository(
membershipRepo as any,
joinRequestRepo as any,
mapper as any,
);
const membership = {
teamId: 'team-1',
driverId: 'driver-1',
role: 'driver',
status: 'active',
joinedAt: new Date('2025-01-01T00:00:00.000Z'),
};
await expect(repo.saveMembership(membership as any)).resolves.toBe(membership);
expect(mapper.toOrmMembership).toHaveBeenCalledWith(membership);
expect(membershipRepo.save).toHaveBeenCalledWith({ teamId: 'team-1', driverId: 'driver-1' });
});
});

View File

@@ -0,0 +1,104 @@
import type { Repository } from 'typeorm';
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import type { Team } from '@core/racing/domain/entities/Team';
import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership';
import { TeamJoinRequestOrmEntity, TeamMembershipOrmEntity, TeamOrmEntity } from '../entities/TeamOrmEntities';
import { TeamMembershipOrmMapper, TeamOrmMapper } from '../mappers/TeamOrmMappers';
export class TypeOrmTeamRepository implements ITeamRepository {
constructor(
private readonly repo: Repository<TeamOrmEntity>,
private readonly mapper: TeamOrmMapper,
) {}
async findById(id: string): Promise<Team | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findAll(): Promise<Team[]> {
const entities = await this.repo.find();
return entities.map((e) => this.mapper.toDomain(e));
}
async findByLeagueId(leagueId: string): Promise<Team[]> {
const entities = await this.repo
.createQueryBuilder('team')
.where(':leagueId = ANY(team.leagues)', { leagueId })
.getMany();
return entities.map((e) => this.mapper.toDomain(e));
}
async create(team: Team): Promise<Team> {
await this.repo.save(this.mapper.toOrmEntity(team));
return team;
}
async update(team: Team): Promise<Team> {
await this.repo.save(this.mapper.toOrmEntity(team));
return team;
}
async delete(id: string): Promise<void> {
await this.repo.delete({ id });
}
async exists(id: string): Promise<boolean> {
const count = await this.repo.count({ where: { id } });
return count > 0;
}
}
export class TypeOrmTeamMembershipRepository implements ITeamMembershipRepository {
constructor(
private readonly membershipRepo: Repository<TeamMembershipOrmEntity>,
private readonly joinRequestRepo: Repository<TeamJoinRequestOrmEntity>,
private readonly mapper: TeamMembershipOrmMapper,
) {}
async getMembership(teamId: string, driverId: string): Promise<TeamMembership | null> {
const entity = await this.membershipRepo.findOne({ where: { teamId, driverId } });
return entity ? this.mapper.toDomainMembership(entity) : null;
}
async getActiveMembershipForDriver(driverId: string): Promise<TeamMembership | null> {
const entity = await this.membershipRepo.findOne({ where: { driverId, status: 'active' } });
return entity ? this.mapper.toDomainMembership(entity) : null;
}
async getTeamMembers(teamId: string): Promise<TeamMembership[]> {
const entities = await this.membershipRepo.find({ where: { teamId, status: 'active' } });
return entities.map((e) => this.mapper.toDomainMembership(e));
}
async saveMembership(membership: TeamMembership): Promise<TeamMembership> {
await this.membershipRepo.save(this.mapper.toOrmMembership(membership));
return membership;
}
async removeMembership(teamId: string, driverId: string): Promise<void> {
await this.membershipRepo.delete({ teamId, driverId });
}
async countByTeamId(teamId: string): Promise<number> {
return this.membershipRepo.count({ where: { teamId, status: 'active' } });
}
async getJoinRequests(teamId: string): Promise<TeamJoinRequest[]> {
const entities = await this.joinRequestRepo.find({ where: { teamId } });
return entities.map((e) => this.mapper.toDomainJoinRequest(e));
}
async saveJoinRequest(request: TeamJoinRequest): Promise<TeamJoinRequest> {
await this.joinRequestRepo.save(this.mapper.toOrmJoinRequest(request));
return request;
}
async removeJoinRequest(requestId: string): Promise<void> {
await this.joinRequestRepo.delete({ id: requestId });
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmDriverRepository } from './TypeOrmDriverRepository';
import { DriverOrmMapper } from '../mappers/DriverOrmMapper';
describe('TypeOrmDriverRepository', () => {
it('constructor requires injected mapper (no internal mapper instantiation)', () => {
const dataSource = {} as unknown as DataSource;
const mapper = {} as unknown as DriverOrmMapper;
const repo = new TypeOrmDriverRepository(dataSource, mapper);
expect(repo).toBeInstanceOf(TypeOrmDriverRepository);
expect((repo as unknown as { mapper: unknown }).mapper).toBe(mapper);
});
it('works with mocked TypeORM DataSource (no DB required)', async () => {
const findOne = async () => null;
const dataSource = {
getRepository: () => ({ findOne }),
} as unknown as DataSource;
const mapper = {
toDomain: () => {
throw new Error('should-not-be-called');
},
} as unknown as DriverOrmMapper;
const repo = new TypeOrmDriverRepository(dataSource, mapper);
await expect(repo.findById('driver-1')).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,61 @@
import type { DataSource } from 'typeorm';
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import type { Driver } from '@core/racing/domain/entities/Driver';
import { DriverOrmEntity } from '../entities/DriverOrmEntity';
import { DriverOrmMapper } from '../mappers/DriverOrmMapper';
export class TypeOrmDriverRepository implements IDriverRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: DriverOrmMapper,
) {}
async findById(id: string): Promise<Driver | null> {
const repo = this.dataSource.getRepository(DriverOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByIRacingId(iracingId: string): Promise<Driver | null> {
const repo = this.dataSource.getRepository(DriverOrmEntity);
const entity = await repo.findOne({ where: { iracingId } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findAll(): Promise<Driver[]> {
const repo = this.dataSource.getRepository(DriverOrmEntity);
const entities = await repo.find();
return entities.map((e) => this.mapper.toDomain(e));
}
async create(driver: Driver): Promise<Driver> {
const repo = this.dataSource.getRepository(DriverOrmEntity);
await repo.save(this.mapper.toOrmEntity(driver));
return driver;
}
async update(driver: Driver): Promise<Driver> {
const repo = this.dataSource.getRepository(DriverOrmEntity);
await repo.save(this.mapper.toOrmEntity(driver));
return driver;
}
async delete(id: string): Promise<void> {
const repo = this.dataSource.getRepository(DriverOrmEntity);
await repo.delete({ id });
}
async exists(id: string): Promise<boolean> {
const repo = this.dataSource.getRepository(DriverOrmEntity);
const count = await repo.count({ where: { id } });
return count > 0;
}
async existsByIRacingId(iracingId: string): Promise<boolean> {
const repo = this.dataSource.getRepository(DriverOrmEntity);
const count = await repo.count({ where: { iracingId } });
return count > 0;
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmLeagueMembershipRepository } from './TypeOrmLeagueMembershipRepository';
import { LeagueMembershipOrmMapper } from '../mappers/LeagueMembershipOrmMapper';
describe('TypeOrmLeagueMembershipRepository', () => {
it('constructor requires injected mapper (no internal mapper instantiation)', () => {
const dataSource = {} as unknown as DataSource;
const mapper = {} as unknown as LeagueMembershipOrmMapper;
const repo = new TypeOrmLeagueMembershipRepository(dataSource, mapper);
expect(repo).toBeInstanceOf(TypeOrmLeagueMembershipRepository);
expect((repo as unknown as { mapper: unknown }).mapper).toBe(mapper);
});
it('works with mocked TypeORM DataSource (no DB required)', async () => {
const getMembershipFindOne = async () => null;
const dataSource = {
getRepository: () => ({ findOne: getMembershipFindOne }),
} as unknown as DataSource;
const mapper = {
toDomain: () => {
throw new Error('should-not-be-called');
},
} as unknown as LeagueMembershipOrmMapper;
const repo = new TypeOrmLeagueMembershipRepository(dataSource, mapper);
await expect(repo.getMembership('league-1', 'driver-1')).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,76 @@
import type { DataSource } from 'typeorm';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { JoinRequest } from '@core/racing/domain/entities/JoinRequest';
import { LeagueMembershipOrmEntity } from '../entities/LeagueMembershipOrmEntity';
import { LeagueMembershipOrmMapper } from '../mappers/LeagueMembershipOrmMapper';
export class TypeOrmLeagueMembershipRepository implements ILeagueMembershipRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: LeagueMembershipOrmMapper,
) {}
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity);
const id = `${leagueId}:${driverId}`;
const entity = await repo.findOne({ where: { id } });
if (!entity) return null;
if (entity.status !== 'active' && entity.status !== 'inactive') return null;
return this.mapper.toDomain(entity);
}
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity);
const entities = await repo.find({ where: { leagueId, status: 'active' } });
return entities.map((e) => this.mapper.toDomain(e));
}
async getJoinRequests(leagueId: string): Promise<JoinRequest[]> {
const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity);
const entities = await repo.find({ where: { leagueId, status: 'pending' } });
return entities.map((e) =>
JoinRequest.rehydrate({
id: e.id,
leagueId: e.leagueId,
driverId: e.driverId,
requestedAt: e.joinedAt,
}),
);
}
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity);
await repo.save(this.mapper.toOrmEntity(membership));
return membership;
}
async removeMembership(leagueId: string, driverId: string): Promise<void> {
const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity);
const id = `${leagueId}:${driverId}`;
await repo.delete({ id });
}
async saveJoinRequest(request: JoinRequest): Promise<JoinRequest> {
const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity);
const entity = new LeagueMembershipOrmEntity();
entity.id = request.id;
entity.leagueId = request.leagueId.toString();
entity.driverId = request.driverId.toString();
entity.role = 'member';
entity.status = 'pending';
entity.joinedAt = request.requestedAt.toDate();
await repo.save(entity);
return request;
}
async removeJoinRequest(requestId: string): Promise<void> {
const repo = this.dataSource.getRepository(LeagueMembershipOrmEntity);
await repo.delete({ id: requestId });
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmRaceRegistrationRepository } from './TypeOrmRaceRegistrationRepository';
import { RaceRegistrationOrmMapper } from '../mappers/RaceRegistrationOrmMapper';
describe('TypeOrmRaceRegistrationRepository', () => {
it('constructor requires injected mapper (no internal mapper instantiation)', () => {
const dataSource = {} as unknown as DataSource;
const mapper = {} as unknown as RaceRegistrationOrmMapper;
const repo = new TypeOrmRaceRegistrationRepository(dataSource, mapper);
expect(repo).toBeInstanceOf(TypeOrmRaceRegistrationRepository);
expect((repo as unknown as { mapper: unknown }).mapper).toBe(mapper);
});
it('works with mocked TypeORM DataSource (no DB required)', async () => {
const count = async () => 0;
const dataSource = {
getRepository: () => ({ count }),
} as unknown as DataSource;
const mapper = {
toDomain: () => {
throw new Error('should-not-be-called');
},
} as unknown as RaceRegistrationOrmMapper;
const repo = new TypeOrmRaceRegistrationRepository(dataSource, mapper);
await expect(repo.getRegistrationCount('race-1')).resolves.toBe(0);
});
});

View File

@@ -0,0 +1,58 @@
import type { DataSource } from 'typeorm';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import { RaceRegistrationOrmEntity } from '../entities/RaceRegistrationOrmEntity';
import { RaceRegistrationOrmMapper } from '../mappers/RaceRegistrationOrmMapper';
export class TypeOrmRaceRegistrationRepository implements IRaceRegistrationRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: RaceRegistrationOrmMapper,
) {}
async isRegistered(raceId: string, driverId: string): Promise<boolean> {
const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity);
const count = await repo.count({ where: { raceId, driverId } });
return count > 0;
}
async getRegisteredDrivers(raceId: string): Promise<string[]> {
const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity);
const entities = await repo.find({ where: { raceId } });
return entities.map((e) => e.driverId);
}
async findByRaceId(raceId: string): Promise<RaceRegistration[]> {
const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity);
const entities = await repo.find({ where: { raceId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async getRegistrationCount(raceId: string): Promise<number> {
const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity);
return repo.count({ where: { raceId } });
}
async register(registration: RaceRegistration): Promise<void> {
const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity);
await repo.save(this.mapper.toOrmEntity(registration));
}
async withdraw(raceId: string, driverId: string): Promise<void> {
const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity);
await repo.delete({ raceId, driverId });
}
async getDriverRegistrations(driverId: string): Promise<string[]> {
const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity);
const entities = await repo.find({ where: { driverId } });
return entities.map((e) => e.raceId);
}
async clearRaceRegistrations(raceId: string): Promise<void> {
const repo = this.dataSource.getRepository(RaceRegistrationOrmEntity);
await repo.delete({ raceId });
}
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmResultRepository } from './TypeOrmResultRepository';
import { ResultOrmMapper } from '../mappers/ResultOrmMapper';
describe('TypeOrmResultRepository', () => {
it('requires an injected mapper (does not construct one internally)', () => {
const dataSource = {} as DataSource;
const mapper = new ResultOrmMapper();
expect(() => new TypeOrmResultRepository(dataSource, mapper)).not.toThrow();
});
it('uses TypeORM DataSource.getRepository (DB-free mocked repository)', async () => {
const mapper = new ResultOrmMapper();
const repoMock = {
findOne: vi.fn(async () => null),
};
const dataSource = {
getRepository: vi.fn(() => repoMock),
} as unknown as DataSource;
const repo = new TypeOrmResultRepository(dataSource, mapper);
await repo.findById('result-1');
expect(dataSource.getRepository).toHaveBeenCalled();
expect(repoMock.findOne).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,94 @@
import type { DataSource } from 'typeorm';
import { In } from 'typeorm';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { Result } from '@core/racing/domain/entities/result/Result';
import { RaceOrmEntity } from '../entities/RaceOrmEntity';
import { ResultOrmEntity } from '../entities/ResultOrmEntity';
import { ResultOrmMapper } from '../mappers/ResultOrmMapper';
export class TypeOrmResultRepository implements IResultRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: ResultOrmMapper,
) {}
async findById(id: string): Promise<Result | null> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findAll(): Promise<Result[]> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
const entities = await repo.find();
return entities.map((e) => this.mapper.toDomain(e));
}
async findByRaceId(raceId: string): Promise<Result[]> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
const entities = await repo.find({ where: { raceId }, order: { position: 'ASC' } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByDriverId(driverId: string): Promise<Result[]> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
const entities = await repo.find({ where: { driverId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Result[]> {
const raceRepo = this.dataSource.getRepository(RaceOrmEntity);
const races = await raceRepo.find({ where: { leagueId }, select: { id: true } });
const raceIds = races.map((r) => r.id);
if (raceIds.length === 0) {
return [];
}
const resultRepo = this.dataSource.getRepository(ResultOrmEntity);
const entities = await resultRepo.find({ where: { driverId, raceId: In(raceIds) } });
return entities.map((e) => this.mapper.toDomain(e));
}
async create(result: Result): Promise<Result> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
await repo.save(this.mapper.toOrmEntity(result));
return result;
}
async createMany(results: Result[]): Promise<Result[]> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
await repo.save(results.map((r) => this.mapper.toOrmEntity(r)));
return results;
}
async update(result: Result): Promise<Result> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
await repo.save(this.mapper.toOrmEntity(result));
return result;
}
async delete(id: string): Promise<void> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
await repo.delete({ id });
}
async deleteByRaceId(raceId: string): Promise<void> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
await repo.delete({ raceId });
}
async exists(id: string): Promise<boolean> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
const count = await repo.count({ where: { id } });
return count > 0;
}
async existsByRaceId(raceId: string): Promise<boolean> {
const repo = this.dataSource.getRepository(ResultOrmEntity);
const count = await repo.count({ where: { raceId } });
return count > 0;
}
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmStandingRepository } from './TypeOrmStandingRepository';
import { LeagueOrmMapper } from '../mappers/LeagueOrmMapper';
import { ResultOrmMapper } from '../mappers/ResultOrmMapper';
import { StandingOrmMapper } from '../mappers/StandingOrmMapper';
describe('TypeOrmStandingRepository', () => {
it('requires injected mappers (does not construct any internally)', () => {
const dataSource = {} as DataSource;
const standingMapper = new StandingOrmMapper();
const resultMapper = new ResultOrmMapper();
const leagueMapper = new LeagueOrmMapper();
const pointsSystems: Record<string, Record<number, number>> = {};
expect(() => new TypeOrmStandingRepository(dataSource, standingMapper, resultMapper, leagueMapper, pointsSystems)).not.toThrow();
});
it('uses TypeORM DataSource.getRepository (DB-free mocked repository)', async () => {
const standingMapper = new StandingOrmMapper();
const resultMapper = new ResultOrmMapper();
const leagueMapper = new LeagueOrmMapper();
const pointsSystems: Record<string, Record<number, number>> = {};
const standingsRepoMock = {
find: vi.fn(async () => []),
};
const resultsRepoMock = {
find: vi.fn(async () => []),
};
const dataSource = {
getRepository: vi.fn((entity: unknown) => {
// Different repos per entity class; this mimics TypeORM behavior enough for constructor wiring tests.
if (typeof entity === 'function' && (entity as { name?: string }).name === 'StandingOrmEntity') {
return standingsRepoMock;
}
return resultsRepoMock;
}),
} as unknown as DataSource;
const repo = new TypeOrmStandingRepository(dataSource, standingMapper, resultMapper, leagueMapper, pointsSystems);
await repo.findByLeagueId('league-1');
expect(dataSource.getRepository).toHaveBeenCalled();
expect(standingsRepoMock.find).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,134 @@
import type { DataSource } from 'typeorm';
import { In } from 'typeorm';
import { Standing } from '@core/racing/domain/entities/Standing';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
import { RaceOrmEntity } from '../entities/RaceOrmEntity';
import { ResultOrmEntity } from '../entities/ResultOrmEntity';
import { StandingOrmEntity } from '../entities/StandingOrmEntity';
import { LeagueOrmMapper } from '../mappers/LeagueOrmMapper';
import { ResultOrmMapper } from '../mappers/ResultOrmMapper';
import { StandingOrmMapper } from '../mappers/StandingOrmMapper';
export class TypeOrmStandingRepository implements IStandingRepository {
constructor(
private readonly dataSource: DataSource,
private readonly standingMapper: StandingOrmMapper,
private readonly resultMapper: ResultOrmMapper,
private readonly leagueMapper: LeagueOrmMapper,
private readonly pointsSystems: Record<string, Record<number, number>>,
) {}
async findByLeagueId(leagueId: string): Promise<Standing[]> {
const repo = this.dataSource.getRepository(StandingOrmEntity);
const entities = await repo.find({ where: { leagueId }, order: { position: 'ASC' } });
return entities.map((e) => this.standingMapper.toDomain(e));
}
async findByDriverIdAndLeagueId(driverId: string, leagueId: string): Promise<Standing | null> {
const repo = this.dataSource.getRepository(StandingOrmEntity);
const entity = await repo.findOne({ where: { leagueId, driverId } });
return entity ? this.standingMapper.toDomain(entity) : null;
}
async findAll(): Promise<Standing[]> {
const repo = this.dataSource.getRepository(StandingOrmEntity);
const entities = await repo.find();
return entities.map((e) => this.standingMapper.toDomain(e));
}
async save(standing: Standing): Promise<Standing> {
const repo = this.dataSource.getRepository(StandingOrmEntity);
await repo.save(this.standingMapper.toOrmEntity(standing));
return standing;
}
async saveMany(standings: Standing[]): Promise<Standing[]> {
const repo = this.dataSource.getRepository(StandingOrmEntity);
await repo.save(standings.map((s) => this.standingMapper.toOrmEntity(s)));
return standings;
}
async delete(leagueId: string, driverId: string): Promise<void> {
const repo = this.dataSource.getRepository(StandingOrmEntity);
await repo.delete({ leagueId, driverId });
}
async deleteByLeagueId(leagueId: string): Promise<void> {
const repo = this.dataSource.getRepository(StandingOrmEntity);
await repo.delete({ leagueId });
}
async exists(leagueId: string, driverId: string): Promise<boolean> {
const repo = this.dataSource.getRepository(StandingOrmEntity);
const count = await repo.count({ where: { leagueId, driverId } });
return count > 0;
}
async recalculate(leagueId: string): Promise<Standing[]> {
const leagueRepo = this.dataSource.getRepository(LeagueOrmEntity);
const leagueEntity = await leagueRepo.findOne({ where: { id: leagueId } });
if (!leagueEntity) {
throw new Error(`League with ID ${leagueId} not found`);
}
const league = this.leagueMapper.toDomain(leagueEntity);
const resolvedPointsSystem =
league.settings.customPoints ??
this.pointsSystems[league.settings.pointsSystem] ??
this.pointsSystems['f1-2024'];
if (!resolvedPointsSystem) {
throw new Error('No points system configured for league');
}
const raceRepo = this.dataSource.getRepository(RaceOrmEntity);
const races = await raceRepo.find({ where: { leagueId, status: 'completed' }, select: { id: true } });
const raceIds = races.map((r) => r.id);
if (raceIds.length === 0) {
await this.deleteByLeagueId(leagueId);
return [];
}
const resultRepo = this.dataSource.getRepository(ResultOrmEntity);
const resultEntities = await resultRepo.find({
where: { raceId: In(raceIds) },
order: { position: 'ASC' },
});
const standingsByDriver = new Map<string, Standing>();
for (const entity of resultEntities) {
const result = this.resultMapper.toDomain(entity);
const driverId = result.driverId.toString();
const current =
standingsByDriver.get(driverId) ??
Standing.create({
leagueId,
driverId,
});
const next = current.addRaceResult(result.position.toNumber(), resolvedPointsSystem);
standingsByDriver.set(driverId, next);
}
const sorted = Array.from(standingsByDriver.values()).sort((a, b) => {
if (b.points.toNumber() !== a.points.toNumber()) return b.points.toNumber() - a.points.toNumber();
if (b.wins !== a.wins) return b.wins - a.wins;
return b.racesCompleted - a.racesCompleted;
});
const updated = sorted.map((standing, index) => standing.updatePosition(index + 1));
await this.deleteByLeagueId(leagueId);
await this.saveMany(updated);
return updated;
}
}

View File

@@ -0,0 +1,204 @@
import { describe, expect, it } from 'vitest';
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
import {
assertArray,
assertBoolean,
assertDate,
assertEnumValue,
assertInteger,
assertIsoDate,
assertNonEmptyString,
assertNumber,
assertOptionalBoolean,
assertOptionalInteger,
assertOptionalNumber,
assertOptionalStringOrNull,
assertRecord,
} from './TypeOrmSchemaGuards';
describe('TypeOrmSchemaGuards', () => {
it('assertNonEmptyString accepts non-empty strings', () => {
expect(() => assertNonEmptyString('Driver', 'name', ' Max ')).not.toThrow();
});
it('assertNonEmptyString throws TypeOrmPersistenceSchemaError with metadata', () => {
try {
assertNonEmptyString('Driver', 'name', '');
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
name: 'TypeOrmPersistenceSchemaError',
entityName: 'Driver',
fieldName: 'name',
reason: 'empty_string',
});
}
});
it('assertIsoDate accepts ISO string with exact toISOString match', () => {
const value = '2025-01-01T00:00:00.000Z';
expect(() => assertIsoDate('SeasonSchedule', 'startDate', value)).not.toThrow();
});
it('assertIsoDate rejects non-ISO / non-normalized values', () => {
try {
assertIsoDate('SeasonSchedule', 'startDate', '2025-01-01');
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'SeasonSchedule',
fieldName: 'startDate',
reason: 'not_iso_date',
});
}
});
it('assertDate accepts valid Date', () => {
expect(() => assertDate('Driver', 'joinedAt', new Date())).not.toThrow();
});
it('assertDate rejects invalid Date objects', () => {
try {
assertDate('Driver', 'joinedAt', new Date('bad-date'));
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Driver',
fieldName: 'joinedAt',
reason: 'invalid_date',
});
}
});
it('assertEnumValue accepts allowed enum strings', () => {
expect(() => assertEnumValue('Race', 'status', 'scheduled', ['scheduled', 'running'] as const)).not.toThrow();
});
it('assertEnumValue rejects disallowed enum strings', () => {
try {
assertEnumValue('Race', 'status', 'completed', ['scheduled', 'running'] as const);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Race',
fieldName: 'status',
reason: 'invalid_enum_value',
});
}
});
it('assertArray accepts arrays and rejects non-arrays', () => {
expect(() => assertArray('LeagueScoringConfig', 'championships', [])).not.toThrow();
try {
assertArray('LeagueScoringConfig', 'championships', {});
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'LeagueScoringConfig',
fieldName: 'championships',
reason: 'not_array',
});
}
});
it('assertRecord accepts plain objects and rejects arrays/null', () => {
expect(() => assertRecord('League', 'settings', { a: 1 })).not.toThrow();
try {
assertRecord('League', 'settings', null);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'League',
fieldName: 'settings',
reason: 'not_object',
});
}
});
it('assertNumber/assertInteger/assertBoolean accept correct primitives and reject others', () => {
expect(() => assertNumber('League', 'settings.maxDrivers', 12.5)).not.toThrow();
expect(() => assertInteger('League', 'settings.maxDrivers', 12)).not.toThrow();
expect(() => assertBoolean('League', 'settings.requireDefense', true)).not.toThrow();
try {
assertNumber('League', 'settings.maxDrivers', '12');
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'League',
fieldName: 'settings.maxDrivers',
reason: 'not_number',
});
}
try {
assertInteger('League', 'settings.maxDrivers', 12.5);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'League',
fieldName: 'settings.maxDrivers',
reason: 'not_integer',
});
}
try {
assertBoolean('League', 'settings.requireDefense', 1);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'League',
fieldName: 'settings.requireDefense',
reason: 'not_boolean',
});
}
});
it('assertOptionalNumber/assertOptionalInteger/assertOptionalBoolean accept null/undefined and validate values when present', () => {
expect(() => assertOptionalNumber('League', 'settings.sessionDuration', null)).not.toThrow();
expect(() => assertOptionalInteger('League', 'settings.maxDrivers', undefined)).not.toThrow();
expect(() => assertOptionalBoolean('League', 'settings.requireDefense', null)).not.toThrow();
try {
assertOptionalInteger('League', 'settings.maxDrivers', 12.5);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'League',
fieldName: 'settings.maxDrivers',
reason: 'not_integer',
});
}
});
it('assertOptionalStringOrNull accepts undefined/null/string and rejects others', () => {
expect(() => assertOptionalStringOrNull('Driver', 'bio', undefined)).not.toThrow();
expect(() => assertOptionalStringOrNull('Driver', 'bio', null)).not.toThrow();
expect(() => assertOptionalStringOrNull('Driver', 'bio', 'Bio')).not.toThrow();
try {
assertOptionalStringOrNull('Driver', 'bio', 123);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaError);
expect(error).toMatchObject({
entityName: 'Driver',
fieldName: 'bio',
reason: 'not_string',
});
}
});
});

View File

@@ -0,0 +1,130 @@
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (value.trim().length === 0) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'empty_string' });
}
}
export function assertIsoDate(entityName: string, fieldName: string, value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' });
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_iso_date' });
}
if (parsed.toISOString() !== value) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_iso_date' });
}
}
export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date {
if (!(value instanceof Date)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_date' });
}
if (Number.isNaN(value.getTime())) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'invalid_date' });
}
}
export function assertEnumValue<TAllowed extends string>(
entityName: string,
fieldName: string,
value: unknown,
allowed: readonly TAllowed[],
): asserts value is TAllowed {
if (typeof value !== 'string') {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (!allowed.includes(value as TAllowed)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
}
}
export function assertArray(entityName: string, fieldName: string, value: unknown): asserts value is unknown[] {
if (!Array.isArray(value)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_array' });
}
}
export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_number' });
}
}
export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_integer' });
}
}
export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean {
if (typeof value !== 'boolean') {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_boolean' });
}
}
export function assertOptionalNumber(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | null | undefined {
if (value === null || value === undefined) {
return;
}
assertNumber(entityName, fieldName, value);
}
export function assertOptionalInteger(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | null | undefined {
if (value === null || value === undefined) {
return;
}
assertInteger(entityName, fieldName, value);
}
export function assertOptionalBoolean(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is boolean | null | undefined {
if (value === null || value === undefined) {
return;
}
assertBoolean(entityName, fieldName, value);
}
export function assertRecord(entityName: string, fieldName: string, value: unknown): asserts value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_object' });
}
}
export function assertOptionalStringOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is string | null | undefined {
if (value === null || value === undefined) {
return;
}
if (typeof value !== 'string') {
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName, reason: 'not_string' });
}
}