feat: add UI components for alert dialog, badge, checkbox, dialog, label, select, sheet, table, textarea
Some checks failed
CI / lint (push) Failing after 1m5s
CI / test-backend (push) Has been skipped
CI / test-frontend (push) Has been skipped
CI / test-e2e (push) Has been skipped

- Implemented AlertDialog component with overlay, content, header, footer, title, description, action, and cancel functionalities.
- Created Badge component with variant support for different styles.
- Developed Checkbox component with custom styling and indicator.
- Added Dialog component with trigger, close, overlay, content, header, footer, title, and description.
- Introduced Label component for form elements.
- Built Select component with trigger, content, group, item, label, separator, and scroll buttons.
- Created Sheet component with trigger, close, overlay, content, header, footer, title, and description.
- Implemented Table component with header, body, footer, row, head, cell, and caption.
- Added Textarea component with custom styling.
- Established API service for game management with CRUD operations and metadata search functionalities.
- Updated dependencies in package lock files.
This commit is contained in:
2026-03-18 19:21:36 +01:00
parent b92cc19137
commit a07096d7c7
95 changed files with 8176 additions and 615 deletions

32
backend/dist/src/app.js vendored Normal file
View File

@@ -0,0 +1,32 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildApp = buildApp;
const fastify_1 = __importDefault(require("fastify"));
const cors_1 = __importDefault(require("@fastify/cors"));
const helmet_1 = __importDefault(require("@fastify/helmet"));
const rate_limit_1 = __importDefault(require("@fastify/rate-limit"));
const health_1 = __importDefault(require("./routes/health"));
const import_1 = __importDefault(require("./routes/import"));
const games_1 = __importDefault(require("./routes/games"));
const metadata_1 = __importDefault(require("./routes/metadata"));
function buildApp() {
const app = (0, fastify_1.default)({
logger: false,
});
void app.register(cors_1.default, { origin: true });
void app.register(helmet_1.default);
void app.register(rate_limit_1.default, { max: 1000, timeWindow: '1 minute' });
void app.register(health_1.default, { prefix: '/api' });
void app.register(import_1.default, { prefix: '/api' });
void app.register(games_1.default, { prefix: '/api' });
void app.register(metadata_1.default, { prefix: '/api' });
return app;
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-07
*/

10
backend/dist/src/config.js vendored Normal file
View File

@@ -0,0 +1,10 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.IMPORT_CONCURRENCY = void 0;
const os_1 = __importDefault(require("os"));
const envVal = Number.parseInt(process.env.IMPORT_CONCURRENCY ?? '', 10);
exports.IMPORT_CONCURRENCY = Number.isFinite(envVal) && envVal > 0 ? envVal : Math.min(8, Math.max(1, os_1.default.cpus().length - 1));
exports.default = exports.IMPORT_CONCURRENCY;

View File

@@ -0,0 +1,212 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GamesController = void 0;
const prisma_1 = require("../plugins/prisma");
class GamesController {
/**
* Listar todos los juegos con sus plataformas y compras
*/
static async listGames() {
return await prisma_1.prisma.game.findMany({
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
orderBy: {
title: 'asc',
},
});
}
/**
* Obtener un juego por ID
*/
static async getGameById(id) {
const game = await prisma_1.prisma.game.findUnique({
where: { id },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
artworks: true,
tags: true,
},
});
if (!game) {
throw new Error('Juego no encontrado');
}
return game;
}
/**
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/
static async listGamesBySource(source) {
return await prisma_1.prisma.game.findMany({
where: { source },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
orderBy: {
title: 'asc',
},
});
}
/**
* Crear un juego nuevo
*/
static async createGame(input) {
const { title, platformId, description, priceCents, currency, store, date, condition, source, sourceId, } = input;
// Generar slug basado en el título
const slug = title
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '');
const gameData = {
title,
slug: `${slug}-${Date.now()}`, // Hacer slug único agregando timestamp
description: description || null,
source: source || 'manual',
sourceId: sourceId || null,
};
// Si se proporciona una plataforma, crearla en gamePlatforms
if (platformId) {
gameData.gamePlatforms = {
create: {
platformId,
},
};
}
// Si se proporciona precio, crear en purchases
if (priceCents) {
gameData.purchases = {
create: {
priceCents,
currency: currency || 'USD',
store: store || null,
date: date ? new Date(date) : new Date(),
},
};
}
return await prisma_1.prisma.game.create({
data: gameData,
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
});
}
/**
* Actualizar un juego existente
*/
static async updateGame(id, input) {
const { title, platformId, description, priceCents, currency, store, date, source, sourceId } = input;
const updateData = {};
if (title !== undefined) {
updateData.title = title;
// Regenerar slug si cambia el título
const slug = title
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '');
updateData.slug = `${slug}-${Date.now()}`;
}
if (description !== undefined) {
updateData.description = description;
}
if (source !== undefined) {
updateData.source = source;
}
if (sourceId !== undefined) {
updateData.sourceId = sourceId;
}
const game = await prisma_1.prisma.game.update({
where: { id },
data: updateData,
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
});
// Si se actualiza plataforma, sincronizar
if (platformId !== undefined) {
// Eliminar relaciones antiguas
await prisma_1.prisma.gamePlatform.deleteMany({
where: { gameId: id },
});
// Crear nueva relación si se proporcionó platformId
if (platformId) {
await prisma_1.prisma.gamePlatform.create({
data: {
gameId: id,
platformId,
},
});
}
}
// Si se actualiza precio, agregar nueva compra (crear histórico)
if (priceCents !== undefined) {
await prisma_1.prisma.purchase.create({
data: {
gameId: id,
priceCents,
currency: currency || 'USD',
store: store || null,
date: date ? new Date(date) : new Date(),
},
});
}
// Retornar el juego actualizado
return await prisma_1.prisma.game.findUniqueOrThrow({
where: { id },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
});
}
/**
* Eliminar un juego (y sus relaciones en cascada)
*/
static async deleteGame(id) {
// Validar que el juego existe
const game = await prisma_1.prisma.game.findUnique({ where: { id } });
if (!game) {
throw new Error('Juego no encontrado');
}
// Eliminar todas las relaciones (Prisma maneja cascada según schema)
await prisma_1.prisma.game.delete({
where: { id },
});
return { message: 'Juego eliminado correctamente' };
}
}
exports.GamesController = GamesController;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-03-18
* Actualizado para soportar fuente (source) en juegos
*/

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

