seed data
This commit is contained in:
@@ -2,6 +2,22 @@
|
|||||||
import { SignupWithEmailUseCase } from '@core/identity/application/use-cases/SignupWithEmailUseCase';
|
import { SignupWithEmailUseCase } from '@core/identity/application/use-cases/SignupWithEmailUseCase';
|
||||||
import { CreateAchievementUseCase } from '@core/identity/application/use-cases/achievement/CreateAchievementUseCase';
|
import { CreateAchievementUseCase } from '@core/identity/application/use-cases/achievement/CreateAchievementUseCase';
|
||||||
import type { Logger } from '@core/shared/application';
|
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 {
|
import {
|
||||||
DRIVER_ACHIEVEMENTS,
|
DRIVER_ACHIEVEMENTS,
|
||||||
STEWARD_ACHIEVEMENTS,
|
STEWARD_ACHIEVEMENTS,
|
||||||
@@ -9,11 +25,26 @@ import {
|
|||||||
COMMUNITY_ACHIEVEMENTS,
|
COMMUNITY_ACHIEVEMENTS,
|
||||||
} from '@core/identity/domain/AchievementConstants';
|
} 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 {
|
export class EnsureInitialData {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly signupUseCase: SignupWithEmailUseCase,
|
private readonly signupUseCase: SignupWithEmailUseCase,
|
||||||
private readonly createAchievementUseCase: CreateAchievementUseCase,
|
private readonly createAchievementUseCase: CreateAchievementUseCase,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
|
private readonly seedDeps?: InMemorySeedDependencies,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(): Promise<void> {
|
async execute(): Promise<void> {
|
||||||
@@ -57,5 +88,125 @@ export class EnsureInitialData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`[Bootstrap] Achievements: ${createdCount} created, ${existingCount} already exist`);
|
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}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
557
adapters/bootstrap/inmemory/InMemoryRacingSeed.ts
Normal file
557
adapters/bootstrap/inmemory/InMemoryRacingSeed.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -44,13 +44,20 @@ export class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepo
|
|||||||
}
|
}
|
||||||
|
|
||||||
async register(registration: RaceRegistration): Promise<void> {
|
async register(registration: RaceRegistration): Promise<void> {
|
||||||
this.logger.debug(`[InMemoryRaceRegistrationRepository] Registering driver ${registration.driverId.toString()} for race ${registration.raceId.toString()}.`);
|
const raceId = registration.raceId.toString();
|
||||||
if (await this.isRegistered(registration.raceId.toString(), registration.driverId.toString())) {
|
const driverId = registration.driverId.toString();
|
||||||
this.logger.warn(`Driver ${registration.driverId.toString()} already registered for race ${registration.raceId.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');
|
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();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,16 +87,19 @@ export class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepo
|
|||||||
|
|
||||||
async clearRaceRegistrations(raceId: string): Promise<void> {
|
async clearRaceRegistrations(raceId: string): Promise<void> {
|
||||||
this.logger.debug(`[InMemoryRaceRegistrationRepository] Clearing all registrations for race: ${raceId}.`);
|
this.logger.debug(`[InMemoryRaceRegistrationRepository] Clearing all registrations for race: ${raceId}.`);
|
||||||
const registrationsToDelete: string[] = [];
|
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
for (const registration of this.registrations.values()) {
|
for (const registration of this.registrations.values()) {
|
||||||
if (registration.raceId.toString() === raceId) {
|
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();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,13 +16,18 @@ export type RacingSeedData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class InMemoryFeedRepository implements IFeedRepository {
|
export class InMemoryFeedRepository implements IFeedRepository {
|
||||||
private readonly feedEvents: FeedItem[];
|
private feedEvents: FeedItem[];
|
||||||
private readonly friendships: Friendship[];
|
private friendships: Friendship[];
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
|
|
||||||
constructor(logger: Logger, seed: RacingSeedData) {
|
constructor(logger: Logger, seed?: RacingSeedData) {
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.logger.info('InMemoryFeedRepository initialized.');
|
this.logger.info('InMemoryFeedRepository initialized.');
|
||||||
|
this.feedEvents = seed?.feedEvents ?? [];
|
||||||
|
this.friendships = seed?.friendships ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
seed(seed: RacingSeedData): void {
|
||||||
this.feedEvents = seed.feedEvents;
|
this.feedEvents = seed.feedEvents;
|
||||||
this.friendships = seed.friendships;
|
this.friendships = seed.friendships;
|
||||||
}
|
}
|
||||||
@@ -72,13 +77,18 @@ export class InMemoryFeedRepository implements IFeedRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class InMemorySocialGraphRepository implements ISocialGraphRepository {
|
export class InMemorySocialGraphRepository implements ISocialGraphRepository {
|
||||||
private readonly friendships: Friendship[];
|
private friendships: Friendship[];
|
||||||
private readonly driversById: Map<string, Driver>;
|
private driversById: Map<string, Driver>;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
|
|
||||||
constructor(logger: Logger, seed: RacingSeedData) {
|
constructor(logger: Logger, seed?: RacingSeedData) {
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.logger.info('InMemorySocialGraphRepository initialized.');
|
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.friendships = seed.friendships;
|
||||||
this.driversById = new Map(seed.drivers.map((d) => [d.id, d]));
|
this.driversById = new Map(seed.drivers.map((d) => [d.id, d]));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ export const AuthProviders: Provider[] = [
|
|||||||
useFactory: (passwordHashingService: IPasswordHashingService, logger: Logger) => {
|
useFactory: (passwordHashingService: IPasswordHashingService, logger: Logger) => {
|
||||||
// Seed initial users for InMemoryUserRepository
|
// Seed initial users for InMemoryUserRepository
|
||||||
const initialUsers: StoredUser[] = [
|
const initialUsers: StoredUser[] = [
|
||||||
// Example user (replace with actual test users as needed)
|
|
||||||
{
|
{
|
||||||
id: 'user-1',
|
// Match seeded racing driver id so dashboard works in inmemory mode.
|
||||||
email: 'test@example.com',
|
id: 'driver-1',
|
||||||
passwordHash: 'demo_salt_moc.elpmaxe@tset', // "test@example.com" reversed
|
email: 'admin@gridpilot.local',
|
||||||
displayName: 'Test User',
|
passwordHash: 'demo_salt_321nimda', // InMemoryPasswordHashingService: "admin123" reversed.
|
||||||
salt: '', // Handled by hashing service
|
displayName: 'Admin',
|
||||||
|
salt: '',
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Provider } from '@nestjs/common';
|
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 { SignupWithEmailUseCase, type SignupWithEmailResult } from '@core/identity/application/use-cases/SignupWithEmailUseCase';
|
||||||
import {
|
import {
|
||||||
CreateAchievementUseCase,
|
CreateAchievementUseCase,
|
||||||
@@ -84,12 +84,52 @@ export const BootstrapProviders: Provider[] = [
|
|||||||
{
|
{
|
||||||
provide: EnsureInitialData,
|
provide: EnsureInitialData,
|
||||||
useFactory: (
|
useFactory: (
|
||||||
signupUseCase: SignupWithEmailUseCase,
|
signupUseCase: SignupWithEmailUseCase,
|
||||||
createAchievementUseCase: CreateAchievementUseCase,
|
createAchievementUseCase: CreateAchievementUseCase,
|
||||||
logger: Logger
|
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',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
129
apps/api/src/domain/persistence/InMemoryPersistenceModule.ts
Normal file
129
apps/api/src/domain/persistence/InMemoryPersistenceModule.ts
Normal 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 {}
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -32,6 +32,7 @@ services:
|
|||||||
- .env.development
|
- .env.development
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
|
- GRIDPILOT_API_PERSISTENCE=inmemory
|
||||||
ports:
|
ports:
|
||||||
- "3001:3000"
|
- "3001:3000"
|
||||||
- "9229:9229"
|
- "9229:9229"
|
||||||
|
|||||||
Reference in New Issue
Block a user