harden media
This commit is contained in:
39
adapters/bootstrap/MediaAssetConfig.ts
Normal file
39
adapters/bootstrap/MediaAssetConfig.ts
Normal 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;
|
||||
123
adapters/bootstrap/SeedRacingData.forceReseed.test.ts
Normal file
123
adapters/bootstrap/SeedRacingData.forceReseed.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
82
adapters/bootstrap/racing/RacingDriverFactory.test.ts
Normal file
82
adapters/bootstrap/racing/RacingDriverFactory.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
53
adapters/bootstrap/racing/RacingTeamFactory.test.ts
Normal file
53
adapters/bootstrap/racing/RacingTeamFactory.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
229
adapters/media/MediaResolverAdapter.test.ts
Normal file
229
adapters/media/MediaResolverAdapter.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* TDD Tests for MediaResolverAdapter and its components
|
||||
*
|
||||
* Tests the complete resolution flow for all media reference types
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
import { MediaResolverAdapter, DefaultResolvers } from './MediaResolverAdapter';
|
||||
import { DefaultMediaResolverAdapter } from './resolvers/DefaultMediaResolverAdapter';
|
||||
import { GeneratedMediaResolverAdapter } from './resolvers/GeneratedMediaResolverAdapter';
|
||||
import { UploadedMediaResolverAdapter } from './resolvers/UploadedMediaResolverAdapter';
|
||||
|
||||
describe('DefaultMediaResolverAdapter', () => {
|
||||
const adapter = new DefaultMediaResolverAdapter();
|
||||
|
||||
describe('System Default URLs', () => {
|
||||
it('should resolve avatar default without variant', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/default/neutral-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve male avatar default', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'male');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/default/male-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve female avatar default', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'female');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/default/female-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve neutral avatar default', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'neutral');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/default/neutral-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve team logo default', async () => {
|
||||
const ref = MediaReference.createSystemDefault('logo');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/default/logo.png');
|
||||
});
|
||||
|
||||
it('should resolve league logo default', async () => {
|
||||
const ref = MediaReference.createSystemDefault('logo');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/default/logo.png');
|
||||
});
|
||||
|
||||
it('should return null for non-system-default references', async () => {
|
||||
const ref = MediaReference.createGenerated('team-123');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeneratedMediaResolverAdapter', () => {
|
||||
const adapter = new GeneratedMediaResolverAdapter();
|
||||
|
||||
describe('Generated URLs', () => {
|
||||
it('should resolve team logo generated', async () => {
|
||||
const ref = MediaReference.createGenerated('team-123');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/teams/123/logo');
|
||||
});
|
||||
|
||||
it('should resolve league logo generated', async () => {
|
||||
const ref = MediaReference.createGenerated('league-456');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/leagues/456/logo');
|
||||
});
|
||||
|
||||
it('should resolve driver avatar generated', async () => {
|
||||
const ref = MediaReference.createGenerated('driver-789');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/avatar/789');
|
||||
});
|
||||
|
||||
it('should handle complex type names with hyphens', async () => {
|
||||
const ref = MediaReference.createGenerated('team-league-123');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/teams/league-123/logo');
|
||||
});
|
||||
|
||||
it('should return null for invalid format (no hyphen)', async () => {
|
||||
const ref = MediaReference.createGenerated('invalid');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-generated references', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UploadedMediaResolverAdapter', () => {
|
||||
const adapter = new UploadedMediaResolverAdapter();
|
||||
|
||||
describe('Uploaded URLs', () => {
|
||||
it('should resolve uploaded media', async () => {
|
||||
const ref = MediaReference.createUploaded('media-123');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/uploaded/media-123');
|
||||
});
|
||||
|
||||
it('should handle different media IDs', async () => {
|
||||
const ref = MediaReference.createUploaded('media-456');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBe('/media/uploaded/media-456');
|
||||
});
|
||||
|
||||
it('should return null for non-uploaded references', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const url = await adapter.resolve(ref);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MediaResolverAdapter (Composite)', () => {
|
||||
const resolver = new MediaResolverAdapter();
|
||||
|
||||
describe('Composite Resolution', () => {
|
||||
it('should resolve system-default references', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'male');
|
||||
const url = await resolver.resolve(ref);
|
||||
expect(url).toBe('/media/default/male-default-avatar.png');
|
||||
});
|
||||
|
||||
it('should resolve generated references', async () => {
|
||||
const ref = MediaReference.createGenerated('team-123');
|
||||
const url = await resolver.resolve(ref);
|
||||
expect(url).toBe('/media/teams/123/logo');
|
||||
});
|
||||
|
||||
it('should resolve uploaded references', async () => {
|
||||
const ref = MediaReference.createUploaded('media-456');
|
||||
const url = await resolver.resolve(ref);
|
||||
expect(url).toBe('/media/uploaded/media-456');
|
||||
});
|
||||
|
||||
it('should return null for none references', async () => {
|
||||
const ref = MediaReference.createNone();
|
||||
const url = await resolver.resolve(ref);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for null/undefined input', async () => {
|
||||
expect(await resolver.resolve(null as unknown as MediaReference)).toBeNull();
|
||||
expect(await resolver.resolve(undefined as unknown as MediaReference)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Factory Functions', () => {
|
||||
it('should create local resolver', () => {
|
||||
const local = DefaultResolvers.local();
|
||||
// Local resolver should work without baseUrl (path-only)
|
||||
expect(local).toBeInstanceOf(MediaResolverAdapter);
|
||||
});
|
||||
|
||||
it('should create production resolver', () => {
|
||||
const prod = DefaultResolvers.production();
|
||||
// Production resolver should work without baseUrl (path-only)
|
||||
expect(prod).toBeInstanceOf(MediaResolverAdapter);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration: End-to-End Resolution', () => {
|
||||
const resolver = new MediaResolverAdapter();
|
||||
|
||||
it('should resolve all reference types consistently', async () => {
|
||||
const testCases = [
|
||||
{
|
||||
ref: MediaReference.createSystemDefault('avatar', 'male'),
|
||||
expected: '/media/default/male-default-avatar.png'
|
||||
},
|
||||
{
|
||||
ref: MediaReference.createSystemDefault('avatar', 'female'),
|
||||
expected: '/media/default/female-default-avatar.png'
|
||||
},
|
||||
{
|
||||
ref: MediaReference.createSystemDefault('logo'),
|
||||
expected: '/media/default/logo.png'
|
||||
},
|
||||
{
|
||||
ref: MediaReference.createGenerated('team-abc123'),
|
||||
expected: '/media/teams/abc123/logo'
|
||||
},
|
||||
{
|
||||
ref: MediaReference.createGenerated('league-def456'),
|
||||
expected: '/media/leagues/def456/logo'
|
||||
},
|
||||
{
|
||||
ref: MediaReference.createUploaded('media-ghi789'),
|
||||
expected: '/media/uploaded/media-ghi789'
|
||||
},
|
||||
{
|
||||
ref: MediaReference.createNone(),
|
||||
expected: null
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const result = await resolver.resolve(testCase.ref);
|
||||
expect(result).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain URL consistency across multiple resolutions', async () => {
|
||||
const ref = MediaReference.createGenerated('team-123');
|
||||
|
||||
const url1 = await resolver.resolve(ref);
|
||||
const url2 = await resolver.resolve(ref);
|
||||
const url3 = await resolver.resolve(ref);
|
||||
|
||||
expect(url1).toBe(url2);
|
||||
expect(url2).toBe(url3);
|
||||
expect(url1).toBe('/media/teams/123/logo');
|
||||
});
|
||||
});
|
||||
127
adapters/media/MediaResolverAdapter.ts
Normal file
127
adapters/media/MediaResolverAdapter.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* MediaResolverAdapter (Composite)
|
||||
*
|
||||
* Composite adapter that delegates resolution to type-specific adapters.
|
||||
* This is the main entry point for media resolution.
|
||||
*/
|
||||
|
||||
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
import { DefaultMediaResolverAdapter } from './resolvers/DefaultMediaResolverAdapter';
|
||||
import { GeneratedMediaResolverAdapter } from './resolvers/GeneratedMediaResolverAdapter';
|
||||
import { UploadedMediaResolverAdapter } from './resolvers/UploadedMediaResolverAdapter';
|
||||
|
||||
/**
|
||||
* Configuration for the composite MediaResolverAdapter
|
||||
*/
|
||||
export interface MediaResolverAdapterConfig {
|
||||
/**
|
||||
* Base path for default assets (defaults to '/media/default')
|
||||
*/
|
||||
defaultPath?: string;
|
||||
|
||||
/**
|
||||
* Base path for generated assets (defaults to '/media/generated')
|
||||
*/
|
||||
generatedPath?: string;
|
||||
|
||||
/**
|
||||
* Base path for uploaded assets (defaults to '/media/uploaded')
|
||||
*/
|
||||
uploadedPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MediaResolverAdapter
|
||||
*
|
||||
* Composite adapter that delegates to type-specific resolvers.
|
||||
* Implements the MediaResolverPort interface.
|
||||
*
|
||||
* Returns path-only URLs (e.g., /media/teams/123/logo) without baseUrl.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const resolver = new MediaResolverAdapter({
|
||||
* defaultPath: '/media/default',
|
||||
* generatedPath: '/media/generated',
|
||||
* uploadedPath: '/media/uploaded'
|
||||
* });
|
||||
*
|
||||
* const path = await resolver.resolve(mediaReference);
|
||||
* ```
|
||||
*/
|
||||
export class MediaResolverAdapter implements MediaResolverPort {
|
||||
private readonly defaultResolver: DefaultMediaResolverAdapter;
|
||||
private readonly generatedResolver: GeneratedMediaResolverAdapter;
|
||||
private readonly uploadedResolver: UploadedMediaResolverAdapter;
|
||||
|
||||
constructor(config: MediaResolverAdapterConfig = {}) {
|
||||
// Initialize type-specific resolvers
|
||||
this.defaultResolver = new DefaultMediaResolverAdapter({
|
||||
basePath: config.defaultPath
|
||||
});
|
||||
|
||||
this.generatedResolver = new GeneratedMediaResolverAdapter({
|
||||
basePath: config.generatedPath
|
||||
});
|
||||
|
||||
this.uploadedResolver = new UploadedMediaResolverAdapter({
|
||||
basePath: config.uploadedPath
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a media reference to a path-only URL
|
||||
*
|
||||
* Delegates to the appropriate type-specific resolver based on the reference type.
|
||||
* Returns paths like /media/... (no baseUrl).
|
||||
*/
|
||||
async resolve(ref: MediaReference): Promise<string | null> {
|
||||
if (!ref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Delegate to the appropriate resolver based on type
|
||||
switch (ref.type) {
|
||||
case 'system-default':
|
||||
return this.defaultResolver.resolve(ref);
|
||||
|
||||
case 'generated':
|
||||
return this.generatedResolver.resolve(ref);
|
||||
|
||||
case 'uploaded':
|
||||
return this.uploadedResolver.resolve(ref);
|
||||
|
||||
case 'none':
|
||||
return null;
|
||||
|
||||
default:
|
||||
// Unknown type
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for creating MediaResolverAdapter instances
|
||||
*/
|
||||
export function createMediaResolver(
|
||||
config: MediaResolverAdapterConfig = {}
|
||||
): MediaResolverAdapter {
|
||||
return new MediaResolverAdapter(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration for development/testing
|
||||
*/
|
||||
export const DefaultResolvers = {
|
||||
/**
|
||||
* Creates a resolver for local development
|
||||
*/
|
||||
local: () => createMediaResolver({}),
|
||||
|
||||
/**
|
||||
* Creates a resolver for production
|
||||
*/
|
||||
production: () => createMediaResolver({})
|
||||
};
|
||||
229
adapters/media/MediaResolverInMemoryAdapter.ts
Normal file
229
adapters/media/MediaResolverInMemoryAdapter.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* In-Memory Media Resolver Adapter
|
||||
*
|
||||
* Stub implementation for testing purposes.
|
||||
* Resolves MediaReference objects to fake URLs without external dependencies.
|
||||
*
|
||||
* Part of the adapters layer, implementing the MediaResolverPort interface.
|
||||
*/
|
||||
|
||||
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
/**
|
||||
* Configuration for InMemoryMediaResolverAdapter
|
||||
*/
|
||||
export interface InMemoryMediaResolverConfig {
|
||||
/**
|
||||
* Base URL to use for generated URLs
|
||||
* @default 'https://fake-media.example.com'
|
||||
*/
|
||||
baseUrl?: string;
|
||||
|
||||
/**
|
||||
* Whether to simulate network delays
|
||||
* @default false
|
||||
*/
|
||||
simulateDelay?: boolean;
|
||||
|
||||
/**
|
||||
* Delay in milliseconds when simulateDelay is true
|
||||
* @default 50
|
||||
*/
|
||||
delayMs?: number;
|
||||
|
||||
/**
|
||||
* Whether to return null for certain reference types (simulating missing media)
|
||||
* @default false
|
||||
*/
|
||||
simulateMissingMedia?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-Memory Media Resolver Adapter
|
||||
*
|
||||
* Stub implementation that resolves media references to fake URLs.
|
||||
* Designed for use in tests and development environments.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const adapter = new InMemoryMediaResolverAdapter({
|
||||
* baseUrl: 'https://test.example.com',
|
||||
* simulateDelay: true
|
||||
* });
|
||||
*
|
||||
* const ref = MediaReference.createSystemDefault('avatar');
|
||||
* const url = await adapter.resolve(ref);
|
||||
* // Returns: '/media/default/male-default-avatar.png'
|
||||
* ```
|
||||
*/
|
||||
export class InMemoryMediaResolverAdapter implements MediaResolverPort {
|
||||
private readonly config: Required<InMemoryMediaResolverConfig>;
|
||||
|
||||
constructor(config: InMemoryMediaResolverConfig = {}) {
|
||||
this.config = {
|
||||
baseUrl: config.baseUrl ?? 'https://fake-media.example.com',
|
||||
simulateDelay: config.simulateDelay ?? false,
|
||||
delayMs: config.delayMs ?? 50,
|
||||
simulateMissingMedia: config.simulateMissingMedia ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a media reference to a path-only URL
|
||||
*
|
||||
* @param ref - The media reference to resolve
|
||||
* @returns Promise resolving to path string or null
|
||||
*/
|
||||
async resolve(ref: MediaReference): Promise<string | null> {
|
||||
// Simulate network delay if configured
|
||||
if (this.config.simulateDelay) {
|
||||
await this.delay(this.config.delayMs);
|
||||
}
|
||||
|
||||
// Simulate missing media for some cases
|
||||
if (this.config.simulateMissingMedia && this.shouldReturnNull()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (ref.type) {
|
||||
case 'system-default':
|
||||
let filename: string;
|
||||
if (ref.variant === 'avatar' && ref.avatarVariant) {
|
||||
filename = `${ref.avatarVariant}-default-avatar.png`;
|
||||
} else if (ref.variant === 'avatar') {
|
||||
filename = `neutral-default-avatar.png`;
|
||||
} else {
|
||||
filename = `${ref.variant}.png`;
|
||||
}
|
||||
return `/media/default/${filename}`;
|
||||
|
||||
case 'generated':
|
||||
// Parse the generationRequestId to extract type and id
|
||||
// Format: "{type}-{id}" where id can contain hyphens
|
||||
if (ref.generationRequestId) {
|
||||
const firstHyphenIndex = ref.generationRequestId.indexOf('-');
|
||||
if (firstHyphenIndex !== -1) {
|
||||
const type = ref.generationRequestId.substring(0, firstHyphenIndex);
|
||||
const id = ref.generationRequestId.substring(firstHyphenIndex + 1);
|
||||
|
||||
// Use the correct API routes
|
||||
if (type === 'team') {
|
||||
return `/media/teams/${id}/logo`;
|
||||
} else if (type === 'league') {
|
||||
return `/media/leagues/${id}/logo`;
|
||||
} else if (type === 'driver') {
|
||||
return `/media/avatar/${id}`;
|
||||
}
|
||||
// Fallback for other types
|
||||
return `/media/generated/${type}/${id}`;
|
||||
}
|
||||
}
|
||||
// Fallback for unexpected format
|
||||
return null;
|
||||
|
||||
case 'uploaded':
|
||||
return `/media/uploaded/${ref.mediaId}`;
|
||||
|
||||
case 'none':
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate network delay
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if this reference should return null (simulating missing media)
|
||||
*/
|
||||
private shouldReturnNull(): boolean {
|
||||
// Randomly return null for 20% of cases
|
||||
return Math.random() < 0.2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured base URL
|
||||
*/
|
||||
getBaseUrl(): string {
|
||||
return this.config.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<InMemoryMediaResolverConfig>): void {
|
||||
Object.assign(this.config, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to default configuration
|
||||
*/
|
||||
reset(): void {
|
||||
this.config.baseUrl = 'https://fake-media.example.com';
|
||||
this.config.simulateDelay = false;
|
||||
this.config.delayMs = 50;
|
||||
this.config.simulateMissingMedia = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a configured in-memory resolver
|
||||
*/
|
||||
export function createInMemoryResolver(
|
||||
config: InMemoryMediaResolverConfig = {}
|
||||
): MediaResolverPort {
|
||||
return new InMemoryMediaResolverAdapter(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured resolver for common test scenarios
|
||||
*/
|
||||
export const TestResolvers = {
|
||||
/**
|
||||
* Fast resolver with no delays
|
||||
*/
|
||||
fast: () => new InMemoryMediaResolverAdapter({
|
||||
baseUrl: 'https://test.example.com',
|
||||
simulateDelay: false,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Slow resolver that simulates network latency
|
||||
*/
|
||||
slow: () => new InMemoryMediaResolverAdapter({
|
||||
baseUrl: 'https://test.example.com',
|
||||
simulateDelay: true,
|
||||
delayMs: 200,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Unreliable resolver that sometimes returns null
|
||||
*/
|
||||
unreliable: () => new InMemoryMediaResolverAdapter({
|
||||
baseUrl: 'https://test.example.com',
|
||||
simulateMissingMedia: true,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Custom base URL resolver
|
||||
*/
|
||||
withBaseUrl: (baseUrl: string) => new InMemoryMediaResolverAdapter({
|
||||
baseUrl,
|
||||
simulateDelay: false,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Local development resolver
|
||||
*/
|
||||
local: () => new InMemoryMediaResolverAdapter({
|
||||
baseUrl: 'http://localhost:3000/media',
|
||||
simulateDelay: false,
|
||||
}),
|
||||
} as const;
|
||||
166
adapters/media/ports/FileSystemMediaStorageAdapter.ts
Normal file
166
adapters/media/ports/FileSystemMediaStorageAdapter.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* FileSystemMediaStorageAdapter
|
||||
*
|
||||
* Concrete adapter for storing media files on the filesystem.
|
||||
* Implements the MediaStoragePort interface.
|
||||
*/
|
||||
|
||||
import { MediaStoragePort, UploadOptions, UploadResult } from '@core/media/application/ports/MediaStoragePort';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Configuration for FileSystemMediaStorageAdapter
|
||||
*/
|
||||
export interface FileSystemMediaStorageConfig {
|
||||
/**
|
||||
* Base directory for storing media files
|
||||
* @default '/data/media'
|
||||
*/
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FileSystemMediaStorageAdapter
|
||||
*
|
||||
* Stores media files in a local filesystem directory.
|
||||
* Uses deterministic storage keys based on mediaId.
|
||||
*/
|
||||
export class FileSystemMediaStorageAdapter implements MediaStoragePort {
|
||||
private readonly baseDir: string;
|
||||
|
||||
constructor(config: FileSystemMediaStorageConfig = {}) {
|
||||
this.baseDir = config.baseDir || '/data/media';
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a media file to the filesystem
|
||||
*
|
||||
* @param buffer File buffer
|
||||
* @param options Upload options
|
||||
* @returns Upload result with storage key
|
||||
*/
|
||||
async uploadMedia(buffer: Buffer, options: UploadOptions): Promise<UploadResult> {
|
||||
try {
|
||||
// Validate content type
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/gif'];
|
||||
if (!allowedTypes.includes(options.mimeType)) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: `Content type ${options.mimeType} is not allowed`,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate deterministic storage key
|
||||
const mediaId = this.generateMediaId(options.filename);
|
||||
const storageKey = `uploaded/${mediaId}`;
|
||||
const filePath = path.join(this.baseDir, storageKey);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
// Write file
|
||||
await fs.writeFile(filePath, buffer);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filename: options.filename,
|
||||
url: storageKey, // Return storage key, not full URL
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a media file from the filesystem
|
||||
*
|
||||
* @param storageKey Storage key (e.g., 'uploaded/media-123')
|
||||
*/
|
||||
async deleteMedia(storageKey: string): Promise<void> {
|
||||
try {
|
||||
const filePath = path.join(this.baseDir, storageKey);
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
// Ignore if file doesn't exist
|
||||
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file bytes as Buffer
|
||||
*
|
||||
* @param storageKey Storage key
|
||||
* @returns Buffer or null if not found
|
||||
*/
|
||||
async getBytes(storageKey: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const filePath = path.join(this.baseDir, storageKey);
|
||||
return await fs.readFile(filePath);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata
|
||||
*
|
||||
* @param storageKey Storage key
|
||||
* @returns File metadata or null if not found
|
||||
*/
|
||||
async getMetadata(storageKey: string): Promise<{ size: number; contentType: string } | null> {
|
||||
try {
|
||||
const filePath = path.join(this.baseDir, storageKey);
|
||||
const stat = await fs.stat(filePath);
|
||||
|
||||
// Determine content type from extension
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const contentType = this.getContentTypeFromExtension(ext);
|
||||
|
||||
return {
|
||||
size: stat.size,
|
||||
contentType,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic media ID from filename
|
||||
*/
|
||||
private generateMediaId(filename: string): string {
|
||||
const timestamp = Date.now();
|
||||
const cleanFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
return `media-${timestamp}-${cleanFilename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type from file extension
|
||||
*/
|
||||
private getContentTypeFromExtension(ext: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.gif': 'image/gif',
|
||||
};
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for creating FileSystemMediaStorageAdapter instances
|
||||
*/
|
||||
export function createFileSystemMediaStorage(
|
||||
config: FileSystemMediaStorageConfig = {}
|
||||
): FileSystemMediaStorageAdapter {
|
||||
return new FileSystemMediaStorageAdapter(config);
|
||||
}
|
||||
@@ -13,9 +13,9 @@ describe('InMemoryImageServiceAdapter', () => {
|
||||
|
||||
const adapter = new InMemoryImageServiceAdapter(logger);
|
||||
|
||||
expect(adapter.getDriverAvatar('driver-1')).toContain('/images/avatars/');
|
||||
expect(adapter.getTeamLogo('team-1')).toBe('/images/ff1600.jpeg');
|
||||
expect(adapter.getLeagueCover('league-1')).toBe('/images/header.jpeg');
|
||||
expect(adapter.getLeagueLogo('league-1')).toBe('/images/ff1600.jpeg');
|
||||
expect(adapter.getDriverAvatar('driver-1')).toBe('/media/avatar/driver-1');
|
||||
expect(adapter.getTeamLogo('team-1')).toBe('/media/teams/team-1/logo');
|
||||
expect(adapter.getLeagueCover('league-1')).toBe('/media/leagues/league-1/cover');
|
||||
expect(adapter.getLeagueLogo('league-1')).toBe('/media/leagues/league-1/logo');
|
||||
});
|
||||
});
|
||||
|
||||
79
adapters/media/resolvers/DefaultMediaResolverAdapter.ts
Normal file
79
adapters/media/resolvers/DefaultMediaResolverAdapter.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* DefaultMediaResolverAdapter
|
||||
*
|
||||
* Resolves system-default media references to public asset URLs.
|
||||
* Part of the adapters layer, implementing the MediaResolverPort interface.
|
||||
*/
|
||||
|
||||
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
/**
|
||||
* Configuration for the DefaultMediaResolverAdapter
|
||||
*/
|
||||
export interface DefaultMediaResolverConfig {
|
||||
/**
|
||||
* Base path for default assets (defaults to '/media/default')
|
||||
*/
|
||||
basePath?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* DefaultMediaResolverAdapter
|
||||
*
|
||||
* Resolves system-default media references to public asset URLs.
|
||||
*
|
||||
* URL format: /media/default/{variant}
|
||||
* Examples:
|
||||
* - /media/default/male-default-avatar
|
||||
* - /media/default/female-default-avatar
|
||||
* - /media/default/neutral-default-avatar
|
||||
* - /media/default/team-logo.png
|
||||
* - /media/default/league-logo.png
|
||||
*/
|
||||
export class DefaultMediaResolverAdapter implements MediaResolverPort {
|
||||
private readonly basePath: string;
|
||||
|
||||
constructor(config: DefaultMediaResolverConfig = {}) {
|
||||
this.basePath = config.basePath || '/media/default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a system-default media reference to a path-only URL
|
||||
* Returns paths like /media/default/{variant} (no baseUrl)
|
||||
*/
|
||||
async resolve(ref: MediaReference): Promise<string | null> {
|
||||
// Only handle system-default references
|
||||
if (ref.type !== 'system-default') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine the filename based on variant and avatarVariant
|
||||
let filename: string;
|
||||
|
||||
if (ref.variant === 'avatar' && ref.avatarVariant) {
|
||||
// Driver avatars must use website public assets:
|
||||
// apps/website/public/images/avatars/{male|female|neutral}-default-avatar.(jpg|jpeg)
|
||||
// We intentionally keep the URL extension-less; MediaController maps it to the real file.
|
||||
filename = `${ref.avatarVariant}-default-avatar`;
|
||||
} else if (ref.variant === 'avatar') {
|
||||
// Avatar without specific variant (fallback to neutral)
|
||||
filename = `neutral-default-avatar`;
|
||||
} else {
|
||||
// Other variants (team, league, etc.)
|
||||
filename = `${ref.variant}.png`;
|
||||
}
|
||||
|
||||
// Return path-only URL
|
||||
return `${this.basePath}/${filename}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for creating DefaultMediaResolverAdapter instances
|
||||
*/
|
||||
export function createDefaultMediaResolver(
|
||||
config: DefaultMediaResolverConfig = {}
|
||||
): DefaultMediaResolverAdapter {
|
||||
return new DefaultMediaResolverAdapter(config);
|
||||
}
|
||||
92
adapters/media/resolvers/GeneratedMediaResolverAdapter.ts
Normal file
92
adapters/media/resolvers/GeneratedMediaResolverAdapter.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* GeneratedMediaResolverAdapter
|
||||
*
|
||||
* Resolves generated media references to image serving URLs.
|
||||
* Part of the adapters layer, implementing the MediaResolverPort interface.
|
||||
*/
|
||||
|
||||
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
/**
|
||||
* Configuration for the GeneratedMediaResolverAdapter
|
||||
*/
|
||||
export interface GeneratedMediaResolverConfig {
|
||||
/**
|
||||
* Base path for generated assets (defaults to '/media/generated')
|
||||
* @deprecated No longer used - returns path-only URLs
|
||||
*/
|
||||
basePath?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeneratedMediaResolverAdapter
|
||||
*
|
||||
* Resolves generated media references to image serving URLs.
|
||||
*
|
||||
* URL format: /media/generated/{type}/{id}
|
||||
* Examples:
|
||||
* - /media/teams/{id}/logo
|
||||
* - /media/leagues/{id}/logo
|
||||
* - /media/avatar/{id}
|
||||
*
|
||||
* The type and id are extracted from the generationRequestId.
|
||||
* Format: "{type}-{id}" (e.g., "team-123", "league-456")
|
||||
*/
|
||||
export class GeneratedMediaResolverAdapter implements MediaResolverPort {
|
||||
constructor(_config: GeneratedMediaResolverConfig = {}) {
|
||||
// basePath is not used since we return path-only URLs
|
||||
// config.basePath is ignored for backward compatibility
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a generated media reference to a path-only URL
|
||||
* Returns paths like /media/teams/{id}/logo (no baseUrl)
|
||||
*/
|
||||
async resolve(ref: MediaReference): Promise<string | null> {
|
||||
// Only handle generated references
|
||||
if (ref.type !== 'generated') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the generationRequestId to extract type and id
|
||||
// Format: "{type}-{id}" or "{type}-{subtype}-{id}"
|
||||
const requestId = ref.generationRequestId;
|
||||
|
||||
if (!requestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the first hyphen to split type and id
|
||||
// Format: "{type}-{id}" where id can contain hyphens
|
||||
const firstHyphenIndex = requestId.indexOf('-');
|
||||
if (firstHyphenIndex === -1) {
|
||||
// Invalid format
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = requestId.substring(0, firstHyphenIndex);
|
||||
const id = requestId.substring(firstHyphenIndex + 1);
|
||||
|
||||
// Return path-only URLs matching the API routes
|
||||
if (type === 'team') {
|
||||
return `/media/teams/${id}/logo`;
|
||||
} else if (type === 'league') {
|
||||
return `/media/leagues/${id}/logo`;
|
||||
} else if (type === 'driver') {
|
||||
return `/media/avatar/${id}`;
|
||||
}
|
||||
|
||||
// Fallback for other types
|
||||
return `/media/generated/${type}/${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for creating GeneratedMediaResolverAdapter instances
|
||||
*/
|
||||
export function createGeneratedMediaResolver(
|
||||
config: GeneratedMediaResolverConfig = {}
|
||||
): GeneratedMediaResolverAdapter {
|
||||
return new GeneratedMediaResolverAdapter(config);
|
||||
}
|
||||
71
adapters/media/resolvers/UploadedMediaResolverAdapter.ts
Normal file
71
adapters/media/resolvers/UploadedMediaResolverAdapter.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* UploadedMediaResolverAdapter
|
||||
*
|
||||
* Resolves uploaded media references to image serving URLs.
|
||||
* Part of the adapters layer, implementing the MediaResolverPort interface.
|
||||
*/
|
||||
|
||||
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
/**
|
||||
* Configuration for the UploadedMediaResolverAdapter
|
||||
*/
|
||||
export interface UploadedMediaResolverConfig {
|
||||
/**
|
||||
* Base path for uploaded assets (defaults to '/media/uploaded')
|
||||
*/
|
||||
basePath?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* UploadedMediaResolverAdapter
|
||||
*
|
||||
* Resolves uploaded media references to image serving URLs.
|
||||
*
|
||||
* URL format: /media/uploaded/{mediaId}
|
||||
* Examples:
|
||||
* - /media/uploaded/media-123
|
||||
* - /media/uploaded/media-456
|
||||
*
|
||||
* Note: This is a stub implementation. In production, this would:
|
||||
* - Check if the media exists in storage
|
||||
* - Handle different file types (images, videos, documents)
|
||||
* - Handle access control and permissions
|
||||
* - Generate signed URLs for private media
|
||||
*/
|
||||
export class UploadedMediaResolverAdapter implements MediaResolverPort {
|
||||
private readonly basePath: string;
|
||||
|
||||
constructor(config: UploadedMediaResolverConfig = {}) {
|
||||
this.basePath = config.basePath || '/media/uploaded';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an uploaded media reference to a path-only URL
|
||||
* Returns paths like /media/uploaded/{mediaId} (no baseUrl)
|
||||
*/
|
||||
async resolve(ref: MediaReference): Promise<string | null> {
|
||||
// Only handle uploaded references
|
||||
if (ref.type !== 'uploaded') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate mediaId exists
|
||||
if (!ref.mediaId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return path-only URL
|
||||
return `${this.basePath}/${ref.mediaId}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function for creating UploadedMediaResolverAdapter instances
|
||||
*/
|
||||
export function createUploadedMediaResolver(
|
||||
config: UploadedMediaResolverConfig = {}
|
||||
): UploadedMediaResolverAdapter {
|
||||
return new UploadedMediaResolverAdapter(config);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { InMemoryDriverRepository } from './InMemoryDriverRepository';
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
describe('InMemoryDriverRepository', () => {
|
||||
let repository: InMemoryDriverRepository;
|
||||
@@ -17,13 +18,23 @@ describe('InMemoryDriverRepository', () => {
|
||||
repository = new InMemoryDriverRepository(mockLogger);
|
||||
});
|
||||
|
||||
const createTestDriver = (id: string, iracingId: string, name: string, country: string) => {
|
||||
return Driver.create({
|
||||
const createTestDriver = (id: string, iracingId: string, name: string, country: string, avatarRef?: MediaReference) => {
|
||||
const props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
avatarRef?: MediaReference;
|
||||
} = {
|
||||
id,
|
||||
iracingId,
|
||||
name,
|
||||
country,
|
||||
});
|
||||
};
|
||||
if (avatarRef !== undefined) {
|
||||
props.avatarRef = avatarRef;
|
||||
}
|
||||
return Driver.create(props);
|
||||
};
|
||||
|
||||
describe('constructor', () => {
|
||||
@@ -188,4 +199,115 @@ describe('InMemoryDriverRepository', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialization with MediaReference', () => {
|
||||
it('should serialize driver with uploaded avatarRef', async () => {
|
||||
const driver = createTestDriver('1', '12345', 'Test Driver', 'US', MediaReference.createUploaded('media-123'));
|
||||
await repository.create(driver);
|
||||
|
||||
const serialized = repository.serialize(driver);
|
||||
expect(serialized.avatarRef).toEqual({ type: 'uploaded', mediaId: 'media-123' });
|
||||
});
|
||||
|
||||
it('should serialize driver with system-default avatarRef', async () => {
|
||||
const driver = createTestDriver('1', '12345', 'Test Driver', 'US', MediaReference.createSystemDefault('avatar'));
|
||||
await repository.create(driver);
|
||||
|
||||
const serialized = repository.serialize(driver);
|
||||
expect(serialized.avatarRef).toEqual({ type: 'system-default', variant: 'avatar' });
|
||||
});
|
||||
|
||||
it('should serialize driver with generated avatarRef', async () => {
|
||||
const driver = createTestDriver('1', '12345', 'Test Driver', 'US', MediaReference.createGenerated('gen-123'));
|
||||
await repository.create(driver);
|
||||
|
||||
const serialized = repository.serialize(driver);
|
||||
expect(serialized.avatarRef).toEqual({ type: 'generated', generationRequestId: 'gen-123' });
|
||||
});
|
||||
|
||||
it('should deserialize driver with uploaded avatarRef', () => {
|
||||
const data = {
|
||||
id: '1',
|
||||
iracingId: '12345',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
bio: null,
|
||||
joinedAt: new Date().toISOString(),
|
||||
category: null,
|
||||
avatarRef: { type: 'uploaded', mediaId: 'media-123' },
|
||||
};
|
||||
|
||||
const driver = repository.deserialize(data);
|
||||
expect(driver.id).toBe('1');
|
||||
expect(driver.avatarRef.type).toBe('uploaded');
|
||||
expect(driver.avatarRef.mediaId).toBe('media-123');
|
||||
});
|
||||
|
||||
it('should deserialize driver with system-default avatarRef', () => {
|
||||
const data = {
|
||||
id: '1',
|
||||
iracingId: '12345',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
bio: null,
|
||||
joinedAt: new Date().toISOString(),
|
||||
category: null,
|
||||
avatarRef: { type: 'system-default', variant: 'avatar' },
|
||||
};
|
||||
|
||||
const driver = repository.deserialize(data);
|
||||
expect(driver.id).toBe('1');
|
||||
expect(driver.avatarRef.type).toBe('system-default');
|
||||
expect(driver.avatarRef.variant).toBe('avatar');
|
||||
});
|
||||
|
||||
it('should deserialize driver with generated avatarRef', () => {
|
||||
const data = {
|
||||
id: '1',
|
||||
iracingId: '12345',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
bio: null,
|
||||
joinedAt: new Date().toISOString(),
|
||||
category: null,
|
||||
avatarRef: { type: 'generated', generationRequestId: 'gen-123' },
|
||||
};
|
||||
|
||||
const driver = repository.deserialize(data);
|
||||
expect(driver.id).toBe('1');
|
||||
expect(driver.avatarRef.type).toBe('generated');
|
||||
expect(driver.avatarRef.generationRequestId).toBe('gen-123');
|
||||
});
|
||||
|
||||
it('should deserialize driver without avatarRef (backward compatibility)', () => {
|
||||
const data = {
|
||||
id: '1',
|
||||
iracingId: '12345',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
bio: null,
|
||||
joinedAt: new Date().toISOString(),
|
||||
category: null,
|
||||
};
|
||||
|
||||
const driver = repository.deserialize(data);
|
||||
expect(driver.id).toBe('1');
|
||||
expect(driver.avatarRef.type).toBe('system-default');
|
||||
expect(driver.avatarRef.variant).toBe('avatar');
|
||||
});
|
||||
|
||||
it('should roundtrip serialize and deserialize with avatarRef', async () => {
|
||||
const originalDriver = createTestDriver('1', '12345', 'Test Driver', 'US', MediaReference.createUploaded('media-456'));
|
||||
await repository.create(originalDriver);
|
||||
|
||||
const serialized = repository.serialize(originalDriver);
|
||||
const deserialized = repository.deserialize(serialized);
|
||||
|
||||
expect(deserialized.id).toBe(originalDriver.id);
|
||||
expect(deserialized.iracingId.toString()).toBe(originalDriver.iracingId.toString());
|
||||
expect(deserialized.name.toString()).toBe(originalDriver.name.toString());
|
||||
expect(deserialized.country.toString()).toBe(originalDriver.country.toString());
|
||||
expect(deserialized.avatarRef.equals(originalDriver.avatarRef)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import { Logger } from '@core/shared/application';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
export class InMemoryDriverRepository implements IDriverRepository {
|
||||
private drivers: Map<string, Driver> = new Map();
|
||||
@@ -91,4 +92,49 @@ export class InMemoryDriverRepository implements IDriverRepository {
|
||||
this.logger.debug(`[InMemoryDriverRepository] Checking existence of driver with iRacing ID: ${iracingId}`);
|
||||
return Promise.resolve(this.iracingIdIndex.has(iracingId));
|
||||
}
|
||||
}
|
||||
|
||||
// Serialization methods for persistence
|
||||
serialize(driver: Driver): Record<string, unknown> {
|
||||
return {
|
||||
id: driver.id,
|
||||
iracingId: driver.iracingId.toString(),
|
||||
name: driver.name.toString(),
|
||||
country: driver.country.toString(),
|
||||
bio: driver.bio?.toString() ?? null,
|
||||
joinedAt: driver.joinedAt.toDate().toISOString(),
|
||||
category: driver.category ?? null,
|
||||
avatarRef: driver.avatarRef.toJSON(),
|
||||
};
|
||||
}
|
||||
|
||||
deserialize(data: Record<string, unknown>): Driver {
|
||||
const props: {
|
||||
id: string;
|
||||
iracingId: string;
|
||||
name: string;
|
||||
country: string;
|
||||
bio?: string;
|
||||
joinedAt: Date;
|
||||
category?: string;
|
||||
avatarRef?: MediaReference;
|
||||
} = {
|
||||
id: data.id as string,
|
||||
iracingId: data.iracingId as string,
|
||||
name: data.name as string,
|
||||
country: data.country as string,
|
||||
joinedAt: new Date(data.joinedAt as string),
|
||||
};
|
||||
|
||||
if (data.bio !== null && data.bio !== undefined) {
|
||||
props.bio = data.bio as string;
|
||||
}
|
||||
if (data.category !== null && data.category !== undefined) {
|
||||
props.category = data.category as string;
|
||||
}
|
||||
if (data.avatarRef !== null && data.avatarRef !== undefined) {
|
||||
props.avatarRef = MediaReference.fromJSON(data.avatarRef as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return Driver.rehydrate(props);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
import { Logger } from '@core/shared/application';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
export class InMemoryLeagueRepository implements ILeagueRepository {
|
||||
private leagues: Map<string, League> = new Map();
|
||||
@@ -132,4 +133,71 @@ export class InMemoryLeagueRepository implements ILeagueRepository {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialization methods for persistence
|
||||
serialize(league: League): Record<string, unknown> {
|
||||
return {
|
||||
id: league.id.toString(),
|
||||
name: league.name.toString(),
|
||||
description: league.description.toString(),
|
||||
ownerId: league.ownerId.toString(),
|
||||
settings: league.settings,
|
||||
category: league.category ?? null,
|
||||
createdAt: league.createdAt.toDate().toISOString(),
|
||||
participantCount: league.getParticipantCount(),
|
||||
socialLinks: league.socialLinks
|
||||
? {
|
||||
discordUrl: league.socialLinks.discordUrl,
|
||||
youtubeUrl: league.socialLinks.youtubeUrl,
|
||||
websiteUrl: league.socialLinks.websiteUrl,
|
||||
}
|
||||
: undefined,
|
||||
logoRef: league.logoRef.toJSON(),
|
||||
};
|
||||
}
|
||||
|
||||
deserialize(data: Record<string, unknown>): League {
|
||||
const props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
settings: any;
|
||||
category?: string;
|
||||
createdAt: Date;
|
||||
participantCount: number;
|
||||
socialLinks?: {
|
||||
discordUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
logoRef?: MediaReference;
|
||||
} = {
|
||||
id: data.id as string,
|
||||
name: data.name as string,
|
||||
description: data.description as string,
|
||||
ownerId: data.ownerId as string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
settings: data.settings as any,
|
||||
createdAt: new Date(data.createdAt as string),
|
||||
participantCount: data.participantCount as number,
|
||||
};
|
||||
|
||||
if (data.category !== null && data.category !== undefined) {
|
||||
props.category = data.category as string;
|
||||
}
|
||||
if (data.socialLinks !== null && data.socialLinks !== undefined) {
|
||||
props.socialLinks = data.socialLinks as {
|
||||
discordUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
}
|
||||
if (data.logoRef !== null && data.logoRef !== undefined) {
|
||||
props.logoRef = MediaReference.fromJSON(data.logoRef as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return League.rehydrate(props);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,10 @@
|
||||
* Stores data in a Map structure.
|
||||
*/
|
||||
|
||||
import type { Team } from '@core/racing/domain/entities/Team';
|
||||
import { Team } from '@core/racing/domain/entities/Team';
|
||||
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
export class InMemoryTeamRepository implements ITeamRepository {
|
||||
private teams: Map<string, Team>;
|
||||
@@ -122,4 +123,53 @@ export class InMemoryTeamRepository implements ITeamRepository {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Serialization methods for persistence
|
||||
serialize(team: Team): Record<string, unknown> {
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name.toString(),
|
||||
tag: team.tag.toString(),
|
||||
description: team.description.toString(),
|
||||
ownerId: team.ownerId.toString(),
|
||||
leagues: team.leagues.map(l => l.toString()),
|
||||
category: team.category ?? null,
|
||||
isRecruiting: team.isRecruiting,
|
||||
createdAt: team.createdAt.toDate().toISOString(),
|
||||
logoRef: team.logoRef.toJSON(),
|
||||
};
|
||||
}
|
||||
|
||||
deserialize(data: Record<string, unknown>): Team {
|
||||
const props: {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
leagues: string[];
|
||||
category?: string;
|
||||
isRecruiting: boolean;
|
||||
createdAt: Date;
|
||||
logoRef?: MediaReference;
|
||||
} = {
|
||||
id: data.id as string,
|
||||
name: data.name as string,
|
||||
tag: data.tag as string,
|
||||
description: data.description as string,
|
||||
ownerId: data.ownerId as string,
|
||||
leagues: data.leagues as string[],
|
||||
isRecruiting: data.isRecruiting as boolean,
|
||||
createdAt: new Date(data.createdAt as string),
|
||||
};
|
||||
|
||||
if (data.category !== null && data.category !== undefined) {
|
||||
props.category = data.category as string;
|
||||
}
|
||||
if (data.logoRef !== null && data.logoRef !== undefined) {
|
||||
props.logoRef = MediaReference.fromJSON(data.logoRef as Record<string, unknown>);
|
||||
}
|
||||
|
||||
return Team.rehydrate(props);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Infrastructure Adapter: InMemoryMediaRepository
|
||||
*
|
||||
* In-memory implementation of IMediaRepository.
|
||||
* Stores URLs for static media assets like logos and images.
|
||||
* Stores URLs for media assets like avatars and logos.
|
||||
*/
|
||||
|
||||
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
|
||||
@@ -11,9 +11,8 @@ import type { Logger } from '@core/shared/application';
|
||||
export class InMemoryMediaRepository implements IMediaRepository {
|
||||
private driverAvatars = new Map<string, string>();
|
||||
private teamLogos = new Map<string, string>();
|
||||
private trackImages = new Map<string, string>();
|
||||
private categoryIcons = new Map<string, string>();
|
||||
private sponsorLogos = new Map<string, string>();
|
||||
private leagueLogos = new Map<string, string>();
|
||||
private leagueCovers = new Map<string, string>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryMediaRepository] Initialized.');
|
||||
@@ -27,16 +26,12 @@ export class InMemoryMediaRepository implements IMediaRepository {
|
||||
return this.teamLogos.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
async getTrackImage(trackId: string): Promise<string | null> {
|
||||
return this.trackImages.get(trackId) ?? null;
|
||||
async getLeagueLogo(leagueId: string): Promise<string | null> {
|
||||
return this.leagueLogos.get(leagueId) ?? null;
|
||||
}
|
||||
|
||||
async getCategoryIcon(categoryId: string): Promise<string | null> {
|
||||
return this.categoryIcons.get(categoryId) ?? null;
|
||||
}
|
||||
|
||||
async getSponsorLogo(sponsorId: string): Promise<string | null> {
|
||||
return this.sponsorLogos.get(sponsorId) ?? null;
|
||||
async getLeagueCover(leagueId: string): Promise<string | null> {
|
||||
return this.leagueCovers.get(leagueId) ?? null;
|
||||
}
|
||||
|
||||
// Helper methods for seeding
|
||||
@@ -48,23 +43,18 @@ export class InMemoryMediaRepository implements IMediaRepository {
|
||||
this.teamLogos.set(teamId, url);
|
||||
}
|
||||
|
||||
setTrackImage(trackId: string, url: string): void {
|
||||
this.trackImages.set(trackId, url);
|
||||
setLeagueLogo(leagueId: string, url: string): void {
|
||||
this.leagueLogos.set(leagueId, url);
|
||||
}
|
||||
|
||||
setCategoryIcon(categoryId: string, url: string): void {
|
||||
this.categoryIcons.set(categoryId, url);
|
||||
}
|
||||
|
||||
setSponsorLogo(sponsorId: string, url: string): void {
|
||||
this.sponsorLogos.set(sponsorId, url);
|
||||
setLeagueCover(leagueId: string, url: string): void {
|
||||
this.leagueCovers.set(leagueId, url);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.driverAvatars.clear();
|
||||
this.teamLogos.clear();
|
||||
this.trackImages.clear();
|
||||
this.categoryIcons.clear();
|
||||
this.sponsorLogos.clear();
|
||||
this.leagueLogos.clear();
|
||||
this.leagueCovers.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,7 @@ export class DriverOrmEntity {
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
category!: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
avatarRef!: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -36,4 +36,7 @@ export class LeagueOrmEntity {
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
websiteUrl!: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
logoRef!: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -28,6 +28,9 @@ export class TeamOrmEntity {
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
logoRef!: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
@Entity({ name: 'racing_team_memberships' })
|
||||
|
||||
@@ -5,9 +5,6 @@ export class TeamStatsOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
teamId!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
logoUrl!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
performanceLevel!: string;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
import { DriverOrmEntity } from '../entities/DriverOrmEntity';
|
||||
import { assertDate, assertNonEmptyString, assertOptionalStringOrNull } from '../schema/TypeOrmSchemaGuards';
|
||||
@@ -13,6 +14,8 @@ export class DriverOrmMapper {
|
||||
entity.bio = domain.bio?.toString() ?? null;
|
||||
entity.joinedAt = domain.joinedAt.toDate();
|
||||
entity.category = domain.category ?? null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
entity.avatarRef = domain.avatarRef.toJSON() as any;
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -35,6 +38,7 @@ export class DriverOrmMapper {
|
||||
bio?: string;
|
||||
joinedAt: Date;
|
||||
category?: string;
|
||||
avatarRef?: MediaReference;
|
||||
} = {
|
||||
id: entity.id,
|
||||
iracingId: entity.iracingId,
|
||||
@@ -49,6 +53,10 @@ export class DriverOrmMapper {
|
||||
if (entity.category !== null && entity.category !== undefined) {
|
||||
props.category = entity.category;
|
||||
}
|
||||
if (entity.avatarRef !== null && entity.avatarRef !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
props.avatarRef = MediaReference.fromJSON(entity.avatarRef as any);
|
||||
}
|
||||
|
||||
return Driver.rehydrate(props);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { League, type LeagueSettings } from '@core/racing/domain/entities/League';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
|
||||
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
|
||||
@@ -161,6 +162,8 @@ export class LeagueOrmMapper {
|
||||
entity.discordUrl = domain.socialLinks?.discordUrl ?? null;
|
||||
entity.youtubeUrl = domain.socialLinks?.youtubeUrl ?? null;
|
||||
entity.websiteUrl = domain.socialLinks?.websiteUrl ?? null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
entity.logoRef = domain.logoRef.toJSON() as any;
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -185,6 +188,10 @@ export class LeagueOrmMapper {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(entity.logoRef !== null && entity.logoRef !== undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
? { logoRef: MediaReference.fromJSON(entity.logoRef as any) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Team } from '@core/racing/domain/entities/Team';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
import { TypeOrmPersistenceSchemaError } from '../errors/TypeOrmPersistenceSchemaError';
|
||||
|
||||
@@ -28,6 +29,8 @@ export class TeamOrmMapper {
|
||||
entity.category = domain.category ?? null;
|
||||
entity.isRecruiting = domain.isRecruiting;
|
||||
entity.createdAt = domain.createdAt.toDate();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
entity.logoRef = domain.logoRef.toJSON() as any;
|
||||
return entity;
|
||||
}
|
||||
|
||||
@@ -57,6 +60,7 @@ export class TeamOrmMapper {
|
||||
category?: string;
|
||||
isRecruiting: boolean;
|
||||
createdAt: Date;
|
||||
logoRef?: MediaReference;
|
||||
} = {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
@@ -72,6 +76,11 @@ export class TeamOrmMapper {
|
||||
rehydrateProps.category = entity.category;
|
||||
}
|
||||
|
||||
if (entity.logoRef !== null && entity.logoRef !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rehydrateProps.logoRef = MediaReference.fromJSON(entity.logoRef as any);
|
||||
}
|
||||
|
||||
return Team.rehydrate(rehydrateProps);
|
||||
} catch {
|
||||
throw new TypeOrmPersistenceSchemaError({ entityName, fieldName: '__root', reason: 'invalid_shape' });
|
||||
|
||||
@@ -15,7 +15,6 @@ export class TeamStatsOrmMapper {
|
||||
toOrmEntity(teamId: string, domain: TeamStats): TeamStatsOrmEntity {
|
||||
const entity = new TeamStatsOrmEntity();
|
||||
entity.teamId = teamId;
|
||||
entity.logoUrl = domain.logoUrl;
|
||||
entity.performanceLevel = domain.performanceLevel;
|
||||
entity.specialization = domain.specialization;
|
||||
entity.region = domain.region;
|
||||
@@ -31,7 +30,6 @@ export class TeamStatsOrmMapper {
|
||||
const entityName = 'TeamStats';
|
||||
|
||||
assertNonEmptyString(entityName, 'teamId', entity.teamId);
|
||||
assertNonEmptyString(entityName, 'logoUrl', entity.logoUrl);
|
||||
assertEnumValue(entityName, 'performanceLevel', entity.performanceLevel, PERFORMANCE_LEVELS);
|
||||
assertEnumValue(entityName, 'specialization', entity.specialization, SPECIALIZATIONS);
|
||||
assertNonEmptyString(entityName, 'region', entity.region);
|
||||
@@ -41,7 +39,6 @@ export class TeamStatsOrmMapper {
|
||||
assertInteger(entityName, 'rating', entity.rating);
|
||||
|
||||
const result: TeamStats = {
|
||||
logoUrl: entity.logoUrl,
|
||||
performanceLevel: entity.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro',
|
||||
specialization: entity.specialization as 'endurance' | 'sprint' | 'mixed',
|
||||
region: entity.region,
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { DataSource } from 'typeorm';
|
||||
|
||||
import { TypeOrmDriverRepository } from './TypeOrmDriverRepository';
|
||||
import { DriverOrmMapper } from '../mappers/DriverOrmMapper';
|
||||
import { Driver } from '@core/racing/domain/entities/Driver';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
describe('TypeOrmDriverRepository', () => {
|
||||
it('constructor requires injected mapper (no internal mapper instantiation)', () => {
|
||||
@@ -33,4 +35,193 @@ describe('TypeOrmDriverRepository', () => {
|
||||
|
||||
await expect(repo.findById('driver-1')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('persists and retrieves driver with avatarRef (roundtrip test)', async () => {
|
||||
// Create a driver with a specific avatar reference
|
||||
const driver = Driver.create({
|
||||
id: 'driver-123',
|
||||
iracingId: '456789',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
avatarRef: MediaReference.createUploaded('media-abc-123'),
|
||||
});
|
||||
|
||||
// Mock entity that would be saved
|
||||
const mockEntity = {
|
||||
id: 'driver-123',
|
||||
iracingId: '456789',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
bio: null,
|
||||
joinedAt: driver.joinedAt.toDate(),
|
||||
category: null,
|
||||
avatarRef: { type: 'uploaded', mediaId: 'media-abc-123' },
|
||||
};
|
||||
|
||||
const savedEntities: any[] = [];
|
||||
|
||||
const repo = {
|
||||
save: async (entity: any) => {
|
||||
savedEntities.push(entity);
|
||||
return entity;
|
||||
},
|
||||
findOne: async () => savedEntities[0] || null,
|
||||
};
|
||||
|
||||
const mapper = new DriverOrmMapper();
|
||||
|
||||
const typeOrmRepo = new TypeOrmDriverRepository(
|
||||
{ getRepository: () => repo } as unknown as DataSource,
|
||||
mapper,
|
||||
);
|
||||
|
||||
// Test save
|
||||
await typeOrmRepo.create(driver);
|
||||
|
||||
expect(savedEntities).toHaveLength(1);
|
||||
expect(savedEntities[0].avatarRef).toEqual({ type: 'uploaded', mediaId: 'media-abc-123' });
|
||||
|
||||
// Test load
|
||||
const loaded = await typeOrmRepo.findById('driver-123');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.avatarRef.type).toBe('uploaded');
|
||||
expect(loaded!.avatarRef.mediaId).toBe('media-abc-123');
|
||||
});
|
||||
|
||||
it('handles system-default avatarRef correctly', async () => {
|
||||
const driver = Driver.create({
|
||||
id: 'driver-456',
|
||||
iracingId: '98765',
|
||||
name: 'Default Driver',
|
||||
country: 'UK',
|
||||
avatarRef: MediaReference.createSystemDefault('avatar'),
|
||||
});
|
||||
|
||||
const mockEntity = {
|
||||
id: 'driver-456',
|
||||
iracingId: '98765',
|
||||
name: 'Default Driver',
|
||||
country: 'UK',
|
||||
bio: null,
|
||||
joinedAt: driver.joinedAt.toDate(),
|
||||
category: null,
|
||||
avatarRef: { type: 'system-default', variant: 'avatar' },
|
||||
};
|
||||
|
||||
const savedEntities: any[] = [];
|
||||
|
||||
const repo = {
|
||||
save: async (entity: any) => {
|
||||
savedEntities.push(entity);
|
||||
return entity;
|
||||
},
|
||||
findOne: async () => savedEntities[0] || null,
|
||||
};
|
||||
|
||||
const mapper = new DriverOrmMapper();
|
||||
|
||||
const typeOrmRepo = new TypeOrmDriverRepository(
|
||||
{ getRepository: () => repo } as unknown as DataSource,
|
||||
mapper,
|
||||
);
|
||||
|
||||
await typeOrmRepo.create(driver);
|
||||
|
||||
expect(savedEntities[0].avatarRef).toEqual({ type: 'system-default', variant: 'avatar' });
|
||||
|
||||
const loaded = await typeOrmRepo.findById('driver-456');
|
||||
expect(loaded!.avatarRef.type).toBe('system-default');
|
||||
expect(loaded!.avatarRef.variant).toBe('avatar');
|
||||
});
|
||||
|
||||
it('handles generated avatarRef correctly', async () => {
|
||||
const driver = Driver.create({
|
||||
id: 'driver-789',
|
||||
iracingId: '11111',
|
||||
name: 'Generated Driver',
|
||||
country: 'DE',
|
||||
avatarRef: MediaReference.createGenerated('gen-req-xyz'),
|
||||
});
|
||||
|
||||
const mockEntity = {
|
||||
id: 'driver-789',
|
||||
iracingId: '11111',
|
||||
name: 'Generated Driver',
|
||||
country: 'DE',
|
||||
bio: null,
|
||||
joinedAt: driver.joinedAt.toDate(),
|
||||
category: null,
|
||||
avatarRef: { type: 'generated', generationRequestId: 'gen-req-xyz' },
|
||||
};
|
||||
|
||||
const savedEntities: any[] = [];
|
||||
|
||||
const repo = {
|
||||
save: async (entity: any) => {
|
||||
savedEntities.push(entity);
|
||||
return entity;
|
||||
},
|
||||
findOne: async () => savedEntities[0] || null,
|
||||
};
|
||||
|
||||
const mapper = new DriverOrmMapper();
|
||||
|
||||
const typeOrmRepo = new TypeOrmDriverRepository(
|
||||
{ getRepository: () => repo } as unknown as DataSource,
|
||||
mapper,
|
||||
);
|
||||
|
||||
await typeOrmRepo.create(driver);
|
||||
|
||||
expect(savedEntities[0].avatarRef).toEqual({ type: 'generated', generationRequestId: 'gen-req-xyz' });
|
||||
|
||||
const loaded = await typeOrmRepo.findById('driver-789');
|
||||
expect(loaded!.avatarRef.type).toBe('generated');
|
||||
expect(loaded!.avatarRef.generationRequestId).toBe('gen-req-xyz');
|
||||
});
|
||||
|
||||
it('handles update with changed avatarRef', async () => {
|
||||
const driver = Driver.create({
|
||||
id: 'driver-update',
|
||||
iracingId: '22222',
|
||||
name: 'Update Driver',
|
||||
country: 'FR',
|
||||
avatarRef: MediaReference.createSystemDefault('avatar'),
|
||||
});
|
||||
|
||||
const savedEntities: any[] = [];
|
||||
|
||||
const repo = {
|
||||
save: async (entity: any) => {
|
||||
savedEntities.push(entity);
|
||||
return entity;
|
||||
},
|
||||
findOne: async () => savedEntities[savedEntities.length - 1] || null,
|
||||
};
|
||||
|
||||
const mapper = new DriverOrmMapper();
|
||||
|
||||
const typeOrmRepo = new TypeOrmDriverRepository(
|
||||
{ getRepository: () => repo } as unknown as DataSource,
|
||||
mapper,
|
||||
);
|
||||
|
||||
// Initial save
|
||||
await typeOrmRepo.create(driver);
|
||||
expect(savedEntities[0].avatarRef).toEqual({ type: 'system-default', variant: 'avatar' });
|
||||
|
||||
// Update with new avatar
|
||||
const updatedDriver = driver.update({
|
||||
avatarRef: MediaReference.createUploaded('new-media-id'),
|
||||
});
|
||||
|
||||
await typeOrmRepo.update(updatedDriver);
|
||||
|
||||
expect(savedEntities).toHaveLength(2);
|
||||
expect(savedEntities[1].avatarRef).toEqual({ type: 'uploaded', mediaId: 'new-media-id' });
|
||||
|
||||
const loaded = await typeOrmRepo.findById('driver-update');
|
||||
expect(loaded!.avatarRef.type).toBe('uploaded');
|
||||
expect(loaded!.avatarRef.mediaId).toBe('new-media-id');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user