diff --git a/adapters/bootstrap/SeedRacingData.ts b/adapters/bootstrap/SeedRacingData.ts index 9bf0bc6e8..7e54fa145 100644 --- a/adapters/bootstrap/SeedRacingData.ts +++ b/adapters/bootstrap/SeedRacingData.ts @@ -8,6 +8,14 @@ import type { ILeagueMembershipRepository } from '@core/racing/domain/repositori import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository'; +import type { ISponsorRepository } from '@core/racing/domain/repositories/ISponsorRepository'; +import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; +import type { ISeasonSponsorshipRepository } from '@core/racing/domain/repositories/ISeasonSponsorshipRepository'; +import type { ISponsorshipRequestRepository } from '@core/racing/domain/repositories/ISponsorshipRequestRepository'; +import type { ILeagueWalletRepository } from '@core/racing/domain/repositories/ILeagueWalletRepository'; +import type { ITransactionRepository } from '@core/racing/domain/repositories/ITransactionRepository'; +import type { IProtestRepository } from '@core/racing/domain/repositories/IProtestRepository'; +import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository'; import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; import { createRacingSeed } from './racing/RacingSeed'; @@ -15,6 +23,13 @@ import { createRacingSeed } from './racing/RacingSeed'; export type RacingSeedDependencies = { driverRepository: IDriverRepository; leagueRepository: ILeagueRepository; + seasonRepository: ISeasonRepository; + seasonSponsorshipRepository: ISeasonSponsorshipRepository; + sponsorshipRequestRepository: ISponsorshipRequestRepository; + leagueWalletRepository: ILeagueWalletRepository; + transactionRepository: ITransactionRepository; + protestRepository: IProtestRepository; + penaltyRepository: IPenaltyRepository; raceRepository: IRaceRepository; resultRepository: IResultRepository; standingRepository: IStandingRepository; @@ -22,6 +37,7 @@ export type RacingSeedDependencies = { raceRegistrationRepository: IRaceRegistrationRepository; teamRepository: ITeamRepository; teamMembershipRepository: ITeamMembershipRepository; + sponsorRepository: ISponsorRepository; feedRepository: IFeedRepository; socialGraphRepository: ISocialGraphRepository; }; @@ -41,6 +57,15 @@ export class SeedRacingData { const seed = createRacingSeed(); + let sponsorshipRequestsSeededViaRepo = false; + const seedableSponsorshipRequests = this.seedDeps + .sponsorshipRequestRepository as unknown as { seed?: (input: unknown) => void }; + + if (typeof seedableSponsorshipRequests.seed === 'function') { + seedableSponsorshipRequests.seed(seed.sponsorshipRequests); + sponsorshipRequestsSeededViaRepo = true; + } + for (const driver of seed.drivers) { try { await this.seedDeps.driverRepository.create(driver); @@ -57,6 +82,65 @@ export class SeedRacingData { } } + for (const season of seed.seasons) { + try { + await this.seedDeps.seasonRepository.create(season); + } catch { + // ignore duplicates + } + } + + + for (const sponsorship of seed.seasonSponsorships) { + try { + await this.seedDeps.seasonSponsorshipRepository.create(sponsorship); + } catch { + // ignore duplicates + } + } + + if (!sponsorshipRequestsSeededViaRepo) { + for (const request of seed.sponsorshipRequests) { + try { + await this.seedDeps.sponsorshipRequestRepository.create(request); + } catch { + // ignore duplicates + } + } + } + + for (const wallet of seed.leagueWallets) { + try { + await this.seedDeps.leagueWalletRepository.create(wallet); + } catch { + // ignore duplicates + } + } + + for (const tx of seed.leagueWalletTransactions) { + try { + await this.seedDeps.transactionRepository.create(tx); + } catch { + // ignore duplicates + } + } + + for (const protest of seed.protests) { + try { + await this.seedDeps.protestRepository.create(protest); + } catch { + // ignore duplicates + } + } + + for (const penalty of seed.penalties) { + try { + await this.seedDeps.penaltyRepository.create(penalty); + } catch { + // ignore duplicates + } + } + for (const race of seed.races) { try { await this.seedDeps.raceRepository.create(race); @@ -79,6 +163,14 @@ export class SeedRacingData { } } + for (const request of seed.leagueJoinRequests) { + try { + await this.seedDeps.leagueMembershipRepository.saveJoinRequest(request); + } catch { + // ignore duplicates + } + } + for (const team of seed.teams) { try { await this.seedDeps.teamRepository.create(team); @@ -87,6 +179,14 @@ export class SeedRacingData { } } + for (const sponsor of seed.sponsors) { + try { + await this.seedDeps.sponsorRepository.create(sponsor); + } catch { + // ignore duplicates + } + } + for (const membership of seed.teamMemberships) { try { await this.seedDeps.teamMembershipRepository.saveMembership(membership); @@ -95,6 +195,14 @@ export class SeedRacingData { } } + for (const request of seed.teamJoinRequests) { + try { + await this.seedDeps.teamMembershipRepository.saveJoinRequest(request); + } catch { + // ignore duplicates + } + } + for (const registration of seed.raceRegistrations) { try { await this.seedDeps.raceRegistrationRepository.register(registration); diff --git a/adapters/bootstrap/racing/RacingLeagueWalletFactory.ts b/adapters/bootstrap/racing/RacingLeagueWalletFactory.ts new file mode 100644 index 000000000..b8f7f9c65 --- /dev/null +++ b/adapters/bootstrap/racing/RacingLeagueWalletFactory.ts @@ -0,0 +1,197 @@ +import { faker } from '@faker-js/faker'; +import type { League } from '@core/racing/domain/entities/League'; +import { LeagueWallet } from '@core/racing/domain/entities/league-wallet/LeagueWallet'; +import { LeagueWalletId } from '@core/racing/domain/entities/league-wallet/LeagueWalletId'; +import { Transaction } from '@core/racing/domain/entities/league-wallet/Transaction'; +import { TransactionId } from '@core/racing/domain/entities/league-wallet/TransactionId'; +import { Money } from '@core/racing/domain/value-objects/Money'; + +type LeagueWalletSeed = { + wallets: LeagueWallet[]; + transactions: Transaction[]; +}; + +export class RacingLeagueWalletFactory { + constructor(private readonly baseDate: Date) {} + + create(leagues: League[]): LeagueWalletSeed { + const wallets: LeagueWallet[] = []; + const transactions: Transaction[] = []; + + for (const league of leagues) { + const leagueId = league.id.toString(); + const walletId = `wallet-${leagueId}`; + const createdAt = faker.date.past({ years: 2, refDate: this.baseDate }); + + // Ensure coverage: + // - some leagues have no wallet (wallet use-case returns WALLET_NOT_FOUND) + // - some wallets have no transactions + // - some wallets have varied transaction types + statuses + const shouldHaveWallet = leagueId !== 'league-2' && leagueId !== 'league-7'; + + if (!shouldHaveWallet) { + continue; + } + + const currency: 'USD' | 'EUR' | 'GBP' = leagueId === 'league-1' ? 'EUR' : 'USD'; + + const baselineBalance = + leagueId === 'league-5' + ? Money.create(2880, 'USD') + : Money.create(faker.number.int({ min: 0, max: 12_000 }), currency); + + let wallet = LeagueWallet.create({ + id: walletId, + leagueId, + balance: baselineBalance, + createdAt, + }); + + const transactionCount = + leagueId === 'league-3' + ? 0 + : leagueId === 'league-5' + ? 14 + : faker.number.int({ min: 2, max: 18 }); + + for (let i = 0; i < transactionCount; i++) { + const id = TransactionId.create(`tx-${leagueId}-${i + 1}`); + const type = this.pickTransactionType(i, leagueId); + const amount = this.pickAmount(type, currency, leagueId); + + let status = this.pickStatus(type, i, leagueId); + + const isDebit = type === 'withdrawal' || type === 'refund'; + if (status === 'completed' && isDebit) { + const expectedNetAmount = amount.calculateNetAmount(); + if (!wallet.canWithdraw(expectedNetAmount)) { + status = type === 'withdrawal' ? 'failed' : 'cancelled'; + } + } + + const tx = Transaction.create({ + id, + walletId: LeagueWalletId.create(walletId), + type, + amount, + status, + completedAt: status === 'completed' ? faker.date.recent({ days: 180, refDate: this.baseDate }) : undefined, + createdAt: faker.date.recent({ days: 240, refDate: this.baseDate }), + description: this.describe(type, leagueId), + metadata: { + leagueId, + source: type === 'sponsorship_payment' ? 'sponsorship' : 'wallet', + }, + }); + + transactions.push(tx); + + // Update wallet balance for completed transactions only. + // Use netAmount because wallet stores net proceeds after platform fee. + if (tx.status === 'completed') { + if (tx.type === 'withdrawal' || tx.type === 'refund') { + wallet = wallet.withdrawFunds(tx.netAmount, tx.id.toString()); + } else { + wallet = wallet.addFunds(tx.netAmount, tx.id.toString()); + } + } + } + + // Explicit edge-case: pending prize payout shows up in "pending payouts" + if (leagueId === 'league-5') { + const pendingPrize = Transaction.create({ + id: TransactionId.create(`tx-${leagueId}-pending-prize`), + walletId: LeagueWalletId.create(walletId), + type: 'prize_payout', + amount: Money.create(600, currency), + status: 'pending', + createdAt: faker.date.recent({ days: 15, refDate: this.baseDate }), + completedAt: undefined, + description: 'Season prize pool payout (pending)', + metadata: { seasonId: 'season-2', placement: 'P1-P3' }, + }); + + transactions.push(pendingPrize); + } + + wallets.push(wallet); + } + + return { wallets, transactions }; + } + + private pickTransactionType(index: number, leagueId: string) { + if (leagueId === 'league-5') { + const types = [ + 'sponsorship_payment', + 'membership_payment', + 'membership_payment', + 'sponsorship_payment', + 'prize_payout', + 'withdrawal', + ] as const; + return types[index % types.length]!; + } + + const types = [ + 'membership_payment', + 'sponsorship_payment', + 'membership_payment', + 'withdrawal', + 'refund', + 'prize_payout', + ] as const; + + return types[index % types.length]!; + } + + private pickAmount(type: Transaction['type'], currency: 'USD' | 'EUR' | 'GBP', leagueId: string): Money { + const asUsd = (amount: number) => Money.create(amount, currency); + + switch (type) { + case 'sponsorship_payment': + return asUsd(leagueId === 'league-5' ? faker.number.int({ min: 400, max: 1600 }) : faker.number.int({ min: 150, max: 2400 })); + case 'membership_payment': + return asUsd(faker.number.int({ min: 10, max: 120 })); + case 'withdrawal': + return asUsd(faker.number.int({ min: 25, max: 800 })); + case 'refund': + return asUsd(faker.number.int({ min: 10, max: 150 })); + case 'prize_payout': + return asUsd(faker.number.int({ min: 100, max: 1500 })); + } + } + + private pickStatus(type: Transaction['type'], index: number, leagueId: string): Transaction['status'] { + if (leagueId === 'league-5') { + if (type === 'withdrawal' && index % 2 === 0) return 'completed'; + if (type === 'prize_payout' && index % 3 === 0) return 'pending'; + return 'completed'; + } + + if (type === 'withdrawal') { + return faker.helpers.arrayElement(['completed', 'pending', 'failed'] as const); + } + + if (type === 'refund') { + return faker.helpers.arrayElement(['completed', 'cancelled'] as const); + } + + return faker.helpers.arrayElement(['completed', 'pending'] as const); + } + + private describe(type: Transaction['type'], leagueId: string): string { + switch (type) { + case 'sponsorship_payment': + return leagueId === 'league-5' ? 'Season sponsorship payment' : 'Sponsorship payment'; + case 'membership_payment': + return 'Membership fee'; + case 'withdrawal': + return 'Owner withdrawal'; + case 'refund': + return 'Refund processed'; + case 'prize_payout': + return 'Prize payout'; + } + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingMembershipFactory.ts b/adapters/bootstrap/racing/RacingMembershipFactory.ts index 284ae8539..3f3be531b 100644 --- a/adapters/bootstrap/racing/RacingMembershipFactory.ts +++ b/adapters/bootstrap/racing/RacingMembershipFactory.ts @@ -1,5 +1,6 @@ import { Driver } from '@core/racing/domain/entities/Driver'; import { League } from '@core/racing/domain/entities/League'; +import { JoinRequest } from '@core/racing/domain/entities/JoinRequest'; import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; import { Race } from '@core/racing/domain/entities/Race'; import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration'; @@ -9,44 +10,308 @@ export class RacingMembershipFactory { createLeagueMemberships(drivers: Driver[], leagues: League[]): LeagueMembership[] { const memberships: LeagueMembership[] = []; + const leagueById = new Map(leagues.map(l => [l.id.toString(), l])); - for (const driver of drivers) { - const driverId = driver.id; - + const add = (props: { + leagueId: string; + driverId: string; + role: 'owner' | 'admin' | 'steward' | 'member'; + status: 'active' | 'inactive' | 'pending'; + joinedDaysAgo: number; + id?: string; + }): void => { memberships.push( LeagueMembership.create({ - leagueId: 'league-5', - driverId, - role: driverId === 'driver-1' ? 'owner' : 'member', - status: 'active', - joinedAt: this.addDays(this.baseDate, -60), + leagueId: props.leagueId, + driverId: props.driverId, + role: props.role, + status: props.status, + joinedAt: this.addDays(this.baseDate, -props.joinedDaysAgo), + ...(props.id !== undefined ? { id: props.id } : {}), }), ); + }; + // Empty league: intentionally no memberships. + // (Keep `league-2` empty if it exists.) + // Widen the type to avoid TS2367 “no overlap” comparisons in some build modes. + const emptyLeagueId: string | undefined = leagueById.has('league-2') ? ('league-2' as string) : undefined; + + // Demo league: "full" + overbooked with pending/inactive members. + const demoLeague = leagueById.get('league-5'); + if (demoLeague) { + const maxDrivers = demoLeague.settings.maxDrivers ?? 32; + const activeDrivers = drivers.slice(0, Math.min(maxDrivers, drivers.length)); + + activeDrivers.forEach((driver, idx) => { + const driverId = driver.id.toString(); + const role = + driverId === 'driver-1' + ? 'owner' + : idx === 1 || idx === 2 + ? 'admin' + : idx === 3 || idx === 4 + ? 'steward' + : 'member'; + + add({ leagueId: demoLeague.id.toString(), driverId, role, status: 'active', joinedDaysAgo: 60 - idx }); + }); + + // Over-cap edge cases (membership exists but not active / pending) + const overbooked = drivers.slice(activeDrivers.length, activeDrivers.length + 4); + overbooked.forEach((driver, idx) => { + add({ + leagueId: demoLeague.id.toString(), + driverId: driver.id.toString(), + role: 'member', + status: idx % 2 === 0 ? 'pending' : 'inactive', + joinedDaysAgo: 10 + idx, + }); + }); + } + + // League with mixed statuses and roles (but not full). + const league1 = leagueById.get('league-1'); + if (league1) { + const pick = drivers.slice(15, 25); + pick.forEach((driver, idx) => { + add({ + leagueId: league1.id.toString(), + driverId: driver.id.toString(), + role: idx === 0 ? 'owner' : idx === 1 ? 'steward' : 'member', + status: idx % 5 === 0 ? 'pending' : idx % 7 === 0 ? 'inactive' : 'active', + joinedDaysAgo: 30 + idx, + }); + }); + } + + // League with only pending memberships (tests "pending list" UX). + const league4 = leagueById.get('league-4'); + if (league4) { + drivers.slice(40, 48).forEach((driver, idx) => { + add({ + leagueId: league4.id.toString(), + driverId: driver.id.toString(), + role: idx === 0 ? 'owner' : 'member', + status: 'pending', + joinedDaysAgo: 3 + idx, + }); + }); + } + + // Spread remaining drivers across remaining leagues to create realistic overlap. + for (const driver of drivers) { + const driverId = driver.id.toString(); const driverNumber = Number(driverId.split('-')[1]); - const extraLeague = leagues[(driverNumber % (leagues.length - 1)) + 1]; - if (extraLeague) { - memberships.push( - LeagueMembership.create({ - leagueId: extraLeague.id.toString(), + for (const league of leagues) { + const leagueId = league.id.toString(); + if (leagueId === 'league-5') continue; + if (emptyLeagueId && leagueId === emptyLeagueId) continue; + + if (driverNumber % 11 === 0 && leagueId === 'league-3') { + add({ + leagueId, + driverId, + role: 'member', + status: 'inactive', + joinedDaysAgo: 120, + }); + continue; + } + + // Sparse membership distribution (not every driver in every league) + if ((driverNumber + Number(leagueId.split('-')[1] ?? 0)) % 9 === 0) { + add({ + leagueId, driverId, role: 'member', status: 'active', - joinedAt: this.addDays(this.baseDate, -40), - }), - ); + joinedDaysAgo: 45, + }); + } } } return memberships; } - createRaceRegistrations(races: Race[]): RaceRegistration[] { + createLeagueJoinRequests( + drivers: Driver[], + leagues: League[], + leagueMemberships: LeagueMembership[], + ): JoinRequest[] { + const membershipIds = new Set(leagueMemberships.map(m => m.id.toString())); + const requests: JoinRequest[] = []; + + const addRequest = (input: { leagueId: string; driverId: string; id?: string; message?: string; requestedAt?: Date }) => { + requests.push( + JoinRequest.create({ + leagueId: input.leagueId, + driverId: input.driverId, + ...(input.id !== undefined && { id: input.id }), + ...(input.message !== undefined && { message: input.message }), + ...(input.requestedAt !== undefined && { requestedAt: input.requestedAt }), + }), + ); + }; + + // League with lots of requests + membership/request conflicts (everyone is a member of league-5 already). + const demoLeagueId = 'league-5'; + const demoDrivers = drivers.slice(10, 35); + demoDrivers.forEach((driver, idx) => { + const message = + idx % 4 === 0 + ? 'Interested in consistent stewarding and clean racing.' + : idx % 4 === 1 + ? undefined + : idx % 4 === 2 + ? '' + : 'Can I join mid-season and still be eligible for points?'; + + addRequest({ + leagueId: demoLeagueId, + driverId: driver.id.toString(), + requestedAt: this.addDays(this.baseDate, -(7 + idx)), + ...(message !== undefined && { message }), + }); + }); + + // League with a few "normal" requests (only drivers who are NOT members already). + const targetLeagueId = 'league-1'; + const nonMembers = drivers + .filter(driver => !membershipIds.has(`${targetLeagueId}:${driver.id.toString()}`)) + .slice(0, 6); + + nonMembers.forEach((driver, idx) => { + addRequest({ + leagueId: targetLeagueId, + driverId: driver.id.toString(), + requestedAt: this.addDays(this.baseDate, -(3 + idx)), + ...(idx % 2 === 0 && { message: 'Looking for regular endurance rounds and stable race times.' }), + }); + }); + + // Single request with no message (explicit id). + const league3Exists = leagues.some(l => l.id.toString() === 'league-3'); + if (league3Exists && drivers[0]) { + addRequest({ + id: 'league-3-join-req-1', + leagueId: 'league-3', + driverId: drivers[0].id.toString(), + requestedAt: this.addDays(this.baseDate, -9), + }); + } + + // Duplicate id edge case (last write wins in in-memory repo). + if (drivers[1]) { + addRequest({ + id: 'dup-league-join-req-1', + leagueId: 'league-7', + driverId: drivers[1].id.toString(), + requestedAt: this.addDays(this.baseDate, -2), + message: 'First request message (will be overwritten).', + }); + + addRequest({ + id: 'dup-league-join-req-1', + leagueId: 'league-7', + driverId: drivers[1].id.toString(), + requestedAt: this.addDays(this.baseDate, -1), + message: 'Updated request message (duplicate id).', + }); + } + + // Explicit conflict: join request exists even though membership exists. + const driver1 = drivers.find(d => d.id.toString() === 'driver-1'); + if (driver1) { + addRequest({ + id: 'conflict-req-league-5-driver-1', + leagueId: demoLeagueId, + driverId: driver1.id.toString(), + requestedAt: this.addDays(this.baseDate, -15), + message: 'Testing UI edge case: request exists for an existing member.', + }); + } + + return requests; + } + + createRaceRegistrations( + races: Race[], + drivers: Driver[], + leagueMemberships: LeagueMembership[], + ): RaceRegistration[] { const registrations: RaceRegistration[] = []; + const activeMembershipKey = new Set( + leagueMemberships + .filter(m => m.status.toString() === 'active') + .map(m => `${m.leagueId.toString()}:${m.driverId.toString()}`), + ); + const scheduled = races.filter((r) => r.status === 'scheduled'); + + for (const race of scheduled) { + const leagueId = race.leagueId.toString(); + const targetCount = (race as unknown as { registeredCount?: number }).registeredCount ?? 0; + + // 25%: intentionally no registrations + if (Number(race.id.toString().split('-')[1] ?? 0) % 4 === 0) { + continue; + } + + const eligibleDrivers = drivers + .map(d => d.id.toString()) + .filter(driverId => activeMembershipKey.has(`${leagueId}:${driverId}`)); + + const desired = Math.min( + eligibleDrivers.length, + Math.max(1, targetCount > 0 ? targetCount : 3), + ); + + const start = Number(race.id.toString().split('-')[1] ?? 0); + for (let i = 0; i < desired; i++) { + const driverId = eligibleDrivers[(start + i) % eligibleDrivers.length]; + if (!driverId) continue; + + registrations.push( + RaceRegistration.create({ + raceId: race.id, + driverId, + }), + ); + } + + // Edge case: one "outsider" registration (driver not active in league) + if (eligibleDrivers.length > 0 && drivers.length > eligibleDrivers.length) { + const outsider = drivers + .map(d => d.id.toString()) + .find(driverId => !activeMembershipKey.has(`${leagueId}:${driverId}`)); + + if (outsider && start % 7 === 0) { + registrations.push( + RaceRegistration.create({ + raceId: race.id, + driverId: outsider, + }), + ); + } + } + + // Edge case: duplicate registration (should be ignored by repo if unique constrained) + if (start % 9 === 0 && registrations.length > 0) { + const last = registrations[registrations.length - 1]!; + registrations.push( + RaceRegistration.create({ + raceId: last.raceId.toString(), + driverId: last.driverId.toString(), + }), + ); + } + } + + // Keep a tiny curated "happy path" for the demo league as well const upcomingDemoLeague = races.filter((r) => r.status === 'scheduled' && r.leagueId === 'league-5').slice(0, 3); - for (const race of upcomingDemoLeague) { registrations.push( RaceRegistration.create({ diff --git a/adapters/bootstrap/racing/RacingResultFactory.ts b/adapters/bootstrap/racing/RacingResultFactory.ts index ea93b4483..85eb63b41 100644 --- a/adapters/bootstrap/racing/RacingResultFactory.ts +++ b/adapters/bootstrap/racing/RacingResultFactory.ts @@ -8,12 +8,45 @@ export class RacingResultFactory { const completed = races.filter((r) => r.status === 'completed'); for (const race of completed) { - const participants = drivers.slice(0, Math.min(16, drivers.length)); + if (drivers.length === 0) continue; - for (let idx = 0; idx < participants.length; idx++) { - const driver = participants[idx]!; + const rng = this.mulberry32(this.hashToSeed(`${race.id}:${race.leagueId}:${race.trackId}`)); + + const minSize = Math.min(6, drivers.length); + const maxSize = Math.min(26, drivers.length); + const participantCount = Math.max( + minSize, + Math.min(maxSize, minSize + Math.floor(rng() * (maxSize - minSize + 1))), + ); + + const offset = Math.floor(rng() * drivers.length); + const participants = Array.from({ length: participantCount }, (_, idx) => drivers[(offset + idx) % drivers.length]!); + + const finishOrder = [...participants]; + this.shuffleInPlace(finishOrder, rng); + + const gridOrder = [...participants]; + this.shuffleInPlace(gridOrder, rng); + + const baseLap = 82_000 + Math.floor(rng() * 12_000); // 1:22.000 - 1:34.000-ish + + for (let idx = 0; idx < finishOrder.length; idx++) { + const driver = finishOrder[idx]!; const position = idx + 1; - const startPosition = ((idx + 3) % participants.length) + 1; + + const startPosition = gridOrder.findIndex(d => d.id.toString() === driver.id.toString()) + 1; + + const lapJitter = Math.floor(rng() * 900); + const fastestLap = baseLap + lapJitter + (position - 1) * 35; + + const incidents = + rng() < 0.55 + ? 0 + : rng() < 0.85 + ? 1 + : rng() < 0.95 + ? 2 + : 3 + Math.floor(rng() * 6); results.push( RaceResult.create({ @@ -21,9 +54,9 @@ export class RacingResultFactory { raceId: race.id, driverId: driver.id, position, - startPosition, - fastestLap: 88_000 + idx * 120, - incidents: idx % 4 === 0 ? 2 : 0, + startPosition: Math.max(1, startPosition), + fastestLap, + incidents, }), ); } @@ -31,4 +64,33 @@ export class RacingResultFactory { return results; } + + private shuffleInPlace(items: T[], rng: () => number): void { + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + const tmp = items[i]!; + items[i] = items[j]!; + items[j] = tmp; + } + } + + private hashToSeed(input: string): number { + let hash = 2166136261; + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; + } + + private mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } } \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts b/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts new file mode 100644 index 000000000..70ebcad35 --- /dev/null +++ b/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts @@ -0,0 +1,248 @@ +import { faker } from '@faker-js/faker'; +import type { League } from '@core/racing/domain/entities/League'; +import { SponsorshipRequest } from '@core/racing/domain/entities/SponsorshipRequest'; +import { Season } from '@core/racing/domain/entities/season/Season'; +import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship'; +import type { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor'; +import { Money } from '@core/racing/domain/value-objects/Money'; + +export class RacingSeasonSponsorshipFactory { + constructor(private readonly baseDate: Date) {} + + createSeasons(leagues: League[]): Season[] { + const seasons: Season[] = []; + + for (const league of leagues) { + const leagueId = league.id.toString(); + + if (leagueId === 'league-5') { + seasons.push( + Season.create({ + id: 'season-1', + leagueId, + gameId: 'iracing', + name: 'Season 1 (GT Sprint)', + year: 2025, + order: 1, + status: 'active', + startDate: this.daysFromBase(-30), + }), + Season.create({ + id: 'season-2', + leagueId, + gameId: 'iracing', + name: 'Season 2 (Endurance Cup)', + year: 2024, + order: 0, + status: 'completed', + startDate: this.daysFromBase(-120), + endDate: this.daysFromBase(-60), + }), + Season.create({ + id: 'season-3', + leagueId, + gameId: 'iracing', + name: 'Season 3 (Planned)', + year: 2025, + order: 2, + status: 'planned', + startDate: this.daysFromBase(14), + }), + ); + continue; + } + + if (leagueId === 'league-3') { + seasons.push( + Season.create({ + id: 'league-3-season-a', + leagueId, + gameId: 'iracing', + name: 'Split Season A', + year: 2025, + order: 1, + status: 'active', + startDate: this.daysFromBase(-10), + }), + Season.create({ + id: 'league-3-season-b', + leagueId, + gameId: 'iracing', + name: 'Split Season B', + year: 2025, + order: 2, + status: 'active', + startDate: this.daysFromBase(-3), + }), + ); + continue; + } + + const baseYear = this.baseDate.getUTCFullYear(); + const seasonCount = leagueId === 'league-2' ? 1 : faker.number.int({ min: 1, max: 3 }); + + for (let i = 0; i < seasonCount; i++) { + const id = `${leagueId}-season-${i + 1}`; + const isFirst = i === 0; + + const status: Season['status'] = + leagueId === 'league-1' && isFirst + ? 'active' + : leagueId === 'league-2' + ? 'planned' + : isFirst + ? faker.helpers.arrayElement(['active', 'planned'] as const) + : faker.helpers.arrayElement(['completed', 'archived', 'cancelled'] as const); + + const startOffset = + status === 'active' + ? faker.number.int({ min: -60, max: -1 }) + : status === 'planned' + ? faker.number.int({ min: 7, max: 60 }) + : faker.number.int({ min: -200, max: -90 }); + + const endOffset = + status === 'completed' || status === 'archived' || status === 'cancelled' + ? faker.number.int({ min: -89, max: -7 }) + : undefined; + + seasons.push( + Season.create({ + id, + leagueId, + gameId: 'iracing', + name: `${faker.word.adjective()} ${faker.word.noun()} Season`, + year: baseYear + faker.number.int({ min: -1, max: 1 }), + order: i + 1, + status, + startDate: this.daysFromBase(startOffset), + ...(endOffset !== undefined ? { endDate: this.daysFromBase(endOffset) } : {}), + }), + ); + } + } + + return seasons; + } + + createSeasonSponsorships(seasons: Season[], sponsors: Sponsor[]): SeasonSponsorship[] { + const sponsorships: SeasonSponsorship[] = []; + const sponsorIds = sponsors.map((s) => s.id.toString()); + + for (const season of seasons) { + const sponsorshipCount = + season.id === 'season-1' + ? 2 + : season.status === 'active' + ? faker.number.int({ min: 0, max: 2 }) + : faker.number.int({ min: 0, max: 1 }); + + const usedSponsorIds = new Set(); + + for (let i = 0; i < sponsorshipCount; i++) { + const tier: SeasonSponsorship['tier'] = + i === 0 ? 'main' : faker.helpers.arrayElement(['secondary', 'secondary', 'main'] as const); + + const sponsorIdCandidate = faker.helpers.arrayElement(sponsorIds); + const sponsorId = usedSponsorIds.has(sponsorIdCandidate) + ? faker.helpers.arrayElement(sponsorIds) + : sponsorIdCandidate; + + usedSponsorIds.add(sponsorId); + + const base = SeasonSponsorship.create({ + id: `season-sponsorship-${season.id}-${i + 1}`, + seasonId: season.id, + leagueId: season.leagueId, + sponsorId, + tier, + pricing: Money.create( + tier === 'main' ? faker.number.int({ min: 900, max: 2500 }) : faker.number.int({ min: 250, max: 900 }), + 'USD', + ), + createdAt: faker.date.recent({ days: 120, refDate: this.baseDate }), + description: tier === 'main' ? 'Main sponsor slot' : 'Secondary sponsor slot', + ...(season.status === 'active' + ? { + status: faker.helpers.arrayElement(['active', 'pending'] as const), + activatedAt: faker.date.recent({ days: 30, refDate: this.baseDate }), + } + : season.status === 'completed' || season.status === 'archived' + ? { + status: faker.helpers.arrayElement(['ended', 'cancelled'] as const), + endedAt: faker.date.recent({ days: 200, refDate: this.baseDate }), + } + : { + status: faker.helpers.arrayElement(['pending', 'cancelled'] as const), + }), + }); + + sponsorships.push(base); + } + } + + return sponsorships; + } + + createSponsorshipRequests(seasons: Season[], sponsors: Sponsor[]): SponsorshipRequest[] { + const requests: SponsorshipRequest[] = []; + const sponsorIds = sponsors.map((s) => s.id.toString()); + + for (const season of seasons) { + const isHighTrafficDemo = season.id === 'season-1'; + const maxRequests = + isHighTrafficDemo ? 8 : season.status === 'active' ? faker.number.int({ min: 0, max: 4 }) : faker.number.int({ min: 0, max: 1 }); + + for (let i = 0; i < maxRequests; i++) { + const tier: SponsorshipRequest['tier'] = + i === 0 ? 'main' : faker.helpers.arrayElement(['secondary', 'secondary', 'main'] as const); + + const sponsorId = + isHighTrafficDemo && i === 0 + ? 'demo-sponsor-1' + : faker.helpers.arrayElement(sponsorIds); + + const offeredAmount = Money.create( + tier === 'main' ? faker.number.int({ min: 1000, max: 3500 }) : faker.number.int({ min: 200, max: 1200 }), + 'USD', + ); + + const includeMessage = i % 3 !== 0; + const message = includeMessage + ? faker.helpers.arrayElement([ + 'We would love to sponsor this season — high-quality sim racing content fits our brand.', + 'We can provide prize pool support + gear giveaways for podium finishers.', + 'Interested in the main slot: branding on liveries + broadcast overlays.', + 'We want a secondary slot to test engagement before a bigger commitment.', + ]) + : undefined; + + // A mix of statuses for edge cases (pending is what the UI lists) + const status = + season.status === 'active' + ? faker.helpers.arrayElement(['pending', 'pending', 'pending', 'rejected', 'withdrawn'] as const) + : faker.helpers.arrayElement(['pending', 'rejected'] as const); + + requests.push( + SponsorshipRequest.create({ + id: `sponsorship-request-${season.id}-${i + 1}`, + sponsorId, + entityType: 'season', + entityId: season.id, + tier, + offeredAmount, + ...(message !== undefined ? { message } : {}), + status, + createdAt: faker.date.recent({ days: 60, refDate: this.baseDate }), + }), + ); + } + } + + return requests; + } + + private daysFromBase(days: number): Date { + return new Date(this.baseDate.getTime() + days * 24 * 60 * 60 * 1000); + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingSeed.ts b/adapters/bootstrap/racing/RacingSeed.ts index 559ff0d08..b06e18046 100644 --- a/adapters/bootstrap/racing/RacingSeed.ts +++ b/adapters/bootstrap/racing/RacingSeed.ts @@ -7,7 +7,12 @@ import { Result as RaceResult } from '@core/racing/domain/entities/result/Result import { Standing } from '@core/racing/domain/entities/Standing'; import { Team } from '@core/racing/domain/entities/Team'; import { Track } from '@core/racing/domain/entities/Track'; -import type { TeamMembership } from '@core/racing/domain/types/TeamMembership'; +import { JoinRequest } from '@core/racing/domain/entities/JoinRequest'; +import { SponsorshipRequest } from '@core/racing/domain/entities/SponsorshipRequest'; +import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor'; +import { Season } from '@core/racing/domain/entities/season/Season'; +import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSponsorship'; +import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership'; import type { FeedItem } from '@core/social/domain/types/FeedItem'; import { RacingDriverFactory } from './RacingDriverFactory'; import { RacingFeedFactory } from './RacingFeedFactory'; @@ -19,6 +24,10 @@ import { RacingResultFactory } from './RacingResultFactory'; import { RacingStandingFactory } from './RacingStandingFactory'; import { RacingTeamFactory } from './RacingTeamFactory'; import { RacingTrackFactory } from './RacingTrackFactory'; +import { RacingSponsorFactory } from './RacingSponsorFactory'; +import { RacingSeasonSponsorshipFactory } from './RacingSeasonSponsorshipFactory'; +import { RacingLeagueWalletFactory } from './RacingLeagueWalletFactory'; +import { RacingStewardingFactory } from './RacingStewardingFactory'; export type Friendship = { driverId: string; @@ -28,13 +37,23 @@ export type Friendship = { export type RacingSeed = { drivers: Driver[]; leagues: League[]; + seasons: Season[]; + seasonSponsorships: SeasonSponsorship[]; + sponsorshipRequests: SponsorshipRequest[]; + leagueWallets: import('@core/racing/domain/entities/league-wallet/LeagueWallet').LeagueWallet[]; + leagueWalletTransactions: import('@core/racing/domain/entities/league-wallet/Transaction').Transaction[]; + protests: import('@core/racing/domain/entities/Protest').Protest[]; + penalties: import('@core/racing/domain/entities/penalty/Penalty').Penalty[]; races: Race[]; results: RaceResult[]; standings: Standing[]; leagueMemberships: LeagueMembership[]; + leagueJoinRequests: JoinRequest[]; raceRegistrations: RaceRegistration[]; teams: Team[]; teamMemberships: TeamMembership[]; + teamJoinRequests: TeamJoinRequest[]; + sponsors: Sponsor[]; tracks: Track[]; friendships: Friendship[]; feedEvents: FeedItem[]; @@ -68,6 +87,9 @@ class RacingSeedFactory { const resultFactory = new RacingResultFactory(); const standingFactory = new RacingStandingFactory(); const membershipFactory = new RacingMembershipFactory(this.baseDate); + const sponsorFactory = new RacingSponsorFactory(this.baseDate); + const seasonSponsorshipFactory = new RacingSeasonSponsorshipFactory(this.baseDate); + const leagueWalletFactory = new RacingLeagueWalletFactory(this.baseDate); const friendshipFactory = new RacingFriendshipFactory(); const feedFactory = new RacingFeedFactory(this.baseDate); @@ -75,27 +97,51 @@ class RacingSeedFactory { const tracks = trackFactory.create(); const leagueFactory = new RacingLeagueFactory(this.baseDate, drivers); const leagues = leagueFactory.create(); - const teamFactory = new RacingTeamFactory(this.baseDate, drivers, leagues); - const teams = teamFactory.createTeams(); + const sponsors = sponsorFactory.create(); + const seasons = seasonSponsorshipFactory.createSeasons(leagues); + const seasonSponsorships = seasonSponsorshipFactory.createSeasonSponsorships(seasons, sponsors); + const sponsorshipRequests = seasonSponsorshipFactory.createSponsorshipRequests(seasons, sponsors); + + const leagueMemberships = membershipFactory.createLeagueMemberships(drivers, leagues); + const leagueJoinRequests = membershipFactory.createLeagueJoinRequests(drivers, leagues, leagueMemberships); + + const { wallets: leagueWallets, transactions: leagueWalletTransactions } = leagueWalletFactory.create(leagues); + + const teamFactory = new RacingTeamFactory(this.baseDate); + const teams = teamFactory.createTeams(drivers, leagues); const races = raceFactory.create(leagues, tracks); const results = resultFactory.create(drivers, races); const standings = standingFactory.create(leagues, races, results); - const leagueMemberships = membershipFactory.createLeagueMemberships(drivers, leagues); - const raceRegistrations = membershipFactory.createRaceRegistrations(races); + const raceRegistrations = membershipFactory.createRaceRegistrations(races, drivers, leagueMemberships); const teamMemberships = teamFactory.createTeamMemberships(drivers, teams); + const teamJoinRequests = teamFactory.createTeamJoinRequests(drivers, teams, teamMemberships); + + const stewardingFactory = new RacingStewardingFactory(this.baseDate); + const { protests, penalties } = stewardingFactory.create(races, drivers, leagueMemberships); + const friendships = friendshipFactory.create(drivers); const feedEvents = feedFactory.create(drivers, friendships, races, leagues); return { drivers, leagues, + seasons, + seasonSponsorships, + sponsorshipRequests, + leagueWallets, + leagueWalletTransactions, + protests, + penalties, races, results, standings, leagueMemberships, + leagueJoinRequests, raceRegistrations, teams, teamMemberships, + teamJoinRequests, + sponsors, tracks, friendships, feedEvents, diff --git a/adapters/bootstrap/racing/RacingSponsorFactory.ts b/adapters/bootstrap/racing/RacingSponsorFactory.ts new file mode 100644 index 000000000..caea40337 --- /dev/null +++ b/adapters/bootstrap/racing/RacingSponsorFactory.ts @@ -0,0 +1,125 @@ +import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor'; +import { faker } from '@faker-js/faker'; + +export class RacingSponsorFactory { + constructor(private readonly baseDate: Date) {} + + create(): Sponsor[] { + const demoSponsor = Sponsor.create({ + id: 'demo-sponsor-1', + name: 'GridPilot Sim Racing Supply', + contactEmail: 'partnerships@gridpilot.example', + logoUrl: 'http://localhost:3000/images/header.jpeg', + websiteUrl: 'https://gridpilot.example/sponsors/gridpilot-sim-racing-supply', + createdAt: faker.date.past({ years: 2, refDate: this.baseDate }), + }); + + const sponsorCount = 49; + + const sponsorNames = [ + 'Red Bull Energy', + 'Shell Racing', + 'Michelin Tires', + 'Castrol Oil', + 'Pirelli Tires', + 'Goodyear Tires', + 'Bridgestone', + 'Continental Tires', + 'Dunlop Tires', + 'Yokohama Tires', + 'Hankook Tires', + 'BFGoodrich', + 'Firestone Tires', + 'Kumho Tires', + 'Nexen Tires', + 'Falken Tires', + 'Toyo Tires', + 'Maxxis Tires', + 'Cooper Tires', + 'General Tires', + 'Sparco Motorsport', + 'OMP Racing', + 'Simucube', + 'Fanatec', + 'Moza Racing', + 'Heusinkveld Engineering', + 'Next Level Racing', + 'Thrustmaster Racing', + 'Logitech G Racing', + 'GoPro Motorsports', + 'VRS DirectForce', + 'Ascher Racing', + 'RaceRoom Store', + 'iRacing Credits', + 'Assetto Corsa Competizione', + 'RaceDepartment', + 'SimRacing.GP', + 'Grid Finder', + 'Racing Gloves Co.', + 'Hydration Labs', + 'Energy Bar Co.', + 'Broadcast Overlay Studio', + 'Carbon Fiber Works', + 'Brake Pad Supply', + 'Pit Strategy Analytics', + 'Telemetry Tools', + 'Setup Shop', + 'Streaming Partner', + 'Hardware Tuning Lab', + 'Trackside Media', + ]; + + const logoPaths = [ + 'http://localhost:3000/images/header.jpeg', + 'http://localhost:3000/images/ff1600.jpeg', + 'http://localhost:3000/images/avatars/male-default-avatar.jpg', + 'http://localhost:3000/images/avatars/female-default-avatar.jpeg', + 'http://localhost:3000/images/avatars/neutral-default-avatar.jpeg', + 'http://localhost:3000/images/leagues/placeholder-cover.svg', + 'http://localhost:3000/favicon.svg', + ]; + + const websiteUrls = [ + 'https://www.redbull.com', + 'https://www.shell.com', + 'https://www.michelin.com', + 'https://www.castrol.com', + 'https://www.pirelli.com', + 'https://www.goodyear.com', + 'https://www.bridgestone.com', + 'https://www.continental-tires.com', + 'https://www.dunlop.eu', + 'https://www.yokohamatire.com', + 'https://www.hankooktire.com', + 'https://www.sparco-official.com', + 'https://www.omp.com', + 'https://fanatec.com', + 'https://mozaracing.com', + 'https://heusinkveld.com', + 'https://nextlevelracing.com', + 'https://www.thrustmaster.com', + 'https://www.logitechg.com', + 'https://gopro.com', + ]; + + const sponsors = Array.from({ length: sponsorCount }, (_, idx) => { + const i = idx + 1; + const name = sponsorNames[idx % sponsorNames.length]!; + const safeName = name.replace(/\s+/g, '').toLowerCase(); + + const logoUrl = logoPaths[idx % logoPaths.length]!; + const websiteUrl = websiteUrls[idx % websiteUrls.length]!; + + return Sponsor.create({ + id: `sponsor-${i}`, + name, + contactEmail: `partnerships+${safeName}@example.com`, + ...(logoUrl ? { logoUrl } : {}), + ...(websiteUrl ? { websiteUrl } : {}), + createdAt: faker.date.past({ years: 5, refDate: this.baseDate }), + }); + }); + + return [demoSponsor, ...sponsors]; + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingStewardingFactory.ts b/adapters/bootstrap/racing/RacingStewardingFactory.ts new file mode 100644 index 000000000..5cc99b308 --- /dev/null +++ b/adapters/bootstrap/racing/RacingStewardingFactory.ts @@ -0,0 +1,260 @@ +import { faker } from '@faker-js/faker'; +import type { Driver } from '@core/racing/domain/entities/Driver'; +import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; +import { Protest } from '@core/racing/domain/entities/Protest'; +import type { Race } from '@core/racing/domain/entities/Race'; +import { Penalty } from '@core/racing/domain/entities/penalty/Penalty'; + +type StewardingSeed = { + protests: Protest[]; + penalties: Penalty[]; +}; + +export class RacingStewardingFactory { + constructor(private readonly baseDate: Date) {} + + create(races: Race[], drivers: Driver[], leagueMemberships: LeagueMembership[]): StewardingSeed { + const protests: Protest[] = []; + const penalties: Penalty[] = []; + + const driversById = new Map(drivers.map((d) => [d.id.toString(), d])); + const activeMembersByLeague = new Map(); + + for (const membership of leagueMemberships) { + if (membership.status.toString() !== 'active') continue; + const leagueId = membership.leagueId.toString(); + const driverId = membership.driverId.toString(); + if (!driversById.has(driverId)) continue; + + const list = activeMembersByLeague.get(leagueId) ?? []; + list.push(driverId); + activeMembersByLeague.set(leagueId, list); + } + + const racesByLeague = new Map(); + for (const race of races) { + const leagueId = race.leagueId.toString(); + const list = racesByLeague.get(leagueId) ?? []; + list.push(race); + racesByLeague.set(leagueId, list); + } + + // Make league-5 the "busy demo league" for stewarding UIs: + // - at least one pending/under_review protest + // - some resolved protests + // - penalties with and without protest linkage + const demoLeagueRaces = (racesByLeague.get('league-5') ?? []).slice().sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()); + const demoMembers = activeMembersByLeague.get('league-5') ?? []; + + if (demoLeagueRaces.length > 0 && demoMembers.length >= 4) { + const [firstRace, secondRace, thirdRace] = demoLeagueRaces; + + const protester = demoMembers[0]!; + const accused = demoMembers[1]!; + const steward = demoMembers[2]!; + const spare = demoMembers[3]!; + + if (firstRace) { + protests.push( + Protest.create({ + id: 'protest-1', + raceId: firstRace.id.toString(), + protestingDriverId: protester, + accusedDriverId: accused, + incident: { + lap: 1, + description: 'Avoidable contact into Turn 1 causing a spin and damage.', + timeInRace: 85, + }, + comment: 'I left a car width but got punted. Please review the onboard and external.', + proofVideoUrl: 'https://example.com/videos/protest-1', + status: 'pending', + filedAt: faker.date.recent({ days: 6, refDate: this.baseDate }), + }), + ); + + // No penalty yet (pending), but seed a direct steward warning on same race. + penalties.push( + Penalty.create({ + id: 'penalty-1', + leagueId: 'league-5', + raceId: firstRace.id.toString(), + driverId: accused, + type: 'warning', + reason: 'Repeated track limits warnings (race director note).', + issuedBy: steward, + status: 'applied', + issuedAt: faker.date.recent({ days: 5, refDate: this.baseDate }), + appliedAt: faker.date.recent({ days: 5, refDate: this.baseDate }), + notes: 'Keep it within track limits; further offenses will escalate.', + }), + ); + } + + if (secondRace) { + protests.push( + Protest.create({ + id: 'protest-2', + raceId: secondRace.id.toString(), + protestingDriverId: spare, + accusedDriverId: accused, + incident: { + lap: 7, + description: 'Unsafe rejoin after off-track caused a collision with following traffic.', + timeInRace: 610, + }, + proofVideoUrl: 'https://example.com/videos/protest-2', + status: 'under_review', + reviewedBy: steward, + filedAt: faker.date.recent({ days: 12, refDate: this.baseDate }), + }), + ); + + // Under review penalty still pending (linked to protest) + penalties.push( + Penalty.create({ + id: 'penalty-2', + leagueId: 'league-5', + raceId: secondRace.id.toString(), + driverId: accused, + type: 'time_penalty', + value: 10, + reason: 'Unsafe rejoin (protest pending review)', + protestId: 'protest-2', + issuedBy: steward, + status: 'pending', + issuedAt: faker.date.recent({ days: 10, refDate: this.baseDate }), + notes: 'Will be applied to results if upheld.', + }), + ); + } + + if (thirdRace) { + const upheld = Protest.create({ + id: 'protest-3', + raceId: thirdRace.id.toString(), + protestingDriverId: protester, + accusedDriverId: spare, + incident: { + lap: 12, + description: 'Brake check on the straight leading to rear-end contact.', + timeInRace: 1280, + }, + status: 'upheld', + reviewedBy: steward, + decisionNotes: 'Brake check is not acceptable. Penalty applied.', + reviewedAt: faker.date.recent({ days: 20, refDate: this.baseDate }), + filedAt: faker.date.recent({ days: 25, refDate: this.baseDate }), + }); + + protests.push(upheld); + + penalties.push( + Penalty.create({ + id: 'penalty-3', + leagueId: 'league-5', + raceId: thirdRace.id.toString(), + driverId: spare, + type: 'points_deduction', + value: 5, + reason: upheld.incident.description.toString(), + protestId: upheld.id, + issuedBy: steward, + status: 'applied', + issuedAt: faker.date.recent({ days: 20, refDate: this.baseDate }), + appliedAt: faker.date.recent({ days: 19, refDate: this.baseDate }), + notes: 'Applied after stewarding decision.', + }), + ); + + const dismissed = Protest.create({ + id: 'protest-4', + raceId: thirdRace.id.toString(), + protestingDriverId: accused, + accusedDriverId: protester, + incident: { + lap: 3, + description: 'Minor side-by-side contact with no loss of control.', + timeInRace: 240, + }, + status: 'dismissed', + reviewedBy: steward, + decisionNotes: 'Racing incident, no further action.', + reviewedAt: faker.date.recent({ days: 18, refDate: this.baseDate }), + filedAt: faker.date.recent({ days: 22, refDate: this.baseDate }), + }); + + protests.push(dismissed); + } + } + + // Fill other leagues lightly with variety (including awaiting_defense and withdrawn). + for (const [leagueId, leagueRaces] of racesByLeague.entries()) { + if (leagueId === 'league-5') continue; + const members = activeMembersByLeague.get(leagueId) ?? []; + if (members.length < 3) continue; + + const completedRaces = leagueRaces.filter((r) => r.status.toString() === 'completed').slice(0, 2); + if (completedRaces.length === 0) continue; + + const [race] = completedRaces; + if (!race) continue; + + const a = members[0]!; + const b = members[1]!; + const steward = members[2]!; + + const seedOne = faker.number.int({ min: 0, max: 2 }) === 0; + if (!seedOne) continue; + + const status = faker.helpers.arrayElement(['awaiting_defense', 'withdrawn'] as const); + + const protest = Protest.create({ + id: `protest-${leagueId}-${race.id.toString()}`, + raceId: race.id.toString(), + protestingDriverId: a, + accusedDriverId: b, + incident: { + lap: faker.number.int({ min: 1, max: 20 }), + description: faker.helpers.arrayElement([ + 'Divebomb attempt from too far back caused avoidable contact.', + 'Blocking under braking and changing line multiple times.', + 'Track limits abuse provided a sustained advantage.', + 'Blue flag was ignored causing unnecessary time loss.', + ]), + }, + status, + ...(status === 'awaiting_defense' + ? { + defenseRequestedBy: steward, + defenseRequestedAt: faker.date.recent({ days: 3, refDate: this.baseDate }), + } + : {}), + filedAt: faker.date.recent({ days: 14, refDate: this.baseDate }), + }); + + protests.push(protest); + + if (status === 'withdrawn') { + // A non-protest-linked penalty can still exist for the same race. + penalties.push( + Penalty.create({ + id: `penalty-${leagueId}-${race.id.toString()}`, + leagueId, + raceId: race.id.toString(), + driverId: b, + type: faker.helpers.arrayElement(['grid_penalty', 'license_points'] as const), + value: faker.number.int({ min: 2, max: 5 }), + reason: 'Steward discretion: repeated behavior across sessions.', + issuedBy: steward, + status: 'applied', + issuedAt: faker.date.recent({ days: 9, refDate: this.baseDate }), + appliedAt: faker.date.recent({ days: 9, refDate: this.baseDate }), + }), + ); + } + } + + return { protests, penalties }; + } +} \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingTeamFactory.ts b/adapters/bootstrap/racing/RacingTeamFactory.ts index 34031e29b..01ea381f8 100644 --- a/adapters/bootstrap/racing/RacingTeamFactory.ts +++ b/adapters/bootstrap/racing/RacingTeamFactory.ts @@ -1,25 +1,21 @@ import { Driver } from '@core/racing/domain/entities/Driver'; import { League } from '@core/racing/domain/entities/League'; import { Team } from '@core/racing/domain/entities/Team'; -import type { TeamMembership } from '@core/racing/domain/types/TeamMembership'; +import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership'; import { faker } from '@faker-js/faker'; export class RacingTeamFactory { - constructor( - private readonly baseDate: Date, - private readonly drivers: Driver[], - private readonly leagues: League[], - ) {} + constructor(private readonly baseDate: Date) {} - createTeams(): Team[] { + createTeams(drivers: Driver[], leagues: League[]): Team[] { const teamCount = 15; return Array.from({ length: teamCount }, (_, idx) => { const i = idx + 1; - const owner = faker.helpers.arrayElement(this.drivers); + const owner = faker.helpers.arrayElement(drivers); const teamLeagues = faker.helpers.arrayElements( - this.leagues.map(l => l.id.toString()), - { min: 0, max: 3 } + leagues.map((l) => l.id.toString()), + { min: 0, max: 3 }, ); return Team.create({ @@ -111,4 +107,74 @@ export class RacingTeamFactory { return memberships; } + createTeamJoinRequests( + drivers: Driver[], + teams: Team[], + teamMemberships: TeamMembership[], + ): TeamJoinRequest[] { + const membershipIds = new Set(teamMemberships.map(m => `${m.teamId}:${m.driverId}`)); + const requests: TeamJoinRequest[] = []; + + const addRequest = (request: TeamJoinRequest): void => { + requests.push(request); + }; + + const team1 = teams[0]; + if (team1) { + const candidateDriverIds = drivers + .map(d => d.id.toString()) + .filter(driverId => !membershipIds.has(`${team1.id.toString()}:${driverId}`)) + .slice(0, 8); + + candidateDriverIds.forEach((driverId, idx) => { + addRequest({ + id: `team-join-${team1.id.toString()}-${driverId}`, + teamId: team1.id.toString(), + driverId, + requestedAt: this.addDays(this.baseDate, -(5 + idx)), + ...(idx % 3 === 0 + ? { message: 'Can I join as a substitute driver for endurance events?' } + : idx % 3 === 2 + ? { message: '' } + : {}), + }); + }); + + // Conflict edge case: owner submits a join request to own team. + addRequest({ + id: `team-join-${team1.id.toString()}-${team1.ownerId.toString()}-conflict`, + teamId: team1.id.toString(), + driverId: team1.ownerId.toString(), + requestedAt: this.addDays(this.baseDate, -1), + message: 'Testing edge case: owner submitted join request.', + }); + } + + const team3 = teams[2]; + if (team3 && drivers[0]) { + const driverId = drivers[0].id.toString(); + addRequest({ + id: 'dup-team-join-req-1', + teamId: team3.id.toString(), + driverId, + requestedAt: this.addDays(this.baseDate, -10), + message: 'First request message (will be overwritten).', + }); + + addRequest({ + id: 'dup-team-join-req-1', + teamId: team3.id.toString(), + driverId, + requestedAt: this.addDays(this.baseDate, -9), + message: 'Updated request message (duplicate id).', + }); + } + + return requests; + } + + private addDays(date: Date, days: number): Date { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); + } + } \ No newline at end of file diff --git a/adapters/bootstrap/racing/RacingTrackFactory.ts b/adapters/bootstrap/racing/RacingTrackFactory.ts index 3c0061c96..5db3e7147 100644 --- a/adapters/bootstrap/racing/RacingTrackFactory.ts +++ b/adapters/bootstrap/racing/RacingTrackFactory.ts @@ -2,6 +2,10 @@ import { Track } from '@core/racing/domain/entities/Track'; export class RacingTrackFactory { create(): Track[] { + // Only a subset of track images exist locally in `apps/website/public/images/tracks`. + // Use a real local image for a few, and a stable local placeholder for the rest. + const placeholderImageUrl = '/images/leagues/placeholder-cover.svg'; + return [ // Road tracks - various difficulties Track.create({ @@ -25,7 +29,7 @@ export class RacingTrackFactory { difficulty: 'intermediate', lengthKm: 5.793, turns: 11, - imageUrl: '/images/tracks/monza.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), Track.create({ @@ -37,7 +41,7 @@ export class RacingTrackFactory { difficulty: 'advanced', lengthKm: 5.148, turns: 15, - imageUrl: '/images/tracks/nurburgring.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), Track.create({ @@ -49,7 +53,7 @@ export class RacingTrackFactory { difficulty: 'intermediate', lengthKm: 5.891, turns: 18, - imageUrl: '/images/tracks/silverstone.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), Track.create({ @@ -61,7 +65,7 @@ export class RacingTrackFactory { difficulty: 'expert', lengthKm: 5.807, turns: 18, - imageUrl: '/images/tracks/suzuka.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), Track.create({ @@ -73,7 +77,7 @@ export class RacingTrackFactory { difficulty: 'advanced', lengthKm: 3.602, turns: 11, - imageUrl: '/images/tracks/laguna.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), Track.create({ @@ -85,7 +89,7 @@ export class RacingTrackFactory { difficulty: 'intermediate', lengthKm: 4.259, turns: 14, - imageUrl: '/images/tracks/zandvoort.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), Track.create({ @@ -97,7 +101,7 @@ export class RacingTrackFactory { difficulty: 'advanced', lengthKm: 4.909, turns: 19, - imageUrl: '/images/tracks/imola.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), Track.create({ @@ -109,7 +113,7 @@ export class RacingTrackFactory { difficulty: 'expert', lengthKm: 13.626, turns: 38, - imageUrl: '/images/tracks/le-mans.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), Track.create({ @@ -121,7 +125,7 @@ export class RacingTrackFactory { difficulty: 'intermediate', lengthKm: 4.574, turns: 17, - imageUrl: '/images/tracks/hockenheim.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), // Oval tracks @@ -146,7 +150,7 @@ export class RacingTrackFactory { difficulty: 'advanced', lengthKm: 4.192, turns: 4, - imageUrl: '/images/tracks/indianapolis.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), Track.create({ @@ -158,7 +162,7 @@ export class RacingTrackFactory { difficulty: 'beginner', lengthKm: 4.280, turns: 4, - imageUrl: '/images/tracks/talladega.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), // Street tracks @@ -171,7 +175,7 @@ export class RacingTrackFactory { difficulty: 'intermediate', lengthKm: 5.410, turns: 19, - imageUrl: '/images/tracks/miami.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), Track.create({ @@ -183,7 +187,7 @@ export class RacingTrackFactory { difficulty: 'advanced', lengthKm: 6.201, turns: 17, - imageUrl: '/images/tracks/las-vegas.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), // Dirt tracks @@ -196,7 +200,7 @@ export class RacingTrackFactory { difficulty: 'beginner', lengthKm: 0.805, turns: 4, - imageUrl: '/images/tracks/eldora.jpg', + imageUrl: placeholderImageUrl, gameId: 'iracing', }), ]; diff --git a/adapters/media/ports/InMemoryImageServiceAdapter.test.ts b/adapters/media/ports/InMemoryImageServiceAdapter.test.ts index 364bd72aa..f6936ebcf 100644 --- a/adapters/media/ports/InMemoryImageServiceAdapter.test.ts +++ b/adapters/media/ports/InMemoryImageServiceAdapter.test.ts @@ -13,9 +13,9 @@ describe('InMemoryImageServiceAdapter', () => { const adapter = new InMemoryImageServiceAdapter(logger); - expect(adapter.getDriverAvatar('driver-1')).toContain('/avatars/driver-1.png'); - expect(adapter.getTeamLogo('team-1')).toContain('/logos/team-team-1.png'); - expect(adapter.getLeagueCover('league-1')).toContain('/covers/league-league-1.png'); - expect(adapter.getLeagueLogo('league-1')).toContain('/logos/league-league-1.png'); + expect(adapter.getDriverAvatar('driver-1')).toContain('/images/avatars/'); + expect(adapter.getTeamLogo('team-1')).toBe('/images/ff1600.jpeg'); + expect(adapter.getLeagueCover('league-1')).toBe('/images/header.jpeg'); + expect(adapter.getLeagueLogo('league-1')).toBe('/images/ff1600.jpeg'); }); }); diff --git a/adapters/media/ports/InMemoryImageServiceAdapter.ts b/adapters/media/ports/InMemoryImageServiceAdapter.ts index 6234d68e8..40e03aec9 100644 --- a/adapters/media/ports/InMemoryImageServiceAdapter.ts +++ b/adapters/media/ports/InMemoryImageServiceAdapter.ts @@ -8,21 +8,30 @@ export class InMemoryImageServiceAdapter implements IImageServicePort { getDriverAvatar(driverId: string): string { this.logger.debug(`[InMemoryImageServiceAdapter] Getting avatar for driver: ${driverId}`); - return `https://cdn.example.com/avatars/${driverId}.png`; // Mock URL + const driverNumber = Number(driverId.replace('driver-', '')); + const index = Number.isFinite(driverNumber) ? driverNumber % 3 : 0; + + const avatars = [ + '/images/avatars/male-default-avatar.jpg', + '/images/avatars/female-default-avatar.jpeg', + '/images/avatars/neutral-default-avatar.jpeg', + ] as const; + + return avatars[index] ?? avatars[0]; } getTeamLogo(teamId: string): string { this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for team: ${teamId}`); - return `https://cdn.example.com/logos/team-${teamId}.png`; // Mock URL + return '/images/ff1600.jpeg'; } getLeagueCover(leagueId: string): string { this.logger.debug(`[InMemoryImageServiceAdapter] Getting cover for league: ${leagueId}`); - return `https://cdn.example.com/covers/league-${leagueId}.png`; // Mock URL + return '/images/header.jpeg'; } getLeagueLogo(leagueId: string): string { this.logger.debug(`[InMemoryImageServiceAdapter] Getting logo for league: ${leagueId}`); - return `https://cdn.example.com/logos/league-${leagueId}.png`; // Mock URL + return '/images/ff1600.jpeg'; } } diff --git a/apps/api/src/domain/bootstrap/BootstrapProviders.ts b/apps/api/src/domain/bootstrap/BootstrapProviders.ts index ea667a5a0..cc022d9df 100644 --- a/apps/api/src/domain/bootstrap/BootstrapProviders.ts +++ b/apps/api/src/domain/bootstrap/BootstrapProviders.ts @@ -46,6 +46,13 @@ export const BootstrapProviders: Provider[] = [ useFactory: ( driverRepository: RacingSeedDependencies['driverRepository'], leagueRepository: RacingSeedDependencies['leagueRepository'], + seasonRepository: RacingSeedDependencies['seasonRepository'], + seasonSponsorshipRepository: RacingSeedDependencies['seasonSponsorshipRepository'], + sponsorshipRequestRepository: RacingSeedDependencies['sponsorshipRequestRepository'], + leagueWalletRepository: RacingSeedDependencies['leagueWalletRepository'], + transactionRepository: RacingSeedDependencies['transactionRepository'], + protestRepository: RacingSeedDependencies['protestRepository'], + penaltyRepository: RacingSeedDependencies['penaltyRepository'], raceRepository: RacingSeedDependencies['raceRepository'], resultRepository: RacingSeedDependencies['resultRepository'], standingRepository: RacingSeedDependencies['standingRepository'], @@ -53,11 +60,19 @@ export const BootstrapProviders: Provider[] = [ raceRegistrationRepository: RacingSeedDependencies['raceRegistrationRepository'], teamRepository: RacingSeedDependencies['teamRepository'], teamMembershipRepository: RacingSeedDependencies['teamMembershipRepository'], + sponsorRepository: RacingSeedDependencies['sponsorRepository'], feedRepository: RacingSeedDependencies['feedRepository'], socialGraphRepository: RacingSeedDependencies['socialGraphRepository'], ): RacingSeedDependencies => ({ driverRepository, leagueRepository, + seasonRepository, + seasonSponsorshipRepository, + sponsorshipRequestRepository, + leagueWalletRepository, + transactionRepository, + protestRepository, + penaltyRepository, raceRepository, resultRepository, standingRepository, @@ -65,12 +80,20 @@ export const BootstrapProviders: Provider[] = [ raceRegistrationRepository, teamRepository, teamMembershipRepository, + sponsorRepository, feedRepository, socialGraphRepository, }), inject: [ 'IDriverRepository', 'ILeagueRepository', + 'ISeasonRepository', + 'ISeasonSponsorshipRepository', + 'ISponsorshipRequestRepository', + 'ILeagueWalletRepository', + 'ITransactionRepository', + 'IProtestRepository', + 'IPenaltyRepository', 'IRaceRepository', 'IResultRepository', 'IStandingRepository', @@ -78,6 +101,7 @@ export const BootstrapProviders: Provider[] = [ 'IRaceRegistrationRepository', 'ITeamRepository', 'ITeamMembershipRepository', + 'ISponsorRepository', 'IFeedRepository', 'ISocialGraphRepository', ], diff --git a/apps/api/src/domain/bootstrap/RacingSeed.test.ts b/apps/api/src/domain/bootstrap/RacingSeed.test.ts new file mode 100644 index 000000000..4a52fa62b --- /dev/null +++ b/apps/api/src/domain/bootstrap/RacingSeed.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import { createRacingSeed } from '@adapters/bootstrap/racing/RacingSeed'; + +describe('Racing seed (bootstrap)', () => { + it('creates a large, internally consistent seed', () => { + const seed = createRacingSeed(); + + expect(seed.drivers.length).toBeGreaterThanOrEqual(50); + expect(seed.leagues.length).toBeGreaterThanOrEqual(10); + expect(seed.races.length).toBeGreaterThan(0); + + const driverIds = new Set(seed.drivers.map((d) => d.id.toString())); + const leagueIds = new Set(seed.leagues.map((l) => l.id.toString())); + const teamIds = new Set(seed.teams.map((t) => t.id.toString())); + const raceById = new Map(seed.races.map((r) => [r.id.toString(), r] as const)); + const sponsorIds = new Set(seed.sponsors.map((s) => s.id.toString())); + const seasonById = new Map(seed.seasons.map((s) => [s.id, s] as const)); + const walletById = new Map(seed.leagueWallets.map((w) => [w.id.toString(), w] as const)); + const protestById = new Map(seed.protests.map((p) => [p.id, p] as const)); + + // Deterministic demo IDs used by the website + expect(seed.sponsors.some((s) => s.id.toString() === 'demo-sponsor-1')).toBe(true); + + // Seasons + sponsorship ecosystem + expect(seed.seasons.some((s) => s.id === 'season-1')).toBe(true); + expect(seed.seasons.some((s) => s.id === 'season-2')).toBe(true); + + // Referential integrity: seasons must reference real leagues + for (const season of seed.seasons) { + expect(leagueIds.has(season.leagueId)).toBe(true); + } + + // Referential integrity: season sponsorships must reference real season + sponsor + for (const sponsorship of seed.seasonSponsorships) { + expect(seasonById.has(sponsorship.seasonId)).toBe(true); + expect(sponsorIds.has(sponsorship.sponsorId)).toBe(true); + + const season = seasonById.get(sponsorship.seasonId); + if (season && sponsorship.leagueId !== undefined) { + expect(sponsorship.leagueId).toBe(season.leagueId); + } + } + + // Referential integrity: sponsorship requests must reference a real sponsor and target entity + for (const request of seed.sponsorshipRequests) { + expect(sponsorIds.has(request.sponsorId)).toBe(true); + + if (request.entityType === 'season') { + expect(seasonById.has(request.entityId)).toBe(true); + } else if (request.entityType === 'team') { + expect(teamIds.has(request.entityId)).toBe(true); + } else if (request.entityType === 'race') { + expect(raceById.has(request.entityId)).toBe(true); + } else if (request.entityType === 'driver') { + expect(driverIds.has(request.entityId)).toBe(true); + } + } + + const season1PendingRequests = seed.sponsorshipRequests.filter( + (r) => r.entityType === 'season' && r.entityId === 'season-1' && r.status === 'pending', + ); + expect(season1PendingRequests.length).toBeGreaterThan(0); + + // Wallet edge cases: + // - some leagues have no wallet at all + expect(seed.leagueWallets.some((w) => w.leagueId.toString() === 'league-2')).toBe(false); + expect(seed.leagueWallets.some((w) => w.leagueId.toString() === 'league-7')).toBe(false); + + // - some wallets have no transactions (league-3 is intentionally quiet) + const league3Wallet = seed.leagueWallets.find((w) => w.leagueId.toString() === 'league-3'); + if (league3Wallet) { + const league3TxCount = seed.leagueWalletTransactions.filter( + (tx) => tx.walletId.toString() === league3Wallet.id.toString(), + ).length; + expect(league3TxCount).toBe(0); + } + + // - demo wallet has a pending prize payout + const league5Wallet = seed.leagueWallets.find((w) => w.leagueId.toString() === 'league-5'); + expect(league5Wallet).toBeDefined(); + if (league5Wallet) { + const league5PendingPrizes = seed.leagueWalletTransactions.filter( + (tx) => + tx.walletId.toString() === league5Wallet.id.toString() && + tx.type === 'prize_payout' && + tx.status === 'pending', + ); + expect(league5PendingPrizes.length).toBeGreaterThan(0); + } + + // Referential integrity: all transactions reference an existing wallet + for (const tx of seed.leagueWalletTransactions) { + expect(walletById.has(tx.walletId.toString())).toBe(true); + } + + // Wallet invariant safety: no completed debit causes an overdraft when applied in factory order. + for (const wallet of seed.leagueWallets) { + const walletId = wallet.id.toString(); + const completed = seed.leagueWalletTransactions + .filter((t) => t.walletId.toString() === walletId && t.status === 'completed') + .filter((t) => t.type !== 'prize_payout'); + + const sum = (values: number[]) => values.reduce((acc, v) => acc + v, 0); + + const credits = completed.filter((t) => t.type === 'membership_payment' || t.type === 'sponsorship_payment'); + const debits = completed.filter((t) => t.type === 'withdrawal' || t.type === 'refund'); + + const creditNet = sum(credits.map((t) => t.netAmount.amount)); + const debitNet = sum(debits.map((t) => t.netAmount.amount)); + + const baseline = wallet.balance.amount - creditNet + debitNet; + expect(baseline).toBeGreaterThanOrEqual(0); + + const parseSeq = (id: string): number | undefined => { + const match = id.match(/tx-league-\d+-(\d+)$/); + if (!match) return undefined; + const seq = Number(match[1]); + return Number.isFinite(seq) ? seq : undefined; + }; + + const inFactoryOrder = [...completed].sort((a, b) => { + const aSeq = parseSeq(a.id.toString()); + const bSeq = parseSeq(b.id.toString()); + if (aSeq !== undefined && bSeq !== undefined) return aSeq - bSeq; + if (aSeq !== undefined) return -1; + if (bSeq !== undefined) return 1; + return a.createdAt.getTime() - b.createdAt.getTime(); + }); + + let running = baseline; + for (const tx of inFactoryOrder) { + const net = tx.netAmount.amount; + if (tx.type === 'withdrawal' || tx.type === 'refund') { + running -= net; + expect(running).toBeGreaterThanOrEqual(0); + } else { + running += net; + } + } + + expect(running).toBeCloseTo(wallet.balance.amount, 6); + } + + // Membership edge cases: + // - league-2 is intentionally empty + expect(seed.leagueMemberships.some((m) => m.leagueId.toString() === 'league-2')).toBe(false); + + // - league-5 is "busy" (full/overbooked mix of statuses) + const league5Memberships = seed.leagueMemberships.filter((m) => m.leagueId.toString() === 'league-5'); + expect(league5Memberships.length).toBeGreaterThan(0); + expect(league5Memberships.some((m) => m.status.toString() === 'active')).toBe(true); + expect(league5Memberships.some((m) => m.status.toString() === 'pending')).toBe(true); + + // Stewarding referential integrity + for (const protest of seed.protests) { + expect(raceById.has(protest.raceId)).toBe(true); + expect(driverIds.has(protest.protestingDriverId)).toBe(true); + expect(driverIds.has(protest.accusedDriverId)).toBe(true); + + const race = raceById.get(protest.raceId); + if (race) { + const leagueId = race.leagueId.toString(); + + const hasMembership = (driverId: string) => + seed.leagueMemberships.some( + (m) => m.leagueId.toString() === leagueId && m.driverId.toString() === driverId, + ); + + expect(hasMembership(protest.protestingDriverId)).toBe(true); + expect(hasMembership(protest.accusedDriverId)).toBe(true); + } + } + + for (const penalty of seed.penalties) { + expect(raceById.has(penalty.raceId)).toBe(true); + expect(driverIds.has(penalty.driverId)).toBe(true); + + const race = raceById.get(penalty.raceId); + if (race) { + expect(penalty.leagueId).toBe(race.leagueId.toString()); + } + + if (penalty.protestId !== undefined) { + expect(protestById.has(penalty.protestId)).toBe(true); + const protest = protestById.get(penalty.protestId); + if (protest) { + expect(penalty.raceId).toBe(protest.raceId); + } + } + } + + // Stewarding edge cases: demo league has multiple protest statuses + const protestStatusesLeague5 = new Set( + seed.protests + .filter((p) => + seed.races.some((r) => r.id.toString() === p.raceId && r.leagueId.toString() === 'league-5'), + ) + .map((p) => p.status.toString()), + ); + expect(protestStatusesLeague5.has('pending')).toBe(true); + expect(protestStatusesLeague5.has('under_review') || protestStatusesLeague5.has('upheld')).toBe(true); + + // Penalties should include at least one linked to a protest + expect(seed.penalties.some((p) => (p as unknown as { protestId?: string }).protestId)).toBe(true); + + // Basic ID sanity (no duplicates per collection) + const assertNoDuplicateIds = (ids: string[]) => { + const unique = new Set(ids); + expect(unique.size).toBe(ids.length); + }; + + assertNoDuplicateIds(seed.drivers.map((d) => d.id.toString())); + assertNoDuplicateIds(seed.leagues.map((l) => l.id.toString())); + assertNoDuplicateIds(seed.teams.map((t) => t.id.toString())); + assertNoDuplicateIds(seed.sponsors.map((s) => s.id.toString())); + assertNoDuplicateIds(seed.seasons.map((s) => s.id)); + assertNoDuplicateIds(seed.seasonSponsorships.map((s) => s.id)); + assertNoDuplicateIds(seed.sponsorshipRequests.map((r) => r.id)); + assertNoDuplicateIds(seed.protests.map((p) => p.id)); + assertNoDuplicateIds(seed.penalties.map((p) => p.id)); + assertNoDuplicateIds(seed.leagueWallets.map((w) => w.id.toString())); + assertNoDuplicateIds(seed.leagueWalletTransactions.map((t) => t.id.toString())); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/driver/DriverService.test.ts b/apps/api/src/domain/driver/DriverService.test.ts index a42f741a5..a54ed5e60 100644 --- a/apps/api/src/domain/driver/DriverService.test.ts +++ b/apps/api/src/domain/driver/DriverService.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; +import { Result } from '@core/shared/application/Result'; import { DriverService } from './DriverService'; describe('DriverService', () => { const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; it('getDriversLeaderboard executes use case and returns presenter model', async () => { - const getDriversLeaderboardUseCase = { execute: vi.fn(async () => {}) }; + const getDriversLeaderboardUseCase = { execute: vi.fn(async () => Result.ok(undefined)) }; const driversLeaderboardPresenter = { getResponseModel: vi.fn(() => ({ items: [] })) }; const service = new DriverService( @@ -31,7 +32,7 @@ describe('DriverService', () => { }); it('getTotalDrivers executes use case and returns presenter model', async () => { - const getTotalDriversUseCase = { execute: vi.fn(async () => {}) }; + const getTotalDriversUseCase = { execute: vi.fn(async () => Result.ok(undefined)) }; const driverStatsPresenter = { getResponseModel: vi.fn(() => ({ totalDrivers: 123 })) }; const service = new DriverService( @@ -57,7 +58,7 @@ describe('DriverService', () => { }); it('completeOnboarding passes optional bio only when provided', async () => { - const completeDriverOnboardingUseCase = { execute: vi.fn(async () => {}) }; + const completeDriverOnboardingUseCase = { execute: vi.fn(async () => Result.ok(undefined)) }; const service = new DriverService( { execute: vi.fn() } as any, @@ -112,7 +113,7 @@ describe('DriverService', () => { }); it('getDriverRegistrationStatus passes raceId and driverId and returns presenter model', async () => { - const isDriverRegisteredForRaceUseCase = { execute: vi.fn(async () => {}) }; + const isDriverRegisteredForRaceUseCase = { execute: vi.fn(async () => Result.ok(undefined)) }; const driverRegistrationStatusPresenter = { getResponseModel: vi.fn(() => ({ isRegistered: true })) }; const service = new DriverService( @@ -142,7 +143,7 @@ describe('DriverService', () => { it('getCurrentDriver calls repository and returns presenter model', async () => { const driverRepository = { findById: vi.fn(async () => null) }; - const driverPresenter = { getResponseModel: vi.fn(() => null) }; + const driverPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => null) }; const service = new DriverService( { execute: vi.fn() } as any, @@ -168,7 +169,7 @@ describe('DriverService', () => { it('updateDriverProfile builds optional input and returns presenter model', async () => { const updateDriverProfileUseCase = { execute: vi.fn(async () => {}) }; - const driverPresenter = { getResponseModel: vi.fn(() => ({ driver: { id: 'd1' } })) }; + const driverPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ driver: { id: 'd1' } })) }; const service = new DriverService( { execute: vi.fn() } as any, @@ -210,7 +211,7 @@ describe('DriverService', () => { it('getDriver calls repository and returns presenter model', async () => { const driverRepository = { findById: vi.fn(async () => null) }; - const driverPresenter = { getResponseModel: vi.fn(() => ({ driver: null })) }; + const driverPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ driver: null })) }; const service = new DriverService( { execute: vi.fn() } as any, @@ -235,7 +236,7 @@ describe('DriverService', () => { }); it('getDriverProfile executes use case and returns presenter model', async () => { - const getProfileOverviewUseCase = { execute: vi.fn(async () => {}) }; + const getProfileOverviewUseCase = { execute: vi.fn(async () => Result.ok(undefined)) }; const driverProfilePresenter = { getResponseModel: vi.fn(() => ({ profile: { id: 'd1' } })) }; const service = new DriverService( diff --git a/core/racing/domain/entities/sponsor/Sponsor.ts b/core/racing/domain/entities/sponsor/Sponsor.ts index 6f48167fd..d5c2ac611 100644 --- a/core/racing/domain/entities/sponsor/Sponsor.ts +++ b/core/racing/domain/entities/sponsor/Sponsor.ts @@ -5,11 +5,13 @@ * Aggregate root for sponsor information. */ import type { IEntity } from '@core/shared/domain'; +import { SponsorCreatedAt } from './SponsorCreatedAt'; +import { SponsorEmail } from './SponsorEmail'; import { SponsorId } from './SponsorId'; import { SponsorName } from './SponsorName'; -import { SponsorEmail } from './SponsorEmail'; import { Url } from './Url'; -import { SponsorCreatedAt } from './SponsorCreatedAt'; + +// TODO sponsor is not actually the racing domain in my opinion export class Sponsor implements IEntity { readonly id: SponsorId;