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,33 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import type { SnapshotEntityType, SnapshotPeriod } from '@core/analytics/domain/entities/AnalyticsSnapshot';
import type { AnalyticsMetrics } from '@core/analytics/domain/types/AnalyticsSnapshot';
@Index('IDX_analytics_snapshots_entity', ['entityType', 'entityId', 'period'])
@Index('IDX_analytics_snapshots_date_range', ['startDate', 'endDate'])
@Entity({ name: 'analytics_snapshots' })
export class AnalyticsSnapshotOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
entityType!: SnapshotEntityType;
@Column({ type: 'text' })
entityId!: string;
@Column({ type: 'text' })
period!: SnapshotPeriod;
@Column({ type: 'timestamptz' })
startDate!: Date;
@Column({ type: 'timestamptz' })
endDate!: Date;
@Column({ type: 'jsonb' })
metrics!: AnalyticsMetrics;
@Column({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@@ -0,0 +1,36 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import type { EngagementAction, EngagementEntityType } from '@core/analytics/domain/entities/EngagementEvent';
@Index('IDX_analytics_engagement_events_entity', ['entityType', 'entityId'])
@Index('IDX_analytics_engagement_events_action', ['action'])
@Index('IDX_analytics_engagement_events_timestamp', ['timestamp'])
@Entity({ name: 'analytics_engagement_events' })
export class EngagementEventOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
action!: EngagementAction;
@Column({ type: 'text' })
entityType!: EngagementEntityType;
@Column({ type: 'text' })
entityId!: string;
@Column({ type: 'text', nullable: true })
actorId!: string | null;
@Column({ type: 'text' })
actorType!: 'anonymous' | 'driver' | 'sponsor';
@Column({ type: 'text' })
sessionId!: string;
@Column({ type: 'jsonb', nullable: true })
metadata!: Record<string, string | number | boolean> | null;
@Column({ type: 'timestamptz' })
timestamp!: Date;
}

View File

@@ -0,0 +1,42 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import type { EntityType, VisitorType } from '@core/analytics/domain/entities/PageView';
@Index('IDX_analytics_page_views_entity', ['entityType', 'entityId'])
@Index('IDX_analytics_page_views_session', ['sessionId'])
@Index('IDX_analytics_page_views_timestamp', ['timestamp'])
@Entity({ name: 'analytics_page_views' })
export class PageViewOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
entityType!: EntityType;
@Column({ type: 'text' })
entityId!: string;
@Column({ type: 'text', nullable: true })
visitorId!: string | null;
@Column({ type: 'text' })
visitorType!: VisitorType;
@Column({ type: 'text' })
sessionId!: string;
@Column({ type: 'text', nullable: true })
referrer!: string | null;
@Column({ type: 'text', nullable: true })
userAgent!: string | null;
@Column({ type: 'text', nullable: true })
country!: string | null;
@Column({ type: 'timestamptz' })
timestamp!: Date;
@Column({ type: 'int', nullable: true })
durationMs!: number | null;
}

View File

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

View File

