From 2667e11284f9ffcc46369d59a25719edbcf92dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benito=20Rodr=C3=ADguez?= Date: Sun, 22 Mar 2026 11:34:38 +0100 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- backend/dist/src/index.js | 8 +- backend/dist/src/routes/games.js | 63 + backend/dist/src/routes/metadata.js | 14 +- backend/dist/src/services/archiveReader.js | 13 +- backend/dist/src/services/datVerifier.js | 5 +- backend/dist/src/services/igdbClient.js | 25 +- backend/dist/src/services/importService.js | 2 +- backend/dist/src/services/metadataService.js | 109 +- backend/dist/src/services/rawgClient.js | 49 +- backend/dist/src/services/thegamesdbClient.js | 32 +- backend/dist/src/types/index.js | 7 + backend/dist/tests/routes/games.spec.js | 126 ++ backend/dist/tests/routes/metadata.spec.js | 54 +- .../services/fsScanner.archiveEntries.spec.js | 16 +- .../importService.archiveEntry.spec.js | 14 +- .../dist/tests/services/importService.spec.js | 4 + .../tests/services/metadataService.spec.js | 178 +++ backend/src/routes/games.ts | 75 +- backend/src/routes/metadata.ts | 14 +- backend/src/services/igdbClient.ts | 9 +- backend/src/services/metadataService.ts | 120 +- backend/src/services/thegamesdbClient.ts | 20 + backend/src/types/index.ts | 51 +- backend/tests/routes/games.spec.ts | 143 ++ backend/tests/routes/metadata.spec.ts | 62 +- .../services/fsScanner.archiveEntries.spec.ts | 16 +- .../importService.archiveEntry.spec.ts | 16 +- backend/tests/services/importService.spec.ts | 5 + .../tests/services/metadataService.spec.ts | 212 ++- docs/02-tecnico/apis.md | 36 + docs/02-tecnico/game-search.md | 330 +++++ frontend/package.json | 17 +- frontend/src/__tests__/setup.ts | 7 + frontend/src/app/games/add/page.tsx | 128 ++ frontend/src/app/games/page.tsx | 27 +- .../components/games/GamePreviewDialog.tsx | 207 +++ frontend/src/components/games/SearchForm.tsx | 157 ++ .../src/components/games/SearchResults.tsx | 137 ++ .../__tests__/GamePreviewDialog.spec.tsx | 118 ++ .../games/__tests__/SearchForm.spec.tsx | 146 ++ .../games/__tests__/SearchResults.spec.tsx | 137 ++ .../src/components/landing/EmptyState.tsx | 22 +- frontend/src/lib/api.ts | 66 + frontend/vitest.config.ts | 18 + plans/game-search-architecture.md | 803 ++++++++++ yarn.lock | 1288 ++++++++++++++++- 46 files changed, 4949 insertions(+), 157 deletions(-) create mode 100644 backend/dist/src/types/index.js create mode 100644 docs/02-tecnico/game-search.md create mode 100644 frontend/src/__tests__/setup.ts create mode 100644 frontend/src/app/games/add/page.tsx create mode 100644 frontend/src/components/games/GamePreviewDialog.tsx create mode 100644 frontend/src/components/games/SearchForm.tsx create mode 100644 frontend/src/components/games/SearchResults.tsx create mode 100644 frontend/src/components/games/__tests__/GamePreviewDialog.spec.tsx create mode 100644 frontend/src/components/games/__tests__/SearchForm.spec.tsx create mode 100644 frontend/src/components/games/__tests__/SearchResults.spec.tsx create mode 100644 frontend/vitest.config.ts create mode 100644 plans/game-search-architecture.md diff --git a/backend/dist/src/index.js b/backend/dist/src/index.js index 298bee3..ca88a8d 100644 --- a/backend/dist/src/index.js +++ b/backend/dist/src/index.js @@ -6,13 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true }); const dotenv_1 = __importDefault(require("dotenv")); const app_1 = require("./app"); dotenv_1.default.config(); -const port = Number(process.env.PORT ?? 3000); +const port = Number(process.env.PORT ?? 3003); const app = (0, app_1.buildApp)(); const start = async () => { + const host = '0.0.0.0'; try { - await app.listen({ port, host: '0.0.0.0' }); - // eslint-disable-next-line no-console - console.log(`Server listening on http://0.0.0.0:${port}`); + await app.listen({ port, host }); + console.log(`🚀 Server ready and listening on http://${host}:${port}`); } catch (err) { app.log.error(err); diff --git a/backend/dist/src/routes/games.js b/backend/dist/src/routes/games.js index 43cb389..aaea9be 100644 --- a/backend/dist/src/routes/games.js +++ b/backend/dist/src/routes/games.js @@ -3,6 +3,29 @@ Object.defineProperty(exports, "__esModule", { value: true }); const gamesController_1 = require("../controllers/gamesController"); const gameValidator_1 = require("../validators/gameValidator"); const zod_1 = require("zod"); +// Esquema de validación para crear juego desde metadatos +const createGameFromMetadataSchema = zod_1.z.object({ + metadata: zod_1.z.object({ + source: zod_1.z.string(), + externalIds: zod_1.z.record(zod_1.z.number()).optional(), + name: zod_1.z.string().min(1, 'El nombre es requerido'), + slug: zod_1.z.string().optional(), + releaseDate: zod_1.z.string().optional(), + genres: zod_1.z.array(zod_1.z.string()).optional(), + coverUrl: zod_1.z.string().optional(), + }), + overrides: zod_1.z + .object({ + platformId: zod_1.z.string().optional(), + description: zod_1.z.string().optional(), + priceCents: zod_1.z.number().optional(), + currency: zod_1.z.string().optional(), + store: zod_1.z.string().optional(), + date: zod_1.z.string().optional(), + condition: gameValidator_1.GameCondition, + }) + .optional(), +}); async function gamesRoutes(app) { /** * GET /api/games @@ -95,6 +118,46 @@ async function gamesRoutes(app) { throw error; } }); + /** + * POST /api/games/from-metadata + * Crear un juego a partir de metadatos de búsqueda + */ + app.post('/games/from-metadata', async (request, reply) => { + try { + // Validar entrada con Zod + const validated = createGameFromMetadataSchema.parse(request.body); + const { metadata, overrides } = validated; + // Obtener el ID externo principal basado en la fuente + const sourceId = metadata.externalIds?.[metadata.source] + ? String(metadata.externalIds[metadata.source]) + : undefined; + // Mapear metadatos a estructura de CreateGameInput + const gameInput = { + title: metadata.name, + description: overrides?.description, + priceCents: overrides?.priceCents, + currency: overrides?.currency || 'USD', + store: overrides?.store, + date: overrides?.date, + condition: overrides?.condition, + platformId: overrides?.platformId, + source: metadata.source, + sourceId, + }; + // Crear el juego usando GamesController + const game = await gamesController_1.GamesController.createGame(gameInput); + return reply.code(201).send(game); + } + catch (error) { + if (error instanceof zod_1.ZodError) { + return reply.code(400).send({ + error: 'Validación fallida', + details: error.errors, + }); + } + throw error; + } + }); /** * GET /api/games/source/:source * Listar juegos por fuente (rom, manual, igdb, rawg, etc.) diff --git a/backend/dist/src/routes/metadata.js b/backend/dist/src/routes/metadata.js index b374525..71f94da 100644 --- a/backend/dist/src/routes/metadata.js +++ b/backend/dist/src/routes/metadata.js @@ -40,23 +40,25 @@ const zod_2 = require("zod"); const searchMetadataSchema = zod_1.z.object({ q: zod_1.z.string().min(1, 'El parámetro de búsqueda es requerido'), platform: zod_1.z.string().optional(), + year: zod_1.z.coerce.number().int().min(1900).max(2100).optional(), }); async function metadataRoutes(app) { /** - * GET /api/metadata/search?q=query&platform=optional - * Buscar metadata de juegos + * GET /api/metadata/search?q=query&platform=optional&year=optional + * Buscar metadata de juegos en múltiples fuentes (IGDB, RAWG, TheGamesDB) */ app.get('/metadata/search', async (request, reply) => { try { // Validar parámetros de query con Zod const validated = searchMetadataSchema.parse(request.query); - // Llamar a metadataService - const result = await metadataService.enrichGame({ + // Llamar a metadataService.searchGames para obtener múltiples resultados + const results = await metadataService.searchGames({ title: validated.q, platform: validated.platform, + year: validated.year, }); - // Si hay resultado, devolver como array; si no, devolver array vacío - return reply.code(200).send(result ? [result] : []); + // Devolver array de resultados + return reply.code(200).send(results); } catch (error) { if (error instanceof zod_2.ZodError) { diff --git a/backend/dist/src/services/archiveReader.js b/backend/dist/src/services/archiveReader.js index aecf1bf..e17fc69 100644 --- a/backend/dist/src/services/archiveReader.js +++ b/backend/dist/src/services/archiveReader.js @@ -114,7 +114,12 @@ async function streamArchiveEntry(filePath, entryPath, logger = console) { } catch (e) { } } - resolve(proc.stdout); + if (proc.stdout) { + resolve(proc.stdout); + } + else { + resolve(null); + } }; proc.once('error', onProcError); if (proc.stdout && proc.stdout.once) { @@ -152,7 +157,11 @@ async function streamArchiveEntry(filePath, entryPath, logger = console) { // Fallback for zip if (ext === 'zip') { try { - const proc2 = (0, child_process_1.spawn)('unzip', ['-p', filePath, entryPath]); + const proc2 = (0, child_process_1.spawn)('unzip', [ + '-p', + filePath, + entryPath, + ]); const stream2 = await waitForStreamOrError(proc2); if (stream2) return stream2; diff --git a/backend/dist/src/services/datVerifier.js b/backend/dist/src/services/datVerifier.js index b08d262..5f79486 100644 --- a/backend/dist/src/services/datVerifier.js +++ b/backend/dist/src/services/datVerifier.js @@ -9,7 +9,7 @@ function ensureArray(v) { return Array.isArray(v) ? v : [v]; } function normalizeHex(v) { - if (!v) + if (!v || typeof v !== 'string') return undefined; return v.trim().toLowerCase(); } @@ -20,7 +20,8 @@ function parseDat(xml) { trimValues: true, }); const parsed = parser.parse(xml); - const datafile = parsed?.datafile ?? parsed; + const datafile = parsed?.datafile ?? + parsed; const rawGames = ensureArray(datafile?.game); const games = rawGames.map((g) => { // game name may be an attribute or a child node diff --git a/backend/dist/src/services/igdbClient.js b/backend/dist/src/services/igdbClient.js index 528f672..ddb2984 100644 --- a/backend/dist/src/services/igdbClient.js +++ b/backend/dist/src/services/igdbClient.js @@ -27,7 +27,7 @@ async function getToken() { const res = await (0, undici_1.fetch)(`${AUTH_URL}?${params.toString()}`, { method: 'POST' }); if (!res.ok) return null; - const json = await res.json(); + const json = (await res.json()); const token = json.access_token; const expires = Number(json.expires_in) || 0; if (!token) @@ -42,6 +42,19 @@ async function getToken() { } } function mapIgdbHit(r) { + const platforms = Array.isArray(r.platforms) + ? r.platforms.map((p) => ({ + id: p.id, + name: p.name, + abbreviation: p.abbreviation, + slug: p.name?.toLowerCase().replace(/\s+/g, '-'), + })) + : undefined; + const genres = Array.isArray(r.genres) + ? r.genres.map((g) => g.name) + : undefined; + // IGDB devuelve URLs relativas (//images.igdb.com/...), agregar protocolo https: + const coverUrl = r.cover?.url ? `https:${r.cover.url}` : undefined; return { id: r.id, name: r.name, @@ -49,9 +62,9 @@ function mapIgdbHit(r) { releaseDate: r.first_release_date ? new Date(r.first_release_date * 1000).toISOString() : undefined, - genres: Array.isArray(r.genres) ? r.genres : undefined, - platforms: Array.isArray(r.platforms) ? r.platforms : undefined, - coverUrl: r.cover?.url ?? undefined, + genres, + platforms, + coverUrl, source: 'igdb', }; } @@ -66,7 +79,7 @@ async function searchGames(query, _platform) { Accept: 'application/json', 'Content-Type': 'text/plain', }; - const body = `search "${query}"; fields id,name,slug,first_release_date,genres,platforms,cover; limit 10;`; + const body = `search "${query}"; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 10;`; try { const res = await (0, undici_1.fetch)(`${API_URL}/games`, { method: 'POST', headers, body }); if (!res.ok) @@ -93,7 +106,7 @@ async function getGameById(id) { Accept: 'application/json', 'Content-Type': 'text/plain', }; - const body = `where id = ${id}; fields id,name,slug,first_release_date,genres,platforms,cover; limit 1;`; + const body = `where id = ${id}; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 1;`; try { const res = await (0, undici_1.fetch)(`${API_URL}/games`, { method: 'POST', headers, body }); if (!res.ok) diff --git a/backend/dist/src/services/importService.js b/backend/dist/src/services/importService.js index 9245e1f..d1fe0c6 100644 --- a/backend/dist/src/services/importService.js +++ b/backend/dist/src/services/importService.js @@ -67,7 +67,7 @@ async function importDirectory(options, logger = console) { processed++; try { let hashes; - if (file.isArchiveEntry) { + if (file.isArchiveEntry && file.containerPath && file.entryPath) { const stream = await (0, archiveReader_1.streamArchiveEntry)(file.containerPath, file.entryPath, logger); if (!stream) { logger.warn?.({ file }, 'importDirectory: no se pudo extraer entrada del archive, saltando'); diff --git a/backend/dist/src/services/metadataService.js b/backend/dist/src/services/metadataService.js index 8099359..051e606 100644 --- a/backend/dist/src/services/metadataService.js +++ b/backend/dist/src/services/metadataService.js @@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.enrichGame = enrichGame; +exports.searchGames = searchGames; /** * metadataService * - `enrichGame({ title, platform? })` -> intenta IGDB, RAWG, TheGamesDB @@ -43,13 +44,15 @@ const rawg = __importStar(require("./rawgClient")); const thegamesdb = __importStar(require("./thegamesdbClient")); function normalize(hit) { const base = { - source: hit.source ?? 'unknown', + source: hit.source ?? 'igdb', externalIds: {}, + name: hit.name, title: hit.name, - slug: hit.slug, - releaseDate: hit.releaseDate, - genres: hit.genres, - coverUrl: hit.coverUrl, + slug: hit.slug ?? undefined, + releaseDate: hit.releaseDate ?? null, + genres: hit.genres ?? undefined, + coverUrl: hit.coverUrl ?? undefined, + platforms: hit.platforms ?? undefined, }; if (hit.source === 'igdb' && typeof hit.id === 'number') base.externalIds.igdb = hit.id; @@ -90,7 +93,101 @@ async function enrichGame(opts) { } return null; } -exports.default = { enrichGame }; +/** + * Normaliza un nombre para deduplicación + * Convierte a lowercase y remueve caracteres especiales + */ +function normalizeName(name) { + return name + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .trim(); +} +/** + * Prioridad de fuentes para deduplicación + */ +const SOURCE_PRIORITY = { + igdb: 1, + rawg: 2, + thegamesdb: 3, +}; +/** + * Busca juegos en paralelo en IGDB, RAWG y TheGamesDB + * Devuelve múltiples resultados con deduplicación + */ +async function searchGames(opts) { + const { title, platform, year } = opts; + if (!title) + return []; + // Ejecutar búsquedas en paralelo + const [igdbHits, rawgHits, tgdbHits] = await Promise.allSettled([ + igdb.searchGames(title, platform), + rawg.searchGames(title), + thegamesdb.searchGames(title), + ]); + // Extraer resultados de las promesas resueltas + const allResults = []; + if (igdbHits.status === 'fulfilled') { + allResults.push(...igdbHits.value.map(normalize)); + } + if (rawgHits.status === 'fulfilled') { + allResults.push(...rawgHits.value.map(normalize)); + } + if (tgdbHits.status === 'fulfilled') { + allResults.push(...tgdbHits.value.map(normalize)); + } + // Filtrar por año si se proporciona + let filteredResults = allResults; + if (year) { + filteredResults = allResults.filter((result) => { + if (!result.releaseDate) + return false; + const resultYear = new Date(result.releaseDate).getFullYear(); + return resultYear === year; + }); + } + // Deduplicar por nombre normalizado + const deduplicatedMap = new Map(); + for (const result of filteredResults) { + const normalizedName = normalizeName(result.name); + const existing = deduplicatedMap.get(normalizedName); + if (!existing) { + // Primer resultado con este nombre + deduplicatedMap.set(normalizedName, result); + } + else { + // Ya existe, fusionar externalIds y mantener el de mayor prioridad + const existingPriority = SOURCE_PRIORITY[existing.source] ?? 999; + const currentPriority = SOURCE_PRIORITY[result.source] ?? 999; + if (currentPriority < existingPriority) { + // El resultado actual tiene mayor prioridad + // Fusionar externalIds del existente + result.externalIds = { + ...result.externalIds, + ...existing.externalIds, + }; + deduplicatedMap.set(normalizedName, result); + } + else { + // Mantener el existente y fusionar externalIds del actual + existing.externalIds = { + ...existing.externalIds, + ...result.externalIds, + }; + } + } + } + // Convertir el mapa a array y ordenar por prioridad de fuente + const deduplicatedResults = Array.from(deduplicatedMap.values()).sort((a, b) => { + const priorityA = SOURCE_PRIORITY[a.source] ?? 999; + const priorityB = SOURCE_PRIORITY[b.source] ?? 999; + return priorityA - priorityB; + }); + // Limitar a 50 resultados totales + return deduplicatedResults.slice(0, 50); +} +exports.default = { enrichGame, searchGames }; /** * Metadatos: * Autor: GitHub Copilot diff --git a/backend/dist/src/services/rawgClient.js b/backend/dist/src/services/rawgClient.js index 527d21b..60c0441 100644 --- a/backend/dist/src/services/rawgClient.js +++ b/backend/dist/src/services/rawgClient.js @@ -18,18 +18,30 @@ async function searchGames(query) { const res = await (0, undici_1.fetch)(url); if (!res.ok) return []; - const json = await res.json(); + const json = (await res.json()); const hits = Array.isArray(json.results) ? json.results : []; - return hits.map((r) => ({ - id: r.id, - name: r.name, - slug: r.slug, - releaseDate: r.released, - genres: Array.isArray(r.genres) ? r.genres.map((g) => g.name) : undefined, - platforms: r.platforms, - coverUrl: r.background_image ?? undefined, - source: 'rawg', - })); + return hits.map((r) => { + const platforms = Array.isArray(r.platforms) + ? r.platforms.map((p) => ({ + id: p.id, + name: p.name, + slug: p.name?.toLowerCase().replace(/\s+/g, '-'), + })) + : undefined; + const genres = Array.isArray(r.genres) + ? r.genres.map((g) => g.name) + : undefined; + return { + id: r.id, + name: r.name, + slug: r.slug, + releaseDate: r.released, + genres, + platforms, + coverUrl: r.background_image ?? undefined, + source: 'rawg', + }; + }); } catch (err) { // eslint-disable-next-line no-console @@ -46,15 +58,26 @@ async function getGameById(id) { const res = await (0, undici_1.fetch)(url); if (!res.ok) return null; - const json = await res.json(); + const json = (await res.json()); if (!json) return null; + const platforms = Array.isArray(json.platforms) + ? json.platforms.map((p) => ({ + id: p.id, + name: p.name, + slug: p.name?.toLowerCase().replace(/\s+/g, '-'), + })) + : undefined; + const genres = Array.isArray(json.genres) + ? json.genres.map((g) => g.name) + : undefined; return { id: json.id, name: json.name, slug: json.slug, releaseDate: json.released, - genres: Array.isArray(json.genres) ? json.genres.map((g) => g.name) : undefined, + genres, + platforms, coverUrl: json.background_image ?? undefined, source: 'rawg', }; diff --git a/backend/dist/src/services/thegamesdbClient.js b/backend/dist/src/services/thegamesdbClient.js index 6ea5d89..e0c9a41 100644 --- a/backend/dist/src/services/thegamesdbClient.js +++ b/backend/dist/src/services/thegamesdbClient.js @@ -18,18 +18,30 @@ async function searchGames(query) { const res = await (0, undici_1.fetch)(url, { headers: { 'Api-Key': key } }); if (!res.ok) return []; - const json = await res.json(); + const json = (await res.json()); const games = json?.data?.games ?? {}; const baseUrl = json?.data?.base_url?.original ?? ''; const hits = []; for (const gid of Object.keys(games)) { const g = games[gid]; + const genres = Array.isArray(g?.game?.genres) + ? g.game.genres.map((x) => x.name) + : undefined; + const platforms = Array.isArray(g?.game?.platforms) + ? g.game.platforms.map((p) => ({ + id: p.id, + name: p.name, + abbreviation: p.abbreviation, + slug: p.name?.toLowerCase().replace(/\s+/g, '-'), + })) + : undefined; hits.push({ id: Number(gid), name: g?.game?.title ?? g?.title ?? String(gid), slug: g?.game?.slug ?? undefined, releaseDate: g?.game?.release_date ?? undefined, - genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x) => x.name) : undefined, + genres, + platforms, coverUrl: g?.game?.images?.boxart?.[0]?.thumb ? `${baseUrl}${g.game.images.boxart[0].thumb}` : undefined, @@ -53,19 +65,31 @@ async function getGameById(id) { const res = await (0, undici_1.fetch)(url, { headers: { 'Api-Key': key } }); if (!res.ok) return null; - const json = await res.json(); + const json = (await res.json()); const games = json?.data?.games ?? {}; const baseUrl = json?.data?.base_url?.original ?? ''; const firstKey = Object.keys(games)[0]; const g = games[firstKey]; if (!g) return null; + const genres = Array.isArray(g?.game?.genres) + ? g.game.genres.map((x) => x.name) + : undefined; + const platforms = Array.isArray(g?.game?.platforms) + ? g.game.platforms.map((p) => ({ + id: p.id, + name: p.name, + abbreviation: p.abbreviation, + slug: p.name?.toLowerCase().replace(/\s+/g, '-'), + })) + : undefined; return { id: Number(firstKey), name: g?.game?.title ?? g?.title ?? String(firstKey), slug: g?.game?.slug ?? undefined, releaseDate: g?.game?.release_date ?? undefined, - genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x) => x.name) : undefined, + genres, + platforms, coverUrl: g?.game?.images?.boxart?.[0]?.thumb ? `${baseUrl}${g.game.images.boxart[0].thumb}` : undefined, diff --git a/backend/dist/src/types/index.js b/backend/dist/src/types/index.js new file mode 100644 index 0000000..e32f6cb --- /dev/null +++ b/backend/dist/src/types/index.js @@ -0,0 +1,7 @@ +"use strict"; +/** + * Tipos compartidos del backend + * Autor: GitHub Copilot + * Última actualización: 2026-03-18 + */ +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/backend/dist/tests/routes/games.spec.js b/backend/dist/tests/routes/games.spec.js index 70f38c9..3599139 100644 --- a/backend/dist/tests/routes/games.spec.js +++ b/backend/dist/tests/routes/games.spec.js @@ -220,6 +220,132 @@ const prisma_1 = require("../../src/plugins/prisma"); (0, vitest_1.expect)(res.statusCode).toBe(404); }); }); + (0, vitest_1.describe)('POST /api/games/from-metadata', () => { + (0, vitest_1.it)('debería crear un juego a partir de metadatos', async () => { + const payload = { + metadata: { + source: 'igdb', + externalIds: { igdb: 1234 }, + name: 'Super Mario Bros.', + slug: 'super-mario-bros', + releaseDate: '1985-09-13T00:00:00.000Z', + genres: ['Platform'], + coverUrl: 'https://example.com/cover.jpg', + }, + }; + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload, + }); + (0, vitest_1.expect)(res.statusCode).toBe(201); + const body = res.json(); + (0, vitest_1.expect)(body).toHaveProperty('id'); + (0, vitest_1.expect)(body.title).toBe('Super Mario Bros.'); + (0, vitest_1.expect)(body.source).toBe('igdb'); + (0, vitest_1.expect)(body.sourceId).toBe('1234'); + }); + (0, vitest_1.it)('debería crear un juego con overrides', async () => { + const platform = await prisma_1.prisma.platform.create({ + data: { name: 'Nintendo Entertainment System', slug: 'nes' }, + }); + const payload = { + metadata: { + source: 'igdb', + externalIds: { igdb: 1234 }, + name: 'Super Mario Bros.', + slug: 'super-mario-bros', + releaseDate: '1985-09-13T00:00:00.000Z', + genres: ['Platform'], + coverUrl: 'https://example.com/cover.jpg', + }, + overrides: { + platformId: platform.id, + description: 'Descripción personalizada', + priceCents: 10000, + currency: 'USD', + store: 'eBay', + date: '2025-01-15', + condition: 'CIB', + }, + }; + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload, + }); + (0, vitest_1.expect)(res.statusCode).toBe(201); + const body = res.json(); + (0, vitest_1.expect)(body.title).toBe('Super Mario Bros.'); + (0, vitest_1.expect)(body.description).toBe('Descripción personalizada'); + (0, vitest_1.expect)(body.gamePlatforms).toHaveLength(1); + (0, vitest_1.expect)(body.gamePlatforms[0].platformId).toBe(platform.id); + (0, vitest_1.expect)(body.purchases).toHaveLength(1); + (0, vitest_1.expect)(body.purchases[0].priceCents).toBe(10000); + }); + (0, vitest_1.it)('debería devolver 400 si falta el campo metadata', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload: {}, + }); + (0, vitest_1.expect)(res.statusCode).toBe(400); + }); + (0, vitest_1.it)('debería devolver 400 si metadata.name está vacío', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload: { + metadata: { + source: 'igdb', + externalIds: { igdb: 1234 }, + name: '', + slug: 'super-mario-bros', + }, + }, + }); + (0, vitest_1.expect)(res.statusCode).toBe(400); + }); + (0, vitest_1.it)('debería usar el externalId principal como sourceId', async () => { + const payload = { + metadata: { + source: 'rawg', + externalIds: { rawg: 5678, igdb: 1234 }, + name: 'Zelda', + slug: 'zelda', + }, + }; + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload, + }); + (0, vitest_1.expect)(res.statusCode).toBe(201); + const body = res.json(); + (0, vitest_1.expect)(body.source).toBe('rawg'); + (0, vitest_1.expect)(body.sourceId).toBe('5678'); + }); + (0, vitest_1.it)('debería manejar metadata sin externalIds', async () => { + const payload = { + metadata: { + source: 'manual', + externalIds: {}, + name: 'Custom Game', + slug: 'custom-game', + }, + }; + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload, + }); + (0, vitest_1.expect)(res.statusCode).toBe(201); + const body = res.json(); + (0, vitest_1.expect)(body.title).toBe('Custom Game'); + (0, vitest_1.expect)(body.source).toBe('manual'); + (0, vitest_1.expect)(body.sourceId).toBeNull(); + }); + }); }); /** * Metadatos: diff --git a/backend/dist/tests/routes/metadata.spec.js b/backend/dist/tests/routes/metadata.spec.js index deb5dc8..5e92e5c 100644 --- a/backend/dist/tests/routes/metadata.spec.js +++ b/backend/dist/tests/routes/metadata.spec.js @@ -47,19 +47,32 @@ const metadataService = __importStar(require("../../src/services/metadataService vitest_1.vi.restoreAllMocks(); }); (0, vitest_1.describe)('GET /api/metadata/search', () => { - (0, vitest_1.it)('debería devolver resultados cuando se busca un juego existente', async () => { + (0, vitest_1.it)('debería devolver múltiples resultados cuando se busca un juego existente', async () => { const mockResults = [ { source: 'igdb', externalIds: { igdb: 1 }, + name: 'The Legend of Zelda', title: 'The Legend of Zelda', slug: 'the-legend-of-zelda', releaseDate: '1986-02-21', genres: ['Adventure'], coverUrl: 'https://example.com/cover.jpg', + platforms: undefined, + }, + { + source: 'rawg', + externalIds: { rawg: 2 }, + name: 'The Legend of Zelda: A Link to the Past', + title: 'The Legend of Zelda: A Link to the Past', + slug: 'the-legend-of-zelda-a-link-to-the-past', + releaseDate: '1991-11-21', + genres: ['Adventure'], + coverUrl: 'https://example.com/cover2.jpg', + platforms: undefined, }, ]; - vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(mockResults[0]); + vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue(mockResults); const res = await app.inject({ method: 'GET', url: '/api/metadata/search?q=zelda', @@ -68,10 +81,10 @@ const metadataService = __importStar(require("../../src/services/metadataService const body = res.json(); (0, vitest_1.expect)(Array.isArray(body)).toBe(true); (0, vitest_1.expect)(body.length).toBeGreaterThan(0); - (0, vitest_1.expect)(body[0].title).toContain('Zelda'); + (0, vitest_1.expect)(body[0].name).toContain('Zelda'); }); (0, vitest_1.it)('debería devolver lista vacía cuando no hay resultados', async () => { - vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null); + vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]); const res = await app.inject({ method: 'GET', url: '/api/metadata/search?q=nonexistentgame12345', @@ -96,16 +109,43 @@ const metadataService = __importStar(require("../../src/services/metadataService }); (0, vitest_1.expect)(res.statusCode).toBe(400); }); - (0, vitest_1.it)('debería pasar el parámetro platform a enrichGame si se proporciona', async () => { - const enrichSpy = vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null); + (0, vitest_1.it)('debería pasar el parámetro platform a searchGames si se proporciona', async () => { + const searchSpy = vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]); const res = await app.inject({ method: 'GET', url: '/api/metadata/search?q=mario&platform=Nintendo%2064', }); (0, vitest_1.expect)(res.statusCode).toBe(200); - (0, vitest_1.expect)(enrichSpy).toHaveBeenCalledWith({ + (0, vitest_1.expect)(searchSpy).toHaveBeenCalledWith({ title: 'mario', platform: 'Nintendo 64', + year: undefined, + }); + }); + (0, vitest_1.it)('debería pasar el parámetro year a searchGames si se proporciona', async () => { + const searchSpy = vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]); + const res = await app.inject({ + method: 'GET', + url: '/api/metadata/search?q=mario&year=1990', + }); + (0, vitest_1.expect)(res.statusCode).toBe(200); + (0, vitest_1.expect)(searchSpy).toHaveBeenCalledWith({ + title: 'mario', + platform: undefined, + year: 1990, + }); + }); + (0, vitest_1.it)('debería pasar todos los parámetros a searchGames', async () => { + const searchSpy = vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]); + const res = await app.inject({ + method: 'GET', + url: '/api/metadata/search?q=mario&platform=NES&year=1985', + }); + (0, vitest_1.expect)(res.statusCode).toBe(200); + (0, vitest_1.expect)(searchSpy).toHaveBeenCalledWith({ + title: 'mario', + platform: 'NES', + year: 1985, }); }); }); diff --git a/backend/dist/tests/services/fsScanner.archiveEntries.spec.js b/backend/dist/tests/services/fsScanner.archiveEntries.spec.js index 0acf956..ffdff8e 100644 --- a/backend/dist/tests/services/fsScanner.archiveEntries.spec.js +++ b/backend/dist/tests/services/fsScanner.archiveEntries.spec.js @@ -10,7 +10,9 @@ const vitest_1 = require("vitest"); vitest_1.vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vitest_1.vi.fn() })); const fsScanner_1 = __importDefault(require("../../src/services/fsScanner")); const archiveReader_1 = require("../../src/services/archiveReader"); -(0, vitest_1.afterEach)(() => vitest_1.vi.restoreAllMocks()); +(0, vitest_1.afterEach)(() => { + vitest_1.vi.restoreAllMocks(); +}); (0, vitest_1.it)('expone entradas internas de archivos como items virtuales', async () => { const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'fsScanner-test-')); const collectionFile = path_1.default.join(tmpDir, 'collection.zip'); @@ -22,11 +24,13 @@ const archiveReader_1 = require("../../src/services/archiveReader"); const expectedPath = `${collectionFile}::inner/rom1.bin`; const found = results.find((r) => r.path === expectedPath); (0, vitest_1.expect)(found).toBeDefined(); - (0, vitest_1.expect)(found.isArchiveEntry).toBe(true); - (0, vitest_1.expect)(found.containerPath).toBe(collectionFile); - (0, vitest_1.expect)(found.entryPath).toBe('inner/rom1.bin'); - (0, vitest_1.expect)(found.filename).toBe('rom1.bin'); - (0, vitest_1.expect)(found.format).toBe('bin'); + if (found) { + (0, vitest_1.expect)(found.isArchiveEntry).toBe(true); + (0, vitest_1.expect)(found.containerPath).toBe(collectionFile); + (0, vitest_1.expect)(found.entryPath).toBe('inner/rom1.bin'); + (0, vitest_1.expect)(found.filename).toBe('rom1.bin'); + (0, vitest_1.expect)(found.format).toBe('bin'); + } await fs_1.promises.rm(tmpDir, { recursive: true, force: true }); }); (0, vitest_1.it)('ignora entradas con traversal o paths absolutos', async () => { diff --git a/backend/dist/tests/services/importService.archiveEntry.spec.js b/backend/dist/tests/services/importService.archiveEntry.spec.js index 9645e4d..7c8985f 100644 --- a/backend/dist/tests/services/importService.archiveEntry.spec.js +++ b/backend/dist/tests/services/importService.archiveEntry.spec.js @@ -17,11 +17,16 @@ const fsScanner_1 = require("../../src/services/fsScanner"); const archiveReader_1 = require("../../src/services/archiveReader"); const prisma_1 = __importDefault(require("../../src/plugins/prisma")); const crypto_1 = require("crypto"); +// Mock Date.now() para timestamps consistentes en tests +const FIXED_TIMESTAMP = 1234567890123; +const dateNowSpy = vitest_1.vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP); (0, vitest_1.beforeEach)(() => { vitest_1.vi.restoreAllMocks(); + dateNowSpy.mockReturnValue(FIXED_TIMESTAMP); }); (0, vitest_1.describe)('services/importService (archive entries)', () => { (0, vitest_1.it)('procesa una entrada interna usando streamArchiveEntry y crea Game con source=rom', async () => { + const data = Buffer.from('import-archive-test'); const files = [ { path: '/roms/collection.zip::inner/rom1.bin', @@ -29,12 +34,11 @@ const crypto_1 = require("crypto"); entryPath: 'inner/rom1.bin', filename: 'rom1.bin', name: 'inner/rom1.bin', - size: 123, + size: data.length, format: 'bin', isArchiveEntry: true, }, ]; - const data = Buffer.from('import-archive-test'); fsScanner_1.scanDirectory.mockResolvedValue(files); archiveReader_1.streamArchiveEntry.mockResolvedValue(stream_1.Readable.from([data])); prisma_1.default.game.findFirst.mockResolvedValue(null); @@ -53,12 +57,12 @@ const crypto_1 = require("crypto"); }); (0, vitest_1.expect)(prisma_1.default.game.create.mock.calls[0][0]).toEqual({ data: { - title: 'ROM1', - slug: 'rom1-1234567890123', + title: 'rom1', + slug: vitest_1.expect.stringMatching(/^rom1-\d+$/), source: 'rom', romPath: '/roms/collection.zip::inner/rom1.bin', romFilename: 'rom1.bin', - romSize: 123, + romSize: data.length, romChecksum: md5, romFormat: 'bin', romHashes: vitest_1.expect.any(String), diff --git a/backend/dist/tests/services/importService.spec.js b/backend/dist/tests/services/importService.spec.js index 3bdc958..4992215 100644 --- a/backend/dist/tests/services/importService.spec.js +++ b/backend/dist/tests/services/importService.spec.js @@ -19,9 +19,13 @@ const importService_1 = require("../../src/services/importService"); const fsScanner_1 = require("../../src/services/fsScanner"); const checksumService_1 = require("../../src/services/checksumService"); const prisma_1 = __importDefault(require("../../src/plugins/prisma")); +// Mock Date.now() para timestamps consistentes en tests +const FIXED_TIMESTAMP = 1234567890123; +const dateNowSpy = vitest_1.vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP); (0, vitest_1.describe)('services/importService', () => { (0, vitest_1.beforeEach)(() => { vitest_1.vi.clearAllMocks(); + dateNowSpy.mockReturnValue(FIXED_TIMESTAMP); }); (0, vitest_1.it)('exporta createSlug e importDirectory', () => { (0, vitest_1.expect)(typeof importService_1.createSlug).toBe('function'); diff --git a/backend/dist/tests/services/metadataService.spec.js b/backend/dist/tests/services/metadataService.spec.js index f2fa166..f3e20e6 100644 --- a/backend/dist/tests/services/metadataService.spec.js +++ b/backend/dist/tests/services/metadataService.spec.js @@ -100,4 +100,182 @@ const metadataService_1 = require("../../src/services/metadataService"); const res = await (0, metadataService_1.enrichGame)({ title: 'Juego inexistente' }); (0, vitest_1.expect)(res).toBeNull(); }); + (0, vitest_1.describe)('searchGames', () => { + (0, vitest_1.it)('debería buscar en paralelo en IGDB, RAWG y TheGamesDB', async () => { + igdb.searchGames.mockResolvedValue([ + { + id: 1, + name: 'Super Mario Bros.', + slug: 'super-mario-bros', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://igdb.com/cover.jpg', + source: 'igdb', + }, + ]); + rawg.searchGames.mockResolvedValue([ + { + id: 2, + name: 'Super Mario Bros.', + slug: 'super-mario-bros-rawg', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://rawg.com/cover.jpg', + source: 'rawg', + }, + ]); + tgdb.searchGames.mockResolvedValue([ + { + id: 3, + name: 'Super Mario Bros.', + slug: 'super-mario-bros-tgdb', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://tgdb.com/cover.jpg', + source: 'thegamesdb', + }, + ]); + const results = await (0, metadataService_1.searchGames)({ title: 'Super Mario Bros.' }); + (0, vitest_1.expect)(results.length).toBeGreaterThan(0); + (0, vitest_1.expect)(igdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.', undefined); + (0, vitest_1.expect)(rawg.searchGames).toHaveBeenCalledWith('Super Mario Bros.'); + (0, vitest_1.expect)(tgdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.'); + }); + (0, vitest_1.it)('debería deduplicar resultados por nombre normalizado', async () => { + igdb.searchGames.mockResolvedValue([ + { + id: 1, + name: 'Super Mario Bros.', + slug: 'super-mario-bros', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://igdb.com/cover.jpg', + source: 'igdb', + }, + ]); + rawg.searchGames.mockResolvedValue([ + { + id: 2, + name: 'Super Mario Bros.', + slug: 'super-mario-bros-rawg', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://rawg.com/cover.jpg', + source: 'rawg', + }, + ]); + tgdb.searchGames.mockResolvedValue([]); + const results = await (0, metadataService_1.searchGames)({ title: 'Super Mario Bros.' }); + // Debería haber un solo resultado (prioridad IGDB) + (0, vitest_1.expect)(results.length).toBe(1); + (0, vitest_1.expect)(results[0].source).toBe('igdb'); + (0, vitest_1.expect)(results[0].externalIds.igdb).toBe(1); + (0, vitest_1.expect)(results[0].externalIds.rawg).toBe(2); + }); + (0, vitest_1.it)('debería priorizar IGDB > RAWG > TheGamesDB en deduplicación', async () => { + igdb.searchGames.mockResolvedValue([ + { + id: 1, + name: 'Zelda', + slug: 'zelda', + releaseDate: '1986-02-21', + genres: ['Adventure'], + coverUrl: 'http://igdb.com/zelda.jpg', + source: 'igdb', + }, + ]); + rawg.searchGames.mockResolvedValue([ + { + id: 2, + name: 'Zelda', + slug: 'zelda-rawg', + releaseDate: '1986-02-21', + genres: ['Adventure'], + coverUrl: 'http://rawg.com/zelda.jpg', + source: 'rawg', + }, + ]); + tgdb.searchGames.mockResolvedValue([ + { + id: 3, + name: 'Zelda', + slug: 'zelda-tgdb', + releaseDate: '1986-02-21', + genres: ['Adventure'], + coverUrl: 'http://tgdb.com/zelda.jpg', + source: 'thegamesdb', + }, + ]); + const results = await (0, metadataService_1.searchGames)({ title: 'Zelda' }); + (0, vitest_1.expect)(results.length).toBe(1); + (0, vitest_1.expect)(results[0].source).toBe('igdb'); + (0, vitest_1.expect)(results[0].externalIds).toEqual({ + igdb: 1, + rawg: 2, + thegamesdb: 3, + }); + }); + (0, vitest_1.it)('debería devolver array vacío si no hay resultados', async () => { + igdb.searchGames.mockResolvedValue([]); + rawg.searchGames.mockResolvedValue([]); + tgdb.searchGames.mockResolvedValue([]); + const results = await (0, metadataService_1.searchGames)({ title: 'Juego inexistente' }); + (0, vitest_1.expect)(results).toEqual([]); + }); + (0, vitest_1.it)('debería manejar errores de API y continuar con otras fuentes', async () => { + igdb.searchGames.mockRejectedValue(new Error('IGDB error')); + rawg.searchGames.mockResolvedValue([ + { + id: 2, + name: 'Sonic', + slug: 'sonic', + releaseDate: '1991-06-23', + genres: ['Platform'], + coverUrl: 'http://rawg.com/sonic.jpg', + source: 'rawg', + }, + ]); + tgdb.searchGames.mockResolvedValue([]); + const results = await (0, metadataService_1.searchGames)({ title: 'Sonic' }); + (0, vitest_1.expect)(results.length).toBe(1); + (0, vitest_1.expect)(results[0].source).toBe('rawg'); + }); + (0, vitest_1.it)('debería pasar el parámetro platform a IGDB', async () => { + igdb.searchGames.mockResolvedValue([]); + rawg.searchGames.mockResolvedValue([]); + tgdb.searchGames.mockResolvedValue([]); + await (0, metadataService_1.searchGames)({ title: 'Mario', platform: 'NES' }); + (0, vitest_1.expect)(igdb.searchGames).toHaveBeenCalledWith('Mario', 'NES'); + (0, vitest_1.expect)(rawg.searchGames).toHaveBeenCalledWith('Mario'); + (0, vitest_1.expect)(tgdb.searchGames).toHaveBeenCalledWith('Mario'); + }); + (0, vitest_1.it)('debería mantener múltiples resultados con nombres diferentes', async () => { + igdb.searchGames.mockResolvedValue([ + { + id: 1, + name: 'Super Mario Bros.', + slug: 'super-mario-bros', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://igdb.com/smb.jpg', + source: 'igdb', + }, + { + id: 2, + name: 'Super Mario Bros. 2', + slug: 'super-mario-bros-2', + releaseDate: '1988-10-09', + genres: ['Platform'], + coverUrl: 'http://igdb.com/smb2.jpg', + source: 'igdb', + }, + ]); + rawg.searchGames.mockResolvedValue([]); + tgdb.searchGames.mockResolvedValue([]); + const results = await (0, metadataService_1.searchGames)({ title: 'Super Mario' }); + (0, vitest_1.expect)(results.length).toBe(2); + (0, vitest_1.expect)(results[0].name).toBe('Super Mario Bros.'); + (0, vitest_1.expect)(results[1].name).toBe('Super Mario Bros. 2'); + }); + }); }); diff --git a/backend/src/routes/games.ts b/backend/src/routes/games.ts index 659eebd..6e76a66 100644 --- a/backend/src/routes/games.ts +++ b/backend/src/routes/games.ts @@ -1,14 +1,39 @@ import { FastifyInstance } from 'fastify'; import { GamesController } from '../controllers/gamesController'; -import { createGameSchema, updateGameSchema } from '../validators/gameValidator'; -import { ZodError } from 'zod'; +import { createGameSchema, updateGameSchema, GameCondition } from '../validators/gameValidator'; +import { ZodError, z } from 'zod'; import type { GamesListReplyOrError, GameReplyOrError, CreateGameBody, UpdateGameBody, + CreateGameFromMetadataBody, } from '../types'; +// Esquema de validación para crear juego desde metadatos +const createGameFromMetadataSchema = z.object({ + metadata: z.object({ + source: z.string(), + externalIds: z.record(z.number()).optional(), + name: z.string().min(1, 'El nombre es requerido'), + slug: z.string().optional(), + releaseDate: z.string().optional(), + genres: z.array(z.string()).optional(), + coverUrl: z.string().optional(), + }), + overrides: z + .object({ + platformId: z.string().optional(), + description: z.string().optional(), + priceCents: z.number().optional(), + currency: z.string().optional(), + store: z.string().optional(), + date: z.string().optional(), + condition: GameCondition, + }) + .optional(), +}); + async function gamesRoutes(app: FastifyInstance) { /** * GET /api/games @@ -108,6 +133,52 @@ async function gamesRoutes(app: FastifyInstance) { } }); + /** + * POST /api/games/from-metadata + * Crear un juego a partir de metadatos de búsqueda + */ + app.post<{ Body: CreateGameFromMetadataBody; Reply: GameReplyOrError }>( + '/games/from-metadata', + async (request, reply) => { + try { + // Validar entrada con Zod + const validated = createGameFromMetadataSchema.parse(request.body); + const { metadata, overrides } = validated; + + // Obtener el ID externo principal basado en la fuente + const sourceId = metadata.externalIds?.[metadata.source] + ? String(metadata.externalIds[metadata.source]) + : undefined; + + // Mapear metadatos a estructura de CreateGameInput + const gameInput: CreateGameBody = { + title: metadata.name, + description: overrides?.description, + priceCents: overrides?.priceCents, + currency: overrides?.currency || 'USD', + store: overrides?.store, + date: overrides?.date, + condition: overrides?.condition, + platformId: overrides?.platformId, + source: metadata.source, + sourceId, + }; + + // Crear el juego usando GamesController + const game = await GamesController.createGame(gameInput); + return reply.code(201).send(game); + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ + error: 'Validación fallida', + details: error.errors, + }); + } + throw error; + } + } + ); + /** * GET /api/games/source/:source * Listar juegos por fuente (rom, manual, igdb, rawg, etc.) diff --git a/backend/src/routes/metadata.ts b/backend/src/routes/metadata.ts index c6840ef..f9fb52f 100644 --- a/backend/src/routes/metadata.ts +++ b/backend/src/routes/metadata.ts @@ -12,12 +12,13 @@ import type { const searchMetadataSchema = z.object({ q: z.string().min(1, 'El parámetro de búsqueda es requerido'), platform: z.string().optional(), + year: z.coerce.number().int().min(1900).max(2100).optional(), }); async function metadataRoutes(app: FastifyInstance) { /** - * GET /api/metadata/search?q=query&platform=optional - * Buscar metadata de juegos + * GET /api/metadata/search?q=query&platform=optional&year=optional + * Buscar metadata de juegos en múltiples fuentes (IGDB, RAWG, TheGamesDB) */ app.get<{ Querystring: MetadataSearchQuerystring; Reply: MetadataSearchReplyOrError }>( '/metadata/search', @@ -26,14 +27,15 @@ async function metadataRoutes(app: FastifyInstance) { // Validar parámetros de query con Zod const validated = searchMetadataSchema.parse(request.query); - // Llamar a metadataService - const result = await metadataService.enrichGame({ + // Llamar a metadataService.searchGames para obtener múltiples resultados + const results = await metadataService.searchGames({ title: validated.q, platform: validated.platform, + year: validated.year, }); - // Si hay resultado, devolver como array; si no, devolver array vacío - return reply.code(200).send(result ? [result] : []); + // Devolver array de resultados + return reply.code(200).send(results); } catch (error) { if (error instanceof ZodError) { return reply.code(400).send({ diff --git a/backend/src/services/igdbClient.ts b/backend/src/services/igdbClient.ts index c61bc82..911edff 100644 --- a/backend/src/services/igdbClient.ts +++ b/backend/src/services/igdbClient.ts @@ -56,6 +56,9 @@ function mapIgdbHit(r: IgdbGameResponse): MetadataGame { ? r.genres.map((g) => g.name) : undefined; + // IGDB devuelve URLs relativas (//images.igdb.com/...), agregar protocolo https: + const coverUrl = r.cover?.url ? `https:${r.cover.url}` : undefined; + return { id: r.id, name: r.name, @@ -65,7 +68,7 @@ function mapIgdbHit(r: IgdbGameResponse): MetadataGame { : undefined, genres, platforms, - coverUrl: r.cover?.url ?? undefined, + coverUrl, source: 'igdb', }; } @@ -82,7 +85,7 @@ export async function searchGames(query: string, _platform?: string): Promise; - const body = `search "${query}"; fields id,name,slug,first_release_date,genres,platforms,cover; limit 10;`; + const body = `search "${query}"; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 10;`; try { const res = await fetch(`${API_URL}/games`, { method: 'POST', headers, body }); @@ -109,7 +112,7 @@ export async function getGameById(id: number): Promise { 'Content-Type': 'text/plain', } as Record; - const body = `where id = ${id}; fields id,name,slug,first_release_date,genres,platforms,cover; limit 1;`; + const body = `where id = ${id}; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 1;`; try { const res = await fetch(`${API_URL}/games`, { method: 'POST', headers, body }); diff --git a/backend/src/services/metadataService.ts b/backend/src/services/metadataService.ts index 868afcf..8612eda 100644 --- a/backend/src/services/metadataService.ts +++ b/backend/src/services/metadataService.ts @@ -13,14 +13,15 @@ function normalize( hit: igdb.MetadataGame | rawg.MetadataGame | thegamesdb.MetadataGame ): EnrichedGame { const base: EnrichedGame = { - source: hit.source ?? 'unknown', + source: (hit.source as 'igdb' | 'rawg' | 'thegamesdb') ?? 'igdb', externalIds: {}, name: hit.name, title: hit.name, - slug: hit.slug, - releaseDate: hit.releaseDate, - genres: hit.genres, - coverUrl: hit.coverUrl, + slug: hit.slug ?? undefined, + releaseDate: hit.releaseDate ?? null, + genres: hit.genres ?? undefined, + coverUrl: hit.coverUrl ?? undefined, + platforms: hit.platforms ?? undefined, }; if (hit.source === 'igdb' && typeof hit.id === 'number') base.externalIds.igdb = hit.id; @@ -63,7 +64,114 @@ export async function enrichGame(opts: { return null; } -export default { enrichGame }; +/** + * Normaliza un nombre para deduplicación + * Convierte a lowercase y remueve caracteres especiales + */ +function normalizeName(name: string): string { + return name + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .trim(); +} + +/** + * Prioridad de fuentes para deduplicación + */ +const SOURCE_PRIORITY: Record = { + igdb: 1, + rawg: 2, + thegamesdb: 3, +}; + +/** + * Busca juegos en paralelo en IGDB, RAWG y TheGamesDB + * Devuelve múltiples resultados con deduplicación + */ +export async function searchGames(opts: { + title: string; + platform?: string; + year?: number; +}): Promise { + const { title, platform, year } = opts; + if (!title) return []; + + // Ejecutar búsquedas en paralelo + const [igdbHits, rawgHits, tgdbHits] = await Promise.allSettled([ + igdb.searchGames(title, platform), + rawg.searchGames(title), + thegamesdb.searchGames(title), + ]); + + // Extraer resultados de las promesas resueltas + const allResults: EnrichedGame[] = []; + + if (igdbHits.status === 'fulfilled') { + allResults.push(...igdbHits.value.map(normalize)); + } + if (rawgHits.status === 'fulfilled') { + allResults.push(...rawgHits.value.map(normalize)); + } + if (tgdbHits.status === 'fulfilled') { + allResults.push(...tgdbHits.value.map(normalize)); + } + + // Filtrar por año si se proporciona + let filteredResults = allResults; + if (year) { + filteredResults = allResults.filter((result) => { + if (!result.releaseDate) return false; + const resultYear = new Date(result.releaseDate).getFullYear(); + return resultYear === year; + }); + } + + // Deduplicar por nombre normalizado + const deduplicatedMap = new Map(); + + for (const result of filteredResults) { + const normalizedName = normalizeName(result.name); + const existing = deduplicatedMap.get(normalizedName); + + if (!existing) { + // Primer resultado con este nombre + deduplicatedMap.set(normalizedName, result); + } else { + // Ya existe, fusionar externalIds y mantener el de mayor prioridad + const existingPriority = SOURCE_PRIORITY[existing.source] ?? 999; + const currentPriority = SOURCE_PRIORITY[result.source] ?? 999; + + if (currentPriority < existingPriority) { + // El resultado actual tiene mayor prioridad + // Fusionar externalIds del existente + result.externalIds = { + ...result.externalIds, + ...existing.externalIds, + }; + deduplicatedMap.set(normalizedName, result); + } else { + // Mantener el existente y fusionar externalIds del actual + existing.externalIds = { + ...existing.externalIds, + ...result.externalIds, + }; + } + } + } + + // Convertir el mapa a array y ordenar por prioridad de fuente + const deduplicatedResults = Array.from(deduplicatedMap.values()).sort((a, b) => { + const priorityA = SOURCE_PRIORITY[a.source] ?? 999; + const priorityB = SOURCE_PRIORITY[b.source] ?? 999; + return priorityA - priorityB; + }); + + // Limitar a 50 resultados totales + return deduplicatedResults.slice(0, 50); +} + +export default { enrichGame, searchGames }; /** * Metadatos: diff --git a/backend/src/services/thegamesdbClient.ts b/backend/src/services/thegamesdbClient.ts index 19a7116..50cc7ba 100644 --- a/backend/src/services/thegamesdbClient.ts +++ b/backend/src/services/thegamesdbClient.ts @@ -33,12 +33,22 @@ export async function searchGames(query: string): Promise { ? g.game.genres.map((x) => x.name) : undefined; + const platforms: PlatformInfo[] | undefined = Array.isArray(g?.game?.platforms) + ? g.game.platforms.map((p) => ({ + id: p.id, + name: p.name, + abbreviation: p.abbreviation, + slug: p.name?.toLowerCase().replace(/\s+/g, '-'), + })) + : undefined; + hits.push({ id: Number(gid), name: g?.game?.title ?? g?.title ?? String(gid), slug: g?.game?.slug ?? undefined, releaseDate: g?.game?.release_date ?? undefined, genres, + platforms, coverUrl: g?.game?.images?.boxart?.[0]?.thumb ? `${baseUrl}${g.game.images.boxart[0].thumb}` : undefined, @@ -73,12 +83,22 @@ export async function getGameById(id: number): Promise { ? g.game.genres.map((x) => x.name) : undefined; + const platforms: PlatformInfo[] | undefined = Array.isArray(g?.game?.platforms) + ? g.game.platforms.map((p) => ({ + id: p.id, + name: p.name, + abbreviation: p.abbreviation, + slug: p.name?.toLowerCase().replace(/\s+/g, '-'), + })) + : undefined; + return { id: Number(firstKey), name: g?.game?.title ?? g?.title ?? String(firstKey), slug: g?.game?.slug ?? undefined, releaseDate: g?.game?.release_date ?? undefined, genres, + platforms, coverUrl: g?.game?.images?.boxart?.[0]?.thumb ? `${baseUrl}${g.game.images.boxart[0].thumb}` : undefined, diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 85e410d..8280488 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -5,6 +5,11 @@ */ import { Game, Artwork, Purchase, GamePlatform, Tag } from '@prisma/client'; +import { z } from 'zod'; +import type { GameCondition as GameConditionEnum } from '../validators/gameValidator'; + +// Tipo derivado del enum GameCondition +export type GameConditionType = z.infer; // Tipos de respuesta de Prisma con relaciones incluidas export type GameWithRelations = Game & { @@ -73,6 +78,7 @@ export interface TheGamesDBGameResponse { slug?: string; release_date?: string; genres?: { id: number; name: string }[]; + platforms?: { id: number; name: string; abbreviation?: string }[]; images?: { boxart?: { thumb?: string }[]; }; @@ -145,22 +151,48 @@ export interface CreateGameBody { platformId?: string; description?: string; priceCents?: number; - currency?: string; + currency: string; store?: string; date?: string; - condition?: string; - source?: string; + condition?: GameConditionType; + source: string; sourceId?: string; } export interface UpdateGameBody extends Partial {} +export interface EnrichedGame { + source: 'igdb' | 'rawg' | 'thegamesdb'; + externalIds: { igdb?: number; rawg?: number; thegamesdb?: number }; + name: string; // Nombre del juego (compatible con MetadataGame) + title?: string; // Título opcional + slug: string | undefined; + releaseDate: string | null; + genres: string[] | undefined; + coverUrl: string | undefined; + platforms?: PlatformInfo[]; +} + +export interface CreateGameFromMetadataBody { + metadata: EnrichedGame; + overrides?: { + platformId?: string; + description?: string; + priceCents?: number; + currency?: string; + store?: string; + date?: string; + condition?: GameConditionType; + }; +} + export interface MetadataSearchQuerystring { q: string; platform?: string; + year?: number; } -export type MetadataSearchReply = MetadataGame[]; +export type MetadataSearchReply = EnrichedGame[]; export interface ImportScanBody { dir?: string; @@ -179,17 +211,6 @@ export type GamesListReplyOrError = GamesListReply | ErrorResponse; export type MetadataSearchReplyOrError = MetadataSearchReply | ErrorResponse; // Tipos para metadataService -export interface EnrichedGame { - source: string; - externalIds: { igdb?: number; rawg?: number; thegamesdb?: number }; - name: string; // Nombre del juego (compatible con MetadataGame) - title?: string; // Título opcional - slug?: string; - releaseDate?: string; - genres?: string[]; - coverUrl?: string; -} - export interface EnrichGameOptions { title: string; platform?: string; diff --git a/backend/tests/routes/games.spec.ts b/backend/tests/routes/games.spec.ts index 1facce5..52d0deb 100644 --- a/backend/tests/routes/games.spec.ts +++ b/backend/tests/routes/games.spec.ts @@ -252,6 +252,149 @@ describe('Games API', () => { expect(res.statusCode).toBe(404); }); }); + + describe('POST /api/games/from-metadata', () => { + it('debería crear un juego a partir de metadatos', async () => { + const payload = { + metadata: { + source: 'igdb', + externalIds: { igdb: 1234 }, + name: 'Super Mario Bros.', + slug: 'super-mario-bros', + releaseDate: '1985-09-13T00:00:00.000Z', + genres: ['Platform'], + coverUrl: 'https://example.com/cover.jpg', + }, + }; + + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body).toHaveProperty('id'); + expect(body.title).toBe('Super Mario Bros.'); + expect(body.source).toBe('igdb'); + expect(body.sourceId).toBe('1234'); + }); + + it('debería crear un juego con overrides', async () => { + const platform = await prisma.platform.create({ + data: { name: 'Nintendo Entertainment System', slug: 'nes' }, + }); + + const payload = { + metadata: { + source: 'igdb', + externalIds: { igdb: 1234 }, + name: 'Super Mario Bros.', + slug: 'super-mario-bros', + releaseDate: '1985-09-13T00:00:00.000Z', + genres: ['Platform'], + coverUrl: 'https://example.com/cover.jpg', + }, + overrides: { + platformId: platform.id, + description: 'Descripción personalizada', + priceCents: 10000, + currency: 'USD', + store: 'eBay', + date: '2025-01-15', + condition: 'CIB', + }, + }; + + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.title).toBe('Super Mario Bros.'); + expect(body.description).toBe('Descripción personalizada'); + expect(body.gamePlatforms).toHaveLength(1); + expect(body.gamePlatforms[0].platformId).toBe(platform.id); + expect(body.purchases).toHaveLength(1); + expect(body.purchases[0].priceCents).toBe(10000); + }); + + it('debería devolver 400 si falta el campo metadata', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); + + it('debería devolver 400 si metadata.name está vacío', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload: { + metadata: { + source: 'igdb', + externalIds: { igdb: 1234 }, + name: '', + slug: 'super-mario-bros', + }, + }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('debería usar el externalId principal como sourceId', async () => { + const payload = { + metadata: { + source: 'rawg', + externalIds: { rawg: 5678, igdb: 1234 }, + name: 'Zelda', + slug: 'zelda', + }, + }; + + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.source).toBe('rawg'); + expect(body.sourceId).toBe('5678'); + }); + + it('debería manejar metadata sin externalIds', async () => { + const payload = { + metadata: { + source: 'manual', + externalIds: {}, + name: 'Custom Game', + slug: 'custom-game', + }, + }; + + const res = await app.inject({ + method: 'POST', + url: '/api/games/from-metadata', + payload, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.title).toBe('Custom Game'); + expect(body.source).toBe('manual'); + expect(body.sourceId).toBeNull(); + }); + }); }); /** diff --git a/backend/tests/routes/metadata.spec.ts b/backend/tests/routes/metadata.spec.ts index 64cef19..eb86e9d 100644 --- a/backend/tests/routes/metadata.spec.ts +++ b/backend/tests/routes/metadata.spec.ts @@ -17,20 +17,33 @@ describe('Metadata API', () => { }); describe('GET /api/metadata/search', () => { - it('debería devolver resultados cuando se busca un juego existente', async () => { + it('debería devolver múltiples resultados cuando se busca un juego existente', async () => { const mockResults = [ { - source: 'igdb', + source: 'igdb' as const, externalIds: { igdb: 1 }, + name: 'The Legend of Zelda', title: 'The Legend of Zelda', slug: 'the-legend-of-zelda', releaseDate: '1986-02-21', genres: ['Adventure'], coverUrl: 'https://example.com/cover.jpg', + platforms: undefined, + }, + { + source: 'rawg' as const, + externalIds: { rawg: 2 }, + name: 'The Legend of Zelda: A Link to the Past', + title: 'The Legend of Zelda: A Link to the Past', + slug: 'the-legend-of-zelda-a-link-to-the-past', + releaseDate: '1991-11-21', + genres: ['Adventure'], + coverUrl: 'https://example.com/cover2.jpg', + platforms: undefined, }, ]; - vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(mockResults[0]); + vi.spyOn(metadataService, 'searchGames').mockResolvedValue(mockResults); const res = await app.inject({ method: 'GET', @@ -41,11 +54,11 @@ describe('Metadata API', () => { const body = res.json(); expect(Array.isArray(body)).toBe(true); expect(body.length).toBeGreaterThan(0); - expect(body[0].title).toContain('Zelda'); + expect(body[0].name).toContain('Zelda'); }); it('debería devolver lista vacía cuando no hay resultados', async () => { - vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null); + vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]); const res = await app.inject({ method: 'GET', @@ -77,8 +90,8 @@ describe('Metadata API', () => { expect(res.statusCode).toBe(400); }); - it('debería pasar el parámetro platform a enrichGame si se proporciona', async () => { - const enrichSpy = vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null); + it('debería pasar el parámetro platform a searchGames si se proporciona', async () => { + const searchSpy = vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]); const res = await app.inject({ method: 'GET', @@ -86,9 +99,42 @@ describe('Metadata API', () => { }); expect(res.statusCode).toBe(200); - expect(enrichSpy).toHaveBeenCalledWith({ + expect(searchSpy).toHaveBeenCalledWith({ title: 'mario', platform: 'Nintendo 64', + year: undefined, + }); + }); + + it('debería pasar el parámetro year a searchGames si se proporciona', async () => { + const searchSpy = vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]); + + const res = await app.inject({ + method: 'GET', + url: '/api/metadata/search?q=mario&year=1990', + }); + + expect(res.statusCode).toBe(200); + expect(searchSpy).toHaveBeenCalledWith({ + title: 'mario', + platform: undefined, + year: 1990, + }); + }); + + it('debería pasar todos los parámetros a searchGames', async () => { + const searchSpy = vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]); + + const res = await app.inject({ + method: 'GET', + url: '/api/metadata/search?q=mario&platform=NES&year=1985', + }); + + expect(res.statusCode).toBe(200); + expect(searchSpy).toHaveBeenCalledWith({ + title: 'mario', + platform: 'NES', + year: 1985, }); }); }); diff --git a/backend/tests/services/fsScanner.archiveEntries.spec.ts b/backend/tests/services/fsScanner.archiveEntries.spec.ts index fa7bfdc..01ba39b 100644 --- a/backend/tests/services/fsScanner.archiveEntries.spec.ts +++ b/backend/tests/services/fsScanner.archiveEntries.spec.ts @@ -9,7 +9,9 @@ vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vi.fn() import scanDirectory from '../../src/services/fsScanner'; import { listArchiveEntries } from '../../src/services/archiveReader'; -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + vi.restoreAllMocks(); +}); it('expone entradas internas de archivos como items virtuales', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fsScanner-test-')); @@ -26,11 +28,13 @@ it('expone entradas internas de archivos como items virtuales', async () => { const found = results.find((r: any) => r.path === expectedPath); expect(found).toBeDefined(); - expect(found.isArchiveEntry).toBe(true); - expect(found.containerPath).toBe(collectionFile); - expect(found.entryPath).toBe('inner/rom1.bin'); - expect(found.filename).toBe('rom1.bin'); - expect(found.format).toBe('bin'); + if (found) { + expect(found.isArchiveEntry).toBe(true); + expect(found.containerPath).toBe(collectionFile); + expect(found.entryPath).toBe('inner/rom1.bin'); + expect(found.filename).toBe('rom1.bin'); + expect(found.format).toBe('bin'); + } await fs.rm(tmpDir, { recursive: true, force: true }); }); diff --git a/backend/tests/services/importService.archiveEntry.spec.ts b/backend/tests/services/importService.archiveEntry.spec.ts index b450066..b93b847 100644 --- a/backend/tests/services/importService.archiveEntry.spec.ts +++ b/backend/tests/services/importService.archiveEntry.spec.ts @@ -16,12 +16,18 @@ import { streamArchiveEntry } from '../../src/services/archiveReader'; import prisma from '../../src/plugins/prisma'; import { createHash } from 'crypto'; +// Mock Date.now() para timestamps consistentes en tests +const FIXED_TIMESTAMP = 1234567890123; +const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP); + beforeEach(() => { vi.restoreAllMocks(); + dateNowSpy.mockReturnValue(FIXED_TIMESTAMP); }); describe('services/importService (archive entries)', () => { it('procesa una entrada interna usando streamArchiveEntry y crea Game con source=rom', async () => { + const data = Buffer.from('import-archive-test'); const files = [ { path: '/roms/collection.zip::inner/rom1.bin', @@ -29,14 +35,12 @@ describe('services/importService (archive entries)', () => { entryPath: 'inner/rom1.bin', filename: 'rom1.bin', name: 'inner/rom1.bin', - size: 123, + size: data.length, format: 'bin', isArchiveEntry: true, }, ]; - const data = Buffer.from('import-archive-test'); - (scanDirectory as unknown as Mock).mockResolvedValue(files); (streamArchiveEntry as unknown as Mock).mockResolvedValue(Readable.from([data])); @@ -60,12 +64,12 @@ describe('services/importService (archive entries)', () => { }); expect((prisma.game.create as unknown as Mock).mock.calls[0][0]).toEqual({ data: { - title: 'ROM1', - slug: 'rom1-1234567890123', + title: 'rom1', + slug: expect.stringMatching(/^rom1-\d+$/), source: 'rom', romPath: '/roms/collection.zip::inner/rom1.bin', romFilename: 'rom1.bin', - romSize: 123, + romSize: data.length, romChecksum: md5, romFormat: 'bin', romHashes: expect.any(String), diff --git a/backend/tests/services/importService.spec.ts b/backend/tests/services/importService.spec.ts index ffa2fac..6de8847 100644 --- a/backend/tests/services/importService.spec.ts +++ b/backend/tests/services/importService.spec.ts @@ -20,9 +20,14 @@ import { scanDirectory } from '../../src/services/fsScanner'; import { computeHashes } from '../../src/services/checksumService'; import prisma from '../../src/plugins/prisma'; +// Mock Date.now() para timestamps consistentes en tests +const FIXED_TIMESTAMP = 1234567890123; +const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP); + describe('services/importService', () => { beforeEach(() => { vi.clearAllMocks(); + dateNowSpy.mockReturnValue(FIXED_TIMESTAMP); }); it('exporta createSlug e importDirectory', () => { diff --git a/backend/tests/services/metadataService.spec.ts b/backend/tests/services/metadataService.spec.ts index 0f8955e..b5fc3db 100644 --- a/backend/tests/services/metadataService.spec.ts +++ b/backend/tests/services/metadataService.spec.ts @@ -18,7 +18,7 @@ vi.mock('../../src/services/thegamesdbClient', () => ({ import * as igdb from '../../src/services/igdbClient'; import * as rawg from '../../src/services/rawgClient'; import * as tgdb from '../../src/services/thegamesdbClient'; -import { enrichGame } from '../../src/services/metadataService'; +import { enrichGame, searchGames } from '../../src/services/metadataService'; describe('services/metadataService', () => { beforeEach(() => { @@ -79,4 +79,214 @@ describe('services/metadataService', () => { const res = await enrichGame({ title: 'Juego inexistente' }); expect(res).toBeNull(); }); + + describe('searchGames', () => { + it('debería buscar en paralelo en IGDB, RAWG y TheGamesDB', async () => { + (igdb.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 1, + name: 'Super Mario Bros.', + slug: 'super-mario-bros', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://igdb.com/cover.jpg', + source: 'igdb', + }, + ]); + + (rawg.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 2, + name: 'Super Mario Bros.', + slug: 'super-mario-bros-rawg', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://rawg.com/cover.jpg', + source: 'rawg', + }, + ]); + + (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 3, + name: 'Super Mario Bros.', + slug: 'super-mario-bros-tgdb', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://tgdb.com/cover.jpg', + source: 'thegamesdb', + }, + ]); + + const results = await searchGames({ title: 'Super Mario Bros.' }); + + expect(results.length).toBeGreaterThan(0); + expect(igdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.', undefined); + expect(rawg.searchGames).toHaveBeenCalledWith('Super Mario Bros.'); + expect(tgdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.'); + }); + + it('debería deduplicar resultados por nombre normalizado', async () => { + (igdb.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 1, + name: 'Super Mario Bros.', + slug: 'super-mario-bros', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://igdb.com/cover.jpg', + source: 'igdb', + }, + ]); + + (rawg.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 2, + name: 'Super Mario Bros.', + slug: 'super-mario-bros-rawg', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://rawg.com/cover.jpg', + source: 'rawg', + }, + ]); + + (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + + const results = await searchGames({ title: 'Super Mario Bros.' }); + + // Debería haber un solo resultado (prioridad IGDB) + expect(results.length).toBe(1); + expect(results[0].source).toBe('igdb'); + expect(results[0].externalIds.igdb).toBe(1); + expect(results[0].externalIds.rawg).toBe(2); + }); + + it('debería priorizar IGDB > RAWG > TheGamesDB en deduplicación', async () => { + (igdb.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 1, + name: 'Zelda', + slug: 'zelda', + releaseDate: '1986-02-21', + genres: ['Adventure'], + coverUrl: 'http://igdb.com/zelda.jpg', + source: 'igdb', + }, + ]); + + (rawg.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 2, + name: 'Zelda', + slug: 'zelda-rawg', + releaseDate: '1986-02-21', + genres: ['Adventure'], + coverUrl: 'http://rawg.com/zelda.jpg', + source: 'rawg', + }, + ]); + + (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 3, + name: 'Zelda', + slug: 'zelda-tgdb', + releaseDate: '1986-02-21', + genres: ['Adventure'], + coverUrl: 'http://tgdb.com/zelda.jpg', + source: 'thegamesdb', + }, + ]); + + const results = await searchGames({ title: 'Zelda' }); + + expect(results.length).toBe(1); + expect(results[0].source).toBe('igdb'); + expect(results[0].externalIds).toEqual({ + igdb: 1, + rawg: 2, + thegamesdb: 3, + }); + }); + + it('debería devolver array vacío si no hay resultados', async () => { + (igdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + (rawg.searchGames as unknown as ReturnType).mockResolvedValue([]); + (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + + const results = await searchGames({ title: 'Juego inexistente' }); + + expect(results).toEqual([]); + }); + + it('debería manejar errores de API y continuar con otras fuentes', async () => { + (igdb.searchGames as unknown as ReturnType).mockRejectedValue( + new Error('IGDB error') + ); + + (rawg.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 2, + name: 'Sonic', + slug: 'sonic', + releaseDate: '1991-06-23', + genres: ['Platform'], + coverUrl: 'http://rawg.com/sonic.jpg', + source: 'rawg', + }, + ]); + + (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + + const results = await searchGames({ title: 'Sonic' }); + + expect(results.length).toBe(1); + expect(results[0].source).toBe('rawg'); + }); + + it('debería pasar el parámetro platform a IGDB', async () => { + (igdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + (rawg.searchGames as unknown as ReturnType).mockResolvedValue([]); + (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + + await searchGames({ title: 'Mario', platform: 'NES' }); + + expect(igdb.searchGames).toHaveBeenCalledWith('Mario', 'NES'); + expect(rawg.searchGames).toHaveBeenCalledWith('Mario'); + expect(tgdb.searchGames).toHaveBeenCalledWith('Mario'); + }); + + it('debería mantener múltiples resultados con nombres diferentes', async () => { + (igdb.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 1, + name: 'Super Mario Bros.', + slug: 'super-mario-bros', + releaseDate: '1985-09-13', + genres: ['Platform'], + coverUrl: 'http://igdb.com/smb.jpg', + source: 'igdb', + }, + { + id: 2, + name: 'Super Mario Bros. 2', + slug: 'super-mario-bros-2', + releaseDate: '1988-10-09', + genres: ['Platform'], + coverUrl: 'http://igdb.com/smb2.jpg', + source: 'igdb', + }, + ]); + + (rawg.searchGames as unknown as ReturnType).mockResolvedValue([]); + (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + + const results = await searchGames({ title: 'Super Mario' }); + + expect(results.length).toBe(2); + expect(results[0].name).toBe('Super Mario Bros.'); + expect(results[1].name).toBe('Super Mario Bros. 2'); + }); + }); }); diff --git a/docs/02-tecnico/apis.md b/docs/02-tecnico/apis.md index ac11a5d..4e8a7b3 100644 --- a/docs/02-tecnico/apis.md +++ b/docs/02-tecnico/apis.md @@ -415,6 +415,42 @@ class MetadataService { ## Configuración y despliegue +### Configuración de variables de entorno + +Para configurar las credenciales de las APIs externas en Quasar, sigue estos pasos: + +#### 1. Archivo `.env` + +El archivo [`backend/.env`](../../backend/.env) contiene las variables de entorno necesarias. Este archivo **NO** debe ser commiteado al repositorio (ya está incluido en [`.gitignore`](../../.gitignore)). + +#### 2. Variables requeridas + +Añade las siguientes variables a tu archivo [`backend/.env`](../../backend/.env): + +```bash +# IGDB (Internet Game Database) +IGDB_CLIENT_ID=your_igdb_client_id_here +IGDB_CLIENT_SECRET=your_igdb_client_secret_here + +# RAWG Video Games Database +RAWG_API_KEY=your_rawg_api_key_here + +# TheGamesDB +THEGAMESDB_API_KEY=your_thegamesdb_api_key_here +``` + +#### 3. Archivo de ejemplo + +El archivo [`backend/.env.example`](../../backend/.env.example) contiene todas las variables de entorno disponibles con sus descripciones. Úsalo como referencia cuando configures tu entorno. + +#### 4. Instrucciones detalladas por API + +Consulte la sección [Obtención de claves](#obtención-de-claves) para instrucciones detalladas sobre cómo obtener cada credencial: + +- **IGDB**: [https://dev.twitch.tv/console](https://dev.twitch.tv/console) +- **RAWG**: [https://rawg.io/apicreate](https://rawg.io/apicreate) +- **TheGamesDB**: [https://thegamesdb.net/api.php](https://thegamesdb.net/api.php) + ### Testing Without Real Keys Para desarrollo/testing: diff --git a/docs/02-tecnico/game-search.md b/docs/02-tecnico/game-search.md new file mode 100644 index 0000000..2bb2c1e --- /dev/null +++ b/docs/02-tecnico/game-search.md @@ -0,0 +1,330 @@ +# Búsqueda de Juegos + +## Resumen + +La funcionalidad de búsqueda de juegos permite a los usuarios buscar metadatos de juegos desde múltiples fuentes externas (IGDB, RAWG, TheGamesDB) y agregarlos a su biblioteca personal. + +## Arquitectura + +### Backend + +#### Endpoints + +##### `GET /api/metadata/search` + +Busca metadatos de juegos en múltiples fuentes externas. + +**Parámetros de query:** + +- `q` (string, requerido): Término de búsqueda del título del juego +- `platform` (string, opcional): Plataforma para filtrar (ej. "NES", "SNES") +- `year` (number, opcional): Año de lanzamiento para filtrar (1900-2100) + +**Respuesta:** + +```typescript +interface EnrichedGame { + source: 'igdb' | 'rawg' | 'thegamesdb'; + externalIds: { + igdb?: number; + rawg?: number; + thegamesdb?: number; + }; + name: string; + title: string; + slug: string; + releaseDate?: string; + genres?: string[]; + coverUrl?: string; + platforms?: PlatformInfo[]; +} +``` + +**Ejemplo de solicitud:** + +```bash +curl "http://localhost:3003/api/metadata/search?q=Sonic&platform=Genesis&year=1991" +``` + +##### `POST /api/games/from-metadata` + +Crea un juego nuevo a partir de metadatos de búsqueda. + +**Body:** + +```typescript +interface CreateGameFromMetadataBody { + metadata: EnrichedGame; + overrides?: { + platformId?: string; + description?: string; + priceCents?: number; + currency?: string; + store?: string; + date?: string; + condition?: 'Loose' | 'CIB' | 'New'; + }; +} +``` + +**Respuesta:** + +```typescript +interface Game { + id: string; + title: string; + slug: string; + description?: string; + releaseDate?: string; + genre?: string; + platform?: string; + year?: number; + cover?: string; + source: string; + sourceId?: string; +} +``` + +**Ejemplo de solicitud:** + +```bash +curl -X POST http://localhost:3003/api/games/from-metadata \ + -H "Content-Type: application/json" \ + -d '{ + "metadata": { + "source": "igdb", + "externalIds": { "igdb": 12345 }, + "name": "Sonic the Hedgehog", + "slug": "sonic-the-hedgehog", + "releaseDate": "1991-06-23", + "genres": ["Platform"], + "coverUrl": "https://example.com/cover.jpg" + }, + "overrides": { + "description": "Juego clásico de SEGA", + "condition": "CIB" + } + }' +``` + +#### Servicios + +##### `metadataService.searchGames()` + +Orquesta la búsqueda en múltiples fuentes externas. + +**Parámetros:** + +```typescript +interface SearchGamesParams { + title: string; + platform?: string; + year?: number; +} +``` + +**Respuesta:** Array de `EnrichedGame[]` + +**Fuentes soportadas:** + +- **IGDB:** Base de datos de videojuegos más completa +- **RAWG:** API de videojuegos con datos de múltiples fuentes +- **TheGamesDB:** Base de datos comunitaria de videojuegos + +### Frontend + +#### Página `/games/add` + +Página principal para buscar y agregar juegos a la biblioteca. + +**Componentes:** + +##### `SearchForm` + +Formulario de búsqueda con los siguientes campos: + +- **Título** (requerido): Campo de texto para el título del juego +- **Plataforma** (opcional): Dropdown con plataformas comunes +- **Año** (opcional): Campo numérico para filtrar por año + +**Props:** + +```typescript +interface SearchFormProps { + onSearch: (params: SearchGamesParams) => void; + isLoading?: boolean; +} +``` + +##### `SearchResults` + +Muestra los resultados de la búsqueda en una lista. + +**Props:** + +```typescript +interface SearchResultsProps { + results: EnrichedGame[]; + onSelectResult: (result: EnrichedGame) => void; + isLoading?: boolean; +} +``` + +**Características:** + +- Muestra la portada del juego +- Muestra el título, año y géneros +- Indica la fuente de los datos (IGDB, RAWG, TheGamesDB) +- Botón para seleccionar un resultado + +##### `GamePreviewDialog` + +Dialog modal para previsualizar y editar los metadatos antes de guardar. + +**Props:** + +```typescript +interface GamePreviewDialogProps { + open: boolean; + game: EnrichedGame | null; + onClose: () => void; + onSave: (data: CreateGameFromMetadataInput) => void; +} +``` + +**Características:** + +- Muestra la portada del juego +- Campos editables: título, descripción, condición, plataforma +- Botón para guardar el juego en la biblioteca + +#### API Client + +Funciones disponibles en `src/lib/api.ts`: + +```typescript +// Buscar juegos +metadataApi.searchGames(params: SearchGamesParams): Promise + +// Crear juego desde metadatos +metadataApi.createGameFromMetadata(data: CreateGameFromMetadataInput): Promise +``` + +## Flujo de Usuario + +1. El usuario navega a `/games/add` +2. Ingresa un título de búsqueda (opcionalmente plataforma y año) +3. Hace clic en "Buscar" +4. El frontend llama a `metadataApi.searchGames()` +5. El backend busca en IGDB, RAWG y TheGamesDB +6. Los resultados se muestran en `SearchResults` +7. El usuario selecciona un resultado +8. Se abre `GamePreviewDialog` con los metadatos +9. El usuario puede editar los campos según sea necesario +10. Hace clic en "Guardar" +11. El frontend llama a `metadataApi.createGameFromMetadata()` +12. El juego se crea en la base de datos +13. El usuario es redirigido a la página del juego + +## Configuración + +### Variables de Entorno + +**Backend:** + +```env +# IGDB +IGDB_CLIENT_ID=your_client_id +IGDB_CLIENT_SECRET=your_client_secret + +# RAWG +RAWG_API_KEY=your_api_key + +# TheGamesDB +THEGAMESDB_API_KEY=your_api_key +``` + +**Frontend:** + +```env +NEXT_PUBLIC_API_URL=http://localhost:3003/api +``` + +## Tests + +### Backend Tests + +- `tests/services/metadataService.spec.ts`: Tests del servicio de búsqueda +- `tests/routes/metadata.spec.ts`: Tests de endpoints de metadatos +- `tests/routes/games.spec.ts`: Tests de endpoints de juegos + +**Ejecutar tests:** + +```bash +cd backend +yarn test +``` + +### Frontend Tests + +- `src/components/games/__tests__/SearchForm.spec.ts`: Tests del formulario de búsqueda +- `src/components/games/__tests__/SearchResults.spec.ts`: Tests de resultados de búsqueda +- `src/components/games/__tests__/GamePreviewDialog.spec.ts`: Tests del diálogo de previsualización + +**Ejecutar tests:** + +```bash +cd frontend +yarn test +``` + +## Consideraciones de Diseño + +### Múltiples Fuentes + +El sistema busca en múltiples fuentes simultáneamente para maximizar la probabilidad de encontrar resultados. Cada resultado incluye: + +- La fuente de los datos (`source`) +- Los IDs externos (`externalIds`) para referencia futura + +### Normalización de Datos + +Los datos de diferentes fuentes se normalizan a un formato común (`EnrichedGame`): + +- `name` y `title` se unifican +- Las fechas se normalizan a formato ISO +- Los géneros se mapean a strings +- Las plataformas se normalizan + +### Validación + +- Los parámetros de búsqueda se validan con Zod +- Los datos de creación de juego se validan antes de persistir +- Los campos opcionales tienen valores por defecto apropiados + +### Error Handling + +- Errores de API externas no bloquean la búsqueda en otras fuentes +- Errores de validación se devuelven con mensajes claros +- Errores de red se manejan con reintentos y timeouts + +## Rendimiento + +- Las búsquedas en múltiples fuentes se ejecutan en paralelo +- Los resultados se cachean por un período corto +- Las imágenes se cargan de forma diferida (lazy loading) + +## Seguridad + +- Las claves de API se almacenan en variables de entorno +- No se exponen credenciales en el frontend +- Los inputs se validan y sanitizan +- Se implementan rate limiting en las APIs externas + +## Roadmap Futuro + +- [ ] Soporte para más fuentes de metadatos +- [ ] Búsqueda avanzada con filtros adicionales +- [ ] Sugerencias de búsqueda mientras se escribe +- [ ] Importación masiva desde listas externas +- [ ] Sincronización automática de metadatos diff --git a/frontend/package.json b/frontend/package.json index ee2b7e2..dda8a5f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,10 +6,14 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@testing-library/dom": "^10.4.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -24,14 +28,23 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.0", + "@vitest/ui": "^4.1.0", "eslint": "^9", "eslint-config-next": "16.1.6", + "jsdom": "^29.0.1", "shadcn": "^3.8.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "^5", + "vite": "^8.0.1", + "vitest": "^4.1.0" } } diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts new file mode 100644 index 0000000..9347c17 --- /dev/null +++ b/frontend/src/__tests__/setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); diff --git a/frontend/src/app/games/add/page.tsx b/frontend/src/app/games/add/page.tsx new file mode 100644 index 0000000..b18f16d --- /dev/null +++ b/frontend/src/app/games/add/page.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { EnrichedGame, metadataApi } from '@/lib/api'; +import { SearchForm } from '@/components/games/SearchForm'; +import { SearchResults } from '@/components/games/SearchResults'; +import { GamePreviewDialog } from '@/components/games/GamePreviewDialog'; +import Navbar from '@/components/landing/Navbar'; + +export default function AddGamePage() { + const router = useRouter(); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [selectedGame, setSelectedGame] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const handleSearch = async (params: { title: string; platform?: string; year?: number }) => { + setIsSearching(true); + setError(null); + + try { + const results = await metadataApi.searchGames(params); + setSearchResults(results); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al buscar juegos'); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }; + + const handleSelectResult = (game: EnrichedGame) => { + setSelectedGame(game); + }; + + const handleSaveGame = async (data: { + metadata: EnrichedGame; + overrides: { platform?: string; year?: number; description?: string }; + }) => { + setIsSaving(true); + setError(null); + + try { + await metadataApi.createGameFromMetadata(data); + setSelectedGame(null); + router.push('/games'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error al guardar el juego'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* Starfield background */} +
+ + {/* Navbar */} + + + {/* Main Content */} +
+
+
+ {/* Header */} +
+

+ BUSCAR JUEGOS +

+

+ BUSCA EN PROVEEDORES EXTERNOS Y AÑADE JUEGOS A TU BIBLIOTECA +

+
+ + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Search Form */} +
+ +
+ + {/* Search Results */} + {!isSearching && searchResults.length > 0 && ( +
+

+ RESULTADOS DE BÚSQUEDA ({searchResults.length}) +

+ +
+ )} + + {/* Empty State */} + {!isSearching && searchResults.length === 0 && ( +
+

+ NO SE ENCONTRARON RESULTADOS +

+

+ INTENTA CON OTROS TÉRMINOS DE BÚSQUEDA +

+
+ )} +
+
+
+ + {/* Game Preview Dialog */} + !open && setSelectedGame(null)} + onSave={handleSaveGame} + game={selectedGame} + /> +
+ ); +} diff --git a/frontend/src/app/games/page.tsx b/frontend/src/app/games/page.tsx index 031a748..3641ee1 100644 --- a/frontend/src/app/games/page.tsx +++ b/frontend/src/app/games/page.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; import { Game, gamesApi } from '@/lib/api'; import { GameTable } from '@/components/games/GameTable'; import { GameDialog } from '@/components/games/GameDialog'; @@ -120,13 +121,12 @@ export default function GamesPage() {

- + + +
@@ -197,13 +197,12 @@ export default function GamesPage() { : 'NO HAY JUEGOS EN TU BIBLIOTECA.'}

{!searchQuery && ( - + + + )} )} diff --git a/frontend/src/components/games/GamePreviewDialog.tsx b/frontend/src/components/games/GamePreviewDialog.tsx new file mode 100644 index 0000000..03e3b50 --- /dev/null +++ b/frontend/src/components/games/GamePreviewDialog.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { EnrichedGame } from '@/lib/api'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +export interface GamePreviewDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (data: { + metadata: EnrichedGame; + overrides: { platform?: string; year?: number; description?: string }; + }) => void; + game: EnrichedGame | null; +} + +const PLATFORMS = [ + 'NES', + 'SNES', + 'Nintendo 64', + 'Game Boy', + 'Game Boy Color', + 'Game Boy Advance', + 'Nintendo DS', + 'Nintendo 3DS', + 'Nintendo Switch', + 'Sega Genesis', + 'Sega Saturn', + 'Sega Dreamcast', + 'PlayStation', + 'PlayStation 2', + 'PlayStation 3', + 'PlayStation 4', + 'PlayStation 5', + 'Xbox', + 'Xbox 360', + 'Xbox One', + 'Xbox Series X/S', + 'PC', + 'Atari 2600', + 'Commodore 64', + 'Arcade', +]; + +export function GamePreviewDialog({ open, onOpenChange, onSave, game }: GamePreviewDialogProps) { + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [platform, setPlatform] = useState(undefined); + const [year, setYear] = useState(undefined); + + useEffect(() => { + if (game) { + setTitle(game.title || game.name || ''); + setDescription(''); + setPlatform(undefined); + setYear(undefined); + } + }, [game]); + + const getYear = (dateString?: string) => { + if (!dateString) return undefined; + try { + return new Date(dateString).getFullYear().toString(); + } catch { + return undefined; + } + }; + + const getPlatformName = () => { + return game?.platforms && game.platforms.length > 0 ? game.platforms[0].name : null; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!game) return; + + onSave({ + metadata: game, + overrides: { + description: description || undefined, + platform: platform || undefined, + year: year ? parseInt(year, 10) : undefined, + }, + }); + }; + + if (!game) { + return null; + } + + return ( + + + + Previsualizar Juego + + Revisa y edita la información del juego antes de guardarlo en tu biblioteca. + + +
+
+ {game.coverUrl && ( + {`Cover + )} +
+
+ + setTitle(e.target.value)} + placeholder={game.title} + /> +
+ +
+ +