seed data

This commit is contained in:
2025-12-26 22:22:39 +01:00
parent 1e8d84b31b
commit b4f86abf90
10 changed files with 926 additions and 218 deletions

View File

@@ -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<void> {
@@ -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<void> {
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}`,
);
}
}

View File

@@ -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<InMemoryRacingSeedOptions>
> = {
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<string, Record<number, number>>,
): Record<number, number> {
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<string, Set<string>>();
for (const race of races) {
if (race.status !== 'completed') continue;
const set = racesByLeague.get(race.leagueId) ?? new Set<string>();
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<string>();
if (completedRaceIds.size === 0) continue;
const pointsTable = this.resolvePointsSystem(league, pointsSystems);
const byDriver = new Map<string, Standing>();
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();
}

View File

@@ -44,13 +44,20 @@ export class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepo
}
async register(registration: RaceRegistration): Promise<void> {
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<void> {
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();
}
}

View File

@@ -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<string, Driver>;
private friendships: Friendship[];
private driversById: Map<string, Driver>;
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]));
}

View File

@@ -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(),
},
];

View File

@@ -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',
],
},
];

View File

@@ -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 {}

View File

@@ -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: () => <div data-testid="membership-status" />,
}));
vi.mock('next/image', () => ({
__esModule: true,
default: (props: any) => <img {...props} />,
}));
import LeagueHeader from './LeagueHeader';
describe('LeagueHeader', () => {
it('renders league name, description and sponsor', () => {
render(
<LeagueHeader
leagueId="league-1"
leagueName="Test League"
description="A fun test league"
ownerId="owner-1"
ownerName="Owner Name"
mainSponsor={{
name: 'Test Sponsor',
websiteUrl: 'https://example.com',
}}
/>
);
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(
<LeagueHeader
leagueId="league-2"
leagueName="League Without Details"
ownerId="owner-2"
ownerName="Owner 2"
/>
);
expect(screen.getByText('League Without Details')).toBeInTheDocument();
});
});

View File

@@ -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<void>>();
const mockGetLeagueMembers = vi.fn<(leagueId: string) => any[]>();
const mockFindByIds = vi.fn<(ids: string[]) => Promise<DriverDTO[]>>();
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(<LeagueMembers leagueId={leagueId} showActions={false} />);
// 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(<LeagueMembers leagueId={leagueId} showActions={false} />);
await waitFor(() => {
expect(screen.queryByText('Loading members...')).not.toBeInTheDocument();
});
expect(screen.getByText('No members found')).toBeInTheDocument();
});
});

View File

@@ -32,6 +32,7 @@ services:
- .env.development
environment:
- NODE_ENV=development
- GRIDPILOT_API_PERSISTENCE=inmemory
ports:
- "3001:3000"
- "9229:9229"