@@ -0,0 +1,30 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
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 app = (0, app_1.buildApp)();
const start = async () => {
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}`);
}
catch (err) {
app.log.error(err);
process.exit(1);
}
};
// Start only when run directly (avoids starting during tests)
if (require.main === module) {
start();
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-07
*/

116
backend/dist/src/jobs/importRunner.js vendored Normal file
View File

@@ -0,0 +1,116 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.runner = exports.ImportRunner = void 0;
const config_1 = require("../config");
class ImportRunner {
constructor(concurrency) {
this.queue = [];
this.runningCount = 0;
this.completedCount = 0;
this.isRunning = false;
this.stopped = false;
this.concurrency = Math.max(1, concurrency ?? config_1.IMPORT_CONCURRENCY);
}
start() {
if (this.isRunning)
return;
this.isRunning = true;
this.stopped = false;
this._processQueue();
}
async stopAndWait() {
this.stop();
// wait until any running tasks finish
while (this.runningCount > 0) {
await new Promise((res) => setImmediate(res));
}
}
stop() {
if (this.stopped)
return;
this.isRunning = false;
this.stopped = true;
// reject and count all pending tasks (schedule rejection to avoid unhandled rejections)
while (this.queue.length > 0) {
const task = this.queue.shift();
this.completedCount++;
// attach a noop catch so Node doesn't treat the rejection as unhandled
if (task.promise) {
task.promise.catch(() => { });
}
setImmediate(() => {
try {
task.reject(new Error('ImportRunner stopped'));
}
catch (e) {
// noop
}
});
}
}
enqueue(fn) {
if (this.stopped) {
return Promise.reject(new Error('ImportRunner stopped'));
}
let resolveFn;
let rejectFn;
const p = new Promise((res, rej) => {
resolveFn = res;
rejectFn = rej;
});
this.queue.push({ fn, resolve: resolveFn, reject: rejectFn, promise: p });
// start or continue processing immediately so the first task begins right away
if (!this.isRunning) {
this.start();
}
else {
this._processQueue();
}
return p;
}
getStatus() {
return {
queued: this.queue.length,
running: this.runningCount,
completed: this.completedCount,
concurrency: this.concurrency,
};
}
_processQueue() {
if (!this.isRunning)
return;
while (this.runningCount < this.concurrency && this.queue.length > 0) {
const task = this.queue.shift();
const result = Promise.resolve().then(() => task.fn());
this.runningCount++;
result
.then((res) => {
this.runningCount--;
this.completedCount++;
try {
task.resolve(res);
}
catch (e) {
// noop
}
setImmediate(() => this._processQueue());
})
.catch((err) => {
this.runningCount--;
this.completedCount++;
console.error(err);
try {
task.reject(err);
}
catch (e) {
// noop
}
setImmediate(() => this._processQueue());
});
}
}
}
exports.ImportRunner = ImportRunner;
exports.runner = new ImportRunner();
exports.runner.start();
exports.default = exports.runner;

View File

@@ -0,0 +1,19 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectFormat = detectFormat;
const path_1 = __importDefault(require("path"));
function detectFormat(filename) {
const ext = path_1.default.extname(filename || '').toLowerCase();
if (!ext)
return 'bin';
const map = {
'.zip': 'zip',
'.7z': '7z',
'.chd': 'chd',
};
return map[ext] ?? ext.replace(/^\./, '');
}
exports.default = detectFormat;

12
backend/dist/src/plugins/prisma.js vendored Normal file
View File

@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.prisma = void 0;
const client_1 = require("@prisma/client");
const prisma = new client_1.PrismaClient();
exports.prisma = prisma;
exports.default = prisma;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-07
*/

113
backend/dist/src/routes/games.js vendored Normal file
View File

@@ -0,0 +1,113 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const gamesController_1 = require("../controllers/gamesController");
const gameValidator_1 = require("../validators/gameValidator");
const zod_1 = require("zod");
async function gamesRoutes(app) {
/**
* GET /api/games
* Listar todos los juegos
*/
app.get('/games', async (request, reply) => {
const games = await gamesController_1.GamesController.listGames();
return reply.code(200).send(games);
});
/**
* GET /api/games/:id
* Obtener un juego por ID
*/
app.get('/games/:id', async (request, reply) => {
try {
const game = await gamesController_1.GamesController.getGameById(request.params.id);
return reply.code(200).send(game);
}
catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'Juego no encontrado',
});
}
throw error;
}
});
/**
* POST /api/games
* Crear un nuevo juego
*/
app.post('/games', async (request, reply) => {
try {
// Validar entrada con Zod
const validated = gameValidator_1.createGameSchema.parse(request.body);
const game = await gamesController_1.GamesController.createGame(validated);
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;
}
});
/**
* PUT /api/games/:id
* Actualizar un juego existente
*/
app.put('/games/:id', async (request, reply) => {
try {
// Validar entrada con Zod
const validated = gameValidator_1.updateGameSchema.parse(request.body);
const game = await gamesController_1.GamesController.updateGame(request.params.id, validated);
return reply.code(200).send(game);
}
catch (error) {
if (error instanceof zod_1.ZodError) {
return reply.code(400).send({
error: 'Validación fallida',
details: error.errors,
});
}
if (error instanceof Error && error.message.includes('not found')) {
return reply.code(404).send({
error: 'Juego no encontrado',
});
}
throw error;
}
});
/**
* DELETE /api/games/:id
* Eliminar un juego
*/
app.delete('/games/:id', async (request, reply) => {
try {
await gamesController_1.GamesController.deleteGame(request.params.id);
return reply.code(204).send();
}
catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'Juego no encontrado',
});
}
throw error;
}
});
/**
* GET /api/games/source/:source
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/
app.get('/games/source/:source', async (request, reply) => {
const games = await gamesController_1.GamesController.listGamesBySource(request.params.source);
return reply.code(200).send(games);
});
}
exports.default = gamesRoutes;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-03-18
* Actualizado para soportar fuente (source) en juegos
*/

11
backend/dist/src/routes/health.js vendored Normal file
View File

@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = healthRoutes;
async function healthRoutes(app) {
app.get('/health', async () => ({ status: 'ok' }));
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-07
*/

24
backend/dist/src/routes/import.js vendored Normal file
View File

@@ -0,0 +1,24 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = importRoutes;
const importRunner_1 = require("../jobs/importRunner");
const importService_1 = require("../services/importService");
async function importRoutes(app) {
app.post('/import/scan', async (request, reply) => {
const body = request.body;
// Encolar el job en background
setImmediate(() => {
importRunner_1.runner
.enqueue(async () => {
// no await here; background task. Pasamos el logger de Fastify para
// que los mensajes de advertencia se integren con el sistema de logs.
return (0, importService_1.importDirectory)({ dir: body?.dir, persist: body?.persist }, app.log);
})
.catch((err) => {
app.log.warn({ err }, 'Background import task failed');
});
});
// Responder inmediatamente
reply.code(202).send({ status: 'queued' });
});
}

77
backend/dist/src/routes/metadata.js vendored Normal file
View File

@@ -0,0 +1,77 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const metadataService = __importStar(require("../services/metadataService"));
const zod_1 = require("zod");
const zod_2 = require("zod");
// Esquema de validación para parámetros de búsqueda
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(),
});
async function metadataRoutes(app) {
/**
* GET /api/metadata/search?q=query&platform=optional
* Buscar metadata de juegos
*/
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({
title: validated.q,
platform: validated.platform,
});
// Si hay resultado, devolver como array; si no, devolver array vacío
return reply.code(200).send(result ? [result] : []);
}
catch (error) {
if (error instanceof zod_2.ZodError) {
return reply.code(400).send({
error: 'Parámetros de búsqueda inválidos',
details: error.errors,
});
}
throw error;
}
});
}
exports.default = metadataRoutes;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,166 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.listArchiveEntries = listArchiveEntries;
exports.streamArchiveEntry = streamArchiveEntry;
/**
* Servicio: archiveReader
*
* Lista el contenido de contenedores comunes (ZIP, 7z) sin necesidad de
* extraerlos completamente. Intenta usar la utilidad `7z` (7-Zip) con la
* opción `-slt` para obtener un listado con metadatos; si falla y el archivo
* es ZIP, intenta usar `unzip -l` como fallback.
*
* La función principal exportada es `listArchiveEntries(filePath, logger)` y
* devuelve un array de objetos `{ name, size }` con las entradas encontradas.
*
* Nota: este servicio depende de binarios del sistema (`7z`, `unzip`) cuando
* se trabaja con formatos comprimidos. En entornos de CI/producción debe
* asegurarse la presencia de dichas utilidades o los tests deben mockear
* las llamadas a `child_process.exec`.
*/
const path_1 = __importDefault(require("path"));
const child_process_1 = require("child_process");
async function listArchiveEntries(filePath, logger = console) {
const ext = path_1.default.extname(filePath).toLowerCase().replace(/^\./, '');
if (!['zip', '7z'].includes(ext))
return [];
const execCmd = (cmd) => new Promise((resolve, reject) => {
(0, child_process_1.exec)(cmd, (err, stdout, stderr) => {
if (err)
return reject(err);
resolve({ stdout: String(stdout), stderr: String(stderr) });
});
});
// Intentamos 7z -slt (salida técnica fácil de parsear)
const try7z = async () => {
const { stdout } = await execCmd(`7z l -slt ${JSON.stringify(filePath)}`);
const blocks = String(stdout).split(/\r?\n\r?\n/);
const entries = [];
for (const block of blocks) {
const lines = block.split(/\r?\n/);
const pathLine = lines.find((l) => l.startsWith('Path = '));
if (!pathLine)
continue;
const name = pathLine.replace(/^Path = /, '').trim();
const sizeLine = lines.find((l) => l.startsWith('Size = '));
const size = sizeLine ? parseInt(sizeLine.replace(/^Size = /, ''), 10) || 0 : 0;
entries.push({ name, size });
}
return entries;
};
try {
return await try7z();
}
catch (err) {
logger.warn?.({ err, filePath }, 'archiveReader: 7z failed, attempting fallback');
if (ext === 'zip') {
try {
const { stdout } = await execCmd(`unzip -l ${JSON.stringify(filePath)}`);
const lines = String(stdout).split(/\r?\n/);
const entries = [];
for (const line of lines) {
// línea típica: " 12345 path/to/file.bin"
const m = line.match(/^\s*(\d+)\s+(.+)$/);
if (m) {
const size = parseInt(m[1], 10);
const name = m[2].trim();
entries.push({ name, size });
}
}
return entries;
}
catch (err2) {
logger.warn?.({ err2, filePath }, 'archiveReader: unzip fallback failed');
return [];
}
}
return [];
}
}
async function streamArchiveEntry(filePath, entryPath, logger = console) {
const ext = path_1.default.extname(filePath).toLowerCase().replace(/^\./, '');
if (!['zip', '7z'].includes(ext))
return null;
const waitForStreamOrError = (proc) => new Promise((resolve) => {
let settled = false;
const onProcError = () => {
if (settled)
return;
settled = true;
resolve(null);
};
const onStdoutError = () => {
if (settled)
return;
settled = true;
resolve(null);
};
const onData = () => {
if (settled)
return;
settled = true;
try {
proc.removeListener('error', onProcError);
}
catch (e) { }
if (proc.stdout && proc.stdout.removeListener) {
try {
proc.stdout.removeListener('error', onStdoutError);
proc.stdout.removeListener('readable', onData);
proc.stdout.removeListener('data', onData);
}
catch (e) { }
}
resolve(proc.stdout);
};
proc.once('error', onProcError);
if (proc.stdout && proc.stdout.once) {
proc.stdout.once('error', onStdoutError);
proc.stdout.once('readable', onData);
proc.stdout.once('data', onData);
}
else {
// no stdout available
resolve(null);
}
proc.once('close', () => {
if (!settled) {
settled = true;
resolve(null);
}
});
});
// Try 7z first
try {
let proc;
try {
proc = (0, child_process_1.spawn)('7z', ['x', '-so', filePath, entryPath]);
}
catch (err) {
throw err;
}
const stream = await waitForStreamOrError(proc);
if (stream)
return stream;
}
catch (err) {
logger.warn?.({ err, filePath }, 'archiveReader: 7z spawn failed');
}
// Fallback for zip
if (ext === 'zip') {
try {
const proc2 = (0, child_process_1.spawn)('unzip', ['-p', filePath, entryPath]);
const stream2 = await waitForStreamOrError(proc2);
if (stream2)
return stream2;
}
catch (err2) {
logger.warn?.({ err2, filePath }, 'archiveReader: unzip spawn failed');
}
}
return null;
}
exports.default = { listArchiveEntries, streamArchiveEntry };

View File

@@ -0,0 +1,116 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.computeHashes = computeHashes;
exports.computeHashesFromStream = computeHashesFromStream;
/**
* Servicio: checksumService
*
* Calcula sumas y metadatos de un fichero de forma eficiente usando streams.
* Las funciones principales procesan el archivo en streaming para producir:
* - `md5` (hex)
* - `sha1` (hex)
* - `crc32` (hex, 8 caracteres)
* - `size` (bytes)
*
* `computeHashes(filePath)` devuelve un objeto con los valores anteriores y
* está pensado para usarse durante la importación/normalización de ROMs.
*/
const fs_1 = __importDefault(require("fs"));
const crypto_1 = require("crypto");
function makeCRCTable() {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
if (c & 1)
c = 0xedb88320 ^ (c >>> 1);
else
c = c >>> 1;
}
table[n] = c >>> 0;
}
return table;
}
const CRC_TABLE = makeCRCTable();
function updateCrc(crc, buf) {
let c = crc >>> 0;
for (let i = 0; i < buf.length; i++) {
c = (CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)) >>> 0;
}
return c >>> 0;
}
async function computeHashes(filePath) {
return new Promise((resolve, reject) => {
const md5 = (0, crypto_1.createHash)('md5');
const sha1 = (0, crypto_1.createHash)('sha1');
let size = 0;
let crc = 0xffffffff >>> 0;
const rs = fs_1.default.createReadStream(filePath);
rs.on('error', (err) => reject(err));
rs.on('data', (chunk) => {
md5.update(chunk);
sha1.update(chunk);
size += chunk.length;
crc = updateCrc(crc, chunk);
});
rs.on('end', () => {
const md5sum = md5.digest('hex');
const sha1sum = sha1.digest('hex');
const final = (crc ^ 0xffffffff) >>> 0;
const crcHex = final.toString(16).padStart(8, '0').toLowerCase();
resolve({ size, md5: md5sum, sha1: sha1sum, crc32: crcHex });
});
});
}
async function computeHashesFromStream(rs) {
return new Promise((resolve, reject) => {
const md5 = (0, crypto_1.createHash)('md5');
const sha1 = (0, crypto_1.createHash)('sha1');
let size = 0;
let crc = 0xffffffff >>> 0;
let settled = false;
const cleanup = () => {
try {
rs.removeListener('error', onError);
rs.removeListener('data', onData);
rs.removeListener('end', onEnd);
rs.removeListener('close', onClose);
}
catch (e) { }
};
const finalize = () => {
if (settled)
return;
settled = true;
cleanup();
const md5sum = md5.digest('hex');
const sha1sum = sha1.digest('hex');
const final = (crc ^ 0xffffffff) >>> 0;
const crcHex = final.toString(16).padStart(8, '0').toLowerCase();
resolve({ size, md5: md5sum, sha1: sha1sum, crc32: crcHex });
};
const onError = (err) => {
if (settled)
return;
settled = true;
cleanup();
reject(err);
};
const onData = (chunk) => {
md5.update(chunk);
sha1.update(chunk);
size += chunk.length;
crc = updateCrc(crc, chunk);
};
const onEnd = () => finalize();
const onClose = () => finalize();
rs.on('error', onError);
rs.on('data', onData);
rs.on('end', onEnd);
rs.on('close', onClose);
});
}
exports.default = computeHashes;

View File

@@ -0,0 +1,72 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.parseDat = parseDat;
exports.verifyHashesAgainstDat = verifyHashesAgainstDat;
const fast_xml_parser_1 = require("fast-xml-parser");
function ensureArray(v) {
if (v === undefined || v === null)
return [];
return Array.isArray(v) ? v : [v];
}
function normalizeHex(v) {
if (!v)
return undefined;
return v.trim().toLowerCase();
}
function parseDat(xml) {
const parser = new fast_xml_parser_1.XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
trimValues: true,
});
const parsed = parser.parse(xml);
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
const nameAttr = g?.name ?? g?.['@_name'] ?? g?.$?.name;
const romNodes = ensureArray(g?.rom);
const roms = romNodes.map((r) => {
const rname = r?.name ?? r?.['@_name'] ?? r?.['@name'];
const sizeRaw = r?.size ?? r?.['@_size'];
const parsedSize = sizeRaw != null ? Number(sizeRaw) : undefined;
return {
name: String(rname ?? ''),
size: typeof parsedSize === 'number' && !Number.isNaN(parsedSize) ? parsedSize : undefined,
crc: normalizeHex(r?.crc ?? r?.['@_crc'] ?? r?.CRC ?? r?.['CRC']),
md5: normalizeHex(r?.md5 ?? r?.['@_md5'] ?? r?.MD5 ?? r?.['MD5']),
sha1: normalizeHex(r?.sha1 ?? r?.['@_sha1'] ?? r?.SHA1 ?? r?.['SHA1']),
};
});
return {
name: String(nameAttr ?? ''),
roms,
};
});
return { games };
}
function verifyHashesAgainstDat(datDb, hashes) {
const cmp = {
crc: normalizeHex(hashes.crc),
md5: normalizeHex(hashes.md5),
sha1: normalizeHex(hashes.sha1),
size: hashes.size,
};
for (const g of datDb.games) {
for (const r of g.roms) {
if (cmp.crc && r.crc && cmp.crc === r.crc) {
return { gameName: g.name, romName: r.name, matchedOn: 'crc' };
}
if (cmp.md5 && r.md5 && cmp.md5 === r.md5) {
return { gameName: g.name, romName: r.name, matchedOn: 'md5' };
}
if (cmp.sha1 && r.sha1 && cmp.sha1 === r.sha1) {
return { gameName: g.name, romName: r.name, matchedOn: 'sha1' };
}
if (cmp.size !== undefined && r.size !== undefined && Number(cmp.size) === Number(r.size)) {
return { gameName: g.name, romName: r.name, matchedOn: 'size' };
}
}
}
return null;
}

96
backend/dist/src/services/fsScanner.js vendored Normal file
View File

@@ -0,0 +1,96 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.scanDirectory = scanDirectory;
/**
* Servicio: fsScanner
*
* Este módulo proporciona la función `scanDirectory` que recorre recursivamente
* un directorio (ignorando archivos y carpetas que comienzan por `.`) y devuelve
* una lista de metadatos de ficheros encontrados. Cada entrada contiene el
* `path` completo, `filename`, `name`, `size`, `format` detectado por extensión
* y `isArchive` indicando si se trata de un contenedor (zip/7z/chd).
*
* Se usa desde el importService para listar las ROMs/archivos que deben ser
* procesados/hashed y persistidos.
*/
const path_1 = __importDefault(require("path"));
const fs_1 = require("fs");
const fileTypeDetector_1 = require("../lib/fileTypeDetector");
const archiveReader_1 = require("./archiveReader");
const DEFAULT_ARCHIVE_MAX_ENTRIES = 1000;
function getArchiveMaxEntries() {
const parsed = parseInt(process.env.ARCHIVE_MAX_ENTRIES ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ARCHIVE_MAX_ENTRIES;
}
async function scanDirectory(dirPath) {
const results = [];
let archiveEntriesAdded = 0;
async function walk(dir) {
const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith('.'))
continue; // ignore dotfiles
const full = path_1.default.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full);
continue;
}
if (entry.isFile()) {
const stat = await fs_1.promises.stat(full);
const format = (0, fileTypeDetector_1.detectFormat)(entry.name);
const isArchive = ['zip', '7z', 'chd'].includes(format);
results.push({
path: full,
filename: entry.name,
name: entry.name,
size: stat.size,
format,
isArchive,
});
if (isArchive) {
try {
const entries = await (0, archiveReader_1.listArchiveEntries)(full);
const maxEntries = getArchiveMaxEntries();
for (const e of entries) {
if (archiveEntriesAdded >= maxEntries)
break;
if (!e || !e.name)
continue;
// Normalize entry path using posix rules and avoid traversal/absolute paths
const normalized = path_1.default.posix.normalize(e.name);
const parts = normalized.split('/').filter(Boolean);
if (parts.includes('..') || path_1.default.posix.isAbsolute(normalized))
continue;
results.push({
path: `${full}::${normalized}`,
containerPath: full,
entryPath: normalized,
filename: path_1.default.posix.basename(normalized),
name: normalized,
size: e.size,
format: (0, fileTypeDetector_1.detectFormat)(normalized),
isArchive: false,
isArchiveEntry: true,
});
archiveEntriesAdded++;
}
}
catch (err) {
// log for diagnostics but continue
try {
// eslint-disable-next-line no-console
console.debug('fsScanner: listArchiveEntries failed for', full, err);
}
catch (e) { }
}
}
}
}
}
await walk(dirPath);
return results;
}
exports.default = scanDirectory;

116
backend/dist/src/services/igdbClient.js vendored Normal file
View File

@@ -0,0 +1,116 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.searchGames = searchGames;
exports.getGameById = getGameById;
/**
* Cliente IGDB (Twitch OAuth)
* - `searchGames(query, platform?)`
* - `getGameById(id)`
*/
const undici_1 = require("undici");
const AUTH_URL = 'https://id.twitch.tv/oauth2/token';
const API_URL = 'https://api.igdb.com/v4';
let cachedToken = null;
async function getToken() {
if (cachedToken && Date.now() < cachedToken.expiresAt)
return cachedToken.token;
const clientId = process.env.IGDB_CLIENT_ID || process.env.TWITCH_CLIENT_ID;
const clientSecret = process.env.IGDB_CLIENT_SECRET || process.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret)
return null;
try {
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'client_credentials',
});
const res = await (0, undici_1.fetch)(`${AUTH_URL}?${params.toString()}`, { method: 'POST' });
if (!res.ok)
return null;
const json = await res.json();
const token = json.access_token;
const expires = Number(json.expires_in) || 0;
if (!token)
return null;
cachedToken = { token, expiresAt: Date.now() + Math.max(0, expires - 60) * 1000 };
return token;
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('igdbClient.getToken error', err);
return null;
}
}
function mapIgdbHit(r) {
return {
id: r.id,
name: r.name,
slug: r.slug,
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,
source: 'igdb',
};
}
async function searchGames(query, _platform) {
const clientId = process.env.IGDB_CLIENT_ID || process.env.TWITCH_CLIENT_ID;
const token = await getToken();
if (!clientId || !token)
return [];
const headers = {
'Client-ID': clientId,
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'Content-Type': 'text/plain',
};
const body = `search "${query}"; fields id,name,slug,first_release_date,genres,platforms,cover; limit 10;`;
try {
const res = await (0, undici_1.fetch)(`${API_URL}/games`, { method: 'POST', headers, body });
if (!res.ok)
return [];
const json = await res.json();
if (!Array.isArray(json))
return [];
return json.map(mapIgdbHit);
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('igdbClient.searchGames error', err);
return [];
}
}
async function getGameById(id) {
const clientId = process.env.IGDB_CLIENT_ID || process.env.TWITCH_CLIENT_ID;
const token = await getToken();
if (!clientId || !token)
return null;
const headers = {
'Client-ID': clientId,
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'Content-Type': 'text/plain',
};
const body = `where id = ${id}; fields id,name,slug,first_release_date,genres,platforms,cover; limit 1;`;
try {
const res = await (0, undici_1.fetch)(`${API_URL}/games`, { method: 'POST', headers, body });
if (!res.ok)
return null;
const json = await res.json();
if (!Array.isArray(json) || json.length === 0)
return null;
return mapIgdbHit(json[0]);
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('igdbClient.getGameById error', err);
return null;
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,132 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createSlug = createSlug;
exports.importDirectory = importDirectory;
/**
* Servicio: importService
*
* Orquesta el proceso de importación de juegos desde un directorio:
* 1. Lista archivos usando `scanDirectory`.
* 2. Calcula hashes y tamaño con `computeHashes` (streaming).
* 3. Normaliza el nombre a un `slug` y, si `persist` es true, crea/obtiene
* el `Game` correspondiente con source="rom".
*
* `importDirectory` devuelve un resumen con contadores `{ processed, createdCount, upserted }`.
*/
const path_1 = __importDefault(require("path"));
const fs_1 = require("fs");
const fsScanner_1 = require("./fsScanner");
const checksumService_1 = require("./checksumService");
const archiveReader_1 = require("./archiveReader");
const prisma_1 = __importDefault(require("../plugins/prisma"));
/**
* Crea un `slug` a partir de un nombre legible. Usado para generar slugs
* por defecto al crear entradas `Game` cuando no existe la coincidencia.
*/
function createSlug(name) {
return name
.toString()
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
async function importDirectory(options, logger = console) {
const providedDir = options?.dir;
const dir = providedDir ?? process.env.ROMS_PATH ?? path_1.default.join(process.cwd(), 'roms');
const persist = options?.persist !== undefined ? options.persist : true;
// Si no se pasó explícitamente la ruta, validamos que exista y sea un directorio
if (!providedDir) {
try {
const stat = await fs_1.promises.stat(dir);
if (!stat.isDirectory()) {
logger.warn?.({ dir }, 'importDirectory: ruta no es un directorio');
return { processed: 0, createdCount: 0, upserted: 0 };
}
}
catch (err) {
logger.warn?.({ err, dir }, 'importDirectory: ruta no accesible, abortando import');
return { processed: 0, createdCount: 0, upserted: 0 };
}
}
let files = [];
try {
files = await (0, fsScanner_1.scanDirectory)(dir);
}
catch (err) {
logger.warn?.({ err, dir }, 'importDirectory: error listando directorio');
return { processed: 0, createdCount: 0, upserted: 0 };
}
let processed = 0;
let createdCount = 0;
let upserted = 0;
for (const file of files) {
processed++;
try {
let hashes;
if (file.isArchiveEntry) {
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');
continue;
}
hashes = await (0, checksumService_1.computeHashesFromStream)(stream);
}
else {
hashes = await (0, checksumService_1.computeHashes)(file.path);
}
const checksum = hashes.md5;
const size = hashes.size;
const baseName = path_1.default.parse(file.filename).name;
const slug = createSlug(baseName);
if (persist) {
// Buscar si ya existe un juego con este checksum (source=rom)
let game = await prisma_1.default.game.findFirst({
where: {
source: 'rom',
romChecksum: checksum,
},
});
if (!game) {
// Crear nuevo juego con source="rom"
game = await prisma_1.default.game.create({
data: {
title: baseName,
slug: `${slug}-${Date.now()}`,
source: 'rom',
romPath: file.path,
romFilename: file.filename,
romSize: size,
romChecksum: checksum,
romFormat: file.format,
romHashes: JSON.stringify(hashes),
addedAt: new Date(),
lastSeenAt: new Date(),
},
});
createdCount++;
}
else {
// Actualizar lastSeenAt si ya existe
game = await prisma_1.default.game.update({
where: { id: game.id },
data: {
lastSeenAt: new Date(),
romHashes: JSON.stringify(hashes),
},
});
upserted++;
}
}
}
catch (err) {
logger.warn?.({ err, file }, 'importDirectory: error procesando fichero, se continúa con el siguiente');
continue;
}
}
return { processed, createdCount, upserted };
}
exports.default = importDirectory;

View File

@@ -0,0 +1,98 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.enrichGame = enrichGame;
/**
* metadataService
* - `enrichGame({ title, platform? })` -> intenta IGDB, RAWG, TheGamesDB
*/
const igdb = __importStar(require("./igdbClient"));
const rawg = __importStar(require("./rawgClient"));
const thegamesdb = __importStar(require("./thegamesdbClient"));
function normalize(hit) {
const base = {
source: hit.source ?? 'unknown',
externalIds: {},
title: hit.name,
slug: hit.slug,
releaseDate: hit.releaseDate,
genres: hit.genres,
coverUrl: hit.coverUrl,
};
if (hit.source === 'igdb' && typeof hit.id === 'number')
base.externalIds.igdb = hit.id;
if (hit.source === 'rawg' && typeof hit.id === 'number')
base.externalIds.rawg = hit.id;
if (hit.source === 'thegamesdb' && typeof hit.id === 'number')
base.externalIds.thegamesdb = hit.id;
return base;
}
async function enrichGame(opts) {
const title = opts?.title;
if (!title)
return null;
// Prefer IGDB (higher priority)
try {
const igdbHits = await igdb.searchGames(title, opts.platform);
if (igdbHits && igdbHits.length)
return normalize(igdbHits[0]);
}
catch (e) {
// ignore and continue
}
try {
const rawgHits = await rawg.searchGames(title);
if (rawgHits && rawgHits.length)
return normalize(rawgHits[0]);
}
catch (e) {
// ignore
}
try {
const tgHits = await thegamesdb.searchGames(title);
if (tgHits && tgHits.length)
return normalize(tgHits[0]);
}
catch (e) {
// ignore
}
return null;
}
exports.default = { enrichGame };
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

72
backend/dist/src/services/rawgClient.js vendored Normal file
View File

@@ -0,0 +1,72 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.searchGames = searchGames;
exports.getGameById = getGameById;
/**
* Cliente RAWG
* - `searchGames(query)`
* - `getGameById(id)`
*/
const undici_1 = require("undici");
const API_BASE = 'https://api.rawg.io/api';
async function searchGames(query) {
const key = process.env.RAWG_API_KEY;
if (!key)
return [];
try {
const url = `${API_BASE}/games?key=${encodeURIComponent(key)}&search=${encodeURIComponent(query)}&page_size=10`;
const res = await (0, undici_1.fetch)(url);
if (!res.ok)
return [];
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',
}));
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('rawgClient.searchGames error', err);
return [];
}
}
async function getGameById(id) {
const key = process.env.RAWG_API_KEY;
if (!key)
return null;
try {
const url = `${API_BASE}/games/${encodeURIComponent(String(id))}?key=${encodeURIComponent(key)}`;
const res = await (0, undici_1.fetch)(url);
if (!res.ok)
return null;
const json = await res.json();
if (!json)
return null;
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,
coverUrl: json.background_image ?? undefined,
source: 'rawg',
};
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('rawgClient.getGameById error', err);
return null;
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,85 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.searchGames = searchGames;
exports.getGameById = getGameById;
/**
* Cliente TheGamesDB (simple wrapper)
* - `searchGames(query)`
* - `getGameById(id)`
*/
const undici_1 = require("undici");
const API_BASE = 'https://api.thegamesdb.net';
async function searchGames(query) {
const key = process.env.THEGAMESDB_API_KEY;
if (!key)
return [];
try {
const url = `${API_BASE}/v1/Games/ByGameName?name=${encodeURIComponent(query)}`;
const res = await (0, undici_1.fetch)(url, { headers: { 'Api-Key': key } });
if (!res.ok)
return [];
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];
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,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
source: 'thegamesdb',
});
}
return hits;
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('thegamesdbClient.searchGames error', err);
return [];
}
}
async function getGameById(id) {
const key = process.env.THEGAMESDB_API_KEY;
if (!key)
return null;
try {
const url = `${API_BASE}/v1/Games/ByGameID?id=${encodeURIComponent(String(id))}`;
const res = await (0, undici_1.fetch)(url, { headers: { 'Api-Key': key } });
if (!res.ok)
return null;
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;
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,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
source: 'thegamesdb',
};
}
catch (err) {
// eslint-disable-next-line no-console
console.debug('thegamesdbClient.getGameById error', err);
return null;
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,41 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.updateGameSchema = exports.createGameSchema = exports.GameSource = exports.GameCondition = void 0;
const zod_1 = require("zod");
// Enum para condiciones (Loose, CIB, New)
exports.GameCondition = zod_1.z.enum(['Loose', 'CIB', 'New']).optional();
// Enum para fuentes de juegos
exports.GameSource = zod_1.z.enum(['manual', 'rom', 'igdb', 'rawg', 'thegamesdb']).optional();
// Esquema de validación para crear un juego
exports.createGameSchema = zod_1.z.object({
title: zod_1.z.string().min(1, 'El título es requerido').trim(),
platformId: zod_1.z.string().optional(),
description: zod_1.z.string().optional().nullable(),
priceCents: zod_1.z.number().int().positive().optional(),
currency: zod_1.z.string().optional().default('USD'),
store: zod_1.z.string().optional(),
date: zod_1.z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo)
condition: exports.GameCondition,
source: zod_1.z.string().optional().default('manual'), // Fuente del juego
sourceId: zod_1.z.string().optional(), // ID en la fuente externa
});
// Esquema de validación para actualizar un juego (todos los campos son opcionales)
exports.updateGameSchema = zod_1.z
.object({
title: zod_1.z.string().min(1).trim().optional(),
platformId: zod_1.z.string().optional(),
description: zod_1.z.string().optional().nullable(),
priceCents: zod_1.z.number().int().positive().optional(),
currency: zod_1.z.string().optional(),
store: zod_1.z.string().optional(),
date: zod_1.z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo)
condition: exports.GameCondition,
source: zod_1.z.string().optional(), // Fuente del juego
sourceId: zod_1.z.string().optional(), // ID en la fuente externa
})
.strict();
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.linkGameSchema = void 0;
const zod_1 = require("zod");
// Esquema para vincular un juego a un ROM
exports.linkGameSchema = zod_1.z.object({
gameId: zod_1.z.string().min(1, 'El ID del juego es requerido'),
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,90 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const importRunner_1 = require("../../src/jobs/importRunner");
(0, vitest_1.describe)('jobs/importRunner', () => {
(0, vitest_1.it)('enqueue rechaza después de stop', async () => {
const runner = new importRunner_1.ImportRunner(1);
runner.start();
runner.stop();
await (0, vitest_1.expect)(runner.enqueue(() => 'x')).rejects.toThrow();
});
(0, vitest_1.it)('rechaza tareas en cola tras stop', async () => {
const r = new importRunner_1.ImportRunner(1);
// Primera tarea comienza inmediatamente
const t1 = r.enqueue(async () => {
await new Promise((res) => setTimeout(res, 50));
return 'ok1';
});
// Segunda tarea quedará en cola
const t2 = r.enqueue(async () => 'ok2');
// Parar el runner inmediatamente
r.stop();
await (0, vitest_1.expect)(t1).resolves.toBe('ok1');
await (0, vitest_1.expect)(t2).rejects.toThrow(/ImportRunner stopped/);
const s = r.getStatus();
(0, vitest_1.expect)(s.completed).toBeGreaterThanOrEqual(1);
});
(0, vitest_1.it)('completed incrementa en rechazo', async () => {
const runner = new importRunner_1.ImportRunner(1);
runner.start();
const p = runner.enqueue(() => Promise.reject(new Error('boom')));
await (0, vitest_1.expect)(p).rejects.toThrow('boom');
const status = runner.getStatus();
(0, vitest_1.expect)(status.completed).toBeGreaterThanOrEqual(1);
runner.stop();
});
(0, vitest_1.it)('enqueue resuelve con el resultado de la tarea', async () => {
const runner = new importRunner_1.ImportRunner(2);
runner.start();
const result = await runner.enqueue(async () => 'ok');
(0, vitest_1.expect)(result).toBe('ok');
const status = runner.getStatus();
(0, vitest_1.expect)(status.completed).toBe(1);
(0, vitest_1.expect)(status.running).toBe(0);
(0, vitest_1.expect)(status.queued).toBe(0);
(0, vitest_1.expect)(status.concurrency).toBe(2);
runner.stop();
});
(0, vitest_1.it)('respeta la concurrencia configurada', async () => {
const concurrency = 2;
const runner = new importRunner_1.ImportRunner(concurrency);
runner.start();
let active = 0;
const observed = [];
const makeTask = (delay) => async () => {
active++;
observed.push(active);
await new Promise((r) => setTimeout(r, delay));
active--;
return 'done';
};
const promises = [];
for (let i = 0; i < 5; i++) {
promises.push(runner.enqueue(makeTask(80)));
}
await Promise.all(promises);
(0, vitest_1.expect)(Math.max(...observed)).toBeLessThanOrEqual(concurrency);
runner.stop();
});
(0, vitest_1.it)('getStatus reporta queued, running, completed y concurrency', async () => {
const concurrency = 2;
const runner = new importRunner_1.ImportRunner(concurrency);
runner.start();
const p1 = runner.enqueue(() => new Promise((r) => setTimeout(() => r('a'), 60)));
const p2 = runner.enqueue(() => new Promise((r) => setTimeout(() => r('b'), 60)));
const p3 = runner.enqueue(() => new Promise((r) => setTimeout(() => r('c'), 60)));
// allow the runner to start tasks
await new Promise((r) => setImmediate(r));
const statusNow = runner.getStatus();
(0, vitest_1.expect)(statusNow.concurrency).toBe(concurrency);
(0, vitest_1.expect)(statusNow.running).toBeLessThanOrEqual(concurrency);
(0, vitest_1.expect)(statusNow.queued).toBeGreaterThanOrEqual(0);
await Promise.all([p1, p2, p3]);
const statusAfter = runner.getStatus();
(0, vitest_1.expect)(statusAfter.queued).toBe(0);
(0, vitest_1.expect)(statusAfter.running).toBe(0);
(0, vitest_1.expect)(statusAfter.completed).toBe(3);
runner.stop();
});
});

89
backend/dist/tests/models/game.spec.js vendored Normal file
View File

@@ -0,0 +1,89 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const fs_1 = __importDefault(require("fs"));
const os_1 = __importDefault(require("os"));
const path_1 = __importDefault(require("path"));
const child_process_1 = require("child_process");
const vitest_1 = require("vitest");
// Import PrismaClient dynamically after running `prisma generate`
// to allow the test setup to run `prisma generate`/`prisma migrate` first.
// Nota: Estos tests siguen TDD. Al principio deben FALLAR hasta que se creen migraciones.
(0, vitest_1.describe)('Prisma / Game model', () => {
const tmpDir = os_1.default.tmpdir();
const dbFile = path_1.default.join(tmpDir, `quasar-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
const databaseUrl = `file:${dbFile}`;
let prisma;
(0, vitest_1.beforeAll)(async () => {
// Asegurarse de que la DB de prueba no exista antes de empezar
try {
fs_1.default.unlinkSync(dbFile);
}
catch (e) {
/* ignore */
}
// Apuntar Prisma a la DB temporal
process.env.DATABASE_URL = databaseUrl;
// Ejecutar migraciones contra la DB de prueba
// Esto fallará si no hay migraciones: esperado en la fase TDD inicial
(0, child_process_1.execSync)('yarn prisma migrate deploy --schema=./prisma/schema.prisma', {
stdio: 'inherit',
cwd: path_1.default.resolve(__dirname, '..', '..'),
});
// Intentar requerir el cliente generado; si no existe, intentar generarlo (fallback)
let GeneratedPrismaClient;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
GeneratedPrismaClient = require('@prisma/client').PrismaClient;
}
catch (e) {
try {
(0, child_process_1.execSync)('yarn prisma generate --schema=./prisma/schema.prisma', {
stdio: 'inherit',
cwd: path_1.default.resolve(__dirname, '..', '..'),
});
// eslint-disable-next-line @typescript-eslint/no-var-requires
GeneratedPrismaClient = require('@prisma/client').PrismaClient;
}
catch (err) {
// Si generation falla (por ejemplo PnP), reintentar require para mostrar mejor error
// eslint-disable-next-line @typescript-eslint/no-var-requires
GeneratedPrismaClient = require('@prisma/client').PrismaClient;
}
}
prisma = new GeneratedPrismaClient();
await prisma.$connect();
});
(0, vitest_1.afterAll)(async () => {
if (prisma) {
await prisma.$disconnect();
}
try {
fs_1.default.unlinkSync(dbFile);
}
catch (e) {
/* ignore */
}
});
(0, vitest_1.it)('can create a Game and read title/slug', async () => {
const created = await prisma.game.create({ data: { title: 'Test Game', slug: 'test-game' } });
const found = await prisma.game.findUnique({ where: { id: created.id } });
(0, vitest_1.expect)(found).toBeTruthy();
(0, vitest_1.expect)(found?.title).toBe('Test Game');
(0, vitest_1.expect)(found?.slug).toBe('test-game');
});
(0, vitest_1.it)('enforces unique slug constraint', async () => {
const slug = `unique-${Date.now()}`;
await prisma.game.create({ data: { title: 'G1', slug } });
let threw = false;
try {
await prisma.game.create({ data: { title: 'G2', slug } });
}
catch (err) {
threw = true;
}
(0, vitest_1.expect)(threw).toBe(true);
});
});