@@ -0,0 +1,78 @@
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
import type { AnalyticsMetrics, SnapshotEntityType, SnapshotPeriod } from '@core/analytics/domain/types/AnalyticsSnapshot';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
import { assertDate, assertEnumValue, assertNonEmptyString, assertNumber, assertRecord } from '../schema/TypeOrmAnalyticsSchemaGuards';
const VALID_ENTITY_TYPES: readonly SnapshotEntityType[] = ['league', 'driver', 'team', 'race', 'sponsor'] as const;
const VALID_PERIODS: readonly SnapshotPeriod[] = ['daily', 'weekly', 'monthly'] as const;
function assertMetrics(entityName: string, fieldName: string, value: unknown): asserts value is AnalyticsMetrics {
assertRecord(entityName, fieldName, value);
const metrics = value as Record<string, unknown>;
assertNumber(entityName, `${fieldName}.pageViews`, metrics.pageViews);
assertNumber(entityName, `${fieldName}.uniqueVisitors`, metrics.uniqueVisitors);
assertNumber(entityName, `${fieldName}.avgSessionDuration`, metrics.avgSessionDuration);
assertNumber(entityName, `${fieldName}.bounceRate`, metrics.bounceRate);
assertNumber(entityName, `${fieldName}.engagementScore`, metrics.engagementScore);
assertNumber(entityName, `${fieldName}.sponsorClicks`, metrics.sponsorClicks);
assertNumber(entityName, `${fieldName}.sponsorUrlClicks`, metrics.sponsorUrlClicks);
assertNumber(entityName, `${fieldName}.socialShares`, metrics.socialShares);
assertNumber(entityName, `${fieldName}.leagueJoins`, metrics.leagueJoins);
assertNumber(entityName, `${fieldName}.raceRegistrations`, metrics.raceRegistrations);
assertNumber(entityName, `${fieldName}.exposureValue`, metrics.exposureValue);
}
export class AnalyticsSnapshotOrmMapper {
toOrmEntity(domain: AnalyticsSnapshot): AnalyticsSnapshotOrmEntity {
const entity = new AnalyticsSnapshotOrmEntity();
entity.id = domain.id;
entity.entityType = domain.entityType;
entity.entityId = domain.entityId;
entity.period = domain.period;
entity.startDate = domain.startDate;
entity.endDate = domain.endDate;
entity.metrics = domain.metrics;
entity.createdAt = domain.createdAt;
return entity;
}
toDomain(entity: AnalyticsSnapshotOrmEntity): AnalyticsSnapshot {
const entityName = 'AnalyticsSnapshot';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertEnumValue<SnapshotEntityType>(entityName, 'entityType', entity.entityType, VALID_ENTITY_TYPES);
assertNonEmptyString(entityName, 'entityId', entity.entityId);
assertEnumValue<SnapshotPeriod>(entityName, 'period', entity.period, VALID_PERIODS);
assertDate(entityName, 'startDate', entity.startDate);
assertDate(entityName, 'endDate', entity.endDate);
assertMetrics(entityName, 'metrics', entity.metrics);
assertDate(entityName, 'createdAt', entity.createdAt);
return AnalyticsSnapshot.create({
id: entity.id,
entityType: entity.entityType,
entityId: entity.entityId,
period: entity.period,
startDate: entity.startDate,
endDate: entity.endDate,
metrics: entity.metrics,
createdAt: entity.createdAt,
});
} catch (error) {
if (error instanceof TypeOrmAnalyticsSchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted AnalyticsSnapshot';
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
}

View File

@@ -0,0 +1,115 @@
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
import type { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
import {
assertDate,
assertEnumValue,
assertNonEmptyString,
assertOptionalStringOrNull,
assertRecord,
} from '../schema/TypeOrmAnalyticsSchemaGuards';
const VALID_ACTIONS: readonly EngagementAction[] = [
'click_sponsor_logo',
'click_sponsor_url',
'download_livery_pack',
'join_league',
'register_race',
'view_standings',
'view_schedule',
'share_social',
'contact_sponsor',
] as const;
const VALID_ENTITY_TYPES: readonly EngagementEntityType[] = [
'league',
'driver',
'team',
'race',
'sponsor',
'sponsorship',
] as const;
const VALID_ACTOR_TYPES: readonly EngagementEvent['actorType'][] = ['anonymous', 'driver', 'sponsor'] as const;
function assertMetadataShape(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is Record<string, string | number | boolean> | null | undefined {
if (value === undefined || value === null) return;
assertRecord(entityName, fieldName, value);
for (const [k, v] of Object.entries(value)) {
const allowed =
typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === undefined || v === null;
if (!allowed) {
throw new TypeOrmAnalyticsSchemaError({
entityName,
fieldName,
reason: 'invalid_shape',
message: `Invalid metadata value for key "${k}"`,
});
}
}
}
export class EngagementEventOrmMapper {
toOrmEntity(domain: EngagementEvent): EngagementEventOrmEntity {
const entity = new EngagementEventOrmEntity();
entity.id = domain.id;
entity.action = domain.action;
entity.entityType = domain.entityType;
entity.entityId = domain.entityId;
entity.actorId = domain.actorId ?? null;
entity.actorType = domain.actorType;
entity.sessionId = domain.sessionId;
entity.metadata = domain.metadata ?? null;
entity.timestamp = domain.timestamp;
return entity;
}
toDomain(entity: EngagementEventOrmEntity): EngagementEvent {
const entityName = 'AnalyticsEngagementEvent';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertEnumValue<EngagementAction>(entityName, 'action', entity.action, VALID_ACTIONS);
assertEnumValue<EngagementEntityType>(entityName, 'entityType', entity.entityType, VALID_ENTITY_TYPES);
assertNonEmptyString(entityName, 'entityId', entity.entityId);
assertOptionalStringOrNull(entityName, 'actorId', entity.actorId);
assertEnumValue<EngagementEvent['actorType']>(entityName, 'actorType', entity.actorType, VALID_ACTOR_TYPES);
assertNonEmptyString(entityName, 'sessionId', entity.sessionId);
assertMetadataShape(entityName, 'metadata', entity.metadata);
assertDate(entityName, 'timestamp', entity.timestamp);
return EngagementEvent.create({
id: entity.id,
action: entity.action,
entityType: entity.entityType,
entityId: entity.entityId,
actorType: entity.actorType,
sessionId: entity.sessionId,
timestamp: entity.timestamp,
...(entity.actorId !== null && entity.actorId !== undefined ? { actorId: entity.actorId } : {}),
...(entity.metadata !== null && entity.metadata !== undefined ? { metadata: entity.metadata } : {}),
});
} catch (error) {
if (error instanceof TypeOrmAnalyticsSchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted AnalyticsEngagementEvent';
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { PageView } from '@core/analytics/domain/entities/PageView';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { PageViewOrmEntity } from '../entities/PageViewOrmEntity';
import { PageViewOrmMapper } from './PageViewOrmMapper';
describe('PageViewOrmMapper', () => {
it('maps domain -> orm -> domain (round-trip)', () => {
const mapper = new PageViewOrmMapper();
const domain = PageView.create({
id: 'pv_1',
entityType: 'league',
entityId: 'league-1',
visitorType: 'anonymous',
sessionId: 'sess-1',
timestamp: new Date('2025-01-01T00:00:00.000Z'),
visitorId: 'visitor-1',
referrer: 'https://example.com',
userAgent: 'ua',
country: 'DE',
durationMs: 1234,
});
const orm = mapper.toOrmEntity(domain);
expect(orm).toBeInstanceOf(PageViewOrmEntity);
expect(orm.id).toBe(domain.id);
const rehydrated = mapper.toDomain(orm);
expect(rehydrated.id).toBe(domain.id);
expect(rehydrated.entityType).toBe(domain.entityType);
expect(rehydrated.entityId).toBe(domain.entityId);
expect(rehydrated.sessionId).toBe(domain.sessionId);
expect(rehydrated.visitorType).toBe(domain.visitorType);
expect(rehydrated.visitorId).toBe(domain.visitorId);
expect(rehydrated.referrer).toBe(domain.referrer);
expect(rehydrated.userAgent).toBe(domain.userAgent);
expect(rehydrated.country).toBe(domain.country);
expect(rehydrated.durationMs).toBe(domain.durationMs);
expect(rehydrated.timestamp.toISOString()).toBe(domain.timestamp.toISOString());
});
it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => {
const mapper = new PageViewOrmMapper();
const orm = new PageViewOrmEntity();
orm.id = '';
orm.entityType = 'league' as never;
orm.entityId = 'league-1';
orm.visitorId = null;
orm.visitorType = 'anonymous' as never;
orm.sessionId = 'sess-1';
orm.referrer = null;
orm.userAgent = null;
orm.country = null;
orm.timestamp = new Date('2025-01-01T00:00:00.000Z');
orm.durationMs = null;
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
});
});

View File

@@ -0,0 +1,80 @@
import { PageView } from '@core/analytics/domain/entities/PageView';
import type { EntityType, VisitorType } from '@core/analytics/domain/types/PageView';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { PageViewOrmEntity } from '../entities/PageViewOrmEntity';
import {
assertDate,
assertEnumValue,
assertNonEmptyString,
assertOptionalIntegerOrNull,
assertOptionalStringOrNull,
} from '../schema/TypeOrmAnalyticsSchemaGuards';
const VALID_ENTITY_TYPES: readonly EntityType[] = ['league', 'driver', 'team', 'race', 'sponsor'] as const;
const VALID_VISITOR_TYPES: readonly VisitorType[] = ['anonymous', 'driver', 'sponsor'] as const;
export class PageViewOrmMapper {
toOrmEntity(domain: PageView): PageViewOrmEntity {
const entity = new PageViewOrmEntity();
entity.id = domain.id;
entity.entityType = domain.entityType;
entity.entityId = domain.entityId;
entity.visitorId = domain.visitorId ?? null;
entity.visitorType = domain.visitorType;
entity.sessionId = domain.sessionId;
entity.referrer = domain.referrer ?? null;
entity.userAgent = domain.userAgent ?? null;
entity.country = domain.country ?? null;
entity.timestamp = domain.timestamp;
entity.durationMs = domain.durationMs ?? null;
return entity;
}
toDomain(entity: PageViewOrmEntity): PageView {
const entityName = 'AnalyticsPageView';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertEnumValue<EntityType>(entityName, 'entityType', entity.entityType, VALID_ENTITY_TYPES);
assertNonEmptyString(entityName, 'entityId', entity.entityId);
assertOptionalStringOrNull(entityName, 'visitorId', entity.visitorId);
assertEnumValue<VisitorType>(entityName, 'visitorType', entity.visitorType, VALID_VISITOR_TYPES);
assertNonEmptyString(entityName, 'sessionId', entity.sessionId);
assertOptionalStringOrNull(entityName, 'referrer', entity.referrer);
assertOptionalStringOrNull(entityName, 'userAgent', entity.userAgent);
assertOptionalStringOrNull(entityName, 'country', entity.country);
assertDate(entityName, 'timestamp', entity.timestamp);
assertOptionalIntegerOrNull(entityName, 'durationMs', entity.durationMs);
return PageView.create({
id: entity.id,
entityType: entity.entityType,
entityId: entity.entityId,
visitorType: entity.visitorType,
sessionId: entity.sessionId,
timestamp: entity.timestamp,
...(entity.visitorId !== null && entity.visitorId !== undefined ? { visitorId: entity.visitorId } : {}),
...(entity.referrer !== null && entity.referrer !== undefined ? { referrer: entity.referrer } : {}),
...(entity.userAgent !== null && entity.userAgent !== undefined ? { userAgent: entity.userAgent } : {}),
...(entity.country !== null && entity.country !== undefined ? { country: entity.country } : {}),
...(entity.durationMs !== null && entity.durationMs !== undefined ? { durationMs: entity.durationMs } : {}),
});
} catch (error) {
if (error instanceof TypeOrmAnalyticsSchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted AnalyticsPageView';
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
}

View File

@@ -0,0 +1,82 @@
import { type Repository } from 'typeorm';
import type { IAnalyticsSnapshotRepository } from '@core/analytics/domain/repositories/IAnalyticsSnapshotRepository';
import type { SnapshotEntityType, SnapshotPeriod } from '@core/analytics/domain/types/AnalyticsSnapshot';
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
import { AnalyticsSnapshotOrmMapper } from '../mappers/AnalyticsSnapshotOrmMapper';
export class TypeOrmAnalyticsSnapshotRepository implements IAnalyticsSnapshotRepository {
constructor(
private readonly snapshotRepo: Repository<AnalyticsSnapshotOrmEntity>,
private readonly mapper: AnalyticsSnapshotOrmMapper,
) {}
async save(snapshot: AnalyticsSnapshot): Promise<void> {
await this.snapshotRepo.save(this.mapper.toOrmEntity(snapshot));
}
async findById(id: string): Promise<AnalyticsSnapshot | null> {
const entity = await this.snapshotRepo.findOneBy({ id });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]> {
const entities = await this.snapshotRepo.find({
where: { entityType, entityId },
order: { endDate: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async findByPeriod(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
startDate: Date,
endDate: Date,
): Promise<AnalyticsSnapshot | null> {
const qb = this.snapshotRepo
.createQueryBuilder('s')
.where('s.entityType = :entityType', { entityType })
.andWhere('s.entityId = :entityId', { entityId })
.andWhere('s.period = :period', { period })
.andWhere('s.startDate >= :startDate', { startDate })
.andWhere('s.endDate <= :endDate', { endDate })
.orderBy('s.endDate', 'DESC')
.limit(1);
const entity = await qb.getOne();
return entity ? this.mapper.toDomain(entity) : null;
}
async findLatest(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
): Promise<AnalyticsSnapshot | null> {
const entity = await this.snapshotRepo.findOne({
where: { entityType, entityId, period },
order: { endDate: 'DESC' },
});
return entity ? this.mapper.toDomain(entity) : null;
}
async getHistoricalSnapshots(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
limit: number,
): Promise<AnalyticsSnapshot[]> {
const entities = await this.snapshotRepo.find({
where: { entityType, entityId, period },
order: { endDate: 'DESC' },
...(typeof limit === 'number' ? { take: limit } : {}),
});
return entities.map((e) => this.mapper.toDomain(e));
}
}

View File

@@ -0,0 +1,80 @@
import { Between, MoreThanOrEqual, type FindOptionsWhere, type Repository } from 'typeorm';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import type { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
import { EngagementEventOrmMapper } from '../mappers/EngagementEventOrmMapper';
export class TypeOrmEngagementRepository implements IEngagementRepository {
constructor(
private readonly engagementRepo: Repository<EngagementEventOrmEntity>,
private readonly mapper: EngagementEventOrmMapper,
) {}
async save(event: EngagementEvent): Promise<void> {
await this.engagementRepo.save(this.mapper.toOrmEntity(event));
}
async findById(id: string): Promise<EngagementEvent | null> {
const entity = await this.engagementRepo.findOneBy({ id });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]> {
const entities = await this.engagementRepo.find({
where: { entityType, entityId },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async findByAction(action: EngagementAction): Promise<EngagementEvent[]> {
const entities = await this.engagementRepo.find({
where: { action },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]> {
const entities = await this.engagementRepo.find({
where: { timestamp: Between(startDate, endDate) },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number> {
const where: FindOptionsWhere<EngagementEventOrmEntity> = { action };
if (entityId) {
where.entityId = entityId;
}
if (since) {
where.timestamp = MoreThanOrEqual(since);
}
return this.engagementRepo.count({ where });
}
async getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number> {
const actions: EngagementAction[] = ['click_sponsor_logo', 'click_sponsor_url'];
const qb = this.engagementRepo
.createQueryBuilder('e')
.where('e.entityId = :entityId', { entityId })
.andWhere('e.action IN (:...actions)', { actions });
if (since) {
qb.andWhere('e.timestamp >= :since', { since });
}
return qb.getCount();
}
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest';
import type { Repository } from 'typeorm';
import { PageView } from '@core/analytics/domain/entities/PageView';
import { PageViewOrmEntity } from '../entities/PageViewOrmEntity';
import { PageViewOrmMapper } from '../mappers/PageViewOrmMapper';
import { TypeOrmPageViewRepository } from './TypeOrmPageViewRepository';
describe('TypeOrmPageViewRepository', () => {
it('saves mapped entities via injected mapper', async () => {
const orm = new PageViewOrmEntity();
orm.id = 'pv_1';
orm.entityType = 'league';
orm.entityId = 'league-1';
orm.visitorId = null;
orm.visitorType = 'anonymous';
orm.sessionId = 'sess-1';
orm.referrer = null;
orm.userAgent = null;
orm.country = null;
orm.timestamp = new Date('2025-01-01T00:00:00.000Z');
orm.durationMs = null;
const mapper: PageViewOrmMapper = {
toOrmEntity: vi.fn().mockReturnValue(orm),
toDomain: vi.fn(),
} as unknown as PageViewOrmMapper;
const repo: Repository<PageViewOrmEntity> = {
save: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<PageViewOrmEntity>;
const sut = new TypeOrmPageViewRepository(repo, mapper);
const domain = PageView.create({
id: 'pv_1',
entityType: 'league',
entityId: 'league-1',
visitorType: 'anonymous',
sessionId: 'sess-1',
timestamp: new Date('2025-01-01T00:00:00.000Z'),
});
await sut.save(domain);
expect(mapper.toOrmEntity).toHaveBeenCalledTimes(1);
expect(repo.save).toHaveBeenCalledWith(orm);
});
it('findById maps entity -> domain', async () => {
const orm = new PageViewOrmEntity();
orm.id = 'pv_1';
orm.entityType = 'league';
orm.entityId = 'league-1';
orm.visitorId = null;
orm.visitorType = 'anonymous';
orm.sessionId = 'sess-1';
orm.referrer = null;
orm.userAgent = null;
orm.country = null;
orm.timestamp = new Date('2025-01-01T00:00:00.000Z');
orm.durationMs = null;
const domain = PageView.create({
id: 'pv_1',
entityType: 'league',
entityId: 'league-1',
visitorType: 'anonymous',
sessionId: 'sess-1',
timestamp: new Date('2025-01-01T00:00:00.000Z'),
});
const mapper: PageViewOrmMapper = {
toOrmEntity: vi.fn(),
toDomain: vi.fn().mockReturnValue(domain),
} as unknown as PageViewOrmMapper;
const repo: Repository<PageViewOrmEntity> = {
findOneBy: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<PageViewOrmEntity>;
const sut = new TypeOrmPageViewRepository(repo, mapper);
const result = await sut.findById('pv_1');
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'pv_1' });
expect(mapper.toDomain).toHaveBeenCalledWith(orm);
expect(result?.id).toBe('pv_1');
});
});

View File

@@ -0,0 +1,82 @@
import { Between, MoreThanOrEqual, type Repository } from 'typeorm';
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
import type { EntityType } from '@core/analytics/domain/types/PageView';
import { PageView } from '@core/analytics/domain/entities/PageView';
import { PageViewOrmEntity } from '../entities/PageViewOrmEntity';
import { PageViewOrmMapper } from '../mappers/PageViewOrmMapper';
type CountRow = { count: string | number } | null | undefined;
export class TypeOrmPageViewRepository implements IPageViewRepository {
constructor(
private readonly pageViewRepo: Repository<PageViewOrmEntity>,
private readonly mapper: PageViewOrmMapper,
) {}
async save(pageView: PageView): Promise<void> {
await this.pageViewRepo.save(this.mapper.toOrmEntity(pageView));
}
async findById(id: string): Promise<PageView | null> {
const entity = await this.pageViewRepo.findOneBy({ id });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]> {
const entities = await this.pageViewRepo.find({
where: { entityType, entityId },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]> {
const entities = await this.pageViewRepo.find({
where: { timestamp: Between(startDate, endDate) },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async findBySession(sessionId: string): Promise<PageView[]> {
const entities = await this.pageViewRepo.find({
where: { sessionId },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
if (!since) {
return this.pageViewRepo.count({ where: { entityType, entityId } });
}
return this.pageViewRepo.count({
where: { entityType, entityId, timestamp: MoreThanOrEqual(since) },
});
}
async countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
const qb = this.pageViewRepo
.createQueryBuilder('pv')
.select('COUNT(DISTINCT COALESCE(pv.visitorId, pv.sessionId))', 'count')
.where('pv.entityType = :entityType', { entityType })
.andWhere('pv.entityId = :entityId', { entityId });
if (since) {
qb.andWhere('pv.timestamp >= :since', { since });
}
const row: CountRow = await qb.getRawOne();
const raw = row?.count;
if (typeof raw === 'number') return raw;
if (typeof raw === 'string') return Number.parseInt(raw, 10);
return 0;
}
}

View File

@@ -0,0 +1,128 @@
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'string') {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (value.trim().length === 0) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'empty_string' });
}
}
export function assertOptionalStringOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is string | null | undefined {
if (value === undefined || value === null) return;
if (typeof value !== 'string') {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_string' });
}
}
export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_number' });
}
}
export function assertOptionalNumberOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | null | undefined {
if (value === undefined || value === null) return;
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_number' });
}
}
export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_integer' });
}
}
export function assertOptionalIntegerOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | null | undefined {
if (value === undefined || value === null) return;
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_integer' });
}
}
export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'boolean') {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_boolean' });
}
}
export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (!(value instanceof Date)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_date' });
}
if (Number.isNaN(value.getTime())) {
throw new TypeOrmAnalyticsSchemaError({ 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 (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'string') {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (!allowed.includes(value as TAllowed)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
}
}
export function assertRecord(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is Record<string, unknown> {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'object' || Array.isArray(value)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_object' });
}
}
export const TypeOrmAnalyticsSchemaGuards = {
assertNonEmptyString,
assertOptionalStringOrNull,
assertNumber,
assertOptionalNumberOrNull,
assertInteger,
assertOptionalIntegerOrNull,
assertBoolean,
assertDate,
assertEnumValue,
assertRecord,
};

View File

@@ -0,0 +1,49 @@
import type { DataSource } from 'typeorm';
import { User } from '@core/identity/domain/entities/User';
import type { IAuthRepository } from '@core/identity/domain/repositories/IAuthRepository';
import type { EmailAddress } from '@core/identity/domain/value-objects/EmailAddress';
import { UserOrmEntity } from './entities/UserOrmEntity';
import { UserOrmMapper } from './mappers/UserOrmMapper';
export class TypeOrmAuthRepository implements IAuthRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: UserOrmMapper,
) {}
async findByEmail(email: EmailAddress): Promise<User | null> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const entity = await repo.findOne({ where: { email: email.value.toLowerCase() } });
return entity ? this.mapper.toDomain(entity) : null;
}
async save(user: User): Promise<void> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const id = user.getId().value;
const email = user.getEmail();
const passwordHash = user.getPasswordHash()?.value;
if (!email) {
throw new Error('Cannot persist user without email');
}
if (!passwordHash) {
throw new Error('Cannot persist user without password hash');
}
const existing = await repo.findOne({ where: { id } });
const entity = new UserOrmEntity();
entity.id = id;
entity.email = email.toLowerCase();
entity.displayName = user.getDisplayName();
entity.passwordHash = passwordHash;
entity.salt = '';
entity.primaryDriverId = user.getPrimaryDriverId() ?? null;
entity.createdAt = existing?.createdAt ?? new Date();
await repo.save(entity);
}
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { TypeOrmUserRepository } from './TypeOrmUserRepository';
describe('TypeOrmUserRepository', () => {
it('does not construct its own mapper dependencies', () => {
const sourcePath = path.resolve(__dirname, 'TypeOrmUserRepository.ts');
const source = fs.readFileSync(sourcePath, 'utf8');
expect(source).not.toMatch(/new\s+UserOrmMapper\s*\(/);
expect(source).not.toMatch(/=\s*new\s+UserOrmMapper\s*\(/);
});
it('requires mapper injection via constructor (no default mapper)', () => {
expect(TypeOrmUserRepository.length).toBe(2);
});
it('uses the injected mapper at runtime (DB-free)', async () => {
const ormRepo = {
findOne: vi.fn().mockResolvedValue({ id: 'u1' }),
};
const dataSource = {
getRepository: vi.fn().mockReturnValue(ormRepo),
};
const mapper = {
toStored: vi.fn().mockReturnValue({ id: 'stored-u1' }),
toOrmEntity: vi.fn(),
};
const repo = new TypeOrmUserRepository(dataSource as any, mapper as any);
const user = await repo.findByEmail('ALICE@EXAMPLE.COM');
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
expect(ormRepo.findOne).toHaveBeenCalledTimes(1);
expect(mapper.toStored).toHaveBeenCalledTimes(1);
expect(user).toEqual({ id: 'stored-u1' });
});
});

View File

@@ -0,0 +1,51 @@
import type { DataSource } from 'typeorm';
import type { IUserRepository, StoredUser } from '@core/identity/domain/repositories/IUserRepository';
import { UserOrmEntity } from './entities/UserOrmEntity';
import { UserOrmMapper } from './mappers/UserOrmMapper';
export class TypeOrmUserRepository implements IUserRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: UserOrmMapper,
) {}
async findByEmail(email: string): Promise<StoredUser | null> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const entity = await repo.findOne({ where: { email: email.toLowerCase() } });
return entity ? this.mapper.toStored(entity) : null;
}
async findById(id: string): Promise<StoredUser | null> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toStored(entity) : null;
}
async create(user: StoredUser): Promise<StoredUser> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const entity = this.mapper.toOrmEntity({
...user,
email: user.email.toLowerCase(),
});
await repo.save(entity);
return user;
}
async update(user: StoredUser): Promise<StoredUser> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const entity = this.mapper.toOrmEntity({
...user,
email: user.email.toLowerCase(),
});
await repo.save(entity);
return user;
}
async emailExists(email: string): Promise<boolean> {
const repo = this.dataSource.getRepository(UserOrmEntity);
const count = await repo.count({ where: { email: email.toLowerCase() } });
return count > 0;
}
}

View File

@@ -0,0 +1,26 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity({ name: 'identity_users' })
export class UserOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Index({ unique: true })
@Column({ type: 'text' })
email!: string;
@Column({ type: 'text' })
displayName!: string;
@Column({ type: 'text' })
passwordHash!: string;
@Column({ type: 'text' })
salt!: string;
@Column({ type: 'text', nullable: true })
primaryDriverId!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@@ -0,0 +1,34 @@
export type TypeOrmIdentitySchemaErrorReason =
| '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 TypeOrmIdentitySchemaError extends Error {
readonly entityName: string;
readonly fieldName: string;
readonly reason: TypeOrmIdentitySchemaErrorReason | (string & {});
constructor(params: {
entityName: string;
fieldName: string;
reason: TypeOrmIdentitySchemaError['reason'];
message?: string;
}) {
const message = params.message ?? `Invalid persisted ${params.entityName}.${params.fieldName}: ${params.reason}`;
super(message);
this.name = 'TypeOrmIdentitySchemaError';
this.entityName = params.entityName;
this.fieldName = params.fieldName;
this.reason = params.reason;
}
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest';
import { User } from '@core/identity/domain/entities/User';
import { UserOrmEntity } from '../entities/UserOrmEntity';
import { TypeOrmIdentitySchemaError } from '../errors/TypeOrmIdentitySchemaError';
import { UserOrmMapper } from './UserOrmMapper';
describe('UserOrmMapper', () => {
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
const mapper = new UserOrmMapper();
const entity = new UserOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.email = 'alice@example.com';
entity.displayName = 'Alice';
entity.passwordHash = 'bcrypt-hash';
entity.salt = '';
entity.primaryDriverId = null;
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
if (typeof (User as unknown as { rehydrate?: unknown }).rehydrate !== 'function') {
throw new Error('rehydrate-missing');
}
const rehydrateSpy = vi.spyOn(User as unknown as { rehydrate: (...args: unknown[]) => unknown }, 'rehydrate');
const createSpy = vi.spyOn(User, 'create').mockImplementation(() => {
throw new Error('create-called');
});
const domain = mapper.toDomain(entity);
expect(domain.getId().value).toBe(entity.id);
expect(domain.getEmail()).toBe(entity.email);
expect(createSpy).not.toHaveBeenCalled();
expect(rehydrateSpy).toHaveBeenCalled();
});
it('toDomain validates persisted shape and throws adapter-scoped base schema error type', () => {
const mapper = new UserOrmMapper();
const entity = new UserOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.email = 123 as unknown as string;
entity.displayName = 'Alice';
entity.passwordHash = 'bcrypt-hash';
entity.salt = '';
entity.primaryDriverId = null;
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(TypeOrmIdentitySchemaError);
expect(error).toMatchObject({
entityName: 'User',
fieldName: 'email',
reason: 'not_string',
});
}
});
});

View File

@@ -0,0 +1,68 @@
import { User } from '@core/identity/domain/entities/User';
import type { StoredUser } from '@core/identity/domain/repositories/IUserRepository';
import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash';
import { UserOrmEntity } from '../entities/UserOrmEntity';
import { TypeOrmIdentitySchemaError } from '../errors/TypeOrmIdentitySchemaError';
import { assertDate, assertNonEmptyString, assertOptionalStringOrNull } from '../schema/TypeOrmIdentitySchemaGuards';
export class UserOrmMapper {
toDomain(entity: UserOrmEntity): User {
const entityName = 'User';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'email', entity.email);
assertNonEmptyString(entityName, 'displayName', entity.displayName);
assertNonEmptyString(entityName, 'passwordHash', entity.passwordHash);
assertNonEmptyString(entityName, 'salt', entity.salt);
assertOptionalStringOrNull(entityName, 'primaryDriverId', entity.primaryDriverId);
assertDate(entityName, 'createdAt', entity.createdAt);
} catch (error) {
if (error instanceof TypeOrmIdentitySchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted User';
throw new TypeOrmIdentitySchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
try {
const passwordHash = entity.passwordHash ? PasswordHash.fromHash(entity.passwordHash) : undefined;
return User.rehydrate({
id: entity.id,
email: entity.email,
displayName: entity.displayName,
...(passwordHash ? { passwordHash } : {}),
...(entity.primaryDriverId ? { primaryDriverId: entity.primaryDriverId } : {}),
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid persisted User';
throw new TypeOrmIdentitySchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
toOrmEntity(stored: StoredUser): UserOrmEntity {
const entity = new UserOrmEntity();
entity.id = stored.id;
entity.email = stored.email;
entity.displayName = stored.displayName;
entity.passwordHash = stored.passwordHash;
entity.salt = stored.salt;
entity.primaryDriverId = stored.primaryDriverId ?? null;
entity.createdAt = stored.createdAt;
return entity;
}
toStored(entity: UserOrmEntity): StoredUser {
return {
id: entity.id,
email: entity.email,
displayName: entity.displayName,
passwordHash: entity.passwordHash,
salt: entity.salt,
primaryDriverId: entity.primaryDriverId ?? undefined,
createdAt: entity.createdAt,
};
}
}

View File

@@ -0,0 +1,34 @@
import { TypeOrmIdentitySchemaError } from '../errors/TypeOrmIdentitySchemaError';
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (value.trim().length === 0) {
throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'empty_string' });
}
}
export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date {
if (!(value instanceof Date)) {
throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'not_date' });
}
if (Number.isNaN(value.getTime())) {
throw new TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'invalid_date' });
}
}
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 TypeOrmIdentitySchemaError({ entityName, fieldName, reason: 'not_string' });
}
}

View File

@@ -0,0 +1,34 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity({ name: 'payments_member_payments' })
export class PaymentsMemberPaymentOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Index()
@Column({ type: 'text' })
feeId!: string;
@Index()
@Column({ type: 'text' })
driverId!: string;
@Column({ type: 'double precision' })
amount!: number;
@Column({ type: 'double precision' })
platformFee!: number;
@Column({ type: 'double precision' })
netAmount!: number;
@Index()
@Column({ type: 'text' })
status!: string;
@Column({ type: 'timestamptz' })
dueDate!: Date;
@Column({ type: 'timestamptz', nullable: true })
paidAt!: Date | null;
}

View File

@@ -0,0 +1,29 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity({ name: 'payments_membership_fees' })
export class PaymentsMembershipFeeOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Index({ unique: true })
@Column({ type: 'text' })
leagueId!: string;
@Column({ type: 'text', nullable: true })
seasonId!: string | null;
@Column({ type: 'text' })
type!: string;
@Column({ type: 'double precision' })
amount!: number;
@Column({ type: 'boolean' })
enabled!: boolean;
@Column({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,44 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity({ name: 'payments_payments' })
export class PaymentsPaymentOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Index()
@Column({ type: 'text' })
leagueId!: string;
@Index()
@Column({ type: 'text' })
payerId!: string;
@Index()
@Column({ type: 'text' })
type!: string;
@Column({ type: 'double precision' })
amount!: number;
@Column({ type: 'double precision' })
platformFee!: number;
@Column({ type: 'double precision' })
netAmount!: number;
@Column({ type: 'text' })
payerType!: string;
@Column({ type: 'text', nullable: true })
seasonId!: string | null;
@Index()
@Column({ type: 'text' })
status!: string;
@Column({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
completedAt!: Date | null;
}

View File

@@ -0,0 +1,43 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Index('IDX_payments_prizes_league_season_position_unique', ['leagueId', 'seasonId', 'position'], { unique: true })
@Entity({ name: 'payments_prizes' })
export class PaymentsPrizeOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Index()
@Column({ type: 'text' })
leagueId!: string;
@Index()
@Column({ type: 'text' })
seasonId!: string;
@Column({ type: 'int' })
position!: number;
@Column({ type: 'text' })
name!: string;
@Column({ type: 'double precision' })
amount!: number;
@Column({ type: 'text' })
type!: string;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'boolean' })
awarded!: boolean;
@Column({ type: 'text', nullable: true })
awardedTo!: string | null;
@Column({ type: 'timestamptz', nullable: true })
awardedAt!: Date | null;
@Column({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@@ -0,0 +1,29 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity({ name: 'payments_wallet_transactions' })
export class PaymentsTransactionOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Index()
@Column({ type: 'text' })
walletId!: string;
@Column({ type: 'text' })
type!: string;
@Column({ type: 'double precision' })
amount!: number;
@Column({ type: 'text' })
description!: string;
@Column({ type: 'text', nullable: true })
referenceId!: string | null;
@Column({ type: 'text', nullable: true })
referenceType!: string | null;
@Column({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@@ -0,0 +1,29 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity({ name: 'payments_wallets' })
export class PaymentsWalletOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Index({ unique: true })
@Column({ type: 'text' })
leagueId!: string;
@Column({ type: 'double precision', default: 0 })
balance!: number;
@Column({ type: 'double precision', default: 0 })
totalRevenue!: number;
@Column({ type: 'double precision', default: 0 })
totalPlatformFees!: number;
@Column({ type: 'double precision', default: 0 })
totalWithdrawn!: number;
@Column({ type: 'text' })
currency!: string;
@Column({ type: 'timestamptz' })
createdAt!: Date;
}

View File

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

View File

@@ -0,0 +1,53 @@
import type { MemberPayment } from '@core/payments/domain/entities/MemberPayment';
import { MemberPayment as MemberPaymentFactory, MemberPaymentStatus } from '@core/payments/domain/entities/MemberPayment';
import { PaymentsMemberPaymentOrmEntity } from '../entities/PaymentsMemberPaymentOrmEntity';
import {
assertDate,
assertEnumValue,
assertNonEmptyString,
assertNumber,
assertOptionalDate,
} from '../schema/TypeOrmPaymentsSchemaGuards';
export class PaymentsMemberPaymentOrmMapper {
toOrmEntity(domain: MemberPayment): PaymentsMemberPaymentOrmEntity {
const entity = new PaymentsMemberPaymentOrmEntity();
entity.id = domain.id;
entity.feeId = domain.feeId;
entity.driverId = domain.driverId;
entity.amount = domain.amount;
entity.platformFee = domain.platformFee;
entity.netAmount = domain.netAmount;
entity.status = domain.status;
entity.dueDate = domain.dueDate;
entity.paidAt = domain.paidAt ?? null;
return entity;
}
toDomain(entity: PaymentsMemberPaymentOrmEntity): MemberPayment {
const entityName = 'PaymentsMemberPayment';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'feeId', entity.feeId);
assertNonEmptyString(entityName, 'driverId', entity.driverId);
assertNumber(entityName, 'amount', entity.amount);
assertNumber(entityName, 'platformFee', entity.platformFee);
assertNumber(entityName, 'netAmount', entity.netAmount);
assertEnumValue(entityName, 'status', entity.status, Object.values(MemberPaymentStatus));
assertDate(entityName, 'dueDate', entity.dueDate);
assertOptionalDate(entityName, 'paidAt', entity.paidAt);
return MemberPaymentFactory.rehydrate({
id: entity.id,
feeId: entity.feeId,
driverId: entity.driverId,
amount: entity.amount,
platformFee: entity.platformFee,
netAmount: entity.netAmount,
status: entity.status,
dueDate: entity.dueDate,
...(entity.paidAt !== null && entity.paidAt !== undefined ? { paidAt: entity.paidAt } : {}),
});
}
}

View File

@@ -0,0 +1,51 @@
import type { MembershipFee } from '@core/payments/domain/entities/MembershipFee';
import { MembershipFee as MembershipFeeFactory, MembershipFeeType } from '@core/payments/domain/entities/MembershipFee';
import { PaymentsMembershipFeeOrmEntity } from '../entities/PaymentsMembershipFeeOrmEntity';
import {
assertBoolean,
assertDate,
assertEnumValue,
assertNonEmptyString,
assertNumber,
assertOptionalStringOrNull,
} from '../schema/TypeOrmPaymentsSchemaGuards';
export class PaymentsMembershipFeeOrmMapper {
toOrmEntity(domain: MembershipFee): PaymentsMembershipFeeOrmEntity {
const entity = new PaymentsMembershipFeeOrmEntity();
entity.id = domain.id;
entity.leagueId = domain.leagueId;
entity.seasonId = domain.seasonId ?? null;
entity.type = domain.type;
entity.amount = domain.amount;
entity.enabled = domain.enabled;
entity.createdAt = domain.createdAt;
entity.updatedAt = domain.updatedAt;
return entity;
}
toDomain(entity: PaymentsMembershipFeeOrmEntity): MembershipFee {
const entityName = 'PaymentsMembershipFee';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'leagueId', entity.leagueId);
assertOptionalStringOrNull(entityName, 'seasonId', entity.seasonId);
assertEnumValue(entityName, 'type', entity.type, Object.values(MembershipFeeType));
assertNumber(entityName, 'amount', entity.amount);
assertBoolean(entityName, 'enabled', entity.enabled);
assertDate(entityName, 'createdAt', entity.createdAt);
assertDate(entityName, 'updatedAt', entity.updatedAt);
return MembershipFeeFactory.rehydrate({
id: entity.id,
leagueId: entity.leagueId,
type: entity.type,
amount: entity.amount,
enabled: entity.enabled,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
...(entity.seasonId !== null && entity.seasonId !== undefined ? { seasonId: entity.seasonId } : {}),
});
}
}

View File

@@ -0,0 +1,70 @@
import type { Payment } from '@core/payments/domain/entities/Payment';
import {
Payment as PaymentFactory,
PaymentStatus,
PaymentType,
PayerType,
} from '@core/payments/domain/entities/Payment';
import { PaymentsPaymentOrmEntity } from '../entities/PaymentsPaymentOrmEntity';
import {
assertDate,
assertEnumValue,
assertNonEmptyString,
assertNumber,
assertOptionalStringOrNull,
} from '../schema/TypeOrmPaymentsSchemaGuards';
export class PaymentsPaymentOrmMapper {
toOrmEntity(domain: Payment): PaymentsPaymentOrmEntity {
const entity = new PaymentsPaymentOrmEntity();
entity.id = domain.id;
entity.type = domain.type;
entity.amount = domain.amount;
entity.platformFee = domain.platformFee;
entity.netAmount = domain.netAmount;
entity.payerId = domain.payerId;
entity.payerType = domain.payerType;
entity.leagueId = domain.leagueId;
entity.seasonId = domain.seasonId ?? null;
entity.status = domain.status;
entity.createdAt = domain.createdAt;
entity.completedAt = domain.completedAt ?? null;
return entity;
}
toDomain(entity: PaymentsPaymentOrmEntity): Payment {
const entityName = 'PaymentsPayment';
assertNonEmptyString(entityName, 'id', entity.id);
assertEnumValue(entityName, 'type', entity.type, Object.values(PaymentType));
assertNumber(entityName, 'amount', entity.amount);
assertNumber(entityName, 'platformFee', entity.platformFee);
assertNumber(entityName, 'netAmount', entity.netAmount);
assertNonEmptyString(entityName, 'payerId', entity.payerId);
assertEnumValue(entityName, 'payerType', entity.payerType, Object.values(PayerType));
assertNonEmptyString(entityName, 'leagueId', entity.leagueId);
assertOptionalStringOrNull(entityName, 'seasonId', entity.seasonId);
assertEnumValue(entityName, 'status', entity.status, Object.values(PaymentStatus));
assertDate(entityName, 'createdAt', entity.createdAt);
if (entity.completedAt !== null && entity.completedAt !== undefined) {
assertDate(entityName, 'completedAt', entity.completedAt);
}
return PaymentFactory.rehydrate({
id: entity.id,
type: entity.type,
amount: entity.amount,
platformFee: entity.platformFee,
netAmount: entity.netAmount,
payerId: entity.payerId,
payerType: entity.payerType,
leagueId: entity.leagueId,
status: entity.status,
createdAt: entity.createdAt,
...(entity.seasonId !== null && entity.seasonId !== undefined ? { seasonId: entity.seasonId } : {}),
...(entity.completedAt !== null && entity.completedAt !== undefined ? { completedAt: entity.completedAt } : {}),
});
}
}

View File

@@ -0,0 +1,65 @@
import type { Prize } from '@core/payments/domain/entities/Prize';
import { Prize as PrizeFactory, PrizeType } from '@core/payments/domain/entities/Prize';
import { PaymentsPrizeOrmEntity } from '../entities/PaymentsPrizeOrmEntity';
import {
assertBoolean,
assertDate,
assertEnumValue,
assertInteger,
assertNonEmptyString,
assertNumber,
assertOptionalDate,
assertOptionalStringOrNull,
} from '../schema/TypeOrmPaymentsSchemaGuards';
export class PaymentsPrizeOrmMapper {
toOrmEntity(domain: Prize): PaymentsPrizeOrmEntity {
const entity = new PaymentsPrizeOrmEntity();
entity.id = domain.id;
entity.leagueId = domain.leagueId;
entity.seasonId = domain.seasonId;
entity.position = domain.position;
entity.name = domain.name;
entity.amount = domain.amount;
entity.type = domain.type;
entity.description = domain.description ?? null;
entity.awarded = domain.awarded;
entity.awardedTo = domain.awardedTo ?? null;
entity.awardedAt = domain.awardedAt ?? null;
entity.createdAt = domain.createdAt;
return entity;
}
toDomain(entity: PaymentsPrizeOrmEntity): Prize {
const entityName = 'PaymentsPrize';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'leagueId', entity.leagueId);
assertNonEmptyString(entityName, 'seasonId', entity.seasonId);
assertInteger(entityName, 'position', entity.position);
assertNonEmptyString(entityName, 'name', entity.name);
assertNumber(entityName, 'amount', entity.amount);
assertEnumValue(entityName, 'type', entity.type, Object.values(PrizeType));
assertOptionalStringOrNull(entityName, 'description', entity.description);
assertBoolean(entityName, 'awarded', entity.awarded);
assertOptionalStringOrNull(entityName, 'awardedTo', entity.awardedTo);
assertOptionalDate(entityName, 'awardedAt', entity.awardedAt);
assertDate(entityName, 'createdAt', entity.createdAt);
return PrizeFactory.rehydrate({
id: entity.id,
leagueId: entity.leagueId,
seasonId: entity.seasonId,
position: entity.position,
name: entity.name,
amount: entity.amount,
type: entity.type,
awarded: entity.awarded,
createdAt: entity.createdAt,
...(entity.description !== null && entity.description !== undefined ? { description: entity.description } : {}),
...(entity.awardedTo !== null && entity.awardedTo !== undefined ? { awardedTo: entity.awardedTo } : {}),
...(entity.awardedAt !== null && entity.awardedAt !== undefined ? { awardedAt: entity.awardedAt } : {}),
});
}
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { TransactionType } from '@core/payments/domain/entities/Wallet';
import { TypeOrmPaymentsSchemaError } from '../errors/TypeOrmPaymentsSchemaError';
import { PaymentsWalletOrmEntity } from '../entities/PaymentsWalletOrmEntity';
import { PaymentsWalletOrmMapper } from './PaymentsWalletOrmMapper';
describe('PaymentsWalletOrmMapper', () => {
it('maps Wallet domain <-> orm', () => {
const wallet = {
id: 'wallet-1',
leagueId: 'league-1',
balance: 10,
totalRevenue: 20,
totalPlatformFees: 1,
totalWithdrawn: 5,
currency: 'USD',
createdAt: new Date('2025-01-01T00:00:00.000Z'),
};
const mapper = new PaymentsWalletOrmMapper();
const orm = mapper.toOrmEntity(wallet);
const rehydrated = mapper.toDomain(orm);
expect(rehydrated.id).toBe('wallet-1');
expect(rehydrated.leagueId).toBe('league-1');
expect(rehydrated.balance).toBe(10);
expect(rehydrated.currency).toBe('USD');
expect(rehydrated.createdAt.toISOString()).toBe('2025-01-01T00:00:00.000Z');
});
it('throws schema error on invalid Wallet', () => {
const mapper = new PaymentsWalletOrmMapper();
const orm = new PaymentsWalletOrmEntity();
orm.id = '';
orm.leagueId = 'league-1';
orm.balance = 0;
orm.totalRevenue = 0;
orm.totalPlatformFees = 0;
orm.totalWithdrawn = 0;
orm.currency = 'USD';
orm.createdAt = new Date();
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmPaymentsSchemaError);
});
it('maps Transaction domain <-> orm', () => {
const transaction = {
id: 'txn-1',
walletId: 'wallet-1',
type: TransactionType.DEPOSIT,
amount: 123,
description: 'Deposit',
createdAt: new Date('2025-01-01T00:00:00.000Z'),
};
const mapper = new PaymentsWalletOrmMapper();
const orm = mapper.toOrmTransaction(transaction);
const rehydrated = mapper.toDomainTransaction(orm);
expect(rehydrated.id).toBe('txn-1');
expect(rehydrated.walletId).toBe('wallet-1');
expect(rehydrated.type).toBe(TransactionType.DEPOSIT);
expect(rehydrated.amount).toBe(123);
});
});

View File

@@ -0,0 +1,93 @@
import type { Wallet, Transaction } from '@core/payments/domain/entities/Wallet';
import { ReferenceType, Transaction as TransactionFactory, TransactionType, Wallet as WalletFactory } from '@core/payments/domain/entities/Wallet';
import { PaymentsTransactionOrmEntity } from '../entities/PaymentsTransactionOrmEntity';
import { PaymentsWalletOrmEntity } from '../entities/PaymentsWalletOrmEntity';
import {
assertDate,
assertEnumValue,
assertNonEmptyString,
assertNumber,
assertOptionalStringOrNull,
} from '../schema/TypeOrmPaymentsSchemaGuards';
export class PaymentsWalletOrmMapper {
toOrmEntity(domain: Wallet): PaymentsWalletOrmEntity {
const entity = new PaymentsWalletOrmEntity();
entity.id = domain.id;
entity.leagueId = domain.leagueId;
entity.balance = domain.balance;
entity.totalRevenue = domain.totalRevenue;
entity.totalPlatformFees = domain.totalPlatformFees;
entity.totalWithdrawn = domain.totalWithdrawn;
entity.currency = domain.currency;
entity.createdAt = domain.createdAt;
return entity;
}
toDomain(entity: PaymentsWalletOrmEntity): Wallet {
const entityName = 'PaymentsWallet';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'leagueId', entity.leagueId);
assertNumber(entityName, 'balance', entity.balance);
assertNumber(entityName, 'totalRevenue', entity.totalRevenue);
assertNumber(entityName, 'totalPlatformFees', entity.totalPlatformFees);
assertNumber(entityName, 'totalWithdrawn', entity.totalWithdrawn);
assertNonEmptyString(entityName, 'currency', entity.currency);
assertDate(entityName, 'createdAt', entity.createdAt);
return WalletFactory.rehydrate({
id: entity.id,
leagueId: entity.leagueId,
balance: entity.balance,
totalRevenue: entity.totalRevenue,
totalPlatformFees: entity.totalPlatformFees,
totalWithdrawn: entity.totalWithdrawn,
currency: entity.currency,
createdAt: entity.createdAt,
});
}
toOrmTransaction(domain: Transaction): PaymentsTransactionOrmEntity {
const entity = new PaymentsTransactionOrmEntity();
entity.id = domain.id;
entity.walletId = domain.walletId;
entity.type = domain.type;
entity.amount = domain.amount;
entity.description = domain.description;
entity.referenceId = domain.referenceId ?? null;
entity.referenceType = domain.referenceType ?? null;
entity.createdAt = domain.createdAt;
return entity;
}
toDomainTransaction(entity: PaymentsTransactionOrmEntity): Transaction {
const entityName = 'PaymentsTransaction';
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'walletId', entity.walletId);
assertNonEmptyString(entityName, 'type', entity.type);
assertEnumValue(entityName, 'type', entity.type, Object.values(TransactionType));
assertNumber(entityName, 'amount', entity.amount);
assertNonEmptyString(entityName, 'description', entity.description);
assertOptionalStringOrNull(entityName, 'referenceId', entity.referenceId);
assertOptionalStringOrNull(entityName, 'referenceType', entity.referenceType);
assertDate(entityName, 'createdAt', entity.createdAt);
if (entity.referenceType !== null && entity.referenceType !== undefined) {
assertEnumValue(entityName, 'referenceType', entity.referenceType, Object.values(ReferenceType));
}
return TransactionFactory.rehydrate({
id: entity.id,
walletId: entity.walletId,
type: entity.type,
amount: entity.amount,
description: entity.description,
createdAt: entity.createdAt,
...(entity.referenceId !== null && entity.referenceId !== undefined ? { referenceId: entity.referenceId } : {}),
...(entity.referenceType !== null && entity.referenceType !== undefined ? { referenceType: entity.referenceType as ReferenceType } : {}),
});
}
}

View File

@@ -0,0 +1,87 @@
import type { DataSource } from 'typeorm';
import type { IMemberPaymentRepository, IMembershipFeeRepository } from '@core/payments/domain/repositories/IMembershipFeeRepository';
import type { MemberPayment } from '@core/payments/domain/entities/MemberPayment';
import type { MembershipFee } from '@core/payments/domain/entities/MembershipFee';
import { PaymentsMemberPaymentOrmEntity } from '../entities/PaymentsMemberPaymentOrmEntity';
import { PaymentsMembershipFeeOrmEntity } from '../entities/PaymentsMembershipFeeOrmEntity';
import { PaymentsMemberPaymentOrmMapper } from '../mappers/PaymentsMemberPaymentOrmMapper';
import { PaymentsMembershipFeeOrmMapper } from '../mappers/PaymentsMembershipFeeOrmMapper';
export class TypeOrmMembershipFeeRepository implements IMembershipFeeRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: PaymentsMembershipFeeOrmMapper,
) {}
async findById(id: string): Promise<MembershipFee | null> {
const repo = this.dataSource.getRepository(PaymentsMembershipFeeOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByLeagueId(leagueId: string): Promise<MembershipFee | null> {
const repo = this.dataSource.getRepository(PaymentsMembershipFeeOrmEntity);
const entity = await repo.findOne({ where: { leagueId } });
return entity ? this.mapper.toDomain(entity) : null;
}
async create(fee: MembershipFee): Promise<MembershipFee> {
const repo = this.dataSource.getRepository(PaymentsMembershipFeeOrmEntity);
await repo.save(this.mapper.toOrmEntity(fee));
return fee;
}
async update(fee: MembershipFee): Promise<MembershipFee> {
const repo = this.dataSource.getRepository(PaymentsMembershipFeeOrmEntity);
await repo.save(this.mapper.toOrmEntity(fee));
return fee;
}
}
export class TypeOrmMemberPaymentRepository implements IMemberPaymentRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: PaymentsMemberPaymentOrmMapper,
) {}
async findById(id: string): Promise<MemberPayment | null> {
const repo = this.dataSource.getRepository(PaymentsMemberPaymentOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByFeeIdAndDriverId(feeId: string, driverId: string): Promise<MemberPayment | null> {
const repo = this.dataSource.getRepository(PaymentsMemberPaymentOrmEntity);
const entity = await repo.findOne({ where: { feeId, driverId } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByLeagueIdAndDriverId(
leagueId: string,
driverId: string,
membershipFeeRepo: IMembershipFeeRepository,
): Promise<MemberPayment[]> {
const fee = await membershipFeeRepo.findByLeagueId(leagueId);
if (!fee) {
return [];
}
const repo = this.dataSource.getRepository(PaymentsMemberPaymentOrmEntity);
const entities = await repo.find({ where: { feeId: fee.id, driverId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async create(payment: MemberPayment): Promise<MemberPayment> {
const repo = this.dataSource.getRepository(PaymentsMemberPaymentOrmEntity);
await repo.save(this.mapper.toOrmEntity(payment));
return payment;
}
async update(payment: MemberPayment): Promise<MemberPayment> {
const repo = this.dataSource.getRepository(PaymentsMemberPaymentOrmEntity);
await repo.save(this.mapper.toOrmEntity(payment));
return payment;
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmPaymentRepository } from './TypeOrmPaymentRepository';
import { PaymentsPaymentOrmMapper } from '../mappers/PaymentsPaymentOrmMapper';
describe('TypeOrmPaymentRepository', () => {
it('constructor requires injected mapper (no internal mapper instantiation)', () => {
const dataSource = {} as unknown as DataSource;
const mapper = {} as unknown as PaymentsPaymentOrmMapper;
const repo = new TypeOrmPaymentRepository(dataSource, mapper);
expect(repo).toBeInstanceOf(TypeOrmPaymentRepository);
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 PaymentsPaymentOrmMapper;
const repo = new TypeOrmPaymentRepository(dataSource, mapper);
await expect(repo.findById('payment-1')).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,67 @@
import type { DataSource } from 'typeorm';
import type { IPaymentRepository } from '@core/payments/domain/repositories/IPaymentRepository';
import type { Payment, PaymentType } from '@core/payments/domain/entities/Payment';
import { PaymentsPaymentOrmEntity } from '../entities/PaymentsPaymentOrmEntity';
import { PaymentsPaymentOrmMapper } from '../mappers/PaymentsPaymentOrmMapper';
export class TypeOrmPaymentRepository implements IPaymentRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: PaymentsPaymentOrmMapper,
) {}
async findById(id: string): Promise<Payment | null> {
const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByLeagueId(leagueId: string): Promise<Payment[]> {
const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity);
const entities = await repo.find({ where: { leagueId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByPayerId(payerId: string): Promise<Payment[]> {
const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity);
const entities = await repo.find({ where: { payerId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByType(type: PaymentType): Promise<Payment[]> {
const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity);
const entities = await repo.find({ where: { type } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByFilters(filters: { leagueId?: string; payerId?: string; type?: PaymentType }): Promise<Payment[]> {
const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity);
const where: {
leagueId?: string;
payerId?: string;
type?: PaymentType;
} = {};
if (filters.leagueId !== undefined) where.leagueId = filters.leagueId;
if (filters.payerId !== undefined) where.payerId = filters.payerId;
if (filters.type !== undefined) where.type = filters.type;
const entities = await repo.find({ where });
return entities.map((e) => this.mapper.toDomain(e));
}
async create(payment: Payment): Promise<Payment> {
const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity);
await repo.save(this.mapper.toOrmEntity(payment));
return payment;
}
async update(payment: Payment): Promise<Payment> {
const repo = this.dataSource.getRepository(PaymentsPaymentOrmEntity);
await repo.save(this.mapper.toOrmEntity(payment));
return payment;
}
}

View File

@@ -0,0 +1,55 @@
import type { DataSource } from 'typeorm';
import type { IPrizeRepository } from '@core/payments/domain/repositories/IPrizeRepository';
import type { Prize } from '@core/payments/domain/entities/Prize';
import { PaymentsPrizeOrmEntity } from '../entities/PaymentsPrizeOrmEntity';
import { PaymentsPrizeOrmMapper } from '../mappers/PaymentsPrizeOrmMapper';
export class TypeOrmPrizeRepository implements IPrizeRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: PaymentsPrizeOrmMapper,
) {}
async findById(id: string): Promise<Prize | null> {
const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByLeagueId(leagueId: string): Promise<Prize[]> {
const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity);
const entities = await repo.find({ where: { leagueId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByLeagueIdAndSeasonId(leagueId: string, seasonId: string): Promise<Prize[]> {
const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity);
const entities = await repo.find({ where: { leagueId, seasonId } });
return entities.map((e) => this.mapper.toDomain(e));
}
async findByPosition(leagueId: string, seasonId: string, position: number): Promise<Prize | null> {
const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity);
const entity = await repo.findOne({ where: { leagueId, seasonId, position } });
return entity ? this.mapper.toDomain(entity) : null;
}
async create(prize: Prize): Promise<Prize> {
const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity);
await repo.save(this.mapper.toOrmEntity(prize));
return prize;
}
async update(prize: Prize): Promise<Prize> {
const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity);
await repo.save(this.mapper.toOrmEntity(prize));
return prize;
}
async delete(id: string): Promise<void> {
const repo = this.dataSource.getRepository(PaymentsPrizeOrmEntity);
await repo.delete({ id });
}
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmWalletRepository } from './TypeOrmWalletRepository';
import { PaymentsWalletOrmMapper } from '../mappers/PaymentsWalletOrmMapper';
describe('TypeOrmWalletRepository', () => {
it('constructor requires injected mapper (no internal mapper instantiation)', () => {
const dataSource = {} as unknown as DataSource;
const mapper = {} as unknown as PaymentsWalletOrmMapper;
const repo = new TypeOrmWalletRepository(dataSource, mapper);
expect(repo).toBeInstanceOf(TypeOrmWalletRepository);
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 PaymentsWalletOrmMapper;
const repo = new TypeOrmWalletRepository(dataSource, mapper);
await expect(repo.findById('wallet-1')).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,64 @@
import type { DataSource } from 'typeorm';
import type { ITransactionRepository, IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { Transaction, Wallet } from '@core/payments/domain/entities/Wallet';
import { PaymentsTransactionOrmEntity } from '../entities/PaymentsTransactionOrmEntity';
import { PaymentsWalletOrmEntity } from '../entities/PaymentsWalletOrmEntity';
import { PaymentsWalletOrmMapper } from '../mappers/PaymentsWalletOrmMapper';
export class TypeOrmWalletRepository implements IWalletRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: PaymentsWalletOrmMapper,
) {}
async findById(id: string): Promise<Wallet | null> {
const repo = this.dataSource.getRepository(PaymentsWalletOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByLeagueId(leagueId: string): Promise<Wallet | null> {
const repo = this.dataSource.getRepository(PaymentsWalletOrmEntity);
const entity = await repo.findOne({ where: { leagueId } });
return entity ? this.mapper.toDomain(entity) : null;
}
async create(wallet: Wallet): Promise<Wallet> {
const repo = this.dataSource.getRepository(PaymentsWalletOrmEntity);
await repo.save(this.mapper.toOrmEntity(wallet));
return wallet;
}
async update(wallet: Wallet): Promise<Wallet> {
const repo = this.dataSource.getRepository(PaymentsWalletOrmEntity);
await repo.save(this.mapper.toOrmEntity(wallet));
return wallet;
}
}
export class TypeOrmTransactionRepository implements ITransactionRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: PaymentsWalletOrmMapper,
) {}
async findById(id: string): Promise<Transaction | null> {
const repo = this.dataSource.getRepository(PaymentsTransactionOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomainTransaction(entity) : null;
}
async findByWalletId(walletId: string): Promise<Transaction[]> {
const repo = this.dataSource.getRepository(PaymentsTransactionOrmEntity);
const entities = await repo.find({ where: { walletId } });
return entities.map((e) => this.mapper.toDomainTransaction(e));
}
async create(transaction: Transaction): Promise<Transaction> {
const repo = this.dataSource.getRepository(PaymentsTransactionOrmEntity);
await repo.save(this.mapper.toOrmTransaction(transaction));
return transaction;
}
}

View File

@@ -0,0 +1,120 @@
import { TypeOrmPaymentsSchemaError } from '../errors/TypeOrmPaymentsSchemaError';
export function assertNonEmptyString(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is string {
if (typeof value !== 'string') {
throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (value.trim().length === 0) {
throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'empty_string' });
}
}
export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeOrmPaymentsSchemaError({ 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 TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_integer' });
}
}
export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean {
if (typeof value !== 'boolean') {
throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_boolean' });
}
}
export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date {
if (!(value instanceof Date)) {
throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_date' });
}
if (Number.isNaN(value.getTime())) {
throw new TypeOrmPaymentsSchemaError({ 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 TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (!allowed.includes(value as TAllowed)) {
throw new TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
}
}
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 TypeOrmPaymentsSchemaError({ entityName, fieldName, reason: 'not_string' });
}
}
export function assertOptionalDate(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is Date | undefined | null {
if (value === undefined || value === null) {
return;
}
assertDate(entityName, fieldName, value);
}
export function assertOptionalNumber(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | undefined | null {
if (value === undefined || value === null) {
return;
}
assertNumber(entityName, fieldName, value);
}
export function assertOptionalBoolean(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is boolean | undefined | null {
if (value === undefined || value === null) {
return;
}
assertBoolean(entityName, fieldName, value);
}
export const TypeOrmPaymentsSchemaGuards = {
assertNonEmptyString,
assertNumber,
assertInteger,
assertBoolean,
assertDate,
assertEnumValue,
assertOptionalStringOrNull,
assertOptionalDate,
assertOptionalNumber,
assertOptionalBoolean,
} as const;

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;
}
}

Some files were not shown because too many files have changed in this diff Show More