seed data
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
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> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
];
|
||||
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
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- GRIDPILOT_API_PERSISTENCE=inmemory
|
||||
ports:
|
||||
- "3001:3000"
|
||||
- "9229:9229"
|
||||
|
||||
Reference in New Issue
Block a user