228
backend/dist/tests/routes/games.spec.js vendored Normal file
View File

@@ -0,0 +1,228 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const app_1 = require("../../src/app");
const prisma_1 = require("../../src/plugins/prisma");
(0, vitest_1.describe)('Games API', () => {
let app;
(0, vitest_1.beforeEach)(async () => {
app = (0, app_1.buildApp)();
await app.ready();
// Limpiar base de datos antes de cada test
// Orden importante: relaciones de FK primero
await prisma_1.prisma.purchase.deleteMany();
await prisma_1.prisma.gamePlatform.deleteMany();
await prisma_1.prisma.artwork.deleteMany();
await prisma_1.prisma.priceHistory.deleteMany();
await prisma_1.prisma.game.deleteMany();
await prisma_1.prisma.platform.deleteMany();
});
(0, vitest_1.afterEach)(async () => {
await app.close();
});
(0, vitest_1.describe)('GET /api/games', () => {
(0, vitest_1.it)('debería devolver una lista vacía cuando no hay juegos', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/games',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
(0, vitest_1.expect)(res.json()).toEqual([]);
});
(0, vitest_1.it)('debería devolver una lista de juegos con todas sus propiedades', async () => {
// Crear un juego de prueba
const platform = await prisma_1.prisma.platform.create({
data: { name: 'Nintendo', slug: 'nintendo' },
});
const game = await prisma_1.prisma.game.create({
data: {
title: 'The Legend of Zelda',
slug: 'legend-of-zelda',
description: 'Un videojuego clásico',
source: 'manual',
gamePlatforms: {
create: {
platformId: platform.id,
},
},
purchases: {
create: {
priceCents: 5000,
currency: 'USD',
store: 'eBay',
date: new Date('2025-01-15'),
},
},
},
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
});
const res = await app.inject({
method: 'GET',
url: '/api/games',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(Array.isArray(body)).toBe(true);
(0, vitest_1.expect)(body.length).toBe(1);
(0, vitest_1.expect)(body[0]).toHaveProperty('id');
(0, vitest_1.expect)(body[0]).toHaveProperty('title');
});
});
(0, vitest_1.describe)('POST /api/games', () => {
(0, vitest_1.it)('debería crear un juego válido con todos los campos', async () => {
// Crear plataforma primero
const platform = await prisma_1.prisma.platform.create({
data: { name: 'Nintendo 64', slug: 'n64' },
});
const payload = {
title: 'Super Mario 64',
platformId: platform.id,
description: 'Notas sobre el juego',
priceCents: 15000,
currency: 'USD',
store: 'Local Shop',
date: '2025-01-20',
condition: 'CIB',
};
const res = await app.inject({
method: 'POST',
url: '/api/games',
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 64');
(0, vitest_1.expect)(body.description).toBe('Notas sobre el juego');
});
(0, vitest_1.it)('debería fallar si falta el título (requerido)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/games',
payload: {
platformId: 'non-existing-id',
priceCents: 10000,
},
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
(0, vitest_1.it)('debería fallar si el título está vacío', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/games',
payload: {
title: '',
platformId: 'some-id',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
(0, vitest_1.it)('debería crear un juego con solo los campos requeridos', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/games',
payload: {
title: 'Game Title Only',
},
});
(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('Game Title Only');
});
});
(0, vitest_1.describe)('PUT /api/games/:id', () => {
(0, vitest_1.it)('debería actualizar un juego existente', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Original Title',
slug: 'original-title',
source: 'manual',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/games/${game.id}`,
payload: {
title: 'Updated Title',
description: 'Updated description',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(body.title).toBe('Updated Title');
(0, vitest_1.expect)(body.description).toBe('Updated description');
});
(0, vitest_1.it)('debería devolver 404 si el juego no existe', async () => {
const res = await app.inject({
method: 'PUT',
url: '/api/games/non-existing-id',
payload: {
title: 'Some Title',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(404);
});
(0, vitest_1.it)('debería permitir actualización parcial', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Original Title',
slug: 'original',
description: 'Original description',
source: 'manual',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/games/${game.id}`,
payload: {
description: 'New description only',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(body.title).toBe('Original Title'); // No cambió
(0, vitest_1.expect)(body.description).toBe('New description only'); // Cambió
});
});
(0, vitest_1.describe)('DELETE /api/games/:id', () => {
(0, vitest_1.it)('debería eliminar un juego existente', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Game to Delete',
slug: 'game-to-delete',
source: 'manual',
},
});
const res = await app.inject({
method: 'DELETE',
url: `/api/games/${game.id}`,
});
(0, vitest_1.expect)(res.statusCode).toBe(204);
// Verificar que el juego fue eliminado
const deletedGame = await prisma_1.prisma.game.findUnique({
where: { id: game.id },
});
(0, vitest_1.expect)(deletedGame).toBeNull();
});
(0, vitest_1.it)('debería devolver 404 si el juego no existe', async () => {
const res = await app.inject({
method: 'DELETE',
url: '/api/games/non-existing-id',
});
(0, vitest_1.expect)(res.statusCode).toBe(404);
});
});
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,17 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const app_1 = require("../../src/app");
(0, vitest_1.describe)('routes/import', () => {
(0, vitest_1.it)('POST /api/import/scan devuelve 202 o 200', async () => {
const app = (0, app_1.buildApp)();
await app.ready();
const res = await app.inject({
method: 'POST',
url: '/api/import/scan',
payload: { persist: false },
});
(0, vitest_1.expect)([200, 202]).toContain(res.statusCode);
await app.close();
});
});

View File

@@ -0,0 +1,117 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const app_1 = require("../../src/app");
const metadataService = __importStar(require("../../src/services/metadataService"));
(0, vitest_1.describe)('Metadata API', () => {
let app;
(0, vitest_1.beforeEach)(async () => {
app = (0, app_1.buildApp)();
await app.ready();
});
(0, vitest_1.afterEach)(async () => {
await app.close();
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 () => {
const mockResults = [
{
source: 'igdb',
externalIds: { igdb: 1 },
title: 'The Legend of Zelda',
slug: 'the-legend-of-zelda',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'https://example.com/cover.jpg',
},
];
vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(mockResults[0]);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=zelda',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
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.it)('debería devolver lista vacía cuando no hay resultados', async () => {
vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=nonexistentgame12345',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(Array.isArray(body)).toBe(true);
(0, vitest_1.expect)(body.length).toBe(0);
});
(0, vitest_1.it)('debería devolver 400 si falta el parámetro query', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search',
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
(0, vitest_1.expect)(res.json()).toHaveProperty('error');
});
(0, vitest_1.it)('debería devolver 400 si el parámetro query está vacío', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=',
});
(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);
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({
title: 'mario',
platform: 'Nintendo 64',
});
});
});
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

