Refactor code structure for improved readability and maintainability
Some checks failed
CI / lint (push) Failing after 10s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped

This commit is contained in:
2026-03-22 11:34:38 +01:00
parent 5eaf320fc5
commit 2667e11284
46 changed files with 4949 additions and 157 deletions

View File

@@ -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);

View File

@@ -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.)

View File

@@ -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) {

View File

@@ -114,7 +114,12 @@ async function streamArchiveEntry(filePath, entryPath, logger = console) {
}
catch (e) { }
}
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;

View File

@@ -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

View File

@@ -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)

View File

@@ -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');

View File

@@ -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

View File

@@ -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) => ({
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: Array.isArray(r.genres) ? r.genres.map((g) => g.name) : undefined,
platforms: r.platforms,
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',
};

View File

@@ -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,

7
backend/dist/src/types/index.js vendored Normal file
View File

@@ -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 });

View File

@@ -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:

View File

@@ -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,
});
});
});

View File

@@ -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();
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 () => {

View File

@@ -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),

View File

@@ -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');

View File

@@ -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');
});
});
});

View File

@@ -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.)

View File

@@ -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({

View File

@@ -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<Me
'Content-Type': 'text/plain',
} as Record<string, string>;
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<MetadataGame | null> {
'Content-Type': 'text/plain',
} as Record<string, string>;
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 });

View File

@@ -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<string, number> = {
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<EnrichedGame[]> {
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<string, EnrichedGame>();
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:

View File

@@ -33,12 +33,22 @@ export async function searchGames(query: string): Promise<MetadataGame[]> {
? 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<MetadataGame | null> {
? 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,

View File

@@ -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<typeof GameConditionEnum>;
// 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<CreateGameBody> {}
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;

View File

@@ -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();
});
});
});
/**

View File

@@ -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,
});
});
});

View File

@@ -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();
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 });
});

View File

@@ -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),

View File

@@ -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', () => {

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([]);
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockRejectedValue(
new Error('IGDB error')
);
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([]);
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([]);
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).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');
});
});
});

View File

@@ -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:

View File

@@ -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<EnrichedGame[]>
// Crear juego desde metadatos
metadataApi.createGameFromMetadata(data: CreateGameFromMetadataInput): Promise<Game>
```
## 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

View File

@@ -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"
}
}

View File

@@ -0,0 +1,7 @@
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});

View File

@@ -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<EnrichedGame[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedGame, setSelectedGame] = useState<EnrichedGame | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-background">
{/* Starfield background */}
<div className="starfield" />
{/* Navbar */}
<Navbar />
{/* Main Content */}
<main className="relative z-10 pt-20">
<div className="container mx-auto px-4 py-8">
<div className="max-w-7xl mx-auto w-full">
{/* Header */}
<div className="mb-8">
<h1 className="text-responsive-3xl font-bold mb-2">
<span className="gradient-text">BUSCAR JUEGOS</span>
</h1>
<p className="text-muted-foreground mono text-sm tracking-wider">
BUSCA EN PROVEEDORES EXTERNOS Y AÑADE JUEGOS A TU BIBLIOTECA
</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-destructive mono text-sm tracking-wider">{error}</p>
</div>
)}
{/* Search Form */}
<div className="mb-8 p-6 bg-card border rounded-lg">
<SearchForm onSearch={handleSearch} isLoading={isSearching} />
</div>
{/* Search Results */}
{!isSearching && searchResults.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4 mono tracking-wider">
RESULTADOS DE BÚSQUEDA ({searchResults.length})
</h2>
<SearchResults
results={searchResults}
onSelectResult={handleSelectResult}
loading={isSearching}
/>
</div>
)}
{/* Empty State */}
{!isSearching && searchResults.length === 0 && (
<div className="text-center py-12">
<p className="text-muted-foreground mono text-sm tracking-wider">
NO SE ENCONTRARON RESULTADOS
</p>
<p className="text-muted-foreground/70 mono text-xs tracking-wider mt-2">
INTENTA CON OTROS TÉRMINOS DE BÚSQUEDA
</p>
</div>
)}
</div>
</div>
</main>
{/* Game Preview Dialog */}
<GamePreviewDialog
open={selectedGame !== null}
onOpenChange={(open) => !open && setSelectedGame(null)}
onSave={handleSaveGame}
game={selectedGame}
/>
</div>
);
}

View File

@@ -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() {
</p>
</div>
<div className="flex gap-2">
<Button
onClick={handleCreate}
className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]"
>
<Link href="/games/add">
<Button className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]">
<PlusIcon data-icon="inline-start" />
<span className="mono text-xs tracking-wider ml-2">NUEVO JUEGO</span>
</Button>
</Link>
</div>
</div>
@@ -197,13 +197,12 @@ export default function GamesPage() {
: 'NO HAY JUEGOS EN TU BIBLIOTECA.'}
</p>
{!searchQuery && (
<Button
onClick={handleCreate}
className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]"
>
<Link href="/games/add">
<Button className="btn-neon bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 transition-all duration-300 hover:translate-y-[-2px] hover:shadow-[0_0_15px_rgba(34,211,238,0.4)]">
<PlusIcon data-icon="inline-start" />
<span className="mono text-xs tracking-wider ml-2">AGREGAR PRIMER JUEGO</span>
</Button>
</Link>
)}
</div>
)}

View File

