harden media

This commit is contained in:
2025-12-31 15:39:28 +01:00
parent 92226800df
commit 8260bf7baf
413 changed files with 8361 additions and 1544 deletions

View File

@@ -0,0 +1,39 @@
/**
* Shared media asset configuration
* This file defines the paths for all media assets used across the application
*/
export interface MediaAssetConfig {
avatars: {
male: string;
female: string;
neutral: string;
};
api: {
avatar: (driverId: string) => string;
teamLogo: (teamId: string) => string;
trackImage: (trackId: string) => string;
sponsorLogo: (sponsorId: string) => string;
categoryIcon: (categoryId: string) => string;
};
}
/**
* Shared media asset paths configuration
* Used by both seed data generation and frontend components
*/
export const mediaAssetConfig: MediaAssetConfig = {
avatars: {
male: '/images/avatars/male-default-avatar.jpg',
female: '/images/avatars/female-default-avatar.jpeg',
neutral: '/images/avatars/neutral-default-avatar.jpeg',
},
api: {
avatar: (driverId: string) => `/api/media/avatar/${driverId}`,
teamLogo: (teamId: string) => `/api/media/teams/${teamId}/logo`,
trackImage: (trackId: string) => `/api/media/tracks/${trackId}/image`,
sponsorLogo: (sponsorId: string) => `/api/media/sponsors/${sponsorId}/logo`,
categoryIcon: (categoryId: string) => `/api/media/categories/${categoryId}/icon`,
},
} as const;

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// IMPORTANT: SeedRacingData imports createRacingSeed from ./racing/RacingSeed
// We mock it to avoid heavy seed generation and to keep the test focused on
// force-reseed decision logic.
vi.mock('./racing/RacingSeed', () => {
return {
createRacingSeed: vi.fn(() => ({
drivers: [],
driverStats: new Map(),
leagues: [],
seasons: [],
seasonSponsorships: [],
sponsorshipRequests: [],
leagueWallets: [],
leagueWalletTransactions: [],
protests: [],
penalties: [],
races: [],
results: [],
standings: [],
leagueMemberships: [],
leagueJoinRequests: [],
raceRegistrations: [],
teams: [],
teamStats: new Map(),
teamMemberships: [],
teamJoinRequests: [],
sponsors: [],
tracks: [],
friendships: [],
feedEvents: [],
})),
};
});
import type { Logger } from '@core/shared/application';
import { SeedRacingData, type RacingSeedDependencies } from './SeedRacingData';
describe('SeedRacingData force reseed behavior', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('clears existing racing data when force reseed is enabled even if drivers are empty (stale teams scenario)', async () => {
process.env.GRIDPILOT_API_FORCE_RESEED = '1';
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
delete process.env.DATABASE_URL;
const logger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const seedDeps = {
driverRepository: {
findAll: vi.fn().mockResolvedValue([]),
create: vi.fn(),
delete: vi.fn(),
},
leagueRepository: { findAll: vi.fn().mockResolvedValue([]), create: vi.fn(), delete: vi.fn() },
seasonRepository: { findByLeagueId: vi.fn().mockResolvedValue([]), create: vi.fn() },
leagueScoringConfigRepository: { findBySeasonId: vi.fn().mockResolvedValue(null), save: vi.fn() },
seasonSponsorshipRepository: { create: vi.fn() },
sponsorshipRequestRepository: { create: vi.fn() },
leagueWalletRepository: { create: vi.fn() },
transactionRepository: { create: vi.fn() },
protestRepository: { create: vi.fn() },
penaltyRepository: { create: vi.fn() },
raceRepository: { findAll: vi.fn().mockResolvedValue([]), create: vi.fn(), delete: vi.fn() },
resultRepository: { findAll: vi.fn().mockResolvedValue([]), createMany: vi.fn() },
standingRepository: { findAll: vi.fn().mockResolvedValue([]), saveMany: vi.fn() },
leagueMembershipRepository: {
saveMembership: vi.fn(),
saveJoinRequest: vi.fn(),
getJoinRequests: vi.fn().mockResolvedValue([]),
getLeagueMembers: vi.fn().mockResolvedValue([]),
removeJoinRequest: vi.fn(),
removeMembership: vi.fn(),
},
raceRegistrationRepository: { register: vi.fn(), clearRaceRegistrations: vi.fn() },
// STALE TEAMS PRESENT
teamRepository: {
findAll: vi.fn().mockResolvedValue([{ id: 't1' }]),
create: vi.fn(),
delete: vi.fn(),
},
teamMembershipRepository: {
saveMembership: vi.fn(),
saveJoinRequest: vi.fn(),
getJoinRequests: vi.fn().mockResolvedValue([]),
getTeamMembers: vi.fn().mockResolvedValue([]),
removeJoinRequest: vi.fn(),
removeMembership: vi.fn(),
},
sponsorRepository: { create: vi.fn() },
feedRepository: {},
socialGraphRepository: {},
driverStatsRepository: { clear: vi.fn(), saveDriverStats: vi.fn() },
teamStatsRepository: { clear: vi.fn(), saveTeamStats: vi.fn(), getTeamStats: vi.fn().mockResolvedValue(null) },
mediaRepository: { clear: vi.fn() },
} as unknown as RacingSeedDependencies;
const s = new SeedRacingData(logger, seedDeps);
// Spy on the private method by monkey-patching (this is the behavior under test)
const clearSpy = vi.fn().mockResolvedValue(undefined);
(s as unknown as { clearExistingRacingData: () => Promise<void> }).clearExistingRacingData = clearSpy;
await s.execute();
expect(clearSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -22,7 +22,7 @@ import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenal
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
import type { ITeamStatsRepository, TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
import { createRacingSeed } from './racing/RacingSeed';
import { seedId } from './racing/SeedIdHelper';
@@ -31,7 +31,6 @@ import { Result } 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 { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
import type { TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
export type RacingSeedDependencies = {
driverRepository: IDriverRepository;
@@ -78,26 +77,33 @@ export class SeedRacingData {
return process.env.DATABASE_URL ? 'postgres' : 'inmemory';
}
private getMediaBaseUrl(): string {
return process.env.NODE_ENV === 'development' ? 'http://localhost:3001' : 'https://api.gridpilot.io';
}
async execute(): Promise<void> {
const existingDrivers = await this.seedDeps.driverRepository.findAll();
const existingTeams = await this.seedDeps.teamRepository.findAll().catch(() => []);
const persistence = this.getApiPersistence();
// Check for force reseed via environment variable
const forceReseedRaw = process.env.GRIDPILOT_API_FORCE_RESEED;
const forceReseed = forceReseedRaw !== undefined && forceReseedRaw !== '0' && forceReseedRaw.toLowerCase() !== 'false';
this.logger.info(
`[Bootstrap] Racing seed precheck: forceReseed=${forceReseed}, drivers=${existingDrivers.length}, teams=${existingTeams.length}, persistence=${persistence}`,
);
if (existingDrivers.length > 0 && !forceReseed) {
this.logger.info('[Bootstrap] Racing seed skipped (drivers already exist), ensuring scoring configs');
await this.ensureScoringConfigsForExistingData();
return;
}
if (forceReseed && existingDrivers.length > 0) {
this.logger.info('[Bootstrap] Force reseed enabled - clearing existing racing data');
// IMPORTANT:
// Force reseed must clear even when drivers are already empty.
// Otherwise stale teams can remain (e.g. with logoRef=system-default/logo),
// and the seed will "ignore duplicates" on create, leaving stale logoRefs in Postgres.
if (forceReseed) {
this.logger.info(
`[Bootstrap] Force reseed enabled - clearing existing racing data (drivers=${existingDrivers.length}, teams=${existingTeams.length})`,
);
await this.clearExistingRacingData();
}
@@ -109,9 +115,8 @@ export class SeedRacingData {
// Clear existing stats repositories
await this.seedDeps.driverStatsRepository.clear();
await this.seedDeps.teamStatsRepository.clear();
await this.seedDeps.mediaRepository.clear();
this.logger.info('[Bootstrap] Cleared existing stats and media repositories');
this.logger.info('[Bootstrap] Cleared existing stats repositories');
let sponsorshipRequestsSeededViaRepo = false;
const seedableSponsorshipRequests = this.seedDeps
@@ -316,9 +321,6 @@ export class SeedRacingData {
// Compute and store team stats from real data
await this.computeAndStoreTeamStats();
// Seed media assets (logos, images)
await this.seedMediaAssets(seed);
this.logger.info(
`[Bootstrap] Seeded racing data: drivers=${seed.drivers.length}, leagues=${seed.leagues.length}, races=${seed.races.length}`,
@@ -429,7 +431,7 @@ export class SeedRacingData {
this.logger.info(`[Bootstrap] Computed and stored stats for ${teams.length} teams`);
}
private calculateTeamStats(team: Team, results: Result[], drivers: Driver[]): TeamStats {
private calculateTeamStats(_team: Team, results: Result[], drivers: Driver[]): TeamStats {
const wins = results.filter(r => r.position.toNumber() === 1).length;
const totalRaces = results.length;
@@ -466,7 +468,6 @@ export class SeedRacingData {
})));
return {
logoUrl: `${this.getMediaBaseUrl()}/api/media/teams/${team.id}/logo`,
performanceLevel,
specialization,
region,
@@ -485,119 +486,171 @@ export class SeedRacingData {
return 'Beginner';
}
private async seedMediaAssets(seed: any): Promise<void> {
const baseUrl = this.getMediaBaseUrl();
// Seed driver avatars using static files
for (const driver of seed.drivers) {
const avatarUrl = this.getDriverAvatarUrl(driver.id);
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setDriverAvatar) {
mediaRepo.setDriverAvatar(driver.id, avatarUrl);
}
}
// Seed team logos using API routes
for (const team of seed.teams) {
const logoUrl = `${baseUrl}/api/media/teams/${team.id}/logo`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setTeamLogo) {
mediaRepo.setTeamLogo(team.id, logoUrl);
}
}
// Seed track images
for (const track of seed.tracks || []) {
const trackImageUrl = `${baseUrl}/api/media/tracks/${track.id}/image`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setTrackImage) {
mediaRepo.setTrackImage(track.id, trackImageUrl);
}
}
// Seed category icons (if categories exist)
const categories = ['beginner', 'intermediate', 'advanced', 'pro', 'endurance', 'sprint'];
for (const category of categories) {
const iconUrl = `${baseUrl}/api/media/categories/${category}/icon`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setCategoryIcon) {
mediaRepo.setCategoryIcon(category, iconUrl);
}
}
// Seed sponsor logos
for (const sponsor of seed.sponsors || []) {
const logoUrl = `${baseUrl}/api/media/sponsors/${sponsor.id}/logo`;
const mediaRepo = this.seedDeps.mediaRepository as any;
if (mediaRepo.setSponsorLogo) {
mediaRepo.setSponsorLogo(sponsor.id, logoUrl);
}
}
this.logger.info(`[Bootstrap] Seeded media assets for ${seed.drivers.length} drivers, ${seed.teams.length} teams`);
}
/**
* Get deterministic avatar URL for a driver based on their ID
* Uses static files from the website public directory
*/
private getDriverAvatarUrl(driverId: string): string {
// Deterministic selection based on driver ID
const numericSuffixMatch = driverId.match(/(\d+)$/);
let useFemale = false;
let useNeutral = false;
if (numericSuffixMatch && numericSuffixMatch[1]) {
const numericSuffix = parseInt(numericSuffixMatch[1], 10);
// 40% female, 40% male, 20% neutral
if (numericSuffix % 5 === 0) {
useNeutral = true;
} else if (numericSuffix % 2 === 0) {
useFemale = true;
}
} else {
// Fallback hash
let hash = 0;
for (let i = 0; i < driverId.length; i++) {
hash = (hash * 31 + driverId.charCodeAt(i)) | 0;
}
const hashValue = Math.abs(hash);
if (hashValue % 5 === 0) {
useNeutral = true;
} else if (hashValue % 2 === 0) {
useFemale = true;
}
}
// Return static file paths that Next.js can serve
if (useNeutral) {
return '/images/avatars/neutral-default-avatar.jpeg';
} else if (useFemale) {
return '/images/avatars/female-default-avatar.jpeg';
} else {
return '/images/avatars/male-default-avatar.jpg';
}
}
private async clearExistingRacingData(): Promise<void> {
// Get all existing drivers
const drivers = await this.seedDeps.driverRepository.findAll();
this.logger.info('[Bootstrap] Starting comprehensive clearing of all racing data');
// Delete drivers first (this should cascade to related data in most cases)
for (const driver of drivers) {
try {
await this.seedDeps.driverRepository.delete(driver.id);
} catch {
// Ignore errors
}
// Clear stats repositories first
try {
await this.seedDeps.driverStatsRepository.clear();
await this.seedDeps.teamStatsRepository.clear();
this.logger.info('[Bootstrap] Cleared stats repositories');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear stats repositories:', error);
}
// Try to clean up other data if repositories support it
// Clear race registrations - get all races first, then clear their registrations
try {
const races = await this.seedDeps.raceRepository.findAll();
for (const race of races) {
try {
await this.seedDeps.raceRegistrationRepository.clearRaceRegistrations(race.id.toString());
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared race registrations');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear race registrations:', error);
}
// Clear team join requests - get all teams first, then clear their join requests
try {
const teams = await this.seedDeps.teamRepository.findAll();
for (const team of teams) {
const joinRequests = await this.seedDeps.teamMembershipRepository.getJoinRequests(team.id.toString());
for (const request of joinRequests) {
try {
await this.seedDeps.teamMembershipRepository.removeJoinRequest(request.id);
} catch {
// Ignore
}
}
}
this.logger.info('[Bootstrap] Cleared team join requests');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear team join requests:', error);
}
// Clear team memberships
try {
const teams = await this.seedDeps.teamRepository.findAll();
for (const team of teams) {
const memberships = await this.seedDeps.teamMembershipRepository.getTeamMembers(team.id.toString());
for (const membership of memberships) {
try {
await this.seedDeps.teamMembershipRepository.removeMembership(team.id.toString(), membership.driverId.toString());
} catch {
// Ignore
}
}
}
this.logger.info('[Bootstrap] Cleared team memberships');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear team memberships:', error);
}
// Clear teams (this is critical - teams have stale logoRef)
try {
const teams = await this.seedDeps.teamRepository.findAll();
for (const team of teams) {
try {
await this.seedDeps.teamRepository.delete(team.id.toString());
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared teams');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear teams:', error);
}
// Clear results
try {
const results = await this.seedDeps.resultRepository.findAll();
for (const result of results) {
try {
await this.seedDeps.resultRepository.delete(result.id.toString());
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared results');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear results:', error);
}
// Clear standings
try {
const standings = await this.seedDeps.standingRepository.findAll();
for (const standing of standings) {
try {
await this.seedDeps.standingRepository.delete(standing.leagueId.toString(), standing.driverId.toString());
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared standings');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear standings:', error);
}
// Clear races
try {
const races = await this.seedDeps.raceRepository.findAll();
for (const race of races) {
try {
await this.seedDeps.raceRepository.delete(race.id.toString());
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared races');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear races:', error);
}
// Clear league join requests
try {
const leagues = await this.seedDeps.leagueRepository.findAll();
for (const league of leagues) {
const joinRequests = await this.seedDeps.leagueMembershipRepository.getJoinRequests(league.id.toString());
for (const request of joinRequests) {
try {
await this.seedDeps.leagueMembershipRepository.removeJoinRequest(request.id);
} catch {
// Ignore
}
}
}
this.logger.info('[Bootstrap] Cleared league join requests');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear league join requests:', error);
}
// Clear league memberships
try {
const leagues = await this.seedDeps.leagueRepository.findAll();
for (const league of leagues) {
const memberships = await this.seedDeps.leagueMembershipRepository.getLeagueMembers(league.id.toString());
for (const membership of memberships) {
try {
await this.seedDeps.leagueMembershipRepository.removeMembership(league.id.toString(), membership.driverId.toString());
} catch {
// Ignore
}
}
}
this.logger.info('[Bootstrap] Cleared league memberships');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear league memberships:', error);
}
// Note: Some repositories don't support direct deletion methods
// The key fix is clearing teams, team memberships, and join requests
// which resolves the logoRef issue
// Clear leagues
try {
const leagues = await this.seedDeps.leagueRepository.findAll();
for (const league of leagues) {
@@ -607,11 +660,48 @@ export class SeedRacingData {
// Ignore
}
}
} catch {
// Ignore
this.logger.info('[Bootstrap] Cleared leagues');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear leagues:', error);
}
this.logger.info('[Bootstrap] Cleared existing racing data');
// Clear drivers (do this last as other data depends on it)
try {
const drivers = await this.seedDeps.driverRepository.findAll();
for (const driver of drivers) {
try {
await this.seedDeps.driverRepository.delete(driver.id);
} catch {
// Ignore
}
}
this.logger.info('[Bootstrap] Cleared drivers');
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear drivers:', error);
}
// Clear social data if repositories support it
try {
const seedableFeed = this.seedDeps.feedRepository as unknown as { clear?: () => void };
if (typeof seedableFeed.clear === 'function') {
seedableFeed.clear();
this.logger.info('[Bootstrap] Cleared feed repository');
}
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear feed repository:', error);
}
try {
const seedableSocial = this.seedDeps.socialGraphRepository as unknown as { clear?: () => void };
if (typeof seedableSocial.clear === 'function') {
seedableSocial.clear();
this.logger.info('[Bootstrap] Cleared social graph repository');
}
} catch (error) {
this.logger.warn('[Bootstrap] Could not clear social graph repository:', error);
}
this.logger.info('[Bootstrap] Completed comprehensive clearing of all racing data');
}
private async ensureScoringConfigsForExistingData(): Promise<void> {
@@ -676,4 +766,4 @@ export class SeedRacingData {
return 'club-default';
}
}
}

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { RacingDriverFactory } from './RacingDriverFactory';
import { MediaReference } from '@core/domain/media/MediaReference';
describe('RacingDriverFactory', () => {
describe('getDriverAvatarRef', () => {
it('should return deterministic MediaReference based on driver ID', () => {
const factory = new RacingDriverFactory(10, new Date(), 'inmemory');
// Test deterministic behavior
const ref1 = factory.getDriverAvatarRef('driver-1');
const ref2 = factory.getDriverAvatarRef('driver-1');
expect(ref1.equals(ref2)).toBe(true);
expect(ref1.type).toBe('system-default');
expect(ref1.variant).toBe('avatar');
});
it('should produce different refs for different IDs', () => {
const factory = new RacingDriverFactory(10, new Date(), 'inmemory');
const ref1 = factory.getDriverAvatarRef('driver-1');
const ref2 = factory.getDriverAvatarRef('driver-2');
// They should be different refs (though both are system-default avatar)
// The hash will be different
expect(ref1.hash()).not.toBe(ref2.hash());
});
it('should use hash % 3 for variant selection', () => {
const factory = new RacingDriverFactory(10, new Date(), 'inmemory');
// Test multiple IDs to ensure distribution
const refs = [
factory.getDriverAvatarRef('driver-1'),
factory.getDriverAvatarRef('driver-2'),
factory.getDriverAvatarRef('driver-3'),
factory.getDriverAvatarRef('driver-4'),
factory.getDriverAvatarRef('driver-5'),
];
// All should be system-default avatar
refs.forEach(ref => {
expect(ref.type).toBe('system-default');
expect(ref.variant).toBe('avatar');
});
});
});
describe('create', () => {
it('should create drivers with avatarRef set', () => {
const factory = new RacingDriverFactory(5, new Date(), 'inmemory');
const drivers = factory.create();
expect(drivers.length).toBe(5);
drivers.forEach(driver => {
expect(driver.avatarRef).toBeDefined();
expect(driver.avatarRef instanceof MediaReference).toBe(true);
expect(driver.avatarRef.type).toBe('system-default');
expect(driver.avatarRef.variant).toBe('avatar');
});
});
it('should create deterministic drivers', () => {
const factory1 = new RacingDriverFactory(3, new Date('2024-01-01'), 'inmemory');
const factory2 = new RacingDriverFactory(3, new Date('2024-01-01'), 'inmemory');
const drivers1 = factory1.create();
const drivers2 = factory2.create();
expect(drivers1.length).toBe(drivers2.length);
for (let i = 0; i < drivers1.length; i++) {
const driver1 = drivers1[i]!;
const driver2 = drivers2[i]!;
expect(driver1.id).toBe(driver2.id);
expect(driver1.avatarRef.equals(driver2.avatarRef)).toBe(true);
}
});
});
});

View File

@@ -1,4 +1,5 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { MediaReference } from '@core/domain/media/MediaReference';
import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper';
@@ -26,22 +27,23 @@ export class RacingDriverFactory {
) {}
/**
* Get deterministic avatar URL for a driver based on their ID
* Uses static files from the website public directory
* Get deterministic MediaReference for a driver's avatar based on their ID
* Uses hash % 3 to determine variant: 0 -> male, 1 -> female, 2 -> neutral
*/
getDriverAvatarUrl(driverId: string): string {
// Deterministic selection based on driver ID
getDriverAvatarRef(driverId: string): MediaReference {
// Deterministic selection based on driver ID hash
const numericSuffixMatch = driverId.match(/(\d+)$/);
let useFemale = false;
let useNeutral = false;
let avatarVariant: 'male' | 'female' | 'neutral';
if (numericSuffixMatch && numericSuffixMatch[1]) {
const numericSuffix = parseInt(numericSuffixMatch[1], 10);
// 40% female, 40% male, 20% neutral
if (numericSuffix % 5 === 0) {
useNeutral = true;
} else if (numericSuffix % 2 === 0) {
useFemale = true;
const hashMod = numericSuffix % 3;
if (hashMod === 0) {
avatarVariant = 'male';
} else if (hashMod === 1) {
avatarVariant = 'female';
} else {
avatarVariant = 'neutral';
}
} else {
// Fallback hash
@@ -49,22 +51,18 @@ export class RacingDriverFactory {
for (let i = 0; i < driverId.length; i++) {
hash = (hash * 31 + driverId.charCodeAt(i)) | 0;
}
const hashValue = Math.abs(hash);
if (hashValue % 5 === 0) {
useNeutral = true;
} else if (hashValue % 2 === 0) {
useFemale = true;
const hashMod = Math.abs(hash) % 3;
if (hashMod === 0) {
avatarVariant = 'male';
} else if (hashMod === 1) {
avatarVariant = 'female';
} else {
avatarVariant = 'neutral';
}
}
// Return static file paths that Next.js can serve
if (useNeutral) {
return '/images/avatars/neutral-default-avatar.jpeg';
} else if (useFemale) {
return '/images/avatars/female-default-avatar.jpeg';
} else {
return '/images/avatars/male-default-avatar.jpg';
}
// Create system-default reference with avatar variant
return MediaReference.systemDefault(avatarVariant);
}
create(): Driver[] {
@@ -99,6 +97,8 @@ export class RacingDriverFactory {
// Assign category - use all available categories
const category = faker.helpers.arrayElement(categories);
const driverId = seedId(`driver-${i}`, this.persistence);
const driverData: {
id: string;
iracingId: string;
@@ -107,13 +107,15 @@ export class RacingDriverFactory {
bio?: string;
joinedAt?: Date;
category?: string;
avatarRef: MediaReference;
} = {
id: seedId(`driver-${i}`, this.persistence),
id: driverId,
iracingId: String(100000 + i),
name: faker.person.fullName(),
country: faker.helpers.arrayElement(countries),
joinedAt,
category,
avatarRef: this.getDriverAvatarRef(driverId),
};
if (hasBio) {

View File

@@ -1,5 +1,6 @@
import { League, LeagueSettings } from '@core/racing/domain/entities/League';
import { Driver } from '@core/racing/domain/entities/Driver';
import { MediaReference } from '@core/domain/media/MediaReference';
import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper';
@@ -389,6 +390,7 @@ export class RacingLeagueFactory {
websiteUrl?: string;
};
participantCount?: number;
logoRef?: MediaReference;
} = {
id: leagueData.id,
name: leagueData.name,
@@ -398,6 +400,7 @@ export class RacingLeagueFactory {
category: leagueData.category,
createdAt: leagueData.createdAt,
participantCount: leagueData.participantCount,
logoRef: MediaReference.generated('league', leagueData.id),
};
if (Object.keys(socialLinks).length > 0) {

View File

@@ -13,7 +13,7 @@ export class RacingSponsorFactory {
id: seedId('demo-sponsor-1', this.persistence),
name: 'GridPilot Sim Racing Supply',
contactEmail: 'partnerships@gridpilot.example',
logoUrl: 'http://localhost:3000/images/header.jpeg',
logoUrl: 'http://localhost:3001/images/header.jpeg',
websiteUrl: 'https://gridpilot.example/sponsors/gridpilot-sim-racing-supply',
createdAt: faker.date.past({ years: 2, refDate: this.baseDate }),
});
@@ -74,13 +74,13 @@ export class RacingSponsorFactory {
];
const logoPaths = [
'http://localhost:3000/images/header.jpeg',
'http://localhost:3000/images/ff1600.jpeg',
'http://localhost:3000/images/avatars/male-default-avatar.jpg',
'http://localhost:3000/images/avatars/female-default-avatar.jpeg',
'http://localhost:3000/images/avatars/neutral-default-avatar.jpeg',
'http://localhost:3000/images/leagues/placeholder-cover.svg',
'http://localhost:3000/favicon.svg',
'http://localhost:3001/images/header.jpeg',
'http://localhost:3001/images/ff1600.jpeg',
'http://localhost:3001/images/avatars/male-default-avatar.jpg',
'http://localhost:3001/images/avatars/female-default-avatar.jpeg',
'http://localhost:3001/images/avatars/neutral-default-avatar.jpeg',
'http://localhost:3001/images/leagues/placeholder-cover.svg',
'http://localhost:3001/favicon.svg',
];
const websiteUrls = [

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { RacingTeamFactory } from './RacingTeamFactory';
import { MediaReference } from '@core/domain/media/MediaReference';
import { RacingDriverFactory } from './RacingDriverFactory';
import { RacingLeagueFactory } from './RacingLeagueFactory';
describe('RacingTeamFactory', () => {
describe('createTeams', () => {
it('should create teams with generated logoRef', () => {
const baseDate = new Date();
const driverFactory = new RacingDriverFactory(10, baseDate, 'inmemory');
const leagueFactory = new RacingLeagueFactory(baseDate, driverFactory.create(), 'inmemory');
const teamFactory = new RacingTeamFactory(baseDate, 'inmemory');
const drivers = driverFactory.create();
const leagues = leagueFactory.create();
const teams = teamFactory.createTeams(drivers, leagues);
expect(teams.length).toBeGreaterThan(0);
teams.forEach(team => {
expect(team.logoRef).toBeDefined();
expect(team.logoRef instanceof MediaReference).toBe(true);
expect(team.logoRef.type).toBe('generated');
expect(team.logoRef.generationRequestId).toBe(`team-${team.id}`);
});
});
it('should create deterministic teams', () => {
const baseDate = new Date('2024-01-01');
const driverFactory = new RacingDriverFactory(5, baseDate, 'inmemory');
const leagueFactory = new RacingLeagueFactory(baseDate, driverFactory.create(), 'inmemory');
const drivers = driverFactory.create();
const leagues = leagueFactory.create();
const teamFactory1 = new RacingTeamFactory(baseDate, 'inmemory');
const teamFactory2 = new RacingTeamFactory(baseDate, 'inmemory');
const teams1 = teamFactory1.createTeams(drivers, leagues);
const teams2 = teamFactory2.createTeams(drivers, leagues);
expect(teams1.length).toBe(teams2.length);
for (let i = 0; i < teams1.length; i++) {
const team1 = teams1[i]!;
const team2 = teams2[i]!;
expect(team1.id).toBe(team2.id);
expect(team1.logoRef.equals(team2.logoRef)).toBe(true);
}
});
});
});

View File

@@ -1,12 +1,12 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { League } from '@core/racing/domain/entities/League';
import { Team } from '@core/racing/domain/entities/Team';
import { MediaReference } from '@core/domain/media/MediaReference';
import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership';
import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper';
export interface TeamStats {
logoUrl: string;
performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
specialization: 'endurance' | 'sprint' | 'mixed';
region: string;
@@ -37,8 +37,10 @@ export class RacingTeamFactory {
// 30-50% of teams are recruiting
const isRecruiting = faker.datatype.boolean({ probability: 0.4 });
const teamId = seedId(`team-${i}`, this.persistence);
return Team.create({
id: seedId(`team-${i}`, this.persistence),
id: teamId,
name: faker.company.name() + ' Racing',
tag: faker.string.alpha({ length: 4, casing: 'upper' }),
description: faker.lorem.sentences(2),
@@ -46,6 +48,7 @@ export class RacingTeamFactory {
leagues: teamLeagues,
isRecruiting,
createdAt: faker.date.past({ years: 2, refDate: this.baseDate }),
logoRef: MediaReference.generated('team', teamId),
});
});
}
@@ -200,16 +203,6 @@ export class RacingTeamFactory {
generateTeamStats(teams: Team[]): Map<string, TeamStats> {
const statsMap = new Map<string, TeamStats>();
// Available logo URLs (simulating media uploads)
const logoUrls = [
'/images/ff1600.jpeg',
'/images/header.jpeg',
'/images/avatars/male-default-avatar.jpg',
'/images/avatars/female-default-avatar.jpeg',
'/images/avatars/neutral-default-avatar.jpeg',
'/images/leagues/placeholder-cover.svg',
];
// Available regions
const regions = ['Europe', 'North America', 'South America', 'Asia', 'Oceania', 'Africa'];
@@ -270,11 +263,7 @@ export class RacingTeamFactory {
const languageCount = faker.number.int({ min: 1, max: 3 });
const languages = faker.helpers.arrayElements(allLanguages, languageCount);
// Generate logo URL (varied)
const logoUrl = logoUrls[i % logoUrls.length] ?? logoUrls[0];
statsMap.set(team.id.toString(), {
logoUrl: logoUrl!,
performanceLevel,
specialization,
region,