inmemory to postgres
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
119
adapters/racing/persistence/typeorm/mappers/TeamOrmMappers.ts
Normal file
119
adapters/racing/persistence/typeorm/mappers/TeamOrmMappers.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user