Refactor code structure for improved readability and maintainability
This commit is contained in:
8
backend/dist/src/index.js
vendored
8
backend/dist/src/index.js
vendored
@@ -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);
|
||||
|
||||
63
backend/dist/src/routes/games.js
vendored
63
backend/dist/src/routes/games.js
vendored
@@ -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.)
|
||||
|
||||
14
backend/dist/src/routes/metadata.js
vendored
14
backend/dist/src/routes/metadata.js
vendored
@@ -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) {
|
||||
|
||||
13
backend/dist/src/services/archiveReader.js
vendored
13
backend/dist/src/services/archiveReader.js
vendored
@@ -114,7 +114,12 @@ async function streamArchiveEntry(filePath, entryPath, logger = console) {
|
||||
}
|
||||
catch (e) { }
|
||||
}
|
||||
resolve(proc.stdout);
|
||||
if (proc.stdout) {
|
||||
resolve(proc.stdout);
|
||||
}
|
||||
else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
proc.once('error', onProcError);
|
||||
if (proc.stdout && proc.stdout.once) {
|
||||
@@ -152,7 +157,11 @@ async function streamArchiveEntry(filePath, entryPath, logger = console) {
|
||||
// Fallback for zip
|
||||
if (ext === 'zip') {
|
||||
try {
|
||||
const proc2 = (0, child_process_1.spawn)('unzip', ['-p', filePath, entryPath]);
|
||||
const proc2 = (0, child_process_1.spawn)('unzip', [
|
||||
'-p',
|
||||
filePath,
|
||||
entryPath,
|
||||
]);
|
||||
const stream2 = await waitForStreamOrError(proc2);
|
||||
if (stream2)
|
||||
return stream2;
|
||||
|
||||
5
backend/dist/src/services/datVerifier.js
vendored
5
backend/dist/src/services/datVerifier.js
vendored
@@ -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
|
||||
|
||||
25
backend/dist/src/services/igdbClient.js
vendored
25
backend/dist/src/services/igdbClient.js
vendored
@@ -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)
|
||||
|
||||
2
backend/dist/src/services/importService.js
vendored
2
backend/dist/src/services/importService.js
vendored
@@ -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');
|
||||
|
||||
109
backend/dist/src/services/metadataService.js
vendored
109
backend/dist/src/services/metadataService.js
vendored
@@ -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
|
||||
|
||||
49
backend/dist/src/services/rawgClient.js
vendored
49
backend/dist/src/services/rawgClient.js
vendored
@@ -18,18 +18,30 @@ async function searchGames(query) {
|
||||
const res = await (0, undici_1.fetch)(url);
|
||||
if (!res.ok)
|
||||
return [];
|
||||
const json = await res.json();
|
||||
const json = (await res.json());
|
||||
const hits = Array.isArray(json.results) ? json.results : [];
|
||||
return hits.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
releaseDate: r.released,
|
||||
genres: Array.isArray(r.genres) ? r.genres.map((g) => g.name) : undefined,
|
||||
platforms: r.platforms,
|
||||
coverUrl: r.background_image ?? undefined,
|
||||
source: 'rawg',
|
||||
}));
|
||||
return hits.map((r) => {
|
||||
const platforms = Array.isArray(r.platforms)
|
||||
? r.platforms.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
|
||||
}))
|
||||
: undefined;
|
||||
const genres = Array.isArray(r.genres)
|
||||
? r.genres.map((g) => g.name)
|
||||
: undefined;
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
releaseDate: r.released,
|
||||
genres,
|
||||
platforms,
|
||||
coverUrl: r.background_image ?? undefined,
|
||||
source: 'rawg',
|
||||
};
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -46,15 +58,26 @@ async function getGameById(id) {
|
||||
const res = await (0, undici_1.fetch)(url);
|
||||
if (!res.ok)
|
||||
return null;
|
||||
const json = await res.json();
|
||||
const json = (await res.json());
|
||||
if (!json)
|
||||
return null;
|
||||
const platforms = Array.isArray(json.platforms)
|
||||
? json.platforms.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
|
||||
}))
|
||||
: undefined;
|
||||
const genres = Array.isArray(json.genres)
|
||||
? json.genres.map((g) => g.name)
|
||||
: undefined;
|
||||
return {
|
||||
id: json.id,
|
||||
name: json.name,
|
||||
slug: json.slug,
|
||||
releaseDate: json.released,
|
||||
genres: Array.isArray(json.genres) ? json.genres.map((g) => g.name) : undefined,
|
||||
genres,
|
||||
platforms,
|
||||
coverUrl: json.background_image ?? undefined,
|
||||
source: 'rawg',
|
||||
};
|
||||
|
||||
32
backend/dist/src/services/thegamesdbClient.js
vendored
32
backend/dist/src/services/thegamesdbClient.js
vendored
@@ -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
7
backend/dist/src/types/index.js
vendored
Normal 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 });
|
||||
126
backend/dist/tests/routes/games.spec.js
vendored
126
backend/dist/tests/routes/games.spec.js
vendored
@@ -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:
|
||||
|
||||
54
backend/dist/tests/routes/metadata.spec.js
vendored
54
backend/dist/tests/routes/metadata.spec.js
vendored
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,9 @@ const vitest_1 = require("vitest");
|
||||
vitest_1.vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vitest_1.vi.fn() }));
|
||||
const fsScanner_1 = __importDefault(require("../../src/services/fsScanner"));
|
||||
const archiveReader_1 = require("../../src/services/archiveReader");
|
||||
(0, vitest_1.afterEach)(() => vitest_1.vi.restoreAllMocks());
|
||||
(0, vitest_1.afterEach)(() => {
|
||||
vitest_1.vi.restoreAllMocks();
|
||||
});
|
||||
(0, vitest_1.it)('expone entradas internas de archivos como items virtuales', async () => {
|
||||
const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'fsScanner-test-'));
|
||||
const collectionFile = path_1.default.join(tmpDir, 'collection.zip');
|
||||
@@ -22,11 +24,13 @@ const archiveReader_1 = require("../../src/services/archiveReader");
|
||||
const expectedPath = `${collectionFile}::inner/rom1.bin`;
|
||||
const found = results.find((r) => r.path === expectedPath);
|
||||
(0, vitest_1.expect)(found).toBeDefined();
|
||||
(0, vitest_1.expect)(found.isArchiveEntry).toBe(true);
|
||||
(0, vitest_1.expect)(found.containerPath).toBe(collectionFile);
|
||||
(0, vitest_1.expect)(found.entryPath).toBe('inner/rom1.bin');
|
||||
(0, vitest_1.expect)(found.filename).toBe('rom1.bin');
|
||||
(0, vitest_1.expect)(found.format).toBe('bin');
|
||||
if (found) {
|
||||
(0, vitest_1.expect)(found.isArchiveEntry).toBe(true);
|
||||
(0, vitest_1.expect)(found.containerPath).toBe(collectionFile);
|
||||
(0, vitest_1.expect)(found.entryPath).toBe('inner/rom1.bin');
|
||||
(0, vitest_1.expect)(found.filename).toBe('rom1.bin');
|
||||
(0, vitest_1.expect)(found.format).toBe('bin');
|
||||
}
|
||||
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
(0, vitest_1.it)('ignora entradas con traversal o paths absolutos', async () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
178
backend/dist/tests/services/metadataService.spec.js
vendored
178
backend/dist/tests/services/metadataService.spec.js
vendored
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@ vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vi.fn()
|
||||
import scanDirectory from '../../src/services/fsScanner';
|
||||
import { listArchiveEntries } from '../../src/services/archiveReader';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('expone entradas internas de archivos como items virtuales', async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fsScanner-test-'));
|
||||
@@ -26,11 +28,13 @@ it('expone entradas internas de archivos como items virtuales', async () => {
|
||||
const found = results.find((r: any) => r.path === expectedPath);
|
||||
|
||||
expect(found).toBeDefined();
|
||||
expect(found.isArchiveEntry).toBe(true);
|
||||
expect(found.containerPath).toBe(collectionFile);
|
||||
expect(found.entryPath).toBe('inner/rom1.bin');
|
||||
expect(found.filename).toBe('rom1.bin');
|
||||
expect(found.format).toBe('bin');
|
||||
if (found) {
|
||||
expect(found.isArchiveEntry).toBe(true);
|
||||
expect(found.containerPath).toBe(collectionFile);
|
||||
expect(found.entryPath).toBe('inner/rom1.bin');
|
||||
expect(found.filename).toBe('rom1.bin');
|
||||
expect(found.format).toBe('bin');
|
||||
}
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
330
docs/02-tecnico/game-search.md
Normal file
330
docs/02-tecnico/game-search.md
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/src/__tests__/setup.ts
Normal file
7
frontend/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
128
frontend/src/app/games/add/page.tsx
Normal file
128
frontend/src/app/games/add/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)]"
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
<span className="mono text-xs tracking-wider ml-2">NUEVO JUEGO</span>
|
||||
</Button>
|
||||
<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)]"
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
<span className="mono text-xs tracking-wider ml-2">AGREGAR PRIMER JUEGO</span>
|
||||
</Button>
|
||||
<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>
|
||||
)}
|
||||
|
||||
207
frontend/src/components/games/GamePreviewDialog.tsx
Normal file
207
frontend/src/components/games/GamePreviewDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/games/SearchForm.tsx
Normal file
157
frontend/src/components/games/SearchForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/games/SearchResults.tsx
Normal file
137
frontend/src/components/games/SearchResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
146
frontend/src/components/games/__tests__/SearchForm.spec.tsx
Normal file
146
frontend/src/components/games/__tests__/SearchForm.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
137
frontend/src/components/games/__tests__/SearchResults.spec.tsx
Normal file
137
frontend/src/components/games/__tests__/SearchResults.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
18
frontend/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
803
plans/game-search-architecture.md
Normal file
803
plans/game-search-architecture.md
Normal 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
|
||||
Reference in New Issue
Block a user