255
backend/dist/tests/routes/roms.spec.js vendored Normal file
View File

@@ -0,0 +1,255 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const app_1 = require("../../src/app");
const prisma_1 = require("../../src/plugins/prisma");
(0, vitest_1.describe)('ROMs API', () => {
let app;
(0, vitest_1.beforeEach)(async () => {
app = (0, app_1.buildApp)();
await app.ready();
// Limpiar base de datos antes de cada test (eliminar ROMs primero por foreign key)
await prisma_1.prisma.romFile.deleteMany();
await prisma_1.prisma.gamePlatform.deleteMany();
await prisma_1.prisma.purchase.deleteMany();
await prisma_1.prisma.artwork.deleteMany();
await prisma_1.prisma.priceHistory.deleteMany();
await prisma_1.prisma.game.deleteMany();
});
(0, vitest_1.afterEach)(async () => {
await app.close();
});
(0, vitest_1.describe)('GET /api/roms', () => {
(0, vitest_1.it)('debería devolver una lista vacía cuando no hay ROMs', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
(0, vitest_1.expect)(res.json()).toEqual([]);
});
(0, vitest_1.it)('debería devolver una lista de ROMs con sus propiedades', async () => {
// Crear un ROM de prueba
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/games/',
filename: 'game.zip',
checksum: 'abc123def456',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(Array.isArray(body)).toBe(true);
(0, vitest_1.expect)(body.length).toBe(1);
(0, vitest_1.expect)(body[0].id).toBe(rom.id);
(0, vitest_1.expect)(body[0].filename).toBe('game.zip');
});
(0, vitest_1.it)('debería incluir información del juego asociado', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Test Game',
slug: 'test-game',
},
});
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'test-with-game.zip',
checksum: 'checksum-game-123',
size: 2048,
format: 'zip',
gameId: game.id,
},
});
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
// Buscar el ROM que creamos por checksum
const createdRom = body.find((r) => r.checksum === 'checksum-game-123');
(0, vitest_1.expect)(createdRom).toBeDefined();
(0, vitest_1.expect)(createdRom.game).toBeDefined();
(0, vitest_1.expect)(createdRom.game.title).toBe('Test Game');
});
});
(0, vitest_1.describe)('GET /api/roms/:id', () => {
(0, vitest_1.it)('debería retornar un ROM existente', async () => {
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game1.zip',
checksum: 'checksum1',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'GET',
url: `/api/roms/${rom.id}`,
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(body.id).toBe(rom.id);
(0, vitest_1.expect)(body.filename).toBe('game1.zip');
});
(0, vitest_1.it)('debería retornar 404 si el ROM no existe', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/roms/non-existing-id',
});
(0, vitest_1.expect)(res.statusCode).toBe(404);
(0, vitest_1.expect)(res.json()).toHaveProperty('error');
});
(0, vitest_1.it)('debería incluir el juego asociado al ROM', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Zelda',
slug: 'zelda',
},
});
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'zelda.zip',
checksum: 'checksum2',
size: 2048,
format: 'zip',
gameId: game.id,
},
});
const res = await app.inject({
method: 'GET',
url: `/api/roms/${rom.id}`,
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(body.game).toBeDefined();
(0, vitest_1.expect)(body.game.title).toBe('Zelda');
});
});
(0, vitest_1.describe)('PUT /api/roms/:id/game', () => {
(0, vitest_1.it)('debería vincular un juego a un ROM existente', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Mario',
slug: 'mario',
},
});
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'mario.zip',
checksum: 'checksum3',
size: 512,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {
gameId: game.id,
},
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
const body = res.json();
(0, vitest_1.expect)(body.gameId).toBe(game.id);
(0, vitest_1.expect)(body.game.title).toBe('Mario');
});
(0, vitest_1.it)('debería devolver 400 si el gameId es inválido', async () => {
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game.zip',
checksum: 'checksum4',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {
gameId: 'invalid-game-id',
},
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
(0, vitest_1.it)('debería devolver 404 si el ROM no existe', async () => {
const game = await prisma_1.prisma.game.create({
data: {
title: 'Test',
slug: 'test',
},
});
const res = await app.inject({
method: 'PUT',
url: '/api/roms/non-existing-id/game',
payload: {
gameId: game.id,
},
});
(0, vitest_1.expect)(res.statusCode).toBe(404);
});
(0, vitest_1.it)('debería devolver 400 si falta gameId', async () => {
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game.zip',
checksum: 'checksum5',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {},
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
});
(0, vitest_1.describe)('DELETE /api/roms/:id', () => {
(0, vitest_1.it)('debería eliminar un ROM existente', async () => {
const rom = await prisma_1.prisma.romFile.create({
data: {
path: '/roms/',
filename: 'delete-me.zip',
checksum: 'checksum6',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'DELETE',
url: `/api/roms/${rom.id}`,
});
(0, vitest_1.expect)(res.statusCode).toBe(204);
// Verificar que el ROM fue eliminado
const deletedRom = await prisma_1.prisma.romFile.findUnique({
where: { id: rom.id },
});
(0, vitest_1.expect)(deletedRom).toBeNull();
});
(0, vitest_1.it)('debería devolver 404 si el ROM no existe', async () => {
const res = await app.inject({
method: 'DELETE',
url: '/api/roms/non-existing-id',
});
(0, vitest_1.expect)(res.statusCode).toBe(404);
});
});
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

19
backend/dist/tests/server.spec.js vendored Normal file
View File

@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const app_1 = require("../src/app");
(0, vitest_1.describe)('Server', () => {
(0, vitest_1.it)('GET /api/health devuelve 200 y { status: "ok" }', async () => {
const app = (0, app_1.buildApp)();
await app.ready();
const res = await app.inject({ method: 'GET', url: '/api/health' });
(0, vitest_1.expect)(res.statusCode).toBe(200);
(0, vitest_1.expect)(res.json()).toEqual({ status: 'ok' });
await app.close();
});
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-07
*/

View File

@@ -0,0 +1,55 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const fs_1 = require("fs");
const path_1 = __importDefault(require("path"));
const child_process_1 = require("child_process");
const checksumService_1 = require("../../src/services/checksumService");
const archiveReader_1 = require("../../src/services/archiveReader");
const crypto_1 = require("crypto");
function hasBinary(bin) {
try {
(0, child_process_1.execSync)(`which ${bin}`, { stdio: 'ignore' });
return true;
}
catch (e) {
return false;
}
}
const wantIntegration = process.env.INTEGRATION === '1';
const canCreate = hasBinary('7z') || hasBinary('zip');
if (!wantIntegration) {
vitest_1.test.skip('archiveReader integration tests require INTEGRATION=1', () => { });
}
else if (!canCreate) {
vitest_1.test.skip('archiveReader integration tests skipped: no archive creation tool (7z or zip) available', () => { });
}
else {
(0, vitest_1.test)('reads entry from zip using system tools', async () => {
const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(process.cwd(), 'tmp-arc-'));
const inner = path_1.default.join(tmpDir, 'game.rom');
const content = 'QUASAR-INTEGRATION-TEST';
await fs_1.promises.writeFile(inner, content);
const archivePath = path_1.default.join(tmpDir, 'simple.zip');
// create zip using available tool
if (hasBinary('7z')) {
(0, child_process_1.execSync)(`7z a -tzip ${JSON.stringify(archivePath)} ${JSON.stringify(inner)}`, {
stdio: 'ignore',
});
}
else {
(0, child_process_1.execSync)(`zip -j ${JSON.stringify(archivePath)} ${JSON.stringify(inner)}`, {
stdio: 'ignore',
});
}
const stream = await (0, archiveReader_1.streamArchiveEntry)(archivePath, path_1.default.basename(inner));
(0, vitest_1.expect)(stream).not.toBeNull();
const hashes = await (0, checksumService_1.computeHashesFromStream)(stream);
const expectedMd5 = (0, crypto_1.createHash)('md5').update(content).digest('hex');
(0, vitest_1.expect)(hashes.md5).toBe(expectedMd5);
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
});
}

View File

@@ -0,0 +1,75 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
// Mockeamos el módulo `child_process` para controlar las llamadas a `exec`.
vitest_1.vi.mock('child_process', () => ({ exec: vitest_1.vi.fn() }));
const child_process = __importStar(require("child_process"));
const archiveReader_1 = require("../../src/services/archiveReader");
(0, vitest_1.describe)('services/archiveReader', () => {
(0, vitest_1.it)('lista entradas usando 7z -slt', async () => {
const stdout = `Path = file1.txt\nSize = 123\nPacked Size = 0\n\nPath = dir/file2.bin\nSize = 456\nPacked Size = 0\n`;
child_process.exec.mockImplementation((cmd, cb) => {
cb(null, stdout, '');
return {};
});
const entries = await (0, archiveReader_1.listArchiveEntries)('/roms/archive.7z', console);
(0, vitest_1.expect)(entries.length).toBe(2);
(0, vitest_1.expect)(entries[0].name).toBe('file1.txt');
(0, vitest_1.expect)(entries[0].size).toBe(123);
child_process.exec.mockRestore?.();
});
(0, vitest_1.it)('usa unzip como fallback para zip cuando 7z falla', async () => {
child_process.exec
.mockImplementationOnce((cmd, cb) => {
// simular fallo de 7z
cb(new Error('7z not found'), '', '');
return {};
})
.mockImplementationOnce((cmd, cb) => {
// salida simulada de unzip -l
cb(null, ' 123 file1.txt\n 456 file2.bin\n', '');
return {};
});
const entries = await (0, archiveReader_1.listArchiveEntries)('/roms/archive.zip', console);
(0, vitest_1.expect)(entries.length).toBe(2);
(0, vitest_1.expect)(entries[0].name).toBe('file1.txt');
child_process.exec.mockRestore?.();
});
(0, vitest_1.it)('retorna vacío para formatos no soportados', async () => {
const entries = await (0, archiveReader_1.listArchiveEntries)('/roms/simple.bin');
(0, vitest_1.expect)(entries).toEqual([]);
});
});

View File

@@ -0,0 +1,89 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const stream_1 = require("stream");
const events_1 = require("events");
vitest_1.vi.mock('child_process', () => ({ spawn: vitest_1.vi.fn() }));
const child_process = __importStar(require("child_process"));
const archiveReader_1 = require("../../src/services/archiveReader");
(0, vitest_1.afterEach)(() => {
vitest_1.vi.restoreAllMocks();
});
(0, vitest_1.describe)('services/archiveReader streamArchiveEntry', () => {
(0, vitest_1.it)('streams entry using 7z stdout', async () => {
const pass = new stream_1.PassThrough();
const proc = new events_1.EventEmitter();
proc.stdout = pass;
child_process.spawn.mockImplementation(() => proc);
// Emular producción de datos de forma asíncrona
setImmediate(() => {
pass.write(Buffer.from('content-from-7z'));
pass.end();
});
const stream = await (0, archiveReader_1.streamArchiveEntry)('/roms/archive.7z', 'path/file.txt');
(0, vitest_1.expect)(stream).not.toBeNull();
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
(0, vitest_1.expect)(Buffer.concat(chunks).toString()).toBe('content-from-7z');
});
(0, vitest_1.it)('falls back to unzip -p when 7z throws', async () => {
const pass = new stream_1.PassThrough();
const proc2 = new events_1.EventEmitter();
proc2.stdout = pass;
child_process.spawn
.mockImplementationOnce(() => {
throw new Error('spawn ENOENT');
})
.mockImplementationOnce(() => proc2);
setImmediate(() => {
pass.write(Buffer.from('fallback-content'));
pass.end();
});
const stream = await (0, archiveReader_1.streamArchiveEntry)('/roms/archive.zip', 'file.dat');
(0, vitest_1.expect)(stream).not.toBeNull();
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
}
(0, vitest_1.expect)(Buffer.concat(chunks).toString()).toBe('fallback-content');
});
(0, vitest_1.it)('returns null for unsupported formats', async () => {
const res = await (0, archiveReader_1.streamArchiveEntry)('/roms/archive.bin', 'entry');
(0, vitest_1.expect)(res).toBeNull();
});
});

View File

@@ -0,0 +1,23 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const path_1 = __importDefault(require("path"));
const checksumService_1 = require("../../src/services/checksumService");
const fixturesDir = path_1.default.join(__dirname, '..', 'fixtures');
const simpleRom = path_1.default.join(fixturesDir, 'simple-rom.bin');
(0, vitest_1.describe)('services/checksumService', () => {
(0, vitest_1.it)('exporta computeHashes', () => {
(0, vitest_1.expect)(typeof checksumService_1.computeHashes).toBe('function');
});
(0, vitest_1.it)('calcula hashes', async () => {
const meta = await (0, checksumService_1.computeHashes)(simpleRom);
(0, vitest_1.expect)(meta).toBeDefined();
(0, vitest_1.expect)(meta.size).toBeGreaterThan(0);
(0, vitest_1.expect)(meta.md5).toBeDefined();
(0, vitest_1.expect)(meta.sha1).toBeDefined();
(0, vitest_1.expect)(meta.crc32).toBeDefined();
});
});

View File

@@ -0,0 +1,23 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const stream_1 = require("stream");
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const checksumService_1 = require("../../src/services/checksumService");
(0, vitest_1.describe)('services/checksumService (stream)', () => {
(0, vitest_1.it)('computeHashesFromStream produces same result as computeHashes(file)', async () => {
const data = Buffer.from('quasar-stream-test');
const tmpDir = await promises_1.default.mkdtemp(path_1.default.join(process.cwd(), 'tmp-checksum-'));
const tmpFile = path_1.default.join(tmpDir, 'test.bin');
await promises_1.default.writeFile(tmpFile, data);
const expected = await (0, checksumService_1.computeHashes)(tmpFile);
const rs = stream_1.Readable.from([data]);
const actual = await (0, checksumService_1.computeHashesFromStream)(rs);
(0, vitest_1.expect)(actual).toEqual(expected);
await promises_1.default.rm(tmpDir, { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,64 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const datVerifier_1 = require("../../src/services/datVerifier");
const FIXTURE = path_1.default.resolve('tests/fixtures/sample.dat.xml');
(0, vitest_1.describe)('services/datVerifier', () => {
(0, vitest_1.it)('parseDat parses simple DAT XML', () => {
const xml = fs_1.default.readFileSync(FIXTURE, 'utf8');
const dat = (0, datVerifier_1.parseDat)(xml);
(0, vitest_1.expect)(dat).toBeTruthy();
(0, vitest_1.expect)(Array.isArray(dat.games)).toBe(true);
(0, vitest_1.expect)(dat.games.length).toBe(2);
const g0 = dat.games[0];
(0, vitest_1.expect)(g0.name).toBe('Game Alpha');
(0, vitest_1.expect)(g0.roms.length).toBeGreaterThan(0);
(0, vitest_1.expect)(g0.roms[0].name).toBe('alpha1.bin');
(0, vitest_1.expect)(g0.roms[0].crc).toBeDefined();
(0, vitest_1.expect)(g0.roms[0].md5).toBeDefined();
const g1 = dat.games[1];
(0, vitest_1.expect)(g1.name).toBe('Game Beta');
(0, vitest_1.expect)(g1.roms.some((r) => r.name === 'beta2.rom')).toBe(true);
});
(0, vitest_1.it)('verifyHashesAgainstDat finds match by CRC', () => {
const xml = fs_1.default.readFileSync(FIXTURE, 'utf8');
const dat = (0, datVerifier_1.parseDat)(xml);
const match = (0, datVerifier_1.verifyHashesAgainstDat)(dat, { crc: 'DEADBEEF' });
(0, vitest_1.expect)(match).not.toBeNull();
(0, vitest_1.expect)(match?.gameName).toBe('Game Beta');
(0, vitest_1.expect)(match?.romName).toBe('beta1.rom');
(0, vitest_1.expect)(match?.matchedOn).toBe('crc');
});
(0, vitest_1.it)('verifyHashesAgainstDat finds match by MD5, SHA1 and size', () => {
const xml = fs_1.default.readFileSync(FIXTURE, 'utf8');
const dat = (0, datVerifier_1.parseDat)(xml);
const md5match = (0, datVerifier_1.verifyHashesAgainstDat)(dat, { md5: '11111111111111111111111111111111' });
(0, vitest_1.expect)(md5match).not.toBeNull();
(0, vitest_1.expect)(md5match?.gameName).toBe('Game Alpha');
(0, vitest_1.expect)(md5match?.romName).toBe('alpha1.bin');
(0, vitest_1.expect)(md5match?.matchedOn).toBe('md5');
const sha1match = (0, datVerifier_1.verifyHashesAgainstDat)(dat, {
sha1: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
});
(0, vitest_1.expect)(sha1match).not.toBeNull();
(0, vitest_1.expect)(sha1match?.gameName).toBe('Game Alpha');
(0, vitest_1.expect)(sha1match?.romName).toBe('alpha2.bin');
(0, vitest_1.expect)(sha1match?.matchedOn).toBe('sha1');
const sizematch = (0, datVerifier_1.verifyHashesAgainstDat)(dat, { size: 4000 });
(0, vitest_1.expect)(sizematch).not.toBeNull();
(0, vitest_1.expect)(sizematch?.gameName).toBe('Game Beta');
(0, vitest_1.expect)(sizematch?.romName).toBe('beta2.rom');
(0, vitest_1.expect)(sizematch?.matchedOn).toBe('size');
});
(0, vitest_1.it)('verifyHashesAgainstDat returns null when no match', () => {
const xml = fs_1.default.readFileSync(FIXTURE, 'utf8');
const dat = (0, datVerifier_1.parseDat)(xml);
const noMatch = (0, datVerifier_1.verifyHashesAgainstDat)(dat, { md5: 'ffffffffffffffffffffffffffffffff' });
(0, vitest_1.expect)(noMatch).toBeNull();
});
});

View File

@@ -0,0 +1,69 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const path_1 = __importDefault(require("path"));
const os_1 = __importDefault(require("os"));
const fs_1 = require("fs");
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.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');
await fs_1.promises.writeFile(collectionFile, '');
archiveReader_1.listArchiveEntries.mockResolvedValue([
{ name: 'inner/rom1.bin', size: 1234 },
]);
const results = await (0, fsScanner_1.default)(tmpDir);
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');
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
});
(0, vitest_1.it)('ignora entradas con traversal o paths absolutos', 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');
await fs_1.promises.writeFile(collectionFile, '');
archiveReader_1.listArchiveEntries.mockResolvedValue([
{ name: '../evil.rom', size: 10 },
{ name: '/abs/evil.rom', size: 20 },
{ name: 'good/rom.bin', size: 30 },
]);
const results = await (0, fsScanner_1.default)(tmpDir);
const safePath = `${collectionFile}::good/rom.bin`;
(0, vitest_1.expect)(results.find((r) => r.path === safePath)).toBeDefined();
(0, vitest_1.expect)(results.find((r) => r.path === `${collectionFile}::../evil.rom`)).toBeUndefined();
(0, vitest_1.expect)(results.find((r) => r.path === `${collectionFile}::/abs/evil.rom`)).toBeUndefined();
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
});
(0, vitest_1.it)('respeta ARCHIVE_MAX_ENTRIES', 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');
await fs_1.promises.writeFile(collectionFile, '');
// Set env var temporarily
const prev = process.env.ARCHIVE_MAX_ENTRIES;
process.env.ARCHIVE_MAX_ENTRIES = '1';
archiveReader_1.listArchiveEntries.mockResolvedValue([
{ name: 'one.bin', size: 1 },
{ name: 'two.bin', size: 2 },
{ name: 'three.bin', size: 3 },
]);
const results = await (0, fsScanner_1.default)(tmpDir);
const matches = results.filter((r) => String(r.path).startsWith(collectionFile + '::'));
(0, vitest_1.expect)(matches.length).toBe(1);
// restore
if (prev === undefined)
delete process.env.ARCHIVE_MAX_ENTRIES;
else
process.env.ARCHIVE_MAX_ENTRIES = prev;
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
});

View File

@@ -0,0 +1,27 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const path_1 = __importDefault(require("path"));
const fsScanner_1 = require("../../src/services/fsScanner");
const fixturesDir = path_1.default.join(__dirname, '..', 'fixtures');
const emptyDir = path_1.default.join(fixturesDir, 'empty');
(0, vitest_1.describe)('services/fsScanner', () => {
(0, vitest_1.it)('exporta scanDirectory', () => {
(0, vitest_1.expect)(typeof fsScanner_1.scanDirectory).toBe('function');
});
(0, vitest_1.it)('carpeta vacía devuelve array', async () => {
const res = await (0, fsScanner_1.scanDirectory)(emptyDir);
(0, vitest_1.expect)(Array.isArray(res)).toBe(true);
(0, vitest_1.expect)(res.length).toBe(0);
});
(0, vitest_1.it)('detecta simple-rom.bin', async () => {
const res = await (0, fsScanner_1.scanDirectory)(fixturesDir);
const found = res.find((r) => r.filename === 'simple-rom.bin' || r.name === 'simple-rom.bin');
(0, vitest_1.expect)(found).toBeTruthy();
(0, vitest_1.expect)(found.size).toBeGreaterThan(0);
(0, vitest_1.expect)(found.format).toBeDefined();
});
});

View File

@@ -0,0 +1,71 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
const stream_1 = require("stream");
vitest_1.vi.mock('../../src/services/fsScanner', () => ({ scanDirectory: vitest_1.vi.fn() }));
vitest_1.vi.mock('../../src/services/archiveReader', () => ({ streamArchiveEntry: vitest_1.vi.fn() }));
vitest_1.vi.mock('../../src/plugins/prisma', () => ({
default: {
game: { findFirst: vitest_1.vi.fn(), create: vitest_1.vi.fn(), update: vitest_1.vi.fn() },
},
}));
const importService_1 = __importDefault(require("../../src/services/importService"));
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");
(0, vitest_1.beforeEach)(() => {
vitest_1.vi.restoreAllMocks();
});
(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 files = [
{
path: '/roms/collection.zip::inner/rom1.bin',
containerPath: '/roms/collection.zip',
entryPath: 'inner/rom1.bin',
filename: 'rom1.bin',
name: 'inner/rom1.bin',
size: 123,
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);
prisma_1.default.game.create.mockResolvedValue({
id: 77,
title: 'ROM1',
slug: 'rom1',
});
const md5 = (0, crypto_1.createHash)('md5').update(data).digest('hex');
const summary = await (0, importService_1.default)({ dir: '/roms', persist: true });
(0, vitest_1.expect)(archiveReader_1.streamArchiveEntry.mock.calls.length).toBe(1);
(0, vitest_1.expect)(archiveReader_1.streamArchiveEntry.mock.calls[0][0]).toBe('/roms/collection.zip');
(0, vitest_1.expect)(archiveReader_1.streamArchiveEntry.mock.calls[0][1]).toBe('inner/rom1.bin');
(0, vitest_1.expect)(prisma_1.default.game.findFirst.mock.calls[0][0]).toEqual({
where: { source: 'rom', romChecksum: md5 },
});
(0, vitest_1.expect)(prisma_1.default.game.create.mock.calls[0][0]).toEqual({
data: {
title: 'ROM1',
slug: 'rom1-1234567890123',
source: 'rom',
romPath: '/roms/collection.zip::inner/rom1.bin',
romFilename: 'rom1.bin',
romSize: 123,
romChecksum: md5,
romFormat: 'bin',
romHashes: vitest_1.expect.any(String),
addedAt: vitest_1.expect.any(Date),
lastSeenAt: vitest_1.expect.any(Date),
},
});
(0, vitest_1.expect)(summary).toEqual({ processed: 1, createdCount: 1, upserted: 0 });
});
});

View File

@@ -0,0 +1,127 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
vitest_1.vi.mock('../../src/services/fsScanner', () => ({
scanDirectory: vitest_1.vi.fn(),
}));
vitest_1.vi.mock('../../src/services/checksumService', () => ({
computeHashes: vitest_1.vi.fn(),
}));
vitest_1.vi.mock('../../src/plugins/prisma', () => ({
default: {
game: { findFirst: vitest_1.vi.fn(), create: vitest_1.vi.fn(), update: vitest_1.vi.fn() },
},
}));
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"));
(0, vitest_1.describe)('services/importService', () => {
(0, vitest_1.beforeEach)(() => {
vitest_1.vi.clearAllMocks();
});
(0, vitest_1.it)('exporta createSlug e importDirectory', () => {
(0, vitest_1.expect)(typeof importService_1.createSlug).toBe('function');
(0, vitest_1.expect)(typeof importService_1.importDirectory).toBe('function');
});
(0, vitest_1.it)('cuando hay un archivo y persist:true crea Game con source=rom y devuelve resumen', async () => {
const files = [
{
path: '/roms/Sonic.bin',
filename: 'Sonic.bin',
name: 'Sonic.bin',
size: 123,
format: 'bin',
isArchive: false,
},
];
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
fsScanner_1.scanDirectory.mockResolvedValue(files);
checksumService_1.computeHashes.mockResolvedValue(hashes);
prisma_1.default.game.findFirst.mockResolvedValue(null);
prisma_1.default.game.create.mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
const summary = await (0, importService_1.importDirectory)({ dir: '/roms', persist: true });
(0, vitest_1.expect)(fsScanner_1.scanDirectory.mock.calls[0][0]).toBe('/roms');
(0, vitest_1.expect)(checksumService_1.computeHashes.mock.calls[0][0]).toBe('/roms/Sonic.bin');
(0, vitest_1.expect)(prisma_1.default.game.findFirst.mock.calls[0][0]).toEqual({
where: { source: 'rom', romChecksum: 'md5-abc' },
});
(0, vitest_1.expect)(prisma_1.default.game.create.mock.calls[0][0]).toEqual({
data: {
title: 'Sonic',
slug: 'sonic-1234567890123',
source: 'rom',
romPath: '/roms/Sonic.bin',
romFilename: 'Sonic.bin',
romSize: 123,
romChecksum: 'md5-abc',
romFormat: 'bin',
romHashes: JSON.stringify(hashes),
addedAt: vitest_1.expect.any(Date),
lastSeenAt: vitest_1.expect.any(Date),
},
});
(0, vitest_1.expect)(summary).toEqual({ processed: 1, createdCount: 1, upserted: 0 });
});
(0, vitest_1.it)('cuando el juego ya existe (mismo checksum), actualiza lastSeenAt', async () => {
const files = [
{
path: '/roms/Sonic.bin',
filename: 'Sonic.bin',
name: 'Sonic.bin',
size: 123,
format: 'bin',
isArchive: false,
},
];
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
fsScanner_1.scanDirectory.mockResolvedValue(files);
checksumService_1.computeHashes.mockResolvedValue(hashes);
prisma_1.default.game.findFirst.mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
prisma_1.default.game.update.mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
const summary = await (0, importService_1.importDirectory)({ dir: '/roms', persist: true });
(0, vitest_1.expect)(prisma_1.default.game.update.mock.calls[0][0]).toEqual({
where: { id: 77 },
data: {
lastSeenAt: vitest_1.expect.any(Date),
romHashes: JSON.stringify(hashes),
},
});
(0, vitest_1.expect)(summary).toEqual({ processed: 1, createdCount: 0, upserted: 1 });
});
(0, vitest_1.it)('cuando persist=false no guarda nada en la base de datos', async () => {
const files = [
{
path: '/roms/Sonic.bin',
filename: 'Sonic.bin',
name: 'Sonic.bin',
size: 123,
format: 'bin',
isArchive: false,
},
];
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
fsScanner_1.scanDirectory.mockResolvedValue(files);
checksumService_1.computeHashes.mockResolvedValue(hashes);
const summary = await (0, importService_1.importDirectory)({ dir: '/roms', persist: false });
(0, vitest_1.expect)(prisma_1.default.game.findFirst).not.toHaveBeenCalled();
(0, vitest_1.expect)(prisma_1.default.game.create).not.toHaveBeenCalled();
(0, vitest_1.expect)(prisma_1.default.game.update).not.toHaveBeenCalled();
(0, vitest_1.expect)(summary).toEqual({ processed: 1, createdCount: 0, upserted: 0 });
});
});

View File

@@ -0,0 +1,103 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const vitest_1 = require("vitest");
vitest_1.vi.mock('../../src/services/igdbClient', () => ({
searchGames: vitest_1.vi.fn(),
getGameById: vitest_1.vi.fn(),
}));
vitest_1.vi.mock('../../src/services/rawgClient', () => ({
searchGames: vitest_1.vi.fn(),
getGameById: vitest_1.vi.fn(),
}));
vitest_1.vi.mock('../../src/services/thegamesdbClient', () => ({
searchGames: vitest_1.vi.fn(),
getGameById: vitest_1.vi.fn(),
}));
const igdb = __importStar(require("../../src/services/igdbClient"));
const rawg = __importStar(require("../../src/services/rawgClient"));
const tgdb = __importStar(require("../../src/services/thegamesdbClient"));
const metadataService_1 = require("../../src/services/metadataService");
(0, vitest_1.describe)('services/metadataService', () => {
(0, vitest_1.beforeEach)(() => {
vitest_1.vi.clearAllMocks();
});
(0, vitest_1.it)('prioriza IGDB cuando hay resultados', async () => {
igdb.searchGames.mockResolvedValue([
{
id: 11,
name: 'Sonic',
slug: 'sonic',
releaseDate: '1991-06-23',
genres: ['Platform'],
coverUrl: 'http://img',
source: 'igdb',
},
]);
rawg.searchGames.mockResolvedValue([]);
tgdb.searchGames.mockResolvedValue([]);
const res = await (0, metadataService_1.enrichGame)({ title: 'Sonic' });
(0, vitest_1.expect)(res).not.toBeNull();
(0, vitest_1.expect)(res?.source).toBe('igdb');
(0, vitest_1.expect)(res?.externalIds.igdb).toBe(11);
(0, vitest_1.expect)(res?.title).toBe('Sonic');
});
(0, vitest_1.it)('cae a RAWG cuando IGDB no responde resultados', async () => {
igdb.searchGames.mockResolvedValue([]);
rawg.searchGames.mockResolvedValue([
{
id: 22,
name: 'Sonic (rawg)',
slug: 'sonic-rawg',
releaseDate: '1991-06-23',
genres: ['Platform'],
coverUrl: 'http://img',
source: 'rawg',
},
]);
tgdb.searchGames.mockResolvedValue([]);
const res = await (0, metadataService_1.enrichGame)({ title: 'Sonic' });
(0, vitest_1.expect)(res).not.toBeNull();
(0, vitest_1.expect)(res?.source).toBe('rawg');
(0, vitest_1.expect)(res?.externalIds.rawg).toBe(22);
});
(0, vitest_1.it)('retorna null si no hay resultados en ninguna API', async () => {
igdb.searchGames.mockResolvedValue([]);
rawg.searchGames.mockResolvedValue([]);
tgdb.searchGames.mockResolvedValue([]);
const res = await (0, metadataService_1.enrichGame)({ title: 'Juego inexistente' });
(0, vitest_1.expect)(res).toBeNull();
});
});

24
backend/dist/tests/setup.js vendored Normal file
View File

@@ -0,0 +1,24 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const dotenv_1 = __importDefault(require("dotenv"));
const child_process_1 = require("child_process");
// Cargar variables de entorno desde .env
dotenv_1.default.config();
// Ejecutar migraciones de Prisma antes de los tests
try {
(0, child_process_1.execSync)('npx prisma migrate deploy', {
cwd: process.cwd(),
stdio: 'inherit',
});
}
catch (error) {
console.error('Failed to run Prisma migrations:', error);
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-12
*/

View File

@@ -0,0 +1,62 @@
/*
Warnings:
- You are about to drop the `RomFile` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the column `extra` on the `Game` table. All the data in the column will be lost.
- Added the required column `source` to the `Game` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "RomFile_checksum_idx";
-- DropIndex
DROP INDEX "RomFile_checksum_key";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "RomFile";
PRAGMA foreign_keys=on;
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Game" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"releaseDate" DATETIME,
"genre" TEXT,
"platform" TEXT,
"year" INTEGER,
"cover" TEXT,
"source" TEXT NOT NULL,
"sourceId" TEXT,
"romPath" TEXT,
"romFilename" TEXT,
"romSize" INTEGER,
"romChecksum" TEXT,
"romFormat" TEXT,
"romHashes" TEXT,
"igdbId" INTEGER,
"rawgId" INTEGER,
"thegamesdbId" INTEGER,
"metadata" TEXT,
"addedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Game" ("createdAt", "description", "id", "igdbId", "rawgId", "releaseDate", "slug", "thegamesdbId", "title", "updatedAt") SELECT "createdAt", "description", "id", "igdbId", "rawgId", "releaseDate", "slug", "thegamesdbId", "title", "updatedAt" FROM "Game";
DROP TABLE "Game";
ALTER TABLE "new_Game" RENAME TO "Game";
CREATE UNIQUE INDEX "Game_slug_key" ON "Game"("slug");
CREATE UNIQUE INDEX "Game_igdbId_key" ON "Game"("igdbId");
CREATE UNIQUE INDEX "Game_rawgId_key" ON "Game"("rawgId");
CREATE UNIQUE INDEX "Game_thegamesdbId_key" ON "Game"("thegamesdbId");
CREATE INDEX "Game_source_idx" ON "Game"("source");
CREATE INDEX "Game_sourceId_idx" ON "Game"("sourceId");
CREATE INDEX "Game_title_idx" ON "Game"("title");
CREATE INDEX "Game_romChecksum_idx" ON "Game"("romChecksum");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,43 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Game" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"releaseDate" DATETIME,
"genre" TEXT,
"platform" TEXT,
"year" INTEGER,
"cover" TEXT,
"source" TEXT NOT NULL DEFAULT 'manual',
"sourceId" TEXT,
"romPath" TEXT,
"romFilename" TEXT,
"romSize" INTEGER,
"romChecksum" TEXT,
"romFormat" TEXT,
"romHashes" TEXT,
"igdbId" INTEGER,
"rawgId" INTEGER,
"thegamesdbId" INTEGER,
"metadata" TEXT,
"addedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Game" ("addedAt", "cover", "createdAt", "description", "genre", "id", "igdbId", "lastSeenAt", "metadata", "platform", "rawgId", "releaseDate", "romChecksum", "romFilename", "romFormat", "romHashes", "romPath", "romSize", "slug", "source", "sourceId", "thegamesdbId", "title", "updatedAt", "year") SELECT "addedAt", "cover", "createdAt", "description", "genre", "id", "igdbId", "lastSeenAt", "metadata", "platform", "rawgId", "releaseDate", "romChecksum", "romFilename", "romFormat", "romHashes", "romPath", "romSize", "slug", "source", "sourceId", "thegamesdbId", "title", "updatedAt", "year" FROM "Game";
DROP TABLE "Game";
ALTER TABLE "new_Game" RENAME TO "Game";
CREATE UNIQUE INDEX "Game_slug_key" ON "Game"("slug");
CREATE UNIQUE INDEX "Game_igdbId_key" ON "Game"("igdbId");
CREATE UNIQUE INDEX "Game_rawgId_key" ON "Game"("rawgId");
CREATE UNIQUE INDEX "Game_thegamesdbId_key" ON "Game"("thegamesdbId");
CREATE INDEX "Game_source_idx" ON "Game"("source");
CREATE INDEX "Game_sourceId_idx" ON "Game"("sourceId");
CREATE INDEX "Game_title_idx" ON "Game"("title");
CREATE INDEX "Game_romChecksum_idx" ON "Game"("romChecksum");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -15,19 +15,49 @@ model Game {
slug String @unique
description String?
releaseDate DateTime?
genre String?
platform String?
year Int?
cover String?
// Fuente del juego
source String @default("manual") // "rom", "manual", "igdb", "rawg", "thegamesdb", etc.
sourceId String? // ID en la fuente externa (para igdb, rawg, etc.)
// Datos específicos de ROM (si source = "rom")
romPath String?
romFilename String?
romSize Int?
romChecksum String?
romFormat String?
romHashes String? // JSON serialized (ej.: {"crc32": "...", "md5": "...", "sha1": "..."})
// IDs de integraciones externas (mantener compatibilidad con datos existentes)
igdbId Int? @unique
rawgId Int? @unique
thegamesdbId Int? @unique
extra String? // JSON serialized (usar parse/stringify al guardar/leer) para compatibilidad con SQLite
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
romFiles RomFile[]
// Metadatos adicionales de integraciones
metadata String? // JSON serialized para datos adicionales de la fuente
// Relaciones existentes
artworks Artwork[]
purchases Purchase[]
gamePlatforms GamePlatform[]
priceHistories PriceHistory[]
tags Tag[]
// Timestamps de ROM (para compatibilidad)
addedAt DateTime @default(now())
lastSeenAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([source])
@@index([sourceId])
@@index([title])
@@index([romChecksum])
}
model Platform {
@@ -47,22 +77,6 @@ model GamePlatform {
@@unique([gameId, platformId])
}
model RomFile {
id String @id @default(cuid())
path String
filename String
checksum String @unique
size Int
format String
hashes String? // JSON serialized (ej.: {"crc32": "...", "md5": "..."})
game Game? @relation(fields: [gameId], references: [id])
gameId String?
addedAt DateTime @default(now())
lastSeenAt DateTime?
status String @default("active")
@@index([checksum])
}
model Artwork {
id String @id @default(cuid())
game Game @relation(fields: [gameId], references: [id])
@@ -104,4 +118,5 @@ model PriceHistory {
// Metadatos:
// Autor: GitHub Copilot
// Última actualización: 2026-02-07
// Última actualización: 2026-03-18
// Unificación de juegos y ROMs en una sola entidad Game

View File

@@ -5,7 +5,6 @@ import rateLimit from '@fastify/rate-limit';
import healthRoutes from './routes/health';
import importRoutes from './routes/import';
import gamesRoutes from './routes/games';
import romsRoutes from './routes/roms';
import metadataRoutes from './routes/metadata';
export function buildApp(): FastifyInstance {
@@ -19,7 +18,6 @@ export function buildApp(): FastifyInstance {
void app.register(healthRoutes, { prefix: '/api' });
void app.register(importRoutes, { prefix: '/api' });
void app.register(gamesRoutes, { prefix: '/api' });
void app.register(romsRoutes, { prefix: '/api' });
void app.register(metadataRoutes, { prefix: '/api' });
return app;

View File

@@ -22,11 +22,67 @@ export class GamesController {
});
}
/**
* Obtener un juego por ID
*/
static async getGameById(id: string) {
const game = await prisma.game.findUnique({
where: { id },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
artworks: true,
tags: true,
},
});
if (!game) {
throw new Error('Juego no encontrado');
}
return game;
}
/**
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/
static async listGamesBySource(source: string) {
return await prisma.game.findMany({
where: { source },
include: {
gamePlatforms: {
include: {
platform: true,
},
},
purchases: true,
},
orderBy: {
title: 'asc',
},
});
}
/**
* Crear un juego nuevo
*/
static async createGame(input: CreateGameInput) {
const { title, platformId, description, priceCents, currency, store, date, condition } = input;
const {
title,
platformId,
description,
priceCents,
currency,
store,
date,
condition,
source,
sourceId,
} = input;
// Generar slug basado en el título
const slug = title
@@ -38,6 +94,8 @@ export class GamesController {
title,
slug: `${slug}-${Date.now()}`, // Hacer slug único agregando timestamp
description: description || null,
source: source || 'manual',
sourceId: sourceId || null,
};
// Si se proporciona una plataforma, crearla en gamePlatforms
@@ -78,7 +136,8 @@ export class GamesController {
* Actualizar un juego existente
*/
static async updateGame(id: string, input: UpdateGameInput) {
const { title, platformId, description, priceCents, currency, store, date } = input;
const { title, platformId, description, priceCents, currency, store, date, source, sourceId } =
input;
const updateData: Prisma.GameUpdateInput = {};
@@ -96,6 +155,14 @@ export class GamesController {
updateData.description = description;
}
if (source !== undefined) {
updateData.source = source;
}
if (sourceId !== undefined) {
updateData.sourceId = sourceId;
}
const game = await prisma.game.update({
where: { id },
data: updateData,
@@ -176,5 +243,6 @@ export class GamesController {
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
* Última actualización: 2026-03-18
* Actualizado para soportar fuente (source) en juegos
*/

View File

@@ -1,96 +0,0 @@
import { prisma } from '../plugins/prisma';
export class RomsController {
/**
* Listar todos los ROMs con sus juegos asociados
*/
static async listRoms() {
return await prisma.romFile.findMany({
include: {
game: true,
},
orderBy: {
filename: 'asc',
},
});
}
/**
* Obtener un ROM por ID con su juego asociado
*/
static async getRomById(id: string) {
const rom = await prisma.romFile.findUnique({
where: { id },
include: {
game: true,
},
});
if (!rom) {
throw new Error('ROM no encontrado');
}
return rom;
}
/**
* Vincular un juego a un ROM existente
*/
static async linkGameToRom(romId: string, gameId: string) {
// Validar que el ROM existe
const rom = await prisma.romFile.findUnique({
where: { id: romId },
});
if (!rom) {
throw new Error('ROM no encontrado');
}
// Validar que el juego existe
const game = await prisma.game.findUnique({
where: { id: gameId },
});
if (!game) {
throw new Error('Juego no encontrado');
}
// Actualizar el ROM con el nuevo gameId
return await prisma.romFile.update({
where: { id: romId },
data: {
gameId,
},
include: {
game: true,
},
});
}
/**
* Eliminar un ROM por ID
*/
static async deleteRom(id: string) {
// Validar que el ROM existe
const rom = await prisma.romFile.findUnique({
where: { id },
});
if (!rom) {
throw new Error('ROM no encontrado');
}
// Eliminar el ROM
await prisma.romFile.delete({
where: { id },
});
return { message: 'ROM eliminado correctamente' };
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -13,6 +13,24 @@ async function gamesRoutes(app: FastifyInstance) {
return reply.code(200).send(games);
});
/**
* GET /api/games/:id
* Obtener un juego por ID
*/
app.get<{ Params: { id: string }; Reply: any }>('/games/:id', async (request, reply) => {
try {
const game = await GamesController.getGameById(request.params.id);
return reply.code(200).send(game);
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'Juego no encontrado',
});
}
throw error;
}
});
/**
* POST /api/games
* Crear un nuevo juego
@@ -80,6 +98,18 @@ async function gamesRoutes(app: FastifyInstance) {
throw error;
}
});
/**
* GET /api/games/source/:source
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
*/
app.get<{ Params: { source: string }; Reply: any[] }>(
'/games/source/:source',
async (request, reply) => {
const games = await GamesController.listGamesBySource(request.params.source);
return reply.code(200).send(games);
}
);
}
export default gamesRoutes;
@@ -87,5 +117,6 @@ export default gamesRoutes;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
* Última actualización: 2026-03-18
* Actualizado para soportar fuente (source) en juegos
*/

View File

@@ -1,95 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { RomsController } from '../controllers/romsController';
import { linkGameSchema } from '../validators/romValidator';
import { ZodError } from 'zod';
async function romsRoutes(app: FastifyInstance) {
/**
* GET /api/roms
* Listar todos los ROMs
*/
app.get<{ Reply: any[] }>('/roms', async (request, reply) => {
const roms = await RomsController.listRoms();
return reply.code(200).send(roms);
});
/**
* GET /api/roms/:id
* Obtener un ROM por ID
*/
app.get<{ Params: { id: string }; Reply: any }>('/roms/:id', async (request, reply) => {
try {
const rom = await RomsController.getRomById(request.params.id);
return reply.code(200).send(rom);
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'ROM no encontrado',
});
}
throw error;
}
});
/**
* PUT /api/roms/:id/game
* Vincular un juego a un ROM
*/
app.put<{ Params: { id: string }; Body: any; Reply: any }>(
'/roms/:id/game',
async (request, reply) => {
try {
// Validar entrada con Zod
const validated = linkGameSchema.parse(request.body);
const rom = await RomsController.linkGameToRom(request.params.id, validated.gameId);
return reply.code(200).send(rom);
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({
error: 'Validación fallida',
details: error.errors,
});
}
if (error instanceof Error) {
if (error.message.includes('ROM no encontrado')) {
return reply.code(404).send({
error: 'ROM no encontrado',
});
}
if (error.message.includes('Juego no encontrado')) {
return reply.code(400).send({
error: 'Game ID inválido o no encontrado',
});
}
}
throw error;
}
}
);
/**
* DELETE /api/roms/:id
* Eliminar un ROM
*/
app.delete<{ Params: { id: string }; Reply: any }>('/roms/:id', async (request, reply) => {
try {
await RomsController.deleteRom(request.params.id);
return reply.code(204).send();
} catch (error) {
if (error instanceof Error && error.message.includes('no encontrado')) {
return reply.code(404).send({
error: 'ROM no encontrado',
});
}
throw error;
}
});
}
export default romsRoutes;
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -1,11 +1,11 @@
/**
* Servicio: importService
*
* Orquesta el proceso de importación de ROMs desde un directorio:
* Orquesta el proceso de importación de juegos desde un directorio:
* 1. Lista archivos usando `scanDirectory`.
* 2. Calcula hashes y tamaño con `computeHashes` (streaming).
* 3. Normaliza el nombre a un `slug` y, si `persist` es true, crea/obtiene
* el `Game` correspondiente y hace `upsert` del `RomFile` en Prisma.
* el `Game` correspondiente con source="rom".
*
* `importDirectory` devuelve un resumen con contadores `{ processed, createdCount, upserted }`.
*/
@@ -89,31 +89,44 @@ export async function importDirectory(
const baseName = path.parse(file.filename).name;
const slug = createSlug(baseName);
let game = null;
if (persist) {
game = await prisma.game.findUnique({ where: { slug } });
if (!game) {
game = await prisma.game.create({ data: { title: baseName, slug } });
createdCount++;
}
await prisma.romFile.upsert({
where: { checksum },
update: { lastSeenAt: new Date(), size, hashes: JSON.stringify(hashes) },
create: {
path: file.path,
filename: file.filename,
checksum,
size,
format: file.format,
hashes: JSON.stringify(hashes),
gameId: game?.id,
// Buscar si ya existe un juego con este checksum (source=rom)
let game = await prisma.game.findFirst({
where: {
source: 'rom',
romChecksum: checksum,
},
});
upserted++;
if (!game) {
// Crear nuevo juego con source="rom"
game = await prisma.game.create({
data: {
title: baseName,
slug: `${slug}-${Date.now()}`,
source: 'rom',
romPath: file.path,
romFilename: file.filename,
romSize: size,
romChecksum: checksum,
romFormat: file.format,
romHashes: JSON.stringify(hashes),
addedAt: new Date(),
lastSeenAt: new Date(),
},
});
createdCount++;
} else {
// Actualizar lastSeenAt si ya existe
game = await prisma.game.update({
where: { id: game.id },
data: {
lastSeenAt: new Date(),
romHashes: JSON.stringify(hashes),
},
});
upserted++;
}
}
} catch (err) {
logger.warn?.(

View File

@@ -3,6 +3,9 @@ import { z } from 'zod';
// Enum para condiciones (Loose, CIB, New)
export const GameCondition = z.enum(['Loose', 'CIB', 'New']).optional();
// Enum para fuentes de juegos
export const GameSource = z.enum(['manual', 'rom', 'igdb', 'rawg', 'thegamesdb']).optional();
// Esquema de validación para crear un juego
export const createGameSchema = z.object({
title: z.string().min(1, 'El título es requerido').trim(),
@@ -13,6 +16,8 @@ export const createGameSchema = z.object({
store: z.string().optional(),
date: z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo)
condition: GameCondition,
source: z.string().optional().default('manual'), // Fuente del juego
sourceId: z.string().optional(), // ID en la fuente externa
});
// Esquema de validación para actualizar un juego (todos los campos son opcionales)
@@ -26,6 +31,8 @@ export const updateGameSchema = z
store: z.string().optional(),
date: z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo)
condition: GameCondition,
source: z.string().optional(), // Fuente del juego
sourceId: z.string().optional(), // ID en la fuente externa
})
.strict();

View File

@@ -11,7 +11,6 @@ describe('Games API', () => {
await app.ready();
// Limpiar base de datos antes de cada test
// Orden importante: relaciones de FK primero
await prisma.romFile.deleteMany();
await prisma.purchase.deleteMany();
await prisma.gamePlatform.deleteMany();
await prisma.artwork.deleteMany();
@@ -46,6 +45,7 @@ describe('Games API', () => {
title: 'The Legend of Zelda',
slug: 'legend-of-zelda',
description: 'Un videojuego clásico',
source: 'manual',
gamePlatforms: {
create: {
platformId: platform.id,
@@ -163,6 +163,7 @@ describe('Games API', () => {
data: {
title: 'Original Title',
slug: 'original-title',
source: 'manual',
},
});
@@ -199,6 +200,7 @@ describe('Games API', () => {
title: 'Original Title',
slug: 'original',
description: 'Original description',
source: 'manual',
},
});
@@ -223,6 +225,7 @@ describe('Games API', () => {
data: {
title: 'Game to Delete',
slug: 'game-to-delete',
source: 'manual',
},
});

View File

@@ -1,295 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { buildApp } from '../../src/app';
import { FastifyInstance } from 'fastify';
import { prisma } from '../../src/plugins/prisma';
describe('ROMs API', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = buildApp();
await app.ready();
// Limpiar base de datos antes de cada test (eliminar ROMs primero por foreign key)
await prisma.romFile.deleteMany();
await prisma.gamePlatform.deleteMany();
await prisma.purchase.deleteMany();
await prisma.artwork.deleteMany();
await prisma.priceHistory.deleteMany();
await prisma.game.deleteMany();
});
afterEach(async () => {
await app.close();
});
describe('GET /api/roms', () => {
it('debería devolver una lista vacía cuando no hay ROMs', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([]);
});
it('debería devolver una lista de ROMs con sus propiedades', async () => {
// Crear un ROM de prueba
const rom = await prisma.romFile.create({
data: {
path: '/roms/games/',
filename: 'game.zip',
checksum: 'abc123def456',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBe(1);
expect(body[0].id).toBe(rom.id);
expect(body[0].filename).toBe('game.zip');
});
it('debería incluir información del juego asociado', async () => {
const game = await prisma.game.create({
data: {
title: 'Test Game',
slug: 'test-game',
},
});
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'test-with-game.zip',
checksum: 'checksum-game-123',
size: 2048,
format: 'zip',
gameId: game.id,
},
});
const res = await app.inject({
method: 'GET',
url: '/api/roms',
});
expect(res.statusCode).toBe(200);
const body = res.json();
// Buscar el ROM que creamos por checksum
const createdRom = body.find((r: any) => r.checksum === 'checksum-game-123');
expect(createdRom).toBeDefined();
expect(createdRom.game).toBeDefined();
expect(createdRom.game.title).toBe('Test Game');
});
});
describe('GET /api/roms/:id', () => {
it('debería retornar un ROM existente', async () => {
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game1.zip',
checksum: 'checksum1',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'GET',
url: `/api/roms/${rom.id}`,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.id).toBe(rom.id);
expect(body.filename).toBe('game1.zip');
});
it('debería retornar 404 si el ROM no existe', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/roms/non-existing-id',
});
expect(res.statusCode).toBe(404);
expect(res.json()).toHaveProperty('error');
});
it('debería incluir el juego asociado al ROM', async () => {
const game = await prisma.game.create({
data: {
title: 'Zelda',
slug: 'zelda',
},
});
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'zelda.zip',
checksum: 'checksum2',
size: 2048,
format: 'zip',
gameId: game.id,
},
});
const res = await app.inject({
method: 'GET',
url: `/api/roms/${rom.id}`,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.game).toBeDefined();
expect(body.game.title).toBe('Zelda');
});
});
describe('PUT /api/roms/:id/game', () => {
it('debería vincular un juego a un ROM existente', async () => {
const game = await prisma.game.create({
data: {
title: 'Mario',
slug: 'mario',
},
});
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'mario.zip',
checksum: 'checksum3',
size: 512,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {
gameId: game.id,
},
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.gameId).toBe(game.id);
expect(body.game.title).toBe('Mario');
});
it('debería devolver 400 si el gameId es inválido', async () => {
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game.zip',
checksum: 'checksum4',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {
gameId: 'invalid-game-id',
},
});
expect(res.statusCode).toBe(400);
});
it('debería devolver 404 si el ROM no existe', async () => {
const game = await prisma.game.create({
data: {
title: 'Test',
slug: 'test',
},
});
const res = await app.inject({
method: 'PUT',
url: '/api/roms/non-existing-id/game',
payload: {
gameId: game.id,
},
});
expect(res.statusCode).toBe(404);
});
it('debería devolver 400 si falta gameId', async () => {
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'game.zip',
checksum: 'checksum5',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'PUT',
url: `/api/roms/${rom.id}/game`,
payload: {},
});
expect(res.statusCode).toBe(400);
});
});
describe('DELETE /api/roms/:id', () => {
it('debería eliminar un ROM existente', async () => {
const rom = await prisma.romFile.create({
data: {
path: '/roms/',
filename: 'delete-me.zip',
checksum: 'checksum6',
size: 1024,
format: 'zip',
},
});
const res = await app.inject({
method: 'DELETE',
url: `/api/roms/${rom.id}`,
});
expect(res.statusCode).toBe(204);
// Verificar que el ROM fue eliminado
const deletedRom = await prisma.romFile.findUnique({
where: { id: rom.id },
});
expect(deletedRom).toBeNull();
});
it('debería devolver 404 si el ROM no existe', async () => {
const res = await app.inject({
method: 'DELETE',
url: '/api/roms/non-existing-id',
});
expect(res.statusCode).toBe(404);
});
});
});
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -6,8 +6,7 @@ vi.mock('../../src/services/fsScanner', () => ({ scanDirectory: vi.fn() }));
vi.mock('../../src/services/archiveReader', () => ({ streamArchiveEntry: vi.fn() }));
vi.mock('../../src/plugins/prisma', () => ({
default: {
game: { findUnique: vi.fn(), create: vi.fn() },
romFile: { upsert: vi.fn() },
game: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
},
}));
@@ -22,7 +21,7 @@ beforeEach(() => {
});
describe('services/importService (archive entries)', () => {
it('procesa una entrada interna usando streamArchiveEntry y hace upsert', async () => {
it('procesa una entrada interna usando streamArchiveEntry y crea Game con source=rom', async () => {
const files = [
{
path: '/roms/collection.zip::inner/rom1.bin',
@@ -41,13 +40,12 @@ describe('services/importService (archive entries)', () => {
(scanDirectory as unknown as Mock).mockResolvedValue(files);
(streamArchiveEntry as unknown as Mock).mockResolvedValue(Readable.from([data]));
(prisma.game.findUnique as unknown as Mock).mockResolvedValue(null);
(prisma.game.findFirst as unknown as Mock).mockResolvedValue(null);
(prisma.game.create as unknown as Mock).mockResolvedValue({
id: 77,
title: 'ROM1',
slug: 'rom1',
});
(prisma.romFile.upsert as unknown as Mock).mockResolvedValue({ id: 1 });
const md5 = createHash('md5').update(data).digest('hex');
@@ -57,12 +55,25 @@ describe('services/importService (archive entries)', () => {
expect((streamArchiveEntry as unknown as Mock).mock.calls[0][0]).toBe('/roms/collection.zip');
expect((streamArchiveEntry as unknown as Mock).mock.calls[0][1]).toBe('inner/rom1.bin');
expect((prisma.romFile.upsert as unknown as Mock).mock.calls.length).toBe(1);
const upsertArgs = (prisma.romFile.upsert as unknown as Mock).mock.calls[0][0];
expect(upsertArgs.where).toEqual({ checksum: md5 });
expect(upsertArgs.create.filename).toBe('rom1.bin');
expect(upsertArgs.create.path).toBe('/roms/collection.zip::inner/rom1.bin');
expect((prisma.game.findFirst as unknown as Mock).mock.calls[0][0]).toEqual({
where: { source: 'rom', romChecksum: md5 },
});
expect((prisma.game.create as unknown as Mock).mock.calls[0][0]).toEqual({
data: {
title: 'ROM1',
slug: 'rom1-1234567890123',
source: 'rom',
romPath: '/roms/collection.zip::inner/rom1.bin',
romFilename: 'rom1.bin',
romSize: 123,
romChecksum: md5,
romFormat: 'bin',
romHashes: expect.any(String),
addedAt: expect.any(Date),
lastSeenAt: expect.any(Date),
},
});
expect(summary).toEqual({ processed: 1, createdCount: 1, upserted: 1 });
expect(summary).toEqual({ processed: 1, createdCount: 1, upserted: 0 });
});
});

View File

@@ -11,8 +11,7 @@ vi.mock('../../src/services/checksumService', () => ({
vi.mock('../../src/plugins/prisma', () => ({
default: {
game: { findUnique: vi.fn(), create: vi.fn() },
romFile: { upsert: vi.fn() },
game: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn() },
},
}));
@@ -31,7 +30,7 @@ describe('services/importService', () => {
expect(typeof importDirectory).toBe('function');
});
it('cuando hay un archivo y persist:true crea Game y hace romFile.upsert, y devuelve resumen', async () => {
it('cuando hay un archivo y persist:true crea Game con source=rom y devuelve resumen', async () => {
const files = [
{
path: '/roms/Sonic.bin',
@@ -48,32 +47,104 @@ describe('services/importService', () => {
(scanDirectory as unknown as Mock).mockResolvedValue(files);
(computeHashes as unknown as Mock).mockResolvedValue(hashes);
(prisma.game.findUnique as unknown as Mock).mockResolvedValue(null);
(prisma.game.findFirst as unknown as Mock).mockResolvedValue(null);
(prisma.game.create as unknown as Mock).mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
(prisma.romFile.upsert as unknown as Mock).mockResolvedValue({ id: 1 });
const summary = await importDirectory({ dir: '/roms', persist: true });
expect((scanDirectory as unknown as Mock).mock.calls[0][0]).toBe('/roms');
expect((computeHashes as unknown as Mock).mock.calls[0][0]).toBe('/roms/Sonic.bin');
expect((prisma.game.findUnique as unknown as Mock).mock.calls[0][0]).toEqual({
where: { slug: 'sonic' },
expect((prisma.game.findFirst as unknown as Mock).mock.calls[0][0]).toEqual({
where: { source: 'rom', romChecksum: 'md5-abc' },
});
expect((prisma.game.create as unknown as Mock).mock.calls[0][0]).toEqual({
data: { title: 'Sonic', slug: 'sonic' },
data: {
title: 'Sonic',
slug: 'sonic-1234567890123',
source: 'rom',
romPath: '/roms/Sonic.bin',
romFilename: 'Sonic.bin',
romSize: 123,
romChecksum: 'md5-abc',
romFormat: 'bin',
romHashes: JSON.stringify(hashes),
addedAt: expect.any(Date),
lastSeenAt: expect.any(Date),
},
});
expect((prisma.romFile.upsert as unknown as Mock).mock.calls.length).toBe(1);
const upsertArgs = (prisma.romFile.upsert as unknown as Mock).mock.calls[0][0];
expect(upsertArgs.where).toEqual({ checksum: 'md5-abc' });
expect(upsertArgs.create.gameId).toBe(77);
expect(upsertArgs.create.filename).toBe('Sonic.bin');
expect(upsertArgs.create.hashes).toBe(JSON.stringify(hashes));
expect(summary).toEqual({ processed: 1, createdCount: 1, upserted: 1 });
expect(summary).toEqual({ processed: 1, createdCount: 1, upserted: 0 });
});
it('cuando el juego ya existe (mismo checksum), actualiza lastSeenAt', async () => {
const files = [
{
path: '/roms/Sonic.bin',
filename: 'Sonic.bin',
name: 'Sonic.bin',
size: 123,
format: 'bin',
isArchive: false,
},
];
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
(scanDirectory as unknown as Mock).mockResolvedValue(files);
(computeHashes as unknown as Mock).mockResolvedValue(hashes);
(prisma.game.findFirst as unknown as Mock).mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
(prisma.game.update as unknown as Mock).mockResolvedValue({
id: 77,
title: 'Sonic',
slug: 'sonic',
});
const summary = await importDirectory({ dir: '/roms', persist: true });
expect((prisma.game.update as unknown as Mock).mock.calls[0][0]).toEqual({
where: { id: 77 },
data: {
lastSeenAt: expect.any(Date),
romHashes: JSON.stringify(hashes),
},
});
expect(summary).toEqual({ processed: 1, createdCount: 0, upserted: 1 });
});
it('cuando persist=false no guarda nada en la base de datos', async () => {
const files = [
{
path: '/roms/Sonic.bin',
filename: 'Sonic.bin',
name: 'Sonic.bin',
size: 123,
format: 'bin',
isArchive: false,
},
];
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
(scanDirectory as unknown as Mock).mockResolvedValue(files);
(computeHashes as unknown as Mock).mockResolvedValue(hashes);
const summary = await importDirectory({ dir: '/roms', persist: false });
expect(prisma.game.findFirst).not.toHaveBeenCalled();
expect(prisma.game.create).not.toHaveBeenCalled();
expect(prisma.game.update).not.toHaveBeenCalled();
expect(summary).toEqual({ processed: 1, createdCount: 0, upserted: 0 });
});
});