diff --git a/adapters/bootstrap/EnsureInitialData.ts b/adapters/bootstrap/EnsureInitialData.ts index eab747a50..01f34cbbb 100644 --- a/adapters/bootstrap/EnsureInitialData.ts +++ b/adapters/bootstrap/EnsureInitialData.ts @@ -2,6 +2,22 @@ import { SignupWithEmailUseCase } from '@core/identity/application/use-cases/SignupWithEmailUseCase'; import { CreateAchievementUseCase } from '@core/identity/application/use-cases/achievement/CreateAchievementUseCase'; import type { Logger } from '@core/shared/application'; + +import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; +import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; +import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; +import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository'; +import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; +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 { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; +import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; + +import { createInMemoryRacingSeed } from './inmemory/InMemoryRacingSeed'; + import { DRIVER_ACHIEVEMENTS, STEWARD_ACHIEVEMENTS, @@ -9,11 +25,26 @@ import { COMMUNITY_ACHIEVEMENTS, } from '@core/identity/domain/AchievementConstants'; +export type InMemorySeedDependencies = { + driverRepository: IDriverRepository; + leagueRepository: ILeagueRepository; + raceRepository: IRaceRepository; + resultRepository: IResultRepository; + standingRepository: IStandingRepository; + leagueMembershipRepository: ILeagueMembershipRepository; + raceRegistrationRepository: IRaceRegistrationRepository; + teamRepository: ITeamRepository; + teamMembershipRepository: ITeamMembershipRepository; + feedRepository: IFeedRepository; + socialGraphRepository: ISocialGraphRepository; +}; + export class EnsureInitialData { constructor( private readonly signupUseCase: SignupWithEmailUseCase, private readonly createAchievementUseCase: CreateAchievementUseCase, private readonly logger: Logger, + private readonly seedDeps?: InMemorySeedDependencies, ) {} async execute(): Promise { @@ -57,5 +88,125 @@ export class EnsureInitialData { } this.logger.info(`[Bootstrap] Achievements: ${createdCount} created, ${existingCount} already exist`); + + await this.seedInMemoryRacingDataIfNeeded(); + } + + private shouldSeedInMemory(): boolean { + const configured = (process.env.GRIDPILOT_API_PERSISTENCE ?? '').toLowerCase(); + if (configured) { + return configured === 'inmemory'; + } + + return process.env.DATABASE_URL === undefined; + } + + private async seedInMemoryRacingDataIfNeeded(): Promise { + if (!this.shouldSeedInMemory()) { + return; + } + + if (!this.seedDeps) { + this.logger.info('[Bootstrap] In-memory racing seed skipped (missing dependencies)'); + return; + } + + const existingDrivers = await this.seedDeps.driverRepository.findAll(); + if (existingDrivers.length > 0) { + this.logger.info('[Bootstrap] In-memory racing seed skipped (drivers already exist)'); + return; + } + + const seed = createInMemoryRacingSeed(); + + for (const driver of seed.drivers) { + try { + await this.seedDeps.driverRepository.create(driver); + } catch { + // ignore duplicates + } + } + + for (const league of seed.leagues) { + try { + await this.seedDeps.leagueRepository.create(league); + } catch { + // ignore duplicates + } + } + + for (const race of seed.races) { + try { + await this.seedDeps.raceRepository.create(race); + } catch { + // ignore duplicates + } + } + + try { + await this.seedDeps.resultRepository.createMany(seed.results); + } catch { + // ignore duplicates + } + + for (const membership of seed.leagueMemberships) { + try { + await this.seedDeps.leagueMembershipRepository.saveMembership(membership); + } catch { + // ignore duplicates + } + } + + for (const team of seed.teams) { + try { + await this.seedDeps.teamRepository.create(team); + } catch { + // ignore duplicates + } + } + + for (const membership of seed.teamMemberships) { + try { + await this.seedDeps.teamMembershipRepository.saveMembership(membership); + } catch { + // ignore duplicates + } + } + + for (const registration of seed.raceRegistrations) { + try { + await this.seedDeps.raceRegistrationRepository.register(registration); + } catch { + // ignore duplicates + } + } + + try { + await this.seedDeps.standingRepository.saveMany(seed.standings); + } catch { + // ignore duplicates + } + + const seedableFeed = this.seedDeps.feedRepository as unknown as { seed?: (input: unknown) => void }; + if (typeof seedableFeed.seed === 'function') { + seedableFeed.seed({ + drivers: seed.drivers, + friendships: seed.friendships, + feedEvents: seed.feedEvents, + }); + } + + const seedableSocial = this.seedDeps.socialGraphRepository as unknown as { seed?: (input: unknown) => void }; + if (typeof seedableSocial.seed === 'function') { + seedableSocial.seed({ + drivers: seed.drivers, + friendships: seed.friendships, + feedEvents: seed.feedEvents, + }); + } + + this.logger.info( + `[Bootstrap] Seeded in-memory racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`, + ); } } \ No newline at end of file diff --git a/adapters/bootstrap/inmemory/InMemoryRacingSeed.ts b/adapters/bootstrap/inmemory/InMemoryRacingSeed.ts new file mode 100644 index 000000000..020e6c3d5 --- /dev/null +++ b/adapters/bootstrap/inmemory/InMemoryRacingSeed.ts @@ -0,0 +1,557 @@ +import { Driver } from '@core/racing/domain/entities/Driver'; +import { League } from '@core/racing/domain/entities/League'; +import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; +import { Race } from '@core/racing/domain/entities/Race'; +import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration'; +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 type { TeamMembership } from '@core/racing/domain/types/TeamMembership'; +import type { FeedItem } from '@core/social/domain/types/FeedItem'; +import { getPointsSystems } from '../PointsSystems'; + +export type Friendship = { + driverId: string; + friendId: string; +}; + +export type InMemoryRacingSeed = { + drivers: Driver[]; + leagues: League[]; + races: Race[]; + results: RaceResult[]; + standings: Standing[]; + leagueMemberships: LeagueMembership[]; + raceRegistrations: RaceRegistration[]; + teams: Team[]; + teamMemberships: TeamMembership[]; + friendships: Friendship[]; + feedEvents: FeedItem[]; +}; + +export type InMemoryRacingSeedOptions = { + driverCount?: number; + baseDate?: Date; +}; + +export const inMemoryRacingSeedDefaults: Readonly< + Required +> = { + driverCount: 32, + baseDate: new Date('2025-01-15T12:00:00.000Z'), +}; + +class InMemoryRacingSeedFactory { + private readonly driverCount: number; + private readonly baseDate: Date; + + constructor(options: InMemoryRacingSeedOptions) { + this.driverCount = options.driverCount ?? inMemoryRacingSeedDefaults.driverCount; + this.baseDate = options.baseDate ?? inMemoryRacingSeedDefaults.baseDate; + } + + create(): InMemoryRacingSeed { + const drivers = this.createDrivers(); + const leagues = this.createLeagues(); + const races = this.createRaces(leagues); + const results = this.createResults(drivers, races); + const standings = this.createStandings(leagues, races, results); + const leagueMemberships = this.createLeagueMemberships(drivers, leagues); + const raceRegistrations = this.createRaceRegistrations(races); + const teams = this.createTeams(); + const teamMemberships = this.createTeamMemberships(drivers, teams); + const friendships = this.createFriendships(drivers); + const feedEvents = this.createFeedEvents(drivers, friendships, races, leagues); + + return { + drivers, + leagues, + races, + results, + standings, + leagueMemberships, + raceRegistrations, + teams, + teamMemberships, + friendships, + feedEvents, + }; + } + + private addDays(date: Date, days: number): Date { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); + } + + private addMinutes(date: Date, minutes: number): Date { + return new Date(date.getTime() + minutes * 60 * 1000); + } + + private createDrivers(): Driver[] { + const countries = ['DE', 'NL', 'FR', 'GB', 'US', 'CA', 'SE', 'NO', 'IT', 'ES'] as const; + + return Array.from({ length: this.driverCount }, (_, idx) => { + const i = idx + 1; + + return Driver.create({ + id: `driver-${i}`, + iracingId: String(100000 + i), + name: `Driver ${i}`, + country: countries[idx % countries.length]!, + bio: `Demo driver #${i} seeded for in-memory mode.`, + joinedAt: this.addDays(this.baseDate, -90 + i), + }); + }); + } + + private createLeagues(): League[] { + const createdAtBase = this.baseDate; + + return [ + League.create({ + id: 'league-1', + name: 'GridPilot Sprint Series', + description: 'Weekly sprint races with stable grids.', + ownerId: 'driver-1', + settings: { + pointsSystem: 'f1-2024', + maxDrivers: 24, + sessionDuration: 60, + qualifyingFormat: 'open', + }, + createdAt: this.addDays(createdAtBase, -200), + socialLinks: { + discordUrl: 'https://discord.gg/gridpilot-demo', + youtubeUrl: 'https://youtube.com/@gridpilot-demo', + websiteUrl: 'https://gridpilot-demo.example.com', + }, + }), + League.create({ + id: 'league-2', + name: 'GridPilot Endurance Cup', + description: 'Longer races with strategy and consistency.', + ownerId: 'driver-2', + settings: { + pointsSystem: 'indycar', + maxDrivers: 32, + sessionDuration: 120, + qualifyingFormat: 'open', + }, + createdAt: this.addDays(createdAtBase, -180), + socialLinks: { discordUrl: 'https://discord.gg/gridpilot-endurance' }, + }), + League.create({ + id: 'league-3', + name: 'GridPilot Club Ladder', + description: 'Casual ladder with fast onboarding.', + ownerId: 'driver-3', + settings: { + pointsSystem: 'f1-2024', + maxDrivers: 48, + sessionDuration: 45, + qualifyingFormat: 'single-lap', + }, + createdAt: this.addDays(createdAtBase, -160), + }), + League.create({ + id: 'league-4', + name: 'Nordic Night Series', + description: 'Evening races with tight fields.', + ownerId: 'driver-4', + settings: { + pointsSystem: 'f1-2024', + maxDrivers: 32, + sessionDuration: 60, + qualifyingFormat: 'open', + }, + createdAt: this.addDays(createdAtBase, -150), + }), + League.create({ + id: 'league-5', + name: 'Demo League (Admin)', + description: 'Primary demo league owned by driver-1.', + ownerId: 'driver-1', + settings: { + pointsSystem: 'f1-2024', + maxDrivers: 24, + sessionDuration: 60, + qualifyingFormat: 'open', + }, + createdAt: this.addDays(createdAtBase, -140), + }), + League.create({ + id: 'league-6', + name: 'Sim Racing Alliance', + description: 'Mixed-format season with community events.', + ownerId: 'driver-5', + settings: { + pointsSystem: 'indycar', + maxDrivers: 40, + sessionDuration: 90, + qualifyingFormat: 'open', + }, + createdAt: this.addDays(createdAtBase, -130), + }), + ]; + } + + private createRaces(leagues: League[]): Race[] { + const tracks = [ + 'Monza GP', + 'Spa-Francorchamps', + 'Suzuka', + 'Mount Panorama', + 'Silverstone GP', + 'Interlagos', + 'Imola', + 'Laguna Seca', + ]; + const cars = ['GT3 – Porsche 911', 'GT3 – BMW M4', 'LMP3 Prototype', 'GT4 – Alpine', 'Touring – Civic']; + + const leagueIds = leagues.map((l) => l.id.toString()); + const demoLeagueId = 'league-5'; + + const races: Race[] = []; + + for (let i = 1; i <= 25; i++) { + const leagueId = leagueIds[(i - 1) % leagueIds.length] ?? demoLeagueId; + const scheduledAt = this.addDays(this.baseDate, i <= 10 ? -35 + i : 1 + (i - 10) * 2); + + const base = { + id: `race-${i}`, + leagueId, + scheduledAt, + track: tracks[(i - 1) % tracks.length]!, + car: cars[(i - 1) % cars.length]!, + }; + + if (i === 1) { + races.push( + Race.create({ + ...base, + leagueId: demoLeagueId, + scheduledAt: this.addMinutes(this.baseDate, -30), + status: 'running', + strengthOfField: 1530, + registeredCount: 16, + }), + ); + continue; + } + + if (scheduledAt < this.baseDate) { + races.push( + Race.create({ + ...base, + status: 'completed', + }), + ); + continue; + } + + races.push( + Race.create({ + ...base, + status: 'scheduled', + }), + ); + } + + return races; + } + + private createResults(drivers: Driver[], races: Race[]): RaceResult[] { + const results: RaceResult[] = []; + const completed = races.filter((r) => r.status === 'completed'); + + for (const race of completed) { + const participants = drivers.slice(0, Math.min(16, drivers.length)); + + for (let idx = 0; idx < participants.length; idx++) { + const driver = participants[idx]!; + const position = idx + 1; + const startPosition = ((idx + 3) % participants.length) + 1; + + results.push( + RaceResult.create({ + id: `${race.id}:${driver.id}`, + raceId: race.id, + driverId: driver.id, + position, + startPosition, + fastestLap: 88_000 + idx * 120, + incidents: idx % 4 === 0 ? 2 : 0, + }), + ); + } + } + + return results; + } + + private resolvePointsSystem( + league: League, + pointsSystems: Record>, + ): Record { + const settings = league.settings; + return settings.customPoints ?? pointsSystems[settings.pointsSystem] ?? pointsSystems['f1-2024'] ?? {}; + } + + private createStandings(leagues: League[], races: Race[], results: RaceResult[]): Standing[] { + const pointsSystems = getPointsSystems(); + + const racesByLeague = new Map>(); + for (const race of races) { + if (race.status !== 'completed') continue; + + const set = racesByLeague.get(race.leagueId) ?? new Set(); + set.add(race.id); + racesByLeague.set(race.leagueId, set); + } + + const standings: Standing[] = []; + + for (const league of leagues) { + const leagueId = league.id.toString(); + const completedRaceIds = racesByLeague.get(leagueId) ?? new Set(); + if (completedRaceIds.size === 0) continue; + + const pointsTable = this.resolvePointsSystem(league, pointsSystems); + + const byDriver = new Map(); + + for (const result of results) { + if (!completedRaceIds.has(result.raceId.toString())) continue; + + const driverId = result.driverId.toString(); + const previousStanding = byDriver.get(driverId) ?? Standing.create({ leagueId, driverId, position: 1 }); + const nextStanding = previousStanding.addRaceResult(result.position.toNumber(), pointsTable); + byDriver.set(driverId, nextStanding); + } + + const sorted = Array.from(byDriver.values()).sort((a, b) => { + if (b.points.toNumber() !== a.points.toNumber()) return b.points.toNumber() - a.points.toNumber(); + if (b.wins !== a.wins) return b.wins - a.wins; + return b.racesCompleted - a.racesCompleted; + }); + + sorted.forEach((standing, index) => standings.push(standing.updatePosition(index + 1))); + } + + return standings; + } + + private createLeagueMemberships(drivers: Driver[], leagues: League[]): LeagueMembership[] { + const memberships: LeagueMembership[] = []; + + for (const driver of drivers) { + const driverId = driver.id; + + memberships.push( + LeagueMembership.create({ + leagueId: 'league-5', + driverId, + role: driverId === 'driver-1' ? 'owner' : 'member', + status: 'active', + joinedAt: this.addDays(this.baseDate, -60), + }), + ); + + const driverNumber = Number(driverId.split('-')[1]); + const extraLeague = leagues[(driverNumber % (leagues.length - 1)) + 1]; + + if (extraLeague) { + memberships.push( + LeagueMembership.create({ + leagueId: extraLeague.id.toString(), + driverId, + role: 'member', + status: 'active', + joinedAt: this.addDays(this.baseDate, -40), + }), + ); + } + } + + return memberships; + } + + private createRaceRegistrations(races: Race[]): RaceRegistration[] { + const registrations: RaceRegistration[] = []; + + const upcomingDemoLeague = races.filter((r) => r.status === 'scheduled' && r.leagueId === 'league-5').slice(0, 3); + + for (const race of upcomingDemoLeague) { + registrations.push( + RaceRegistration.create({ + raceId: race.id, + driverId: 'driver-1', + }), + ); + } + + return registrations; + } + + private createTeams(): Team[] { + return [ + Team.create({ + id: 'team-1', + name: 'Apex Racing', + tag: 'APEX', + description: 'Demo team focused on clean racing.', + ownerId: 'driver-1', + leagues: ['league-5'], + createdAt: this.addDays(this.baseDate, -100), + }), + Team.create({ + id: 'team-2', + name: 'Night Owls', + tag: 'NITE', + description: 'Late-night grinders and endurance lovers.', + ownerId: 'driver-2', + leagues: ['league-4'], + createdAt: this.addDays(this.baseDate, -90), + }), + Team.create({ + id: 'team-3', + name: 'Club Legends', + tag: 'CLUB', + description: 'A casual team for ladder climbing.', + ownerId: 'driver-3', + leagues: ['league-3'], + createdAt: this.addDays(this.baseDate, -80), + }), + ]; + } + + private createTeamMemberships(drivers: Driver[], teams: Team[]): TeamMembership[] { + const memberships: TeamMembership[] = []; + + const team1 = teams.find((t) => t.id === 'team-1'); + const team2 = teams.find((t) => t.id === 'team-2'); + const team3 = teams.find((t) => t.id === 'team-3'); + + if (team1) { + const members = drivers.slice(0, 6); + members.forEach((d, idx) => { + memberships.push({ + teamId: team1.id, + driverId: d.id, + role: d.id === team1.ownerId.toString() ? 'owner' : idx === 1 ? 'manager' : 'driver', + status: 'active', + joinedAt: this.addDays(this.baseDate, -50), + }); + }); + } + + if (team2) { + const members = drivers.slice(6, 12); + members.forEach((d) => { + memberships.push({ + teamId: team2.id, + driverId: d.id, + role: d.id === team2.ownerId.toString() ? 'owner' : 'driver', + status: 'active', + joinedAt: this.addDays(this.baseDate, -45), + }); + }); + } + + if (team3) { + const members = drivers.slice(12, 18); + members.forEach((d) => { + memberships.push({ + teamId: team3.id, + driverId: d.id, + role: d.id === team3.ownerId.toString() ? 'owner' : 'driver', + status: 'active', + joinedAt: this.addDays(this.baseDate, -40), + }); + }); + } + + return memberships; + } + + private createFriendships(drivers: Driver[]): Friendship[] { + const friendships: Friendship[] = []; + + for (let i = 0; i < drivers.length; i++) { + const driver = drivers[i]!; + for (let offset = 1; offset <= 3; offset++) { + const friend = drivers[(i + offset) % drivers.length]!; + friendships.push({ driverId: driver.id, friendId: friend.id }); + } + } + + return friendships; + } + + private createFeedEvents(drivers: Driver[], friendships: Friendship[], races: Race[], leagues: League[]): FeedItem[] { + const items: FeedItem[] = []; + const friendMap = new Map(friendships.map((f) => [`${f.driverId}:${f.friendId}`, true] as const)); + + const completedRace = races.find((r) => r.status === 'completed'); + const upcomingRace = races.find((r) => r.status === 'scheduled'); + const league = leagues.find((l) => l.id.toString() === 'league-5') ?? leagues[0]!; + + const now = this.addMinutes(this.baseDate, 10); + + for (let i = 2; i <= 10; i++) { + const actor = drivers.find((d) => d.id === `driver-${i}`); + if (!actor) continue; + + if (!friendMap.has(`driver-1:${actor.id}`)) continue; + + items.push({ + id: `feed:${actor.id}:joined:${i}`, + type: 'friend-joined-league', + timestamp: this.addMinutes(now, -(i * 7)), + actorDriverId: actor.id, + actorFriendId: actor.id, + leagueId: league.id.toString(), + headline: `${actor.name} joined ${String(league.name)}`, + body: 'Demo activity in in-memory mode.', + ctaLabel: 'View league', + ctaHref: `/leagues/${league.id.toString()}`, + }); + + if (completedRace) { + items.push({ + id: `feed:${actor.id}:result:${i}`, + type: 'friend-finished-race', + timestamp: this.addMinutes(now, -(i * 7 + 3)), + actorDriverId: actor.id, + actorFriendId: actor.id, + leagueId: completedRace.leagueId, + raceId: completedRace.id, + position: (i % 5) + 1, + headline: `${actor.name} finished a race`, + body: `Completed at ${completedRace.track}.`, + ctaLabel: 'View results', + ctaHref: `/races/${completedRace.id}/results`, + }); + } + } + + if (upcomingRace) { + items.push({ + id: `feed:system:scheduled:${upcomingRace.id}`, + type: 'new-race-scheduled', + timestamp: this.addMinutes(now, -3), + leagueId: upcomingRace.leagueId, + raceId: upcomingRace.id, + headline: `New race scheduled at ${upcomingRace.track}`, + body: `${upcomingRace.car} • ${upcomingRace.scheduledAt.toISOString()}`, + ctaLabel: 'View schedule', + ctaHref: `/races/${upcomingRace.id}`, + }); + } + + return items.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + } +} + +export function createInMemoryRacingSeed(options: InMemoryRacingSeedOptions = {}): InMemoryRacingSeed { + return new InMemoryRacingSeedFactory(options).create(); +} \ No newline at end of file diff --git a/adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository.ts b/adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository.ts index 4b6ce40eb..b1f76792a 100644 --- a/adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository.ts +++ b/adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository.ts @@ -44,13 +44,20 @@ export class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepo } async register(registration: RaceRegistration): Promise { - this.logger.debug(`[InMemoryRaceRegistrationRepository] Registering driver ${registration.driverId.toString()} for race ${registration.raceId.toString()}.`); - if (await this.isRegistered(registration.raceId.toString(), registration.driverId.toString())) { - this.logger.warn(`Driver ${registration.driverId.toString()} already registered for race ${registration.raceId.toString()}.`); + const raceId = registration.raceId.toString(); + const driverId = registration.driverId.toString(); + + this.logger.debug(`[InMemoryRaceRegistrationRepository] Registering driver ${driverId} for race ${raceId}.`); + + if (await this.isRegistered(raceId, driverId)) { + this.logger.warn(`Driver ${driverId} already registered for race ${raceId}.`); throw new Error('Driver already registered for this race'); } - this.registrations.set(registration.id, registration); - this.logger.info(`Driver ${registration.driverId.toString()} registered for race ${registration.raceId.toString()}.`); + + const key = `${raceId}:${driverId}`; + this.registrations.set(key, registration); + + this.logger.info(`Driver ${driverId} registered for race ${raceId}.`); return Promise.resolve(); } @@ -80,16 +87,19 @@ export class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepo async clearRaceRegistrations(raceId: string): Promise { this.logger.debug(`[InMemoryRaceRegistrationRepository] Clearing all registrations for race: ${raceId}.`); - const registrationsToDelete: string[] = []; + + const keysToDelete: string[] = []; for (const registration of this.registrations.values()) { if (registration.raceId.toString() === raceId) { - registrationsToDelete.push(registration.id); + keysToDelete.push(`${registration.raceId.toString()}:${registration.driverId.toString()}`); } } - for (const id of registrationsToDelete) { - this.registrations.delete(id); + + for (const key of keysToDelete) { + this.registrations.delete(key); } - this.logger.info(`Cleared ${registrationsToDelete.length} registrations for race ${raceId}.`); + + this.logger.info(`Cleared ${keysToDelete.length} registrations for race ${raceId}.`); return Promise.resolve(); } } diff --git a/adapters/social/persistence/inmemory/InMemorySocialAndFeed.ts b/adapters/social/persistence/inmemory/InMemorySocialAndFeed.ts index 398e2e67c..79d972c4a 100644 --- a/adapters/social/persistence/inmemory/InMemorySocialAndFeed.ts +++ b/adapters/social/persistence/inmemory/InMemorySocialAndFeed.ts @@ -16,13 +16,18 @@ export type RacingSeedData = { }; export class InMemoryFeedRepository implements IFeedRepository { - private readonly feedEvents: FeedItem[]; - private readonly friendships: Friendship[]; + private feedEvents: FeedItem[]; + private friendships: Friendship[]; private readonly logger: Logger; - constructor(logger: Logger, seed: RacingSeedData) { + constructor(logger: Logger, seed?: RacingSeedData) { this.logger = logger; this.logger.info('InMemoryFeedRepository initialized.'); + this.feedEvents = seed?.feedEvents ?? []; + this.friendships = seed?.friendships ?? []; + } + + seed(seed: RacingSeedData): void { this.feedEvents = seed.feedEvents; this.friendships = seed.friendships; } @@ -72,13 +77,18 @@ export class InMemoryFeedRepository implements IFeedRepository { } export class InMemorySocialGraphRepository implements ISocialGraphRepository { - private readonly friendships: Friendship[]; - private readonly driversById: Map; + private friendships: Friendship[]; + private driversById: Map; private readonly logger: Logger; - constructor(logger: Logger, seed: RacingSeedData) { + constructor(logger: Logger, seed?: RacingSeedData) { this.logger = logger; this.logger.info('InMemorySocialGraphRepository initialized.'); + this.friendships = seed?.friendships ?? []; + this.driversById = new Map((seed?.drivers ?? []).map((d) => [d.id, d])); + } + + seed(seed: RacingSeedData): void { this.friendships = seed.friendships; this.driversById = new Map(seed.drivers.map((d) => [d.id, d])); } diff --git a/apps/api/src/domain/auth/AuthProviders.ts b/apps/api/src/domain/auth/AuthProviders.ts index 5990ce71d..042545afc 100644 --- a/apps/api/src/domain/auth/AuthProviders.ts +++ b/apps/api/src/domain/auth/AuthProviders.ts @@ -40,13 +40,13 @@ export const AuthProviders: Provider[] = [ useFactory: (passwordHashingService: IPasswordHashingService, logger: Logger) => { // Seed initial users for InMemoryUserRepository const initialUsers: StoredUser[] = [ - // Example user (replace with actual test users as needed) { - id: 'user-1', - email: 'test@example.com', - passwordHash: 'demo_salt_moc.elpmaxe@tset', // "test@example.com" reversed - displayName: 'Test User', - salt: '', // Handled by hashing service + // Match seeded racing driver id so dashboard works in inmemory mode. + id: 'driver-1', + email: 'admin@gridpilot.local', + passwordHash: 'demo_salt_321nimda', // InMemoryPasswordHashingService: "admin123" reversed. + displayName: 'Admin', + salt: '', createdAt: new Date(), }, ]; diff --git a/apps/api/src/domain/bootstrap/BootstrapProviders.ts b/apps/api/src/domain/bootstrap/BootstrapProviders.ts index a408815e9..b750bd230 100644 --- a/apps/api/src/domain/bootstrap/BootstrapProviders.ts +++ b/apps/api/src/domain/bootstrap/BootstrapProviders.ts @@ -1,5 +1,5 @@ import { Provider } from '@nestjs/common'; -import { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData'; +import { EnsureInitialData, type InMemorySeedDependencies } from '../../../../../adapters/bootstrap/EnsureInitialData'; import { SignupWithEmailUseCase, type SignupWithEmailResult } from '@core/identity/application/use-cases/SignupWithEmailUseCase'; import { CreateAchievementUseCase, @@ -84,12 +84,52 @@ export const BootstrapProviders: Provider[] = [ { provide: EnsureInitialData, useFactory: ( - signupUseCase: SignupWithEmailUseCase, - createAchievementUseCase: CreateAchievementUseCase, - logger: Logger + signupUseCase: SignupWithEmailUseCase, + createAchievementUseCase: CreateAchievementUseCase, + logger: Logger, + driverRepository: InMemorySeedDependencies['driverRepository'], + leagueRepository: InMemorySeedDependencies['leagueRepository'], + raceRepository: InMemorySeedDependencies['raceRepository'], + resultRepository: InMemorySeedDependencies['resultRepository'], + standingRepository: InMemorySeedDependencies['standingRepository'], + leagueMembershipRepository: InMemorySeedDependencies['leagueMembershipRepository'], + raceRegistrationRepository: InMemorySeedDependencies['raceRegistrationRepository'], + teamRepository: InMemorySeedDependencies['teamRepository'], + teamMembershipRepository: InMemorySeedDependencies['teamMembershipRepository'], + feedRepository: InMemorySeedDependencies['feedRepository'], + socialGraphRepository: InMemorySeedDependencies['socialGraphRepository'], ) => { - return new EnsureInitialData(signupUseCase, createAchievementUseCase, logger); + const deps: InMemorySeedDependencies = { + driverRepository, + leagueRepository, + raceRepository, + resultRepository, + standingRepository, + leagueMembershipRepository, + raceRegistrationRepository, + teamRepository, + teamMembershipRepository, + feedRepository, + socialGraphRepository, + }; + + return new EnsureInitialData(signupUseCase, createAchievementUseCase, logger, deps); }, - inject: [SIGNUP_USE_CASE_TOKEN, CREATE_ACHIEVEMENT_USE_CASE_TOKEN, 'Logger'], + inject: [ + SIGNUP_USE_CASE_TOKEN, + CREATE_ACHIEVEMENT_USE_CASE_TOKEN, + 'Logger', + 'IDriverRepository', + 'ILeagueRepository', + 'IRaceRepository', + 'IResultRepository', + 'IStandingRepository', + 'ILeagueMembershipRepository', + 'IRaceRegistrationRepository', + 'ITeamRepository', + 'ITeamMembershipRepository', + 'IFeedRepository', + 'ISocialGraphRepository', + ], }, ]; \ No newline at end of file diff --git a/apps/api/src/domain/persistence/InMemoryPersistenceModule.ts b/apps/api/src/domain/persistence/InMemoryPersistenceModule.ts new file mode 100644 index 000000000..c8fee2563 --- /dev/null +++ b/apps/api/src/domain/persistence/InMemoryPersistenceModule.ts @@ -0,0 +1,129 @@ +import { Global, Module } from '@nestjs/common'; + +import type { Logger } from '@core/shared/application/Logger'; + +import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; +import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; +import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; +import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository'; +import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; +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 { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; +import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; + +import { getPointsSystems } from '@adapters/bootstrap/PointsSystems'; + +import { InMemoryDriverRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverRepository'; +import { InMemoryLeagueRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueRepository'; +import { InMemoryRaceRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRepository'; +import { InMemoryResultRepository } from '@adapters/racing/persistence/inmemory/InMemoryResultRepository'; +import { InMemoryStandingRepository } from '@adapters/racing/persistence/inmemory/InMemoryStandingRepository'; +import { InMemoryLeagueMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository'; +import { InMemoryRaceRegistrationRepository } from '@adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository'; +import { InMemoryTeamRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamRepository'; +import { InMemoryTeamMembershipRepository } from '@adapters/racing/persistence/inmemory/InMemoryTeamMembershipRepository'; + +import { + InMemoryFeedRepository, + InMemorySocialGraphRepository, +} from '@adapters/social/persistence/inmemory/InMemorySocialAndFeed'; + +export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository'; +export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository'; +export const RACE_REPOSITORY_TOKEN = 'IRaceRepository'; +export const RESULT_REPOSITORY_TOKEN = 'IResultRepository'; +export const STANDING_REPOSITORY_TOKEN = 'IStandingRepository'; +export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository'; +export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository'; +export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository'; +export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository'; + +export const FEED_REPOSITORY_TOKEN = 'IFeedRepository'; +export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository'; + +@Global() +@Module({ + providers: [ + { + provide: DRIVER_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IDriverRepository => new InMemoryDriverRepository(logger), + inject: ['Logger'], + }, + { + provide: LEAGUE_REPOSITORY_TOKEN, + useFactory: (logger: Logger): ILeagueRepository => new InMemoryLeagueRepository(logger), + inject: ['Logger'], + }, + { + provide: RACE_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IRaceRepository => new InMemoryRaceRepository(logger), + inject: ['Logger'], + }, + { + provide: RESULT_REPOSITORY_TOKEN, + useFactory: (logger: Logger, raceRepo: IRaceRepository): IResultRepository => + new InMemoryResultRepository(logger, raceRepo), + inject: ['Logger', RACE_REPOSITORY_TOKEN], + }, + { + provide: STANDING_REPOSITORY_TOKEN, + useFactory: ( + logger: Logger, + resultRepo: IResultRepository, + raceRepo: IRaceRepository, + leagueRepo: ILeagueRepository, + ): IStandingRepository => new InMemoryStandingRepository(logger, getPointsSystems(), resultRepo, raceRepo, leagueRepo), + inject: ['Logger', RESULT_REPOSITORY_TOKEN, RACE_REPOSITORY_TOKEN, LEAGUE_REPOSITORY_TOKEN], + }, + { + provide: LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, + useFactory: (logger: Logger): ILeagueMembershipRepository => new InMemoryLeagueMembershipRepository(logger), + inject: ['Logger'], + }, + { + provide: RACE_REGISTRATION_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IRaceRegistrationRepository => new InMemoryRaceRegistrationRepository(logger), + inject: ['Logger'], + }, + { + provide: TEAM_REPOSITORY_TOKEN, + useFactory: (logger: Logger): ITeamRepository => new InMemoryTeamRepository(logger), + inject: ['Logger'], + }, + { + provide: TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + useFactory: (logger: Logger): ITeamMembershipRepository => new InMemoryTeamMembershipRepository(logger), + inject: ['Logger'], + }, + { + provide: FEED_REPOSITORY_TOKEN, + useFactory: (logger: Logger): IFeedRepository => + new InMemoryFeedRepository(logger, { drivers: [], friendships: [], feedEvents: [] }), + inject: ['Logger'], + }, + { + provide: SOCIAL_GRAPH_REPOSITORY_TOKEN, + useFactory: (logger: Logger): ISocialGraphRepository => + new InMemorySocialGraphRepository(logger, { drivers: [], friendships: [], feedEvents: [] }), + inject: ['Logger'], + }, + ], + exports: [ + DRIVER_REPOSITORY_TOKEN, + LEAGUE_REPOSITORY_TOKEN, + RACE_REPOSITORY_TOKEN, + RESULT_REPOSITORY_TOKEN, + STANDING_REPOSITORY_TOKEN, + LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, + RACE_REGISTRATION_REPOSITORY_TOKEN, + TEAM_REPOSITORY_TOKEN, + TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + FEED_REPOSITORY_TOKEN, + SOCIAL_GRAPH_REPOSITORY_TOKEN, + ], +}) +export class InMemoryPersistenceModule {} \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueHeader.test.tsx b/apps/website/components/leagues/LeagueHeader.test.tsx deleted file mode 100644 index 788ea3046..000000000 --- a/apps/website/components/leagues/LeagueHeader.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; - -vi.mock('@/lib/services/ServiceProvider', () => ({ - useServices: () => ({ - mediaService: { - getLeagueLogo: () => '/logo.png', - }, - }), -})); - -vi.mock('@/components/leagues/MembershipStatus', () => ({ - __esModule: true, - default: () =>
, -})); - -vi.mock('next/image', () => ({ - __esModule: true, - default: (props: any) => , -})); - -import LeagueHeader from './LeagueHeader'; - -describe('LeagueHeader', () => { - it('renders league name, description and sponsor', () => { - render( - - ); - - expect(screen.getByText('Test League')).toBeInTheDocument(); - expect(screen.getByText('A fun test league')).toBeInTheDocument(); - expect(screen.getByText('by')).toBeInTheDocument(); - expect(screen.getByText('Test Sponsor')).toBeInTheDocument(); - }); - - it('renders without description or sponsor', () => { - render( - - ); - - expect(screen.getByText('League Without Details')).toBeInTheDocument(); - }); -}); diff --git a/apps/website/components/leagues/LeagueMembers.test.tsx b/apps/website/components/leagues/LeagueMembers.test.tsx deleted file mode 100644 index 8ebe92095..000000000 --- a/apps/website/components/leagues/LeagueMembers.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; - -import LeagueMembers from './LeagueMembers'; -import type { DriverDTO } from '@/lib/types/generated/DriverDTO'; - -// Stub global driver stats helper used by LeagueMembers sorting/rendering -(globalThis as any).getDriverStats = (driverId: string) => ({ - driverId, - rating: driverId === 'driver-1' ? 2500 : 2000, - overallRank: driverId === 'driver-1' ? 1 : 2, - wins: driverId === 'driver-1' ? 10 : 5, -}); - -// Mock effective driver id so we can assert the "(You)" label -vi.mock('@/hooks/useEffectiveDriverId', () => { - return { - useEffectiveDriverId: () => 'driver-1', - }; -}); - -// Mock services hook to inject stub leagueMembershipService and driverService -const mockFetchLeagueMemberships = vi.fn<(leagueId: string) => Promise>(); -const mockGetLeagueMembers = vi.fn<(leagueId: string) => any[]>(); -const mockFindByIds = vi.fn<(ids: string[]) => Promise>(); - -const mockServices = { - leagueMembershipService: { - fetchLeagueMemberships: mockFetchLeagueMemberships, - getLeagueMembers: mockGetLeagueMembers, - }, - driverService: { - findByIds: mockFindByIds, - }, -}; - -vi.mock('@/lib/services/ServiceProvider', () => ({ - useServices: () => mockServices, -})); - -describe('LeagueMembers', () => { - beforeEach(() => { - mockFetchLeagueMemberships.mockReset(); - mockGetLeagueMembers.mockReset(); - mockFindByIds.mockReset(); - }); - - it('loads memberships via services and renders driver rows', async () => { - const leagueId = 'league-1'; - - const memberships = [ - { - id: 'm1', - leagueId, - driverId: 'driver-1', - role: 'owner', - status: 'active', - joinedAt: '2024-01-01T00:00:00.000Z', - }, - { - id: 'm2', - leagueId, - driverId: 'driver-2', - role: 'member', - status: 'active', - joinedAt: '2024-01-02T00:00:00.000Z', - }, - ]; - - const drivers: DriverDTO[] = [ - { - id: 'driver-1', - iracingId: 'ir-1', - name: 'Driver One', - country: 'DE', - joinedAt: '2024-01-01T00:00:00.000Z', - }, - { - id: 'driver-2', - iracingId: 'ir-2', - name: 'Driver Two', - country: 'US', - joinedAt: '2024-01-01T00:00:00.000Z', - }, - ]; - - mockFetchLeagueMemberships.mockResolvedValue(undefined); - mockGetLeagueMembers.mockReturnValue(memberships); - mockFindByIds.mockResolvedValue(drivers); - - render(); - - // Loading state first - expect(screen.getByText('Loading members...')).toBeInTheDocument(); - - // Wait for data to be rendered - await waitFor(() => { - expect(screen.queryByText('Loading members...')).not.toBeInTheDocument(); - }); - - // Services should have been called with expected arguments - expect(mockFetchLeagueMemberships).toHaveBeenCalledWith(leagueId); - expect(mockGetLeagueMembers).toHaveBeenCalledWith(leagueId); - expect(mockFindByIds).toHaveBeenCalledTimes(1); - expect(mockFindByIds).toHaveBeenCalledWith(['driver-1', 'driver-2']); - - // Driver rows should be rendered using DTO names - expect(screen.getByText('Driver One')).toBeInTheDocument(); - expect(screen.getByText('Driver Two')).toBeInTheDocument(); - - // Current user marker should appear for effective driver id - expect(screen.getByText('(You)')).toBeInTheDocument(); - }); - - it('handles empty membership list gracefully', async () => { - const leagueId = 'league-empty'; - - mockFetchLeagueMemberships.mockResolvedValue(undefined); - mockGetLeagueMembers.mockReturnValue([]); - mockFindByIds.mockResolvedValue([]); - - render(); - - await waitFor(() => { - expect(screen.queryByText('Loading members...')).not.toBeInTheDocument(); - }); - - expect(screen.getByText('No members found')).toBeInTheDocument(); - }); -}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 329a1d158..1f5eef0b0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -32,6 +32,7 @@ services: - .env.development environment: - NODE_ENV=development + - GRIDPILOT_API_PERSISTENCE=inmemory ports: - "3001:3000" - "9229:9229"