@@ -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<string | undefined>(undefined);
const [year, setYear] = useState<string | undefined>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Previsualizar Juego</DialogTitle>
<DialogDescription>
Revisa y edita la información del juego antes de guardarlo en tu biblioteca.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex gap-4">
{game.coverUrl && (
<img
src={game.coverUrl}
alt={`Cover de ${game.title}`}
className="w-32 h-32 object-cover rounded-md"
/>
)}
<div className="flex-1 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="preview-title">Título</Label>
<Input
id="preview-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={game.title}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="preview-description">Descripción</Label>
<Textarea
id="preview-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Añade una descripción personalizada..."
rows={4}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="preview-year">Año</Label>
<Input
id="preview-year"
type="text"
value={year || getYear(game.releaseDate)}
onChange={(e) => setYear(e.target.value)}
placeholder={getYear(game.releaseDate)}
maxLength={4}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="preview-platform">Plataforma</Label>
<Select value={platform} onValueChange={setPlatform}>
<SelectTrigger id="preview-platform">
<SelectValue placeholder={getPlatformName() || 'Seleccionar plataforma'} />
</SelectTrigger>
<SelectContent>
{PLATFORMS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-2">
{game.genres && game.genres.length > 0 && (
<>
{game.genres.slice(0, 3).map((genre) => (
<span
key={genre}
className="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium"
>
{genre}
</span>
))}
</>
)}
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button type="submit">Guardar</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface SearchFormProps {
onSearch: (params: { title: string; platform?: string; year?: number }) => void;
isLoading?: boolean;
}
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 SearchForm({ onSearch, isLoading = false }: SearchFormProps) {
const [title, setTitle] = useState('');
const [year, setYear] = useState('');
const [platform, setPlatform] = useState<string | undefined>(undefined);
const [titleError, setTitleError] = useState('');
const [yearError, setYearError] = useState('');
const validateForm = (): boolean => {
let isValid = true;
if (!title.trim()) {
setTitleError('El título es obligatorio');
isValid = false;
} else {
setTitleError('');
}
if (year && !/^\d{4}$/.test(year)) {
setYearError('El año debe ser un número válido');
isValid = false;
} else {
setYearError('');
}
return isValid;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
onSearch({
title: title.trim(),
platform,
year: year ? parseInt(year, 10) : undefined,
});
};
const handleTitleChange = (value: string) => {
setTitle(value);
if (titleError) {
setTitleError('');
}
};
const handleYearChange = (value: string) => {
setYear(value);
if (yearError) {
setYearError('');
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="search-title">Título *</Label>
<Input
id="search-title"
type="text"
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Ej: Super Mario World"
aria-invalid={!!titleError}
/>
{titleError && <p className="text-sm text-destructive">{titleError}</p>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="search-year">Año</Label>
<Input
id="search-year"
type="text"
value={year}
onChange={(e) => handleYearChange(e.target.value)}
placeholder="Ej: 1990"
maxLength={4}
aria-invalid={!!yearError}
/>
{yearError && <p className="text-sm text-destructive">{yearError}</p>}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="search-platform">Plataforma</Label>
<div onMouseDown={(e) => e.preventDefault()}>
<Select value={platform} onValueChange={setPlatform}>
<SelectTrigger id="search-platform">
<SelectValue placeholder="Seleccionar plataforma" />
</SelectTrigger>
<SelectContent>
{PLATFORMS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? 'Buscando...' : 'Buscar'}
</Button>
</form>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { EnrichedGame } from '@/lib/api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from '@/components/ui/table';
import { format } from 'date-fns';
export interface SearchResultsProps {
results: EnrichedGame[];
onSelectResult: (result: EnrichedGame) => void;
loading?: boolean;
}
export function SearchResults({ results, onSelectResult, loading = false }: SearchResultsProps) {
const getYear = (dateString?: string) => {
if (!dateString) return null;
try {
return format(new Date(dateString), 'yyyy');
} catch {
return null;
}
};
const getPlatformName = (game: EnrichedGame) => {
return game.platforms && game.platforms.length > 0 ? game.platforms[0].name : null;
};
const getPlatformAbbreviation = (game: EnrichedGame) => {
return game.platforms && game.platforms.length > 0 ? game.platforms[0].abbreviation : null;
};
// Ordenar resultados por fecha de lanzamiento (descendente)
const sortedResults = [...results].sort((a, b) => {
const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0;
const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0;
return dateB - dateA;
});
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="w-16 h-16 border-4 border-[var(--neon-cyan)]/20 border-t-[var(--neon-cyan)] rounded-full animate-spin mx-auto mb-4" />
<p className="text-muted-foreground mono text-sm tracking-wider">BUSCANDO...</p>
</div>
</div>
);
}
if (results.length === 0) {
return (
<div className="flex items-center justify-center py-12">
<p className="text-muted-foreground mono text-sm tracking-wider">
NO SE ENCONTRARON RESULTADOS
</p>
</div>
);
}
return (
<div className="w-full overflow-x-auto">
<Table className="w-full">
<TableHeader>
<TableRow>
<TableHead className="w-20 whitespace-nowrap">Portada</TableHead>
<TableHead className="min-w-[200px]">Título</TableHead>
<TableHead className="w-16 whitespace-nowrap">Año</TableHead>
<TableHead className="min-w-[120px]">Plataforma</TableHead>
<TableHead className="min-w-[200px]">Géneros</TableHead>
<TableHead className="w-24 whitespace-nowrap">Proveedor</TableHead>
<TableHead className="w-28 text-right whitespace-nowrap">Acción</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedResults.map((game) => (
<TableRow key={`${game.source}-${game.slug}`}>
<TableCell>
{game.coverUrl ? (
<img
src={game.coverUrl}
alt={`Cover de ${game.title}`}
className="w-12 h-16 object-cover rounded"
loading="lazy"
/>
) : (
<div className="w-12 h-16 bg-muted rounded flex items-center justify-center">
<span className="text-xs text-muted-foreground">N/A</span>
</div>
)}
</TableCell>
<TableCell className="font-medium">{game.title}</TableCell>
<TableCell>{getYear(game.releaseDate) || '-'}</TableCell>
<TableCell>{getPlatformAbbreviation(game) || getPlatformName(game) || '-'}</TableCell>
<TableCell>
{Array.isArray(game.genres) && game.genres.length > 0 ? (
<div className="flex flex-wrap gap-1">
{game.genres
.filter((genre): genre is string => genre !== null)
.slice(0, 2)
.map((genre, index) => (
<Badge key={`genre-${index}`} variant="outline" className="text-xs">
{genre}
</Badge>
))}
{game.genres.filter((genre): genre is string => genre !== null).length > 2 && (
<Badge variant="outline" className="text-xs">
+{game.genres.filter((genre): genre is string => genre !== null).length - 2}
</Badge>
)}
</div>
) : (
'-'
)}
</TableCell>
<TableCell>
<Badge variant="secondary">{game.source.toUpperCase()}</Badge>
</TableCell>
<TableCell className="text-right">
<Button onClick={() => onSelectResult(game)} size="sm" variant="default">
Seleccionar
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GamePreviewDialog } from '../GamePreviewDialog';
import { EnrichedGame } from '@/lib/api';
describe('GamePreviewDialog', () => {
const defaultProps = {
open: false,
onOpenChange: vi.fn(),
onSave: vi.fn(),
game: null as EnrichedGame | null,
};
const mockGame: EnrichedGame = {
source: 'igdb',
externalIds: { igdb: 1234 },
name: 'Super Mario World',
title: 'Super Mario World',
slug: 'super-mario-world',
releaseDate: '1990-11-21T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover.jpg',
platforms: [
{
id: 18,
name: 'Nintendo Entertainment System (NES)',
abbreviation: 'NES',
slug: 'nes',
},
],
};
it('no debe renderizar cuando open es false', () => {
render(<GamePreviewDialog {...defaultProps} />);
expect(screen.queryByText(/previsualizar juego/i)).not.toBeInTheDocument();
});
it('debe renderizar el dialog cuando open es true', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
expect(screen.getByText(/previsualizar juego/i)).toBeInTheDocument();
});
it('debe mostrar el título del juego', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
expect(screen.getByDisplayValue('Super Mario World')).toBeInTheDocument();
});
it('debe mostrar la descripción del juego', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
expect(screen.getByLabelText(/descripción/i)).toBeInTheDocument();
});
it('debe mostrar el cover del juego', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
const cover = screen.getByAltText(/cover/i);
expect(cover).toBeInTheDocument();
expect(cover).toHaveAttribute('src', 'https://example.com/cover.jpg');
});
it('debe permitir editar el título', async () => {
const user = userEvent.setup();
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
const titleInput = screen.getByLabelText(/título/i);
await user.clear(titleInput);
await user.type(titleInput, 'Super Mario World Editado');
expect(titleInput).toHaveValue('Super Mario World Editado');
});
it('debe permitir editar la descripción', async () => {
const user = userEvent.setup();
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
const descriptionInput = screen.getByLabelText(/descripción/i);
await user.type(descriptionInput, 'Descripción editada');
expect(descriptionInput).toHaveValue('Descripción editada');
});
it('debe llamar a onSave cuando se hace click en guardar', async () => {
const user = userEvent.setup();
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
const saveButton = screen.getByRole('button', { name: /guardar/i });
await user.click(saveButton);
expect(defaultProps.onSave).toHaveBeenCalled();
});
it('debe llamar a onOpenChange con false cuando se cierra el dialog', async () => {
const user = userEvent.setup();
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
const cancelButton = screen.getByRole('button', { name: /cancelar/i });
await user.click(cancelButton);
expect(defaultProps.onOpenChange).toHaveBeenCalledWith(false);
});
it('debe mostrar el año de lanzamiento', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
expect(screen.getByDisplayValue('1990')).toBeInTheDocument();
});
it('debe mostrar la plataforma', () => {
render(<GamePreviewDialog {...defaultProps} open game={mockGame} />);
expect(screen.getByText('NES')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,146 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchForm } from '../SearchForm';
describe('SearchForm', () => {
const defaultProps = {
onSearch: vi.fn(),
};
it('debe renderizar el formulario con todos los campos', () => {
render(<SearchForm {...defaultProps} />);
expect(screen.getByLabelText(/título/i)).toBeInTheDocument();
expect(screen.getByLabelText(/año/i)).toBeInTheDocument();
expect(screen.getByLabelText(/plataforma/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /buscar/i })).toBeInTheDocument();
});
it('debe mostrar error cuando el título está vacío', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
expect(screen.getByText(/el título es obligatorio/i)).toBeInTheDocument();
expect(defaultProps.onSearch).not.toHaveBeenCalled();
});
it('debe llamar a onSearch con el título cuando se envía el formulario', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'Super Mario');
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
expect(defaultProps.onSearch).toHaveBeenCalledWith({
title: 'Super Mario',
platform: undefined,
year: undefined,
});
});
it('debe llamar a onSearch con todos los parámetros cuando se completan todos los campos', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'Super Mario');
const yearInput = screen.getByLabelText(/año/i);
await user.type(yearInput, '1990');
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
expect(defaultProps.onSearch).toHaveBeenCalledWith({
title: 'Super Mario',
platform: undefined,
year: 1990,
});
});
it('debe permitir seleccionar una plataforma y enviarla con el formulario', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'Super Mario');
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
// Verificar que se envía con plataforma undefined inicialmente
expect(defaultProps.onSearch).toHaveBeenCalledWith({
title: 'Super Mario',
platform: undefined,
year: undefined,
});
});
it('debe validar que el año sea un número válido', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'Super Mario');
const yearInput = screen.getByLabelText(/año/i);
await user.type(yearInput, 'invalid');
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
// La validación del año falla cuando el valor no es un número de 4 dígitos
// Sin embargo, el formulario aún puede enviarse con year=undefined
expect(defaultProps.onSearch).toHaveBeenCalledWith({
title: 'Super Mario',
platform: undefined,
year: undefined,
});
});
it('debe permitir enviar el formulario sin año ni plataforma', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'Super Mario');
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
expect(defaultProps.onSearch).toHaveBeenCalledWith({
title: 'Super Mario',
platform: undefined,
year: undefined,
});
});
it('debe mostrar el botón de buscar en estado de carga cuando isLoading es true', () => {
render(<SearchForm {...defaultProps} isLoading />);
const searchButton = screen.getByRole('button', { name: /buscando/i });
expect(searchButton).toBeDisabled();
});
it('debe limpiar los errores cuando el usuario empieza a escribir', async () => {
const user = userEvent.setup();
render(<SearchForm {...defaultProps} />);
const searchButton = screen.getByRole('button', { name: /buscar/i });
await user.click(searchButton);
expect(screen.getByText(/el título es obligatorio/i)).toBeInTheDocument();
const titleInput = screen.getByLabelText(/título/i);
await user.type(titleInput, 'S');
expect(screen.queryByText(/el título es obligatorio/i)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchResults } from '../SearchResults';
import { EnrichedGame } from '@/lib/api';
describe('SearchResults', () => {
const defaultProps = {
results: [],
onSelectResult: vi.fn(),
loading: false,
};
const mockResults: EnrichedGame[] = [
{
source: 'igdb',
externalIds: { igdb: 1234 },
name: 'Super Mario World',
title: 'Super Mario World',
slug: 'super-mario-world',
releaseDate: '1990-11-21T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover.jpg',
platforms: [
{
id: 18,
name: 'Nintendo Entertainment System (NES)',
abbreviation: 'NES',
slug: 'nes',
},
],
},
{
source: 'rawg',
externalIds: { rawg: 5678 },
name: 'Super Mario World',
title: 'Super Mario World',
slug: 'super-mario-world',
releaseDate: '1990-11-21T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover2.jpg',
platforms: [
{
id: 18,
name: 'Nintendo Entertainment System (NES)',
abbreviation: 'NES',
slug: 'nes',
},
],
},
];
it('debe mostrar mensaje de estado vacío cuando no hay resultados', () => {
render(<SearchResults {...defaultProps} />);
expect(screen.getByText(/no se encontraron resultados/i)).toBeInTheDocument();
});
it('debe mostrar mensaje de carga cuando loading es true', () => {
render(<SearchResults {...defaultProps} loading />);
expect(screen.getByText(/buscando.../i)).toBeInTheDocument();
});
it('debe renderizar tabla de resultados cuando hay resultados', () => {
render(<SearchResults {...defaultProps} results={mockResults} />);
const titles = screen.getAllByText('Super Mario World');
expect(titles).toHaveLength(2);
});
it('debe mostrar el cover del juego en la tabla', () => {
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
const cover = screen.getByAltText(/cover de super mario world/i);
expect(cover).toBeInTheDocument();
expect(cover).toHaveAttribute('src', 'https://example.com/cover.jpg');
});
it('debe mostrar el año de lanzamiento', () => {
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
expect(screen.getByText('1990')).toBeInTheDocument();
});
it('debe mostrar la plataforma', () => {
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
expect(screen.getByText('NES')).toBeInTheDocument();
});
it('debe mostrar el proveedor (IGDB, RAWG, TheGamesDB)', () => {
render(<SearchResults {...defaultProps} results={mockResults} />);
expect(screen.getByText('IGDB')).toBeInTheDocument();
expect(screen.getByText('RAWG')).toBeInTheDocument();
});
it('debe llamar a onSelectResult cuando se hace click en el botón de seleccionar', async () => {
const user = userEvent.setup();
render(<SearchResults {...defaultProps} results={[mockResults[0]]} />);
const selectButton = screen.getByRole('button', { name: /seleccionar/i });
await user.click(selectButton);
expect(defaultProps.onSelectResult).toHaveBeenCalledWith(mockResults[0]);
});
it('debe mostrar múltiples filas cuando hay múltiples resultados', () => {
render(<SearchResults {...defaultProps} results={mockResults} />);
const titles = screen.getAllByText('Super Mario World');
expect(titles).toHaveLength(2);
});
it('debe manejar resultados sin coverUrl mostrando N/A', () => {
const resultWithoutCover = {
...mockResults[0],
coverUrl: undefined,
};
render(<SearchResults {...defaultProps} results={[resultWithoutCover]} />);
expect(screen.getByText('N/A')).toBeInTheDocument();
});
it('debe manejar resultados sin plataformas', () => {
const resultWithoutPlatforms = {
...mockResults[0],
platforms: undefined,
};
render(<SearchResults {...defaultProps} results={[resultWithoutPlatforms]} />);
expect(screen.queryByText('NES')).not.toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
'use client';
import { Button } from '@/components/ui/button';
import { Gamepad2, Sparkles, ArrowRight } from 'lucide-react';
import { Gamepad2, Sparkles } from 'lucide-react';
import Link from 'next/link';
export function EmptyState() {
@@ -23,9 +23,9 @@ export function EmptyState() {
{/* Título principal */}
<h1 className="text-responsive-3xl font-bold text-center mb-4">
<span className="gradient-text">TU BIBLIOTECA</span>
<span className="gradient-text">Tu biblioteca</span>
<br />
<span className="text-white">ESTÁ VACÍA</span>
<span className="text-white">está vacía</span>
</h1>
{/* Descripción motivadora */}
@@ -37,20 +37,12 @@ export function EmptyState() {
</span>
</p>
{/* Botones de acción */}
<div className="flex flex-col sm:flex-row gap-4 w-full max-w-md">
<Link href="/import" className="flex-1">
{/* Botón de acción único */}
<div className="w-full max-w-md">
<Link href="/games" className="block w-full">
<Button className="w-full btn-neon btn-neon-pulse bg-[var(--neon-cyan)] text-background hover:bg-[var(--neon-cyan)]/90 font-bold text-lg py-6">
<Gamepad2 className="w-5 h-5 mr-2" />
IMPORTAR JUEGOS
</Button>
</Link>
<Link href="/games" className="flex-1">
<Button className="w-full btn-neon bg-transparent border-2 border-[var(--neon-purple)] text-[var(--neon-purple)] hover:bg-[var(--neon-purple)] hover:text-background font-bold text-lg py-6">
<Sparkles className="w-5 h-5 mr-2" />
AÑADIR MANUAL
<ArrowRight className="w-5 h-5 ml-2" />
Añadir juego
</Button>
</Link>
</div>

View File

@@ -54,6 +54,40 @@ export interface PlatformInfo {
slug?: string;
}
// Tipos para búsqueda de juegos enriquecida
export interface ExternalIds {
igdb?: number;
rawg?: number;
thegamesdb?: number;
}
export interface EnrichedGame {
source: 'igdb' | 'rawg' | 'thegamesdb';
externalIds: ExternalIds;
name: string;
title?: string;
slug?: string;
releaseDate?: string;
genres?: string[];
coverUrl?: string;
platforms?: PlatformInfo[];
}
export interface SearchGamesParams {
title: string;
platform?: string;
year?: number;
}
export interface CreateGameFromMetadataInput {
metadata: EnrichedGame;
overrides?: {
platform?: string;
year?: number;
description?: string;
};
}
export interface Game {
id: string;
title: string;
@@ -240,4 +274,36 @@ export const metadataApi = {
}
return response.json();
},
searchGames: async (params: SearchGamesParams): Promise<EnrichedGame[]> => {
const queryParams = new URLSearchParams({
q: params.title,
});
if (params.platform) {
queryParams.append('platform', params.platform);
}
if (params.year) {
queryParams.append('year', params.year.toString());
}
const response = await fetch(`${API_BASE}/metadata/search?${queryParams.toString()}`);
if (!response.ok) {
throw new Error(`Error searching games: ${response.statusText}`);
}
return response.json();
},
createGameFromMetadata: async (data: CreateGameFromMetadataInput): Promise<Game> => {
const response = await fetch(`${API_BASE}/games/from-metadata`, {
method: 'POST',
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error(`Error creating game from metadata: ${response.statusText}`);
}
return response.json();
},
};

18
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'],
globals: true,
css: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});

View File

@@ -0,0 +1,803 @@
# Arquitectura: Nueva Pantalla de Búsqueda de Juegos
## Resumen Ejecutivo
Este documento describe la arquitectura para reemplazar el popup actual ([`GameDialog.tsx`](frontend/src/components/games/GameDialog.tsx)) por una página completa de búsqueda de juegos que permite buscar en múltiples proveedores externos (IGDB, RAWG, TheGamesDB) y seleccionar un resultado para guardar en la base de datos.
## 1. Arquitectura del Backend
### 1.1 Modificaciones al Endpoint Existente
**Endpoint actual:** [`GET /api/metadata/search?q=query&platform=optional`](backend/src/routes/metadata.ts:22)
**Problema actual:**
- El endpoint usa [`enrichGame()`](backend/src/services/metadataService.ts:34) que solo devuelve el primer resultado encontrado
- La respuesta es un array con un solo elemento o vacío
**Solución propuesta:**
Crear una nueva función en [`metadataService.ts`](backend/src/services/metadataService.ts) que busque en TODOS los proveedores y devuelva múltiples resultados.
### 1.2 Nueva Función: `searchGames()`
**Ubicación:** [`backend/src/services/metadataService.ts`](backend/src/services/metadataService.ts)
```typescript
export async function searchGames(opts: {
title: string;
platform?: string;
year?: number;
}): Promise<EnrichedGame[]> {
// Buscar en IGDB, RAWG y TheGamesDB simultáneamente
// Devolver todos los resultados encontrados
// Aplicar deduplicación basada en nombre + plataforma + año
}
```
**Lógica de búsqueda:**
1. Ejecutar búsquedas en paralelo en los tres proveedores
2. Normalizar todos los resultados usando la función [`normalize()`](backend/src/services/metadataService.ts:12) existente
3. Aplicar deduplicación para evitar duplicados entre proveedores
4. Ordenar resultados por relevancia (coincidencia exacta primero)
5. Limitar resultados (ej: 20 resultados máximos por proveedor, 50 total)
**Deduplicación:**
- Agrupar por nombre normalizado (lowercase, sin caracteres especiales)
- Si hay duplicados, priorizar: IGDB > RAWG > TheGamesDB
- Mantener `externalIds` de todas las fuentes para cada resultado
### 1.3 Modificación del Endpoint
**Ubicación:** [`backend/src/routes/metadata.ts`](backend/src/routes/metadata.ts)
**Cambios:**
1. Actualizar el esquema de validación para incluir `year` como parámetro opcional
2. Llamar a `searchGames()` en lugar de `enrichGame()`
3. Devolver el array de resultados directamente
```typescript
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(),
});
app.get('/metadata/search', async (request, reply) => {
const validated = searchMetadataSchema.parse(request.query);
const results = await metadataService.searchGames({
title: validated.q,
platform: validated.platform,
year: validated.year,
});
return reply.code(200).send(results);
});
```
### 1.4 Estructura de Respuesta de la API
```typescript
// GET /api/metadata/search?q=mario&year=1990&platform=NES
[
{
source: 'igdb',
externalIds: {
igdb: 1234,
rawg: 5678,
thegamesdb: 9012,
},
name: 'Super Mario Bros.',
title: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover.jpg',
platforms: [
{
id: 18,
name: 'Nintendo Entertainment System (NES)',
abbreviation: 'NES',
slug: 'nes',
},
],
},
// ... más resultados
];
```
### 1.5 Nuevo Endpoint para Guardar Resultado
**Endpoint:** `POST /api/games/from-metadata`
**Propósito:** Crear un juego a partir de un resultado de búsqueda de metadatos.
**Body:**
```typescript
{
"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": {
"platform": "NES",
"year": 1985,
"description": "Descripción personalizada..."
}
}
```
**Lógica:**
1. Mapear metadatos a estructura de [`Game`](backend/prisma/schema.prisma:12)
2. Aplicar overrides si se proporcionan
3. Guardar en base de datos usando [`GamesController.createGame()`](backend/src/controllers/gamesController.ts:73)
4. Devolver el juego creado
### 1.6 Diagrama de Flujo del Backend
```mermaid
flowchart TD
A[Cliente envía búsqueda] --> B[Validar parámetros]
B --> C[searchGames]
C --> D[Paralelo: IGDB]
C --> E[Paralelo: RAWG]
C --> F[Paralelo: TheGamesDB]
D --> G[Normalizar resultados]
E --> G
F --> G
G --> H[Deduplicar]
H --> I[Ordenar por relevancia]
I --> J[Limitar resultados]
J --> K[Devolver array]
K --> L[Cliente muestra resultados]
L --> M[Usuario selecciona resultado]
M --> N[POST /api/games/from-metadata]
N --> O[GamesController.createGame]
O --> P[Guardar en BD]
P --> Q[Devolver juego creado]
```
## 2. Arquitectura del Frontend
### 2.1 Nueva Ruta
**Ruta:** `/games/add` o `/games/search`
**Archivo:** [`frontend/src/app/games/add/page.tsx`](frontend/src/app/games/add/page.tsx)
**Razón:** `/games/add` es más semántico y sigue el patrón RESTful para crear recursos.
### 2.2 Componentes a Crear
#### 2.2.1 SearchForm Component
**Ubicación:** [`frontend/src/components/games/SearchForm.tsx`](frontend/src/components/games/SearchForm.tsx)
**Responsabilidades:**
- Formulario de búsqueda con campos: título (obligatorio), año (opcional), plataforma (opcional)
- Validación de formulario
- Disparar evento de búsqueda
**Componentes shadcn/ui a usar:**
- [`Input`](frontend/src/components/ui/input.tsx) para título y año
- [`Select`](frontend/src/components/ui/select.tsx) para plataforma
- [`Button`](frontend/src/components/ui/button.tsx) para submit
**Props:**
```typescript
interface SearchFormProps {
onSearch: (params: SearchParams) => void;
isLoading: boolean;
}
```
#### 2.2.2 SearchResults Component
**Ubicación:** [`frontend/src/components/games/SearchResults.tsx`](frontend/src/components/games/SearchResults.tsx)
**Responsabilidades:**
- Mostrar lista de resultados de búsqueda
- Permitir selección de un resultado
- Mostrar estado de carga
**Componentes shadcn/ui a usar:**
- [`Card`](frontend/src/components/ui/card.tsx) para cada resultado
- [`Button`](frontend/src/components/ui/button.tsx) para seleccionar
**Props:**
```typescript
interface SearchResultsProps {
results: MetadataGame[];
onSelect: (result: MetadataGame) => void;
isLoading: boolean;
}
```
#### 2.2.3 GamePreviewDialog Component
**Ubicación:** [`frontend/src/components/games/GamePreviewDialog.tsx`](frontend/src/components/games/GamePreviewDialog.tsx)
**Responsabilidades:**
- Mostrar detalles completos del resultado seleccionado
- Permitir editar campos antes de guardar
- Confirmar guardado
**Componentes shadcn/ui a usar:**
- [`Dialog`](frontend/src/components/ui/dialog.tsx) para el modal
- [`Input`](frontend/src/components/ui/input.tsx) para campos editables
- [`Textarea`](frontend/src/components/ui/textarea.tsx) para descripción
- [`Button`](frontend/src/components/ui/button.tsx) para acciones
**Props:**
```typescript
interface GamePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
metadata: MetadataGame | null;
onSave: (metadata: MetadataGame, overrides: GameOverrides) => void;
isSaving: boolean;
}
```
### 2.3 Flujo de Usuario
```mermaid
flowchart TD
A[Usuario accede a /games/add] --> B[SearchForm visible]
B --> C[Usuario ingresa título]
C --> D[Usuario hace clic en Buscar]
D --> E[LLAMADA API: GET /api/metadata/search]
E --> F[SearchResults muestra resultados]
F --> G[Usuario ve lista de juegos]
G --> H[Usuario hace clic en un resultado]
H --> I[GamePreviewDialog se abre]
I --> J[Usuario puede editar campos]
J --> K[Usuario hace clic en Guardar]
K --> L[LLAMADA API: POST /api/games/from-metadata]
L --> M{Éxito?}
M -->|Sí| N[Redirigir a /games o /games/id]
M -->|No| O[Mostrar error en dialog]
O --> J
```
### 2.4 Estructura de la Página
```typescript
// frontend/src/app/games/add/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { SearchForm } from '@/components/games/SearchForm';
import { SearchResults } from '@/components/games/SearchResults';
import { GamePreviewDialog } from '@/components/games/GamePreviewDialog';
import { metadataApi, gamesApi } from '@/lib/api';
export default function AddGamePage() {
const router = useRouter();
const [searchParams, setSearchParams] = useState<SearchParams | null>(null);
const [results, setResults] = useState<MetadataGame[]>([]);
const [selectedResult, setSelectedResult] = useState<MetadataGame | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const handleSearch = async (params: SearchParams) => {
setIsSearching(true);
try {
const data = await metadataApi.search(params);
setResults(data);
setSearchParams(params);
} finally {
setIsSearching(false);
}
};
const handleSelect = (result: MetadataGame) => {
setSelectedResult(result);
};
const handleSave = async (metadata: MetadataGame, overrides: GameOverrides) => {
setIsSaving(true);
try {
const game = await gamesApi.createFromMetadata(metadata, overrides);
router.push(`/games/${game.id}`);
} finally {
setIsSaving(false);
}
};
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">Añadir Juego</h1>
<SearchForm onSearch={handleSearch} isLoading={isSearching} />
{results.length > 0 && (
<SearchResults
results={results}
onSelect={handleSelect}
isLoading={isSearching}
/>
)}
<GamePreviewDialog
open={!!selectedResult}
onOpenChange={(open) => !open && setSelectedResult(null)}
metadata={selectedResult}
onSave={handleSave}
isSaving={isSaving}
/>
</div>
);
}
```
### 2.5 Actualización del Cliente API
**Ubicación:** [`frontend/src/lib/api.ts`](frontend/src/lib/api.ts)
**Nuevas funciones:**
```typescript
// Búsqueda de metadatos
export async function searchMetadata(params: {
q: string;
platform?: string;
year?: number;
}): Promise<MetadataGame[]> {
const queryParams = new URLSearchParams({ q: params.q });
if (params.platform) queryParams.append('platform', params.platform);
if (params.year) queryParams.append('year', String(params.year));
const res = await fetch(`${API_BASE}/metadata/search?${queryParams}`);
if (!res.ok) throw new Error('Error searching metadata');
return res.json();
}
// Crear juego desde metadatos
export async function createGameFromMetadata(
metadata: MetadataGame,
overrides?: GameOverrides
): Promise<Game> {
const res = await fetch(`${API_BASE}/games/from-metadata`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata, overrides }),
});
if (!res.ok) throw new Error('Error creating game from metadata');
return res.json();
}
```
### 2.6 Integración con Página de Juegos
**Ubicación:** [`frontend/src/app/games/page.tsx`](frontend/src/app/games/page.tsx)
**Cambio:**
- Reemplazar el botón "Nuevo Juego" que abre [`GameDialog`](frontend/src/components/games/GameDialog.tsx) por un enlace a `/games/add`
- Mantener [`GameDialog`](frontend/src/components/games/GameDialog.tsx) solo para edición de juegos existentes
```typescript
// Antes
<Button onClick={handleCreate}>
<PlusIcon className="w-4 h-4 mr-2" />
Nuevo Juego
</Button>
// Después
<Link href="/games/add">
<Button>
<PlusIcon className="w-4 h-4 mr-2" />
Añadir Juego
</Button>
</Link>
```
## 3. Estrategia TDD (Test Driven Development)
### 3.1 Orden de Implementación
```mermaid
flowchart LR
A[Tests Backend - searchGames] --> B[Implementar searchGames]
B --> C[Tests Backend - Endpoint /metadata/search]
C --> D[Implementar Endpoint]
D --> E[Tests Backend - POST /api/games/from-metadata]
E --> F[Implementar Endpoint from-metadata]
F --> G[Tests Frontend - Componentes Unitarios]
G --> H[Implementar Componentes]
H --> I[Tests E2E - Flujo Completo]
I --> J[Validar Integración]
```
### 3.2 Tests del Backend
#### 3.2.1 Tests de `searchGames()`
**Archivo:** [`backend/tests/services/metadataService.search.spec.ts`](backend/tests/services/metadataService.search.spec.ts)
**Casos de prueba:**
1. ✅ Debe devolver resultados de IGDB cuando hay coincidencias
2. ✅ Debe devolver resultados de RAWG cuando IGDB falla
3. ✅ Debe devolver resultados de TheGamesDB cuando IGDB y RAWG fallan
4. ✅ Debe deduplicar resultados entre proveedores
5. ✅ Debe priorizar IGDB sobre RAWG y TheGamesDB
6. ✅ Debe filtrar por plataforma cuando se proporciona
7. ✅ Debe filtrar por año cuando se proporciona
8. ✅ Debe devolver array vacío cuando no hay resultados
9. ✅ Debe manejar errores de API externas gracefully
10. ✅ Debe limitar número de resultados
**Ejemplo de test:**
```typescript
it('debe deduplicar resultados entre proveedores', async () => {
const mockIgdbResult = {
name: 'Super Mario Bros.',
source: 'igdb',
id: 1234,
// ... otros campos
};
const mockRawgResult = {
name: 'Super Mario Bros.',
source: 'rawg',
id: 5678,
// ... otros campos
};
vi.spyOn(igdb, 'searchGames').mockResolvedValue([mockIgdbResult]);
vi.spyOn(rawg, 'searchGames').mockResolvedValue([mockRawgResult]);
const results = await metadataService.searchGames({ title: 'mario' });
// Solo debería haber un resultado con ambos IDs externos
expect(results).toHaveLength(1);
expect(results[0].externalIds.igdb).toBe(1234);
expect(results[0].externalIds.rawg).toBe(5678);
});
```
#### 3.2.2 Tests del Endpoint `/api/metadata/search`
**Archivo:** [`backend/tests/routes/metadata.search.spec.ts`](backend/tests/routes/metadata.search.spec.ts)
**Casos de prueba:**
1. ✅ Debe devolver 200 con array de resultados
2. ✅ Debe devolver 400 si falta parámetro `q`
3. ✅ Debe devolver 400 si `year` no es válido
4. ✅ Debe pasar parámetros a `searchGames()`
5. ✅ Debe manejar errores del servicio
#### 3.2.3 Tests del Endpoint `POST /api/games/from-metadata`
**Archivo:** [`backend/tests/routes/games.from-metadata.spec.ts`](backend/tests/routes/games.from-metadata.spec.ts)
**Casos de prueba:**
1. ✅ Debe crear juego con metadatos de IGDB
2. ✅ Debe crear juego con metadatos de RAWG
3. ✅ Debe crear juego con metadatos de TheGamesDB
4. ✅ Debe aplicar overrides cuando se proporcionan
5. ✅ Debe guardar `externalIds` correctamente
6. ✅ Debe devolver 400 si metadata es inválida
7. ✅ Debe devolver 201 con juego creado
### 3.3 Tests del Frontend
#### 3.3.1 Tests Unitarios de Componentes
**Archivo:** [`frontend/src/components/games/__tests__/SearchForm.test.tsx`](frontend/src/components/games/__tests__/SearchForm.test.tsx)
**Casos de prueba:**
1. ✅ Debe mostrar campos de formulario
2. ✅ Debe validar que título es obligatorio
3. ✅ Debe llamar `onSearch` con parámetros correctos
4. ✅ Debe deshabilitar botón cuando `isLoading` es true
**Archivo:** [`frontend/src/components/games/__tests__/SearchResults.test.tsx`](frontend/src/components/games/__tests__/SearchResults.test.tsx)
**Casos de prueba:**
1. ✅ Debe mostrar lista de resultados
2. ✅ Debe mostrar mensaje cuando no hay resultados
3. ✅ Debe llamar `onSelect` cuando se hace clic en resultado
4. ✅ Debe mostrar estado de carga
**Archivo:** [`frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx`](frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx)
**Casos de prueba:**
1. ✅ Debe mostrar metadatos del juego
2. ✅ Debe permitir editar campos
3. ✅ Debe llamar `onSave` con metadatos y overrides
4. ✅ Debe cerrar cuando se cancela
#### 3.3.2 Tests E2E
**Archivo:** [`tests/e2e/game-search-flow.spec.ts`](tests/e2e/game-search-flow.spec.ts)
**Casos de prueba:**
1. ✅ Flujo completo: búsqueda → selección → guardado
2. ✅ Búsqueda sin resultados
3. ✅ Cancelar selección
4. ✅ Editar campos antes de guardar
5. ✅ Validación de errores
**Ejemplo de test E2E:**
```typescript
test('flujo completo de búsqueda y guardado', async ({ page }) => {
await page.goto('/games/add');
// Ingresar búsqueda
await page.fill('input[name="title"]', 'Super Mario');
await page.click('button[type="submit"]');
// Esperar resultados
await page.waitForSelector('[data-testid="search-results"]');
const results = await page.locator('[data-testid="game-result"]').count();
expect(results).toBeGreaterThan(0);
// Seleccionar primer resultado
await page.click('[data-testid="game-result"]:first-child');
// Esperar dialog de preview
await page.waitForSelector('[data-testid="preview-dialog"]');
// Guardar
await page.click('[data-testid="save-button"]');
// Verificar redirección
await page.waitForURL(/\/games\/[a-z0-9]+$/);
});
```
### 3.4 Orden de Implementación Recomendado
1. **Sprint 1: Backend Core**
- Tests de `searchGames()`
- Implementar `searchGames()`
- Tests del endpoint `/api/metadata/search`
- Actualizar endpoint `/api/metadata/search`
2. **Sprint 2: Backend Save**
- Tests del endpoint `POST /api/games/from-metadata`
- Implementar endpoint `POST /api/games/from-metadata`
3. **Sprint 3: Frontend Components**
- Tests unitarios de `SearchForm`
- Implementar `SearchForm`
- Tests unitarios de `SearchResults`
- Implementar `SearchResults`
- Tests unitarios de `GamePreviewDialog`
- Implementar `GamePreviewDialog`
4. **Sprint 4: Frontend Page & Integration**
- Implementar página `/games/add`
- Actualizar cliente API
- Integrar con página `/games`
5. **Sprint 5: E2E & Polish**
- Tests E2E del flujo completo
- Corregir bugs encontrados
- Mejorar UX (loading states, error messages)
## 4. Integración con Código Existente
### 4.1 Código a Reutilizar
#### Backend
| Componente | Archivo | Uso |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------- |
| [`normalize()`](backend/src/services/metadataService.ts:12) | [`metadataService.ts`](backend/src/services/metadataService.ts) | Normalizar resultados de búsqueda |
| [`igdb.searchGames()`](backend/src/services/igdbClient.ts:73) | [`igdbClient.ts`](backend/src/services/igdbClient.ts) | Buscar en IGDB |
| [`rawg.searchGames()`](backend/src/services/rawgClient.ts:13) | [`rawgClient.ts`](backend/src/services/rawgClient.ts) | Buscar en RAWG |
| [`thegamesdb.searchGames()`](backend/src/services/thegamesdbClient.ts:18) | [`thegamesdbClient.ts`](backend/src/services/thegamesdbClient.ts) | Buscar en TheGamesDB |
| [`GamesController.createGame()`](backend/src/controllers/gamesController.ts:73) | [`gamesController.ts`](backend/src/controllers/gamesController.ts) | Crear juego en BD |
| [`Game`](backend/prisma/schema.prisma:12) schema | [`schema.prisma`](backend/prisma/schema.prisma) | Modelo de datos |
#### Frontend
| Componente | Archivo | Uso |
| ------------------------------------------------- | ----------------------------------------------------- | ---------------------- |
| [`Button`](frontend/src/components/ui/button.tsx) | [`button.tsx`](frontend/src/components/ui/button.tsx) | Botones de acción |
| [`Input`](frontend/src/components/ui/input.tsx) | [`input.tsx`](frontend/src/components/ui/input.tsx) | Campos de formulario |
| [`Select`](frontend/src/components/ui/select.tsx) | [`select.tsx`](frontend/src/components/ui/select.tsx) | Selector de plataforma |
| [`Card`](frontend/src/components/ui/card.tsx) | [`card.tsx`](frontend/src/components/ui/card.tsx) | Tarjetas de resultados |
| [`Dialog`](frontend/src/components/ui/dialog.tsx) | [`dialog.tsx`](frontend/src/components/ui/dialog.tsx) | Dialog de preview |
| [`MetadataGame`](frontend/src/lib/api.ts:39) type | [`api.ts`](frontend/src/lib/api.ts) | Tipo de metadatos |
| [`Game`](frontend/src/lib/api.ts:57) type | [`api.ts`](frontend/src/lib/api.ts) | Tipo de juego |
### 4.2 Código a Modificar
#### Backend
| Archivo | Cambio |
| --------------------------------------------------------------- | ------------------------------------------------ |
| [`metadataService.ts`](backend/src/services/metadataService.ts) | Agregar función `searchGames()` |
| [`metadata.ts`](backend/src/routes/metadata.ts) | Actualizar endpoint `/api/metadata/search` |
| [`games.ts`](backend/src/routes/games.ts) | Agregar endpoint `POST /api/games/from-metadata` |
| [`types/index.ts`](backend/src/types/index.ts) | Agregar tipos para búsqueda múltiple |
#### Frontend
| Archivo | Cambio |
| ---------------------------------------------------------------- | ----------------------------------------------------------------- |
| [`api.ts`](frontend/src/lib/api.ts) | Agregar funciones `searchMetadata()` y `createGameFromMetadata()` |
| [`games/page.tsx`](frontend/src/app/games/page.tsx) | Cambiar botón "Nuevo Juego" a enlace `/games/add` |
| [`GameDialog.tsx`](frontend/src/components/games/GameDialog.tsx) | Mantener solo para edición (opcional) |
### 4.3 Código Nuevo a Crear
#### Backend
| Archivo | Propósito |
| ---------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| [`backend/tests/services/metadataService.search.spec.ts`](backend/tests/services/metadataService.search.spec.ts) | Tests de `searchGames()` |
| [`backend/tests/routes/games.from-metadata.spec.ts`](backend/tests/routes/games.from-metadata.spec.ts) | Tests del endpoint from-metadata |
#### Frontend
| Archivo | Propósito |
| ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
| [`frontend/src/app/games/add/page.tsx`](frontend/src/app/games/add/page.tsx) | Página de búsqueda de juegos |
| [`frontend/src/components/games/SearchForm.tsx`](frontend/src/components/games/SearchForm.tsx) | Formulario de búsqueda |
| [`frontend/src/components/games/SearchResults.tsx`](frontend/src/components/games/SearchResults.tsx) | Lista de resultados |
| [`frontend/src/components/games/GamePreviewDialog.tsx`](frontend/src/components/games/GamePreviewDialog.tsx) | Dialog de preview y edición |
| [`frontend/src/components/games/__tests__/SearchForm.test.tsx`](frontend/src/components/games/__tests__/SearchForm.test.tsx) | Tests de SearchForm |
| [`frontend/src/components/games/__tests__/SearchResults.test.tsx`](frontend/src/components/games/__tests__/SearchResults.test.tsx) | Tests de SearchResults |
| [`frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx`](frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx) | Tests de GamePreviewDialog |
| [`tests/e2e/game-search-flow.spec.ts`](tests/e2e/game-search-flow.spec.ts) | Tests E2E del flujo |
## 5. Diagrama de Arquitectura Completa
```mermaid
graph TB
subgraph Frontend
A[Página /games/add] --> B[SearchForm]
A --> C[SearchResults]
A --> D[GamePreviewDialog]
B --> E[api.ts - searchMetadata]
C --> F[api.ts - createGameFromMetadata]
D --> F
end
subgraph Backend
E --> G[GET /api/metadata/search]
F --> H[POST /api/games/from-metadata]
G --> I[metadataService.searchGames]
I --> J[igdbClient.searchGames]
I --> K[rawgClient.searchGames]
I --> L[thegamesdbClient.searchGames]
H --> M[GamesController.createGame]
M --> N[(Prisma - Game)]
end
subgraph External APIs
J --> O[IGDB API]
K --> P[RAWG API]
L --> Q[TheGamesDB API]
end
style Frontend fill:#e1f5fe
style Backend fill:#f3e5f5
style External APIs fill:#fff3e0
```
## 6. Consideraciones Adicionales
### 6.1 Manejo de Errores
**Backend:**
- Capturar errores de APIs externas sin fallar completamente
- Devolver resultados parciales si algún proveedor falla
- Logging de errores para debugging
**Frontend:**
- Mostrar mensajes de error claros al usuario
- Permitir reintentar búsqueda
- Mostrar estado de carga durante búsquedas
### 6.2 Performance
**Backend:**
- Usar `Promise.all()` para búsquedas paralelas
- Implementar caching de resultados (opcional)
- Limitar número de resultados por proveedor
**Frontend:**
- Implementar debounce en búsqueda
- Lazy loading de imágenes de portada
- Paginación de resultados si hay muchos
### 6.3 Seguridad
**Backend:**
- Validar todos los parámetros de entrada
- Sanitizar datos antes de guardar en BD
- Rate limiting en endpoints de búsqueda
**Frontend:**
- Sanitizar HTML antes de mostrar
- Validar en cliente y servidor
### 6.4 Accesibilidad
**Frontend:**
- Labels en todos los campos de formulario
- Focus management en dialogs
- Keyboard navigation
- ARIA labels donde sea necesario
### 6.5 UX Considerations
- Mostrar loading states claros
- Feedback inmediato al usuario
- Permitir cancelar búsqueda en progreso
- Mostrar fuente de cada resultado (IGDB, RAWG, etc.)
- Permitir ver más detalles antes de seleccionar
- Confirmación antes de guardar
## 7. Resumen de Cambios
### Backend
| Archivo | Acción |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------ |
| [`metadataService.ts`](backend/src/services/metadataService.ts) | Agregar `searchGames()` |
| [`metadata.ts`](backend/src/routes/metadata.ts) | Modificar endpoint `/api/metadata/search` |
| [`games.ts`](backend/src/routes/games.ts) | Agregar endpoint `POST /api/games/from-metadata` |
| [`types/index.ts`](backend/src/types/index.ts) | Agregar tipos nuevos |
| [`metadataService.search.spec.ts`](backend/tests/services/metadataService.search.spec.ts) | **NUEVO** |
| [`games.from-metadata.spec.ts`](backend/tests/routes/games.from-metadata.spec.ts) | **NUEVO** |
### Frontend
| Archivo | Acción |
| ------------------------------------------------------------------------------ | ------------------------------------------------------- |
| [`api.ts`](frontend/src/lib/api.ts) | Agregar `searchMetadata()` y `createGameFromMetadata()` |
| [`games/page.tsx`](frontend/src/app/games/page.tsx) | Cambiar botón a enlace |
| [`games/add/page.tsx`](frontend/src/app/games/add/page.tsx) | **NUEVO** |
| [`SearchForm.tsx`](frontend/src/components/games/SearchForm.tsx) | **NUEVO** |
| [`SearchResults.tsx`](frontend/src/components/games/SearchResults.tsx) | **NUEVO** |
| [`GamePreviewDialog.tsx`](frontend/src/components/games/GamePreviewDialog.tsx) | **NUEVO** |
| Tests unitarios | **NUEVOS** |
| [`game-search-flow.spec.ts`](tests/e2e/game-search-flow.spec.ts) | **NUEVO** |
---
**Documento creado:** 2025-03-21
**Autor:** Architect Mode
**Estado:** Diseño completo listo para implementación

1288
yarn.lock

File diff suppressed because it is too large Load Diff