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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user