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

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

View File

@@ -6,13 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
const dotenv_1 = __importDefault(require("dotenv"));
const app_1 = require("./app");
dotenv_1.default.config();
const port = Number(process.env.PORT ?? 3000);
const port = Number(process.env.PORT ?? 3003);
const app = (0, app_1.buildApp)();
const start = async () => {
const host = '0.0.0.0';
try {
await app.listen({ port, host: '0.0.0.0' });
// eslint-disable-next-line no-console
console.log(`Server listening on http://0.0.0.0:${port}`);
await app.listen({ port, host });
console.log(`🚀 Server ready and listening on http://${host}:${port}`);
}
catch (err) {
app.log.error(err);

View File

@@ -3,6 +3,29 @@ Object.defineProperty(exports, "__esModule", { value: true });
const gamesController_1 = require("../controllers/gamesController");
const gameValidator_1 = require("../validators/gameValidator");
const zod_1 = require("zod");
// Esquema de validación para crear juego desde metadatos
const createGameFromMetadataSchema = zod_1.z.object({
metadata: zod_1.z.object({
source: zod_1.z.string(),
externalIds: zod_1.z.record(zod_1.z.number()).optional(),
name: zod_1.z.string().min(1, 'El nombre es requerido'),
slug: zod_1.z.string().optional(),
releaseDate: zod_1.z.string().optional(),
genres: zod_1.z.array(zod_1.z.string()).optional(),
coverUrl: zod_1.z.string().optional(),
}),
overrides: zod_1.z
.object({
platformId: zod_1.z.string().optional(),
description: zod_1.z.string().optional(),
priceCents: zod_1.z.number().optional(),
currency: zod_1.z.string().optional(),
store: zod_1.z.string().optional(),
date: zod_1.z.string().optional(),
condition: gameValidator_1.GameCondition,
})
.optional(),
});
async function gamesRoutes(app) {
/**
* GET /api/games
@@ -95,6 +118,46 @@ async function gamesRoutes(app) {
throw error;
}
});
/**
* POST /api/games/from-metadata
* Crear un juego a partir de metadatos de búsqueda
*/
app.post('/games/from-metadata', async (request, reply) => {
try {
// Validar entrada con Zod
const validated = createGameFromMetadataSchema.parse(request.body);
const { metadata, overrides } = validated;
// Obtener el ID externo principal basado en la fuente
const sourceId = metadata.externalIds?.[metadata.source]
? String(metadata.externalIds[metadata.source])
: undefined;
// Mapear metadatos a estructura de CreateGameInput
const gameInput = {
title: metadata.name,
description: overrides?.description,
priceCents: overrides?.priceCents,
currency: overrides?.currency || 'USD',
store: overrides?.store,
date: overrides?.date,
condition: overrides?.condition,
platformId: overrides?.platformId,
source: metadata.source,
sourceId,
};
// Crear el juego usando GamesController
const game = await gamesController_1.GamesController.createGame(gameInput);
return reply.code(201).send(game);
}
catch (error) {
if (error instanceof zod_1.ZodError) {
return reply.code(400).send({
error: 'Validación fallida',
details: error.errors,
});
}
throw error;
}
});
/**
* GET /api/games/source/:source
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)

View File

@@ -40,23 +40,25 @@ const zod_2 = require("zod");
const searchMetadataSchema = zod_1.z.object({
q: zod_1.z.string().min(1, 'El parámetro de búsqueda es requerido'),
platform: zod_1.z.string().optional(),
year: zod_1.z.coerce.number().int().min(1900).max(2100).optional(),
});
async function metadataRoutes(app) {
/**
* GET /api/metadata/search?q=query&platform=optional
* Buscar metadata de juegos
* GET /api/metadata/search?q=query&platform=optional&year=optional
* Buscar metadata de juegos en múltiples fuentes (IGDB, RAWG, TheGamesDB)
*/
app.get('/metadata/search', async (request, reply) => {
try {
// Validar parámetros de query con Zod
const validated = searchMetadataSchema.parse(request.query);
// Llamar a metadataService
const result = await metadataService.enrichGame({
// Llamar a metadataService.searchGames para obtener múltiples resultados
const results = await metadataService.searchGames({
title: validated.q,
platform: validated.platform,
year: validated.year,
});
// Si hay resultado, devolver como array; si no, devolver array vacío
return reply.code(200).send(result ? [result] : []);
// Devolver array de resultados
return reply.code(200).send(results);
}
catch (error) {
if (error instanceof zod_2.ZodError) {

View File

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

View File

@@ -9,7 +9,7 @@ function ensureArray(v) {
return Array.isArray(v) ? v : [v];
}
function normalizeHex(v) {
if (!v)
if (!v || typeof v !== 'string')
return undefined;
return v.trim().toLowerCase();
}
@@ -20,7 +20,8 @@ function parseDat(xml) {
trimValues: true,
});
const parsed = parser.parse(xml);
const datafile = parsed?.datafile ?? parsed;
const datafile = parsed?.datafile ??
parsed;
const rawGames = ensureArray(datafile?.game);
const games = rawGames.map((g) => {
// game name may be an attribute or a child node

View File

@@ -27,7 +27,7 @@ async function getToken() {
const res = await (0, undici_1.fetch)(`${AUTH_URL}?${params.toString()}`, { method: 'POST' });
if (!res.ok)
return null;
const json = await res.json();
const json = (await res.json());
const token = json.access_token;
const expires = Number(json.expires_in) || 0;
if (!token)
@@ -42,6 +42,19 @@ async function getToken() {
}
}
function mapIgdbHit(r) {
const platforms = Array.isArray(r.platforms)
? r.platforms.map((p) => ({
id: p.id,
name: p.name,
abbreviation: p.abbreviation,
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
}))
: undefined;
const genres = Array.isArray(r.genres)
? r.genres.map((g) => g.name)
: undefined;
// IGDB devuelve URLs relativas (//images.igdb.com/...), agregar protocolo https:
const coverUrl = r.cover?.url ? `https:${r.cover.url}` : undefined;
return {
id: r.id,
name: r.name,
@@ -49,9 +62,9 @@ function mapIgdbHit(r) {
releaseDate: r.first_release_date
? new Date(r.first_release_date * 1000).toISOString()
: undefined,
genres: Array.isArray(r.genres) ? r.genres : undefined,
platforms: Array.isArray(r.platforms) ? r.platforms : undefined,
coverUrl: r.cover?.url ?? undefined,
genres,
platforms,
coverUrl,
source: 'igdb',
};
}
@@ -66,7 +79,7 @@ async function searchGames(query, _platform) {
Accept: 'application/json',
'Content-Type': 'text/plain',
};
const body = `search "${query}"; fields id,name,slug,first_release_date,genres,platforms,cover; limit 10;`;
const body = `search "${query}"; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 10;`;
try {
const res = await (0, undici_1.fetch)(`${API_URL}/games`, { method: 'POST', headers, body });
if (!res.ok)
@@ -93,7 +106,7 @@ async function getGameById(id) {
Accept: 'application/json',
'Content-Type': 'text/plain',
};
const body = `where id = ${id}; fields id,name,slug,first_release_date,genres,platforms,cover; limit 1;`;
const body = `where id = ${id}; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 1;`;
try {
const res = await (0, undici_1.fetch)(`${API_URL}/games`, { method: 'POST', headers, body });
if (!res.ok)

View File

@@ -67,7 +67,7 @@ async function importDirectory(options, logger = console) {
processed++;
try {
let hashes;
if (file.isArchiveEntry) {
if (file.isArchiveEntry && file.containerPath && file.entryPath) {
const stream = await (0, archiveReader_1.streamArchiveEntry)(file.containerPath, file.entryPath, logger);
if (!stream) {
logger.warn?.({ file }, 'importDirectory: no se pudo extraer entrada del archive, saltando');

View File

@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.enrichGame = enrichGame;
exports.searchGames = searchGames;
/**
* metadataService
* - `enrichGame({ title, platform? })` -> intenta IGDB, RAWG, TheGamesDB
@@ -43,13 +44,15 @@ const rawg = __importStar(require("./rawgClient"));
const thegamesdb = __importStar(require("./thegamesdbClient"));
function normalize(hit) {
const base = {
source: hit.source ?? 'unknown',
source: hit.source ?? 'igdb',
externalIds: {},
name: hit.name,
title: hit.name,
slug: hit.slug,
releaseDate: hit.releaseDate,
genres: hit.genres,
coverUrl: hit.coverUrl,
slug: hit.slug ?? undefined,
releaseDate: hit.releaseDate ?? null,
genres: hit.genres ?? undefined,
coverUrl: hit.coverUrl ?? undefined,
platforms: hit.platforms ?? undefined,
};
if (hit.source === 'igdb' && typeof hit.id === 'number')
base.externalIds.igdb = hit.id;
@@ -90,7 +93,101 @@ async function enrichGame(opts) {
}
return null;
}
exports.default = { enrichGame };
/**
* Normaliza un nombre para deduplicación
* Convierte a lowercase y remueve caracteres especiales
*/
function normalizeName(name) {
return name
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.trim();
}
/**
* Prioridad de fuentes para deduplicación
*/
const SOURCE_PRIORITY = {
igdb: 1,
rawg: 2,
thegamesdb: 3,
};
/**
* Busca juegos en paralelo en IGDB, RAWG y TheGamesDB
* Devuelve múltiples resultados con deduplicación
*/
async function searchGames(opts) {
const { title, platform, year } = opts;
if (!title)
return [];
// Ejecutar búsquedas en paralelo
const [igdbHits, rawgHits, tgdbHits] = await Promise.allSettled([
igdb.searchGames(title, platform),
rawg.searchGames(title),
thegamesdb.searchGames(title),
]);
// Extraer resultados de las promesas resueltas
const allResults = [];
if (igdbHits.status === 'fulfilled') {
allResults.push(...igdbHits.value.map(normalize));
}
if (rawgHits.status === 'fulfilled') {
allResults.push(...rawgHits.value.map(normalize));
}
if (tgdbHits.status === 'fulfilled') {
allResults.push(...tgdbHits.value.map(normalize));
}
// Filtrar por año si se proporciona
let filteredResults = allResults;
if (year) {
filteredResults = allResults.filter((result) => {
if (!result.releaseDate)
return false;
const resultYear = new Date(result.releaseDate).getFullYear();
return resultYear === year;
});
}
// Deduplicar por nombre normalizado
const deduplicatedMap = new Map();
for (const result of filteredResults) {
const normalizedName = normalizeName(result.name);
const existing = deduplicatedMap.get(normalizedName);
if (!existing) {
// Primer resultado con este nombre
deduplicatedMap.set(normalizedName, result);
}
else {
// Ya existe, fusionar externalIds y mantener el de mayor prioridad
const existingPriority = SOURCE_PRIORITY[existing.source] ?? 999;
const currentPriority = SOURCE_PRIORITY[result.source] ?? 999;
if (currentPriority < existingPriority) {
// El resultado actual tiene mayor prioridad
// Fusionar externalIds del existente
result.externalIds = {
...result.externalIds,
...existing.externalIds,
};
deduplicatedMap.set(normalizedName, result);
}
else {
// Mantener el existente y fusionar externalIds del actual
existing.externalIds = {
...existing.externalIds,
...result.externalIds,
};
}
}
}
// Convertir el mapa a array y ordenar por prioridad de fuente
const deduplicatedResults = Array.from(deduplicatedMap.values()).sort((a, b) => {
const priorityA = SOURCE_PRIORITY[a.source] ?? 999;
const priorityB = SOURCE_PRIORITY[b.source] ?? 999;
return priorityA - priorityB;
});
// Limitar a 50 resultados totales
return deduplicatedResults.slice(0, 50);
}
exports.default = { enrichGame, searchGames };
/**
* Metadatos:
* Autor: GitHub Copilot

View File

@@ -18,18 +18,30 @@ async function searchGames(query) {
const res = await (0, undici_1.fetch)(url);
if (!res.ok)
return [];
const json = await res.json();
const json = (await res.json());
const hits = Array.isArray(json.results) ? json.results : [];
return hits.map((r) => ({
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',
};

View File

@@ -18,18 +18,30 @@ async function searchGames(query) {
const res = await (0, undici_1.fetch)(url, { headers: { 'Api-Key': key } });
if (!res.ok)
return [];
const json = await res.json();
const json = (await res.json());
const games = json?.data?.games ?? {};
const baseUrl = json?.data?.base_url?.original ?? '';
const hits = [];
for (const gid of Object.keys(games)) {
const g = games[gid];
const genres = Array.isArray(g?.game?.genres)
? g.game.genres.map((x) => x.name)
: undefined;
const platforms = Array.isArray(g?.game?.platforms)
? g.game.platforms.map((p) => ({
id: p.id,
name: p.name,
abbreviation: p.abbreviation,
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
}))
: undefined;
hits.push({
id: Number(gid),
name: g?.game?.title ?? g?.title ?? String(gid),
slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined,
genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x) => x.name) : undefined,
genres,
platforms,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
@@ -53,19 +65,31 @@ async function getGameById(id) {
const res = await (0, undici_1.fetch)(url, { headers: { 'Api-Key': key } });
if (!res.ok)
return null;
const json = await res.json();
const json = (await res.json());
const games = json?.data?.games ?? {};
const baseUrl = json?.data?.base_url?.original ?? '';
const firstKey = Object.keys(games)[0];
const g = games[firstKey];
if (!g)
return null;
const genres = Array.isArray(g?.game?.genres)
? g.game.genres.map((x) => x.name)
: undefined;
const platforms = Array.isArray(g?.game?.platforms)
? g.game.platforms.map((p) => ({
id: p.id,
name: p.name,
abbreviation: p.abbreviation,
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
}))
: undefined;
return {
id: Number(firstKey),
name: g?.game?.title ?? g?.title ?? String(firstKey),
slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined,
genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x) => x.name) : undefined,
genres,
platforms,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,

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

@@ -0,0 +1,7 @@
"use strict";
/**
* Tipos compartidos del backend
* Autor: GitHub Copilot
* Última actualización: 2026-03-18
*/
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -220,6 +220,132 @@ const prisma_1 = require("../../src/plugins/prisma");
(0, vitest_1.expect)(res.statusCode).toBe(404);
});
});
(0, vitest_1.describe)('POST /api/games/from-metadata', () => {
(0, vitest_1.it)('debería crear un juego a partir de metadatos', async () => {
const payload = {
metadata: {
source: 'igdb',
externalIds: { igdb: 1234 },
name: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover.jpg',
},
};
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload,
});
(0, vitest_1.expect)(res.statusCode).toBe(201);
const body = res.json();
(0, vitest_1.expect)(body).toHaveProperty('id');
(0, vitest_1.expect)(body.title).toBe('Super Mario Bros.');
(0, vitest_1.expect)(body.source).toBe('igdb');
(0, vitest_1.expect)(body.sourceId).toBe('1234');
});
(0, vitest_1.it)('debería crear un juego con overrides', async () => {
const platform = await prisma_1.prisma.platform.create({
data: { name: 'Nintendo Entertainment System', slug: 'nes' },
});
const payload = {
metadata: {
source: 'igdb',
externalIds: { igdb: 1234 },
name: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover.jpg',
},
overrides: {
platformId: platform.id,
description: 'Descripción personalizada',
priceCents: 10000,
currency: 'USD',
store: 'eBay',
date: '2025-01-15',
condition: 'CIB',
},
};
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload,
});
(0, vitest_1.expect)(res.statusCode).toBe(201);
const body = res.json();
(0, vitest_1.expect)(body.title).toBe('Super Mario Bros.');
(0, vitest_1.expect)(body.description).toBe('Descripción personalizada');
(0, vitest_1.expect)(body.gamePlatforms).toHaveLength(1);
(0, vitest_1.expect)(body.gamePlatforms[0].platformId).toBe(platform.id);
(0, vitest_1.expect)(body.purchases).toHaveLength(1);
(0, vitest_1.expect)(body.purchases[0].priceCents).toBe(10000);
});
(0, vitest_1.it)('debería devolver 400 si falta el campo metadata', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload: {},
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
(0, vitest_1.it)('debería devolver 400 si metadata.name está vacío', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload: {
metadata: {
source: 'igdb',
externalIds: { igdb: 1234 },
name: '',
slug: 'super-mario-bros',
},
},
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
(0, vitest_1.it)('debería usar el externalId principal como sourceId', async () => {
const payload = {
metadata: {
source: 'rawg',
externalIds: { rawg: 5678, igdb: 1234 },
name: 'Zelda',
slug: 'zelda',
},
};
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload,
});
(0, vitest_1.expect)(res.statusCode).toBe(201);
const body = res.json();
(0, vitest_1.expect)(body.source).toBe('rawg');
(0, vitest_1.expect)(body.sourceId).toBe('5678');
});
(0, vitest_1.it)('debería manejar metadata sin externalIds', async () => {
const payload = {
metadata: {
source: 'manual',
externalIds: {},
name: 'Custom Game',
slug: 'custom-game',
},
};
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload,
});
(0, vitest_1.expect)(res.statusCode).toBe(201);
const body = res.json();
(0, vitest_1.expect)(body.title).toBe('Custom Game');
(0, vitest_1.expect)(body.source).toBe('manual');
(0, vitest_1.expect)(body.sourceId).toBeNull();
});
});
});
/**
* Metadatos:

View File

@@ -47,19 +47,32 @@ const metadataService = __importStar(require("../../src/services/metadataService
vitest_1.vi.restoreAllMocks();
});
(0, vitest_1.describe)('GET /api/metadata/search', () => {
(0, vitest_1.it)('debería devolver resultados cuando se busca un juego existente', async () => {
(0, vitest_1.it)('debería devolver múltiples resultados cuando se busca un juego existente', async () => {
const mockResults = [
{
source: 'igdb',
externalIds: { igdb: 1 },
name: 'The Legend of Zelda',
title: 'The Legend of Zelda',
slug: 'the-legend-of-zelda',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'https://example.com/cover.jpg',
platforms: undefined,
},
{
source: 'rawg',
externalIds: { rawg: 2 },
name: 'The Legend of Zelda: A Link to the Past',
title: 'The Legend of Zelda: A Link to the Past',
slug: 'the-legend-of-zelda-a-link-to-the-past',
releaseDate: '1991-11-21',
genres: ['Adventure'],
coverUrl: 'https://example.com/cover2.jpg',
platforms: undefined,
},
];
vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(mockResults[0]);
vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue(mockResults);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=zelda',
@@ -68,10 +81,10 @@ const metadataService = __importStar(require("../../src/services/metadataService
const body = res.json();
(0, vitest_1.expect)(Array.isArray(body)).toBe(true);
(0, vitest_1.expect)(body.length).toBeGreaterThan(0);
(0, vitest_1.expect)(body[0].title).toContain('Zelda');
(0, vitest_1.expect)(body[0].name).toContain('Zelda');
});
(0, vitest_1.it)('debería devolver lista vacía cuando no hay resultados', async () => {
vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=nonexistentgame12345',
@@ -96,16 +109,43 @@ const metadataService = __importStar(require("../../src/services/metadataService
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
(0, vitest_1.it)('debería pasar el parámetro platform a enrichGame si se proporciona', async () => {
const enrichSpy = vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
(0, vitest_1.it)('debería pasar el parámetro platform a searchGames si se proporciona', async () => {
const searchSpy = vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=mario&platform=Nintendo%2064',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
(0, vitest_1.expect)(enrichSpy).toHaveBeenCalledWith({
(0, vitest_1.expect)(searchSpy).toHaveBeenCalledWith({
title: 'mario',
platform: 'Nintendo 64',
year: undefined,
});
});
(0, vitest_1.it)('debería pasar el parámetro year a searchGames si se proporciona', async () => {
const searchSpy = vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=mario&year=1990',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
(0, vitest_1.expect)(searchSpy).toHaveBeenCalledWith({
title: 'mario',
platform: undefined,
year: 1990,
});
});
(0, vitest_1.it)('debería pasar todos los parámetros a searchGames', async () => {
const searchSpy = vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=mario&platform=NES&year=1985',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
(0, vitest_1.expect)(searchSpy).toHaveBeenCalledWith({
title: 'mario',
platform: 'NES',
year: 1985,
});
});
});

View File

@@ -10,7 +10,9 @@ const vitest_1 = require("vitest");
vitest_1.vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vitest_1.vi.fn() }));
const fsScanner_1 = __importDefault(require("../../src/services/fsScanner"));
const archiveReader_1 = require("../../src/services/archiveReader");
(0, vitest_1.afterEach)(() => vitest_1.vi.restoreAllMocks());
(0, vitest_1.afterEach)(() => {
vitest_1.vi.restoreAllMocks();
});
(0, vitest_1.it)('expone entradas internas de archivos como items virtuales', async () => {
const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'fsScanner-test-'));
const collectionFile = path_1.default.join(tmpDir, 'collection.zip');
@@ -22,11 +24,13 @@ const archiveReader_1 = require("../../src/services/archiveReader");
const expectedPath = `${collectionFile}::inner/rom1.bin`;
const found = results.find((r) => r.path === expectedPath);
(0, vitest_1.expect)(found).toBeDefined();
(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 () => {

View File

@@ -17,11 +17,16 @@ const fsScanner_1 = require("../../src/services/fsScanner");
const archiveReader_1 = require("../../src/services/archiveReader");
const prisma_1 = __importDefault(require("../../src/plugins/prisma"));
const crypto_1 = require("crypto");
// Mock Date.now() para timestamps consistentes en tests
const FIXED_TIMESTAMP = 1234567890123;
const dateNowSpy = vitest_1.vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP);
(0, vitest_1.beforeEach)(() => {
vitest_1.vi.restoreAllMocks();
dateNowSpy.mockReturnValue(FIXED_TIMESTAMP);
});
(0, vitest_1.describe)('services/importService (archive entries)', () => {
(0, vitest_1.it)('procesa una entrada interna usando streamArchiveEntry y crea Game con source=rom', async () => {
const data = Buffer.from('import-archive-test');
const files = [
{
path: '/roms/collection.zip::inner/rom1.bin',
@@ -29,12 +34,11 @@ const crypto_1 = require("crypto");
entryPath: 'inner/rom1.bin',
filename: 'rom1.bin',
name: 'inner/rom1.bin',
size: 123,
size: data.length,
format: 'bin',
isArchiveEntry: true,
},
];
const data = Buffer.from('import-archive-test');
fsScanner_1.scanDirectory.mockResolvedValue(files);
archiveReader_1.streamArchiveEntry.mockResolvedValue(stream_1.Readable.from([data]));
prisma_1.default.game.findFirst.mockResolvedValue(null);
@@ -53,12 +57,12 @@ const crypto_1 = require("crypto");
});
(0, vitest_1.expect)(prisma_1.default.game.create.mock.calls[0][0]).toEqual({
data: {
title: 'ROM1',
slug: 'rom1-1234567890123',
title: 'rom1',
slug: vitest_1.expect.stringMatching(/^rom1-\d+$/),
source: 'rom',
romPath: '/roms/collection.zip::inner/rom1.bin',
romFilename: 'rom1.bin',
romSize: 123,
romSize: data.length,
romChecksum: md5,
romFormat: 'bin',
romHashes: vitest_1.expect.any(String),

View File

@@ -19,9 +19,13 @@ const importService_1 = require("../../src/services/importService");
const fsScanner_1 = require("../../src/services/fsScanner");
const checksumService_1 = require("../../src/services/checksumService");
const prisma_1 = __importDefault(require("../../src/plugins/prisma"));
// Mock Date.now() para timestamps consistentes en tests
const FIXED_TIMESTAMP = 1234567890123;
const dateNowSpy = vitest_1.vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP);
(0, vitest_1.describe)('services/importService', () => {
(0, vitest_1.beforeEach)(() => {
vitest_1.vi.clearAllMocks();
dateNowSpy.mockReturnValue(FIXED_TIMESTAMP);
});
(0, vitest_1.it)('exporta createSlug e importDirectory', () => {
(0, vitest_1.expect)(typeof importService_1.createSlug).toBe('function');

View File

@@ -100,4 +100,182 @@ const metadataService_1 = require("../../src/services/metadataService");
const res = await (0, metadataService_1.enrichGame)({ title: 'Juego inexistente' });
(0, vitest_1.expect)(res).toBeNull();
});
(0, vitest_1.describe)('searchGames', () => {
(0, vitest_1.it)('debería buscar en paralelo en IGDB, RAWG y TheGamesDB', async () => {
igdb.searchGames.mockResolvedValue([
{
id: 1,
name: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://igdb.com/cover.jpg',
source: 'igdb',
},
]);
rawg.searchGames.mockResolvedValue([
{
id: 2,
name: 'Super Mario Bros.',
slug: 'super-mario-bros-rawg',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://rawg.com/cover.jpg',
source: 'rawg',
},
]);
tgdb.searchGames.mockResolvedValue([
{
id: 3,
name: 'Super Mario Bros.',
slug: 'super-mario-bros-tgdb',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://tgdb.com/cover.jpg',
source: 'thegamesdb',
},
]);
const results = await (0, metadataService_1.searchGames)({ title: 'Super Mario Bros.' });
(0, vitest_1.expect)(results.length).toBeGreaterThan(0);
(0, vitest_1.expect)(igdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.', undefined);
(0, vitest_1.expect)(rawg.searchGames).toHaveBeenCalledWith('Super Mario Bros.');
(0, vitest_1.expect)(tgdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.');
});
(0, vitest_1.it)('debería deduplicar resultados por nombre normalizado', async () => {
igdb.searchGames.mockResolvedValue([
{
id: 1,
name: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://igdb.com/cover.jpg',
source: 'igdb',
},
]);
rawg.searchGames.mockResolvedValue([
{
id: 2,
name: 'Super Mario Bros.',
slug: 'super-mario-bros-rawg',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://rawg.com/cover.jpg',
source: 'rawg',
},
]);
tgdb.searchGames.mockResolvedValue([]);
const results = await (0, metadataService_1.searchGames)({ title: 'Super Mario Bros.' });
// Debería haber un solo resultado (prioridad IGDB)
(0, vitest_1.expect)(results.length).toBe(1);
(0, vitest_1.expect)(results[0].source).toBe('igdb');
(0, vitest_1.expect)(results[0].externalIds.igdb).toBe(1);
(0, vitest_1.expect)(results[0].externalIds.rawg).toBe(2);
});
(0, vitest_1.it)('debería priorizar IGDB > RAWG > TheGamesDB en deduplicación', async () => {
igdb.searchGames.mockResolvedValue([
{
id: 1,
name: 'Zelda',
slug: 'zelda',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'http://igdb.com/zelda.jpg',
source: 'igdb',
},
]);
rawg.searchGames.mockResolvedValue([
{
id: 2,
name: 'Zelda',
slug: 'zelda-rawg',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'http://rawg.com/zelda.jpg',
source: 'rawg',
},
]);
tgdb.searchGames.mockResolvedValue([
{
id: 3,
name: 'Zelda',
slug: 'zelda-tgdb',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'http://tgdb.com/zelda.jpg',
source: 'thegamesdb',
},
]);
const results = await (0, metadataService_1.searchGames)({ title: 'Zelda' });
(0, vitest_1.expect)(results.length).toBe(1);
(0, vitest_1.expect)(results[0].source).toBe('igdb');
(0, vitest_1.expect)(results[0].externalIds).toEqual({
igdb: 1,
rawg: 2,
thegamesdb: 3,
});
});
(0, vitest_1.it)('debería devolver array vacío si no hay resultados', async () => {
igdb.searchGames.mockResolvedValue([]);
rawg.searchGames.mockResolvedValue([]);
tgdb.searchGames.mockResolvedValue([]);
const results = await (0, metadataService_1.searchGames)({ title: 'Juego inexistente' });
(0, vitest_1.expect)(results).toEqual([]);
});
(0, vitest_1.it)('debería manejar errores de API y continuar con otras fuentes', async () => {
igdb.searchGames.mockRejectedValue(new Error('IGDB error'));
rawg.searchGames.mockResolvedValue([
{
id: 2,
name: 'Sonic',
slug: 'sonic',
releaseDate: '1991-06-23',
genres: ['Platform'],
coverUrl: 'http://rawg.com/sonic.jpg',
source: 'rawg',
},
]);
tgdb.searchGames.mockResolvedValue([]);
const results = await (0, metadataService_1.searchGames)({ title: 'Sonic' });
(0, vitest_1.expect)(results.length).toBe(1);
(0, vitest_1.expect)(results[0].source).toBe('rawg');
});
(0, vitest_1.it)('debería pasar el parámetro platform a IGDB', async () => {
igdb.searchGames.mockResolvedValue([]);
rawg.searchGames.mockResolvedValue([]);
tgdb.searchGames.mockResolvedValue([]);
await (0, metadataService_1.searchGames)({ title: 'Mario', platform: 'NES' });
(0, vitest_1.expect)(igdb.searchGames).toHaveBeenCalledWith('Mario', 'NES');
(0, vitest_1.expect)(rawg.searchGames).toHaveBeenCalledWith('Mario');
(0, vitest_1.expect)(tgdb.searchGames).toHaveBeenCalledWith('Mario');
});
(0, vitest_1.it)('debería mantener múltiples resultados con nombres diferentes', async () => {
igdb.searchGames.mockResolvedValue([
{
id: 1,
name: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://igdb.com/smb.jpg',
source: 'igdb',
},
{
id: 2,
name: 'Super Mario Bros. 2',
slug: 'super-mario-bros-2',
releaseDate: '1988-10-09',
genres: ['Platform'],
coverUrl: 'http://igdb.com/smb2.jpg',
source: 'igdb',
},
]);
rawg.searchGames.mockResolvedValue([]);
tgdb.searchGames.mockResolvedValue([]);
const results = await (0, metadataService_1.searchGames)({ title: 'Super Mario' });
(0, vitest_1.expect)(results.length).toBe(2);
(0, vitest_1.expect)(results[0].name).toBe('Super Mario Bros.');
(0, vitest_1.expect)(results[1].name).toBe('Super Mario Bros. 2');
});
});
});

View File

@@ -1,14 +1,39 @@
import { FastifyInstance } from 'fastify';
import { GamesController } from '../controllers/gamesController';
import { createGameSchema, updateGameSchema } from '../validators/gameValidator';
import { ZodError } from 'zod';
import { createGameSchema, updateGameSchema, GameCondition } from '../validators/gameValidator';
import { ZodError, z } from 'zod';
import type {
GamesListReplyOrError,
GameReplyOrError,
CreateGameBody,
UpdateGameBody,
CreateGameFromMetadataBody,
} from '../types';
// Esquema de validación para crear juego desde metadatos
const createGameFromMetadataSchema = z.object({
metadata: z.object({
source: z.string(),
externalIds: z.record(z.number()).optional(),
name: z.string().min(1, 'El nombre es requerido'),
slug: z.string().optional(),
releaseDate: z.string().optional(),
genres: z.array(z.string()).optional(),
coverUrl: z.string().optional(),
}),
overrides: z
.object({
platformId: z.string().optional(),
description: z.string().optional(),
priceCents: z.number().optional(),
currency: z.string().optional(),
store: z.string().optional(),
date: z.string().optional(),
condition: GameCondition,
})
.optional(),
});
async function gamesRoutes(app: FastifyInstance) {
/**
* GET /api/games
@@ -108,6 +133,52 @@ async function gamesRoutes(app: FastifyInstance) {
}
});
/**
* POST /api/games/from-metadata
* Crear un juego a partir de metadatos de búsqueda
*/
app.post<{ Body: CreateGameFromMetadataBody; Reply: GameReplyOrError }>(
'/games/from-metadata',
async (request, reply) => {
try {
// Validar entrada con Zod
const validated = createGameFromMetadataSchema.parse(request.body);
const { metadata, overrides } = validated;
// Obtener el ID externo principal basado en la fuente
const sourceId = metadata.externalIds?.[metadata.source]
? String(metadata.externalIds[metadata.source])
: undefined;
// Mapear metadatos a estructura de CreateGameInput
const gameInput: CreateGameBody = {
title: metadata.name,
description: overrides?.description,
priceCents: overrides?.priceCents,
currency: overrides?.currency || 'USD',
store: overrides?.store,
date: overrides?.date,
condition: overrides?.condition,
platformId: overrides?.platformId,
source: metadata.source,
sourceId,
};
// Crear el juego usando GamesController
const game = await GamesController.createGame(gameInput);
return reply.code(201).send(game);
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({
error: 'Validación fallida',
details: error.errors,
});
}
throw error;
}
}
);
/**
* GET /api/games/source/:source
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)

View File

@@ -12,12 +12,13 @@ import type {
const searchMetadataSchema = z.object({
q: z.string().min(1, 'El parámetro de búsqueda es requerido'),
platform: z.string().optional(),
year: z.coerce.number().int().min(1900).max(2100).optional(),
});
async function metadataRoutes(app: FastifyInstance) {
/**
* GET /api/metadata/search?q=query&platform=optional
* Buscar metadata de juegos
* GET /api/metadata/search?q=query&platform=optional&year=optional
* Buscar metadata de juegos en múltiples fuentes (IGDB, RAWG, TheGamesDB)
*/
app.get<{ Querystring: MetadataSearchQuerystring; Reply: MetadataSearchReplyOrError }>(
'/metadata/search',
@@ -26,14 +27,15 @@ async function metadataRoutes(app: FastifyInstance) {
// Validar parámetros de query con Zod
const validated = searchMetadataSchema.parse(request.query);
// Llamar a metadataService
const result = await metadataService.enrichGame({
// Llamar a metadataService.searchGames para obtener múltiples resultados
const results = await metadataService.searchGames({
title: validated.q,
platform: validated.platform,
year: validated.year,
});
// Si hay resultado, devolver como array; si no, devolver array vacío
return reply.code(200).send(result ? [result] : []);
// Devolver array de resultados
return reply.code(200).send(results);
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({

View File

@@ -56,6 +56,9 @@ function mapIgdbHit(r: IgdbGameResponse): MetadataGame {
? r.genres.map((g) => g.name)
: undefined;
// IGDB devuelve URLs relativas (//images.igdb.com/...), agregar protocolo https:
const coverUrl = r.cover?.url ? `https:${r.cover.url}` : undefined;
return {
id: r.id,
name: r.name,
@@ -65,7 +68,7 @@ function mapIgdbHit(r: IgdbGameResponse): MetadataGame {
: undefined,
genres,
platforms,
coverUrl: r.cover?.url ?? undefined,
coverUrl,
source: 'igdb',
};
}
@@ -82,7 +85,7 @@ export async function searchGames(query: string, _platform?: string): Promise<Me
'Content-Type': 'text/plain',
} as Record<string, string>;
const body = `search "${query}"; fields id,name,slug,first_release_date,genres,platforms,cover; limit 10;`;
const body = `search "${query}"; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 10;`;
try {
const res = await fetch(`${API_URL}/games`, { method: 'POST', headers, body });
@@ -109,7 +112,7 @@ export async function getGameById(id: number): Promise<MetadataGame | null> {
'Content-Type': 'text/plain',
} as Record<string, string>;
const body = `where id = ${id}; fields id,name,slug,first_release_date,genres,platforms,cover; limit 1;`;
const body = `where id = ${id}; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 1;`;
try {
const res = await fetch(`${API_URL}/games`, { method: 'POST', headers, body });

View File

@@ -13,14 +13,15 @@ function normalize(
hit: igdb.MetadataGame | rawg.MetadataGame | thegamesdb.MetadataGame
): EnrichedGame {
const base: EnrichedGame = {
source: hit.source ?? 'unknown',
source: (hit.source as 'igdb' | 'rawg' | 'thegamesdb') ?? 'igdb',
externalIds: {},
name: hit.name,
title: hit.name,
slug: hit.slug,
releaseDate: hit.releaseDate,
genres: hit.genres,
coverUrl: hit.coverUrl,
slug: hit.slug ?? undefined,
releaseDate: hit.releaseDate ?? null,
genres: hit.genres ?? undefined,
coverUrl: hit.coverUrl ?? undefined,
platforms: hit.platforms ?? undefined,
};
if (hit.source === 'igdb' && typeof hit.id === 'number') base.externalIds.igdb = hit.id;
@@ -63,7 +64,114 @@ export async function enrichGame(opts: {
return null;
}
export default { enrichGame };
/**
* Normaliza un nombre para deduplicación
* Convierte a lowercase y remueve caracteres especiales
*/
function normalizeName(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.trim();
}
/**
* Prioridad de fuentes para deduplicación
*/
const SOURCE_PRIORITY: Record<string, number> = {
igdb: 1,
rawg: 2,
thegamesdb: 3,
};
/**
* Busca juegos en paralelo en IGDB, RAWG y TheGamesDB
* Devuelve múltiples resultados con deduplicación
*/
export async function searchGames(opts: {
title: string;
platform?: string;
year?: number;
}): Promise<EnrichedGame[]> {
const { title, platform, year } = opts;
if (!title) return [];
// Ejecutar búsquedas en paralelo
const [igdbHits, rawgHits, tgdbHits] = await Promise.allSettled([
igdb.searchGames(title, platform),
rawg.searchGames(title),
thegamesdb.searchGames(title),
]);
// Extraer resultados de las promesas resueltas
const allResults: EnrichedGame[] = [];
if (igdbHits.status === 'fulfilled') {
allResults.push(...igdbHits.value.map(normalize));
}
if (rawgHits.status === 'fulfilled') {
allResults.push(...rawgHits.value.map(normalize));
}
if (tgdbHits.status === 'fulfilled') {
allResults.push(...tgdbHits.value.map(normalize));
}
// Filtrar por año si se proporciona
let filteredResults = allResults;
if (year) {
filteredResults = allResults.filter((result) => {
if (!result.releaseDate) return false;
const resultYear = new Date(result.releaseDate).getFullYear();
return resultYear === year;
});
}
// Deduplicar por nombre normalizado
const deduplicatedMap = new Map<string, EnrichedGame>();
for (const result of filteredResults) {
const normalizedName = normalizeName(result.name);
const existing = deduplicatedMap.get(normalizedName);
if (!existing) {
// Primer resultado con este nombre
deduplicatedMap.set(normalizedName, result);
} else {
// Ya existe, fusionar externalIds y mantener el de mayor prioridad
const existingPriority = SOURCE_PRIORITY[existing.source] ?? 999;
const currentPriority = SOURCE_PRIORITY[result.source] ?? 999;
if (currentPriority < existingPriority) {
// El resultado actual tiene mayor prioridad
// Fusionar externalIds del existente
result.externalIds = {
...result.externalIds,
...existing.externalIds,
};
deduplicatedMap.set(normalizedName, result);
} else {
// Mantener el existente y fusionar externalIds del actual
existing.externalIds = {
...existing.externalIds,
...result.externalIds,
};
}
}
}
// Convertir el mapa a array y ordenar por prioridad de fuente
const deduplicatedResults = Array.from(deduplicatedMap.values()).sort((a, b) => {
const priorityA = SOURCE_PRIORITY[a.source] ?? 999;
const priorityB = SOURCE_PRIORITY[b.source] ?? 999;
return priorityA - priorityB;
});
// Limitar a 50 resultados totales
return deduplicatedResults.slice(0, 50);
}
export default { enrichGame, searchGames };
/**
* Metadatos:

View File

@@ -33,12 +33,22 @@ export async function searchGames(query: string): Promise<MetadataGame[]> {
? g.game.genres.map((x) => x.name)
: undefined;
const platforms: PlatformInfo[] | undefined = Array.isArray(g?.game?.platforms)
? g.game.platforms.map((p) => ({
id: p.id,
name: p.name,
abbreviation: p.abbreviation,
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
}))
: undefined;
hits.push({
id: Number(gid),
name: g?.game?.title ?? g?.title ?? String(gid),
slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined,
genres,
platforms,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
@@ -73,12 +83,22 @@ export async function getGameById(id: number): Promise<MetadataGame | null> {
? g.game.genres.map((x) => x.name)
: undefined;
const platforms: PlatformInfo[] | undefined = Array.isArray(g?.game?.platforms)
? g.game.platforms.map((p) => ({
id: p.id,
name: p.name,
abbreviation: p.abbreviation,
slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
}))
: undefined;
return {
id: Number(firstKey),
name: g?.game?.title ?? g?.title ?? String(firstKey),
slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined,
genres,
platforms,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,

View File

@@ -5,6 +5,11 @@
*/
import { Game, Artwork, Purchase, GamePlatform, Tag } from '@prisma/client';
import { z } from 'zod';
import type { GameCondition as GameConditionEnum } from '../validators/gameValidator';
// Tipo derivado del enum GameCondition
export type GameConditionType = z.infer<typeof GameConditionEnum>;
// Tipos de respuesta de Prisma con relaciones incluidas
export type GameWithRelations = Game & {
@@ -73,6 +78,7 @@ export interface TheGamesDBGameResponse {
slug?: string;
release_date?: string;
genres?: { id: number; name: string }[];
platforms?: { id: number; name: string; abbreviation?: string }[];
images?: {
boxart?: { thumb?: string }[];
};
@@ -145,22 +151,48 @@ export interface CreateGameBody {
platformId?: string;
description?: string;
priceCents?: number;
currency?: string;
currency: string;
store?: string;
date?: string;
condition?: string;
source?: string;
condition?: GameConditionType;
source: string;
sourceId?: string;
}
export interface UpdateGameBody extends Partial<CreateGameBody> {}
export interface EnrichedGame {
source: 'igdb' | 'rawg' | 'thegamesdb';
externalIds: { igdb?: number; rawg?: number; thegamesdb?: number };
name: string; // Nombre del juego (compatible con MetadataGame)
title?: string; // Título opcional
slug: string | undefined;
releaseDate: string | null;
genres: string[] | undefined;
coverUrl: string | undefined;
platforms?: PlatformInfo[];
}
export interface CreateGameFromMetadataBody {
metadata: EnrichedGame;
overrides?: {
platformId?: string;
description?: string;
priceCents?: number;
currency?: string;
store?: string;
date?: string;
condition?: GameConditionType;
};
}
export interface MetadataSearchQuerystring {
q: string;
platform?: string;
year?: number;
}
export type MetadataSearchReply = MetadataGame[];
export type MetadataSearchReply = EnrichedGame[];
export interface ImportScanBody {
dir?: string;
@@ -179,17 +211,6 @@ export type GamesListReplyOrError = GamesListReply | ErrorResponse;
export type MetadataSearchReplyOrError = MetadataSearchReply | ErrorResponse;
// Tipos para metadataService
export interface EnrichedGame {
source: string;
externalIds: { igdb?: number; rawg?: number; thegamesdb?: number };
name: string; // Nombre del juego (compatible con MetadataGame)
title?: string; // Título opcional
slug?: string;
releaseDate?: string;
genres?: string[];
coverUrl?: string;
}
export interface EnrichGameOptions {
title: string;
platform?: string;

View File

@@ -252,6 +252,149 @@ describe('Games API', () => {
expect(res.statusCode).toBe(404);
});
});
describe('POST /api/games/from-metadata', () => {
it('debería crear un juego a partir de metadatos', async () => {
const payload = {
metadata: {
source: 'igdb',
externalIds: { igdb: 1234 },
name: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover.jpg',
},
};
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body).toHaveProperty('id');
expect(body.title).toBe('Super Mario Bros.');
expect(body.source).toBe('igdb');
expect(body.sourceId).toBe('1234');
});
it('debería crear un juego con overrides', async () => {
const platform = await prisma.platform.create({
data: { name: 'Nintendo Entertainment System', slug: 'nes' },
});
const payload = {
metadata: {
source: 'igdb',
externalIds: { igdb: 1234 },
name: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13T00:00:00.000Z',
genres: ['Platform'],
coverUrl: 'https://example.com/cover.jpg',
},
overrides: {
platformId: platform.id,
description: 'Descripción personalizada',
priceCents: 10000,
currency: 'USD',
store: 'eBay',
date: '2025-01-15',
condition: 'CIB',
},
};
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.title).toBe('Super Mario Bros.');
expect(body.description).toBe('Descripción personalizada');
expect(body.gamePlatforms).toHaveLength(1);
expect(body.gamePlatforms[0].platformId).toBe(platform.id);
expect(body.purchases).toHaveLength(1);
expect(body.purchases[0].priceCents).toBe(10000);
});
it('debería devolver 400 si falta el campo metadata', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload: {},
});
expect(res.statusCode).toBe(400);
});
it('debería devolver 400 si metadata.name está vacío', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload: {
metadata: {
source: 'igdb',
externalIds: { igdb: 1234 },
name: '',
slug: 'super-mario-bros',
},
},
});
expect(res.statusCode).toBe(400);
});
it('debería usar el externalId principal como sourceId', async () => {
const payload = {
metadata: {
source: 'rawg',
externalIds: { rawg: 5678, igdb: 1234 },
name: 'Zelda',
slug: 'zelda',
},
};
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.source).toBe('rawg');
expect(body.sourceId).toBe('5678');
});
it('debería manejar metadata sin externalIds', async () => {
const payload = {
metadata: {
source: 'manual',
externalIds: {},
name: 'Custom Game',
slug: 'custom-game',
},
};
const res = await app.inject({
method: 'POST',
url: '/api/games/from-metadata',
payload,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.title).toBe('Custom Game');
expect(body.source).toBe('manual');
expect(body.sourceId).toBeNull();
});
});
});
/**

View File

@@ -17,20 +17,33 @@ describe('Metadata API', () => {
});
describe('GET /api/metadata/search', () => {
it('debería devolver resultados cuando se busca un juego existente', async () => {
it('debería devolver múltiples resultados cuando se busca un juego existente', async () => {
const mockResults = [
{
source: 'igdb',
source: 'igdb' as const,
externalIds: { igdb: 1 },
name: 'The Legend of Zelda',
title: 'The Legend of Zelda',
slug: 'the-legend-of-zelda',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'https://example.com/cover.jpg',
platforms: undefined,
},
{
source: 'rawg' as const,
externalIds: { rawg: 2 },
name: 'The Legend of Zelda: A Link to the Past',
title: 'The Legend of Zelda: A Link to the Past',
slug: 'the-legend-of-zelda-a-link-to-the-past',
releaseDate: '1991-11-21',
genres: ['Adventure'],
coverUrl: 'https://example.com/cover2.jpg',
platforms: undefined,
},
];
vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(mockResults[0]);
vi.spyOn(metadataService, 'searchGames').mockResolvedValue(mockResults);
const res = await app.inject({
method: 'GET',
@@ -41,11 +54,11 @@ describe('Metadata API', () => {
const body = res.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThan(0);
expect(body[0].title).toContain('Zelda');
expect(body[0].name).toContain('Zelda');
});
it('debería devolver lista vacía cuando no hay resultados', async () => {
vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
@@ -77,8 +90,8 @@ describe('Metadata API', () => {
expect(res.statusCode).toBe(400);
});
it('debería pasar el parámetro platform a enrichGame si se proporciona', async () => {
const enrichSpy = vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
it('debería pasar el parámetro platform a searchGames si se proporciona', async () => {
const searchSpy = vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
@@ -86,9 +99,42 @@ describe('Metadata API', () => {
});
expect(res.statusCode).toBe(200);
expect(enrichSpy).toHaveBeenCalledWith({
expect(searchSpy).toHaveBeenCalledWith({
title: 'mario',
platform: 'Nintendo 64',
year: undefined,
});
});
it('debería pasar el parámetro year a searchGames si se proporciona', async () => {
const searchSpy = vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=mario&year=1990',
});
expect(res.statusCode).toBe(200);
expect(searchSpy).toHaveBeenCalledWith({
title: 'mario',
platform: undefined,
year: 1990,
});
});
it('debería pasar todos los parámetros a searchGames', async () => {
const searchSpy = vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=mario&platform=NES&year=1985',
});
expect(res.statusCode).toBe(200);
expect(searchSpy).toHaveBeenCalledWith({
title: 'mario',
platform: 'NES',
year: 1985,
});
});
});

View File

@@ -9,7 +9,9 @@ vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vi.fn()
import scanDirectory from '../../src/services/fsScanner';
import { listArchiveEntries } from '../../src/services/archiveReader';
afterEach(() => vi.restoreAllMocks());
afterEach(() => {
vi.restoreAllMocks();
});
it('expone entradas internas de archivos como items virtuales', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fsScanner-test-'));
@@ -26,11 +28,13 @@ it('expone entradas internas de archivos como items virtuales', async () => {
const found = results.find((r: any) => r.path === expectedPath);
expect(found).toBeDefined();
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 });
});

View File

@@ -16,12 +16,18 @@ import { streamArchiveEntry } from '../../src/services/archiveReader';
import prisma from '../../src/plugins/prisma';
import { createHash } from 'crypto';
// Mock Date.now() para timestamps consistentes en tests
const FIXED_TIMESTAMP = 1234567890123;
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP);
beforeEach(() => {
vi.restoreAllMocks();
dateNowSpy.mockReturnValue(FIXED_TIMESTAMP);
});
describe('services/importService (archive entries)', () => {
it('procesa una entrada interna usando streamArchiveEntry y crea Game con source=rom', async () => {
const data = Buffer.from('import-archive-test');
const files = [
{
path: '/roms/collection.zip::inner/rom1.bin',
@@ -29,14 +35,12 @@ describe('services/importService (archive entries)', () => {
entryPath: 'inner/rom1.bin',
filename: 'rom1.bin',
name: 'inner/rom1.bin',
size: 123,
size: data.length,
format: 'bin',
isArchiveEntry: true,
},
];
const data = Buffer.from('import-archive-test');
(scanDirectory as unknown as Mock).mockResolvedValue(files);
(streamArchiveEntry as unknown as Mock).mockResolvedValue(Readable.from([data]));
@@ -60,12 +64,12 @@ describe('services/importService (archive entries)', () => {
});
expect((prisma.game.create as unknown as Mock).mock.calls[0][0]).toEqual({
data: {
title: 'ROM1',
slug: 'rom1-1234567890123',
title: 'rom1',
slug: expect.stringMatching(/^rom1-\d+$/),
source: 'rom',
romPath: '/roms/collection.zip::inner/rom1.bin',
romFilename: 'rom1.bin',
romSize: 123,
romSize: data.length,
romChecksum: md5,
romFormat: 'bin',
romHashes: expect.any(String),

View File

@@ -20,9 +20,14 @@ import { scanDirectory } from '../../src/services/fsScanner';
import { computeHashes } from '../../src/services/checksumService';
import prisma from '../../src/plugins/prisma';
// Mock Date.now() para timestamps consistentes en tests
const FIXED_TIMESTAMP = 1234567890123;
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP);
describe('services/importService', () => {
beforeEach(() => {
vi.clearAllMocks();
dateNowSpy.mockReturnValue(FIXED_TIMESTAMP);
});
it('exporta createSlug e importDirectory', () => {

View File

@@ -18,7 +18,7 @@ vi.mock('../../src/services/thegamesdbClient', () => ({
import * as igdb from '../../src/services/igdbClient';
import * as rawg from '../../src/services/rawgClient';
import * as tgdb from '../../src/services/thegamesdbClient';
import { enrichGame } from '../../src/services/metadataService';
import { enrichGame, searchGames } from '../../src/services/metadataService';
describe('services/metadataService', () => {
beforeEach(() => {
@@ -79,4 +79,214 @@ describe('services/metadataService', () => {
const res = await enrichGame({ title: 'Juego inexistente' });
expect(res).toBeNull();
});
describe('searchGames', () => {
it('debería buscar en paralelo en IGDB, RAWG y TheGamesDB', async () => {
(igdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 1,
name: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://igdb.com/cover.jpg',
source: 'igdb',
},
]);
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 2,
name: 'Super Mario Bros.',
slug: 'super-mario-bros-rawg',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://rawg.com/cover.jpg',
source: 'rawg',
},
]);
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 3,
name: 'Super Mario Bros.',
slug: 'super-mario-bros-tgdb',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://tgdb.com/cover.jpg',
source: 'thegamesdb',
},
]);
const results = await searchGames({ title: 'Super Mario Bros.' });
expect(results.length).toBeGreaterThan(0);
expect(igdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.', undefined);
expect(rawg.searchGames).toHaveBeenCalledWith('Super Mario Bros.');
expect(tgdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.');
});
it('debería deduplicar resultados por nombre normalizado', async () => {
(igdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 1,
name: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://igdb.com/cover.jpg',
source: 'igdb',
},
]);
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 2,
name: 'Super Mario Bros.',
slug: 'super-mario-bros-rawg',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://rawg.com/cover.jpg',
source: 'rawg',
},
]);
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const results = await searchGames({ title: 'Super Mario Bros.' });
// Debería haber un solo resultado (prioridad IGDB)
expect(results.length).toBe(1);
expect(results[0].source).toBe('igdb');
expect(results[0].externalIds.igdb).toBe(1);
expect(results[0].externalIds.rawg).toBe(2);
});
it('debería priorizar IGDB > RAWG > TheGamesDB en deduplicación', async () => {
(igdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 1,
name: 'Zelda',
slug: 'zelda',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'http://igdb.com/zelda.jpg',
source: 'igdb',
},
]);
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 2,
name: 'Zelda',
slug: 'zelda-rawg',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'http://rawg.com/zelda.jpg',
source: 'rawg',
},
]);
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 3,
name: 'Zelda',
slug: 'zelda-tgdb',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'http://tgdb.com/zelda.jpg',
source: 'thegamesdb',
},
]);
const results = await searchGames({ title: 'Zelda' });
expect(results.length).toBe(1);
expect(results[0].source).toBe('igdb');
expect(results[0].externalIds).toEqual({
igdb: 1,
rawg: 2,
thegamesdb: 3,
});
});
it('debería devolver array vacío si no hay resultados', async () => {
(igdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const results = await searchGames({ title: 'Juego inexistente' });
expect(results).toEqual([]);
});
it('debería manejar errores de API y continuar con otras fuentes', async () => {
(igdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('IGDB error')
);
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 2,
name: 'Sonic',
slug: 'sonic',
releaseDate: '1991-06-23',
genres: ['Platform'],
coverUrl: 'http://rawg.com/sonic.jpg',
source: 'rawg',
},
]);
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const results = await searchGames({ title: 'Sonic' });
expect(results.length).toBe(1);
expect(results[0].source).toBe('rawg');
});
it('debería pasar el parámetro platform a IGDB', async () => {
(igdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
await searchGames({ title: 'Mario', platform: 'NES' });
expect(igdb.searchGames).toHaveBeenCalledWith('Mario', 'NES');
expect(rawg.searchGames).toHaveBeenCalledWith('Mario');
expect(tgdb.searchGames).toHaveBeenCalledWith('Mario');
});
it('debería mantener múltiples resultados con nombres diferentes', async () => {
(igdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: 1,
name: 'Super Mario Bros.',
slug: 'super-mario-bros',
releaseDate: '1985-09-13',
genres: ['Platform'],
coverUrl: 'http://igdb.com/smb.jpg',
source: 'igdb',
},
{
id: 2,
name: 'Super Mario Bros. 2',
slug: 'super-mario-bros-2',
releaseDate: '1988-10-09',
genres: ['Platform'],
coverUrl: 'http://igdb.com/smb2.jpg',
source: 'igdb',
},
]);
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const results = await searchGames({ title: 'Super Mario' });
expect(results.length).toBe(2);
expect(results[0].name).toBe('Super Mario Bros.');
expect(results[1].name).toBe('Super Mario Bros. 2');
});
});
});