Compare commits
3 Commits
4298b003d9
...
a702310da4
| Author | SHA1 | Date | |
|---|---|---|---|
| a702310da4 | |||
| 0526ff960f | |||
| 12636aefc3 |
@@ -30,6 +30,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Install native archive tools (p7zip, chdman)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# 7z / p7zip
|
||||
sudo apt-get install -y p7zip-full p7zip-rar || true
|
||||
# chdman (intentar instalar desde paquetes disponibles: mame-tools o mame)
|
||||
sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: Prisma: generate (may fail on some runners)
|
||||
run: yarn workspace quasar-backend run prisma:generate
|
||||
continue-on-error: true
|
||||
@@ -57,6 +66,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Install native archive tools (p7zip, chdman)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y p7zip-full p7zip-rar || true
|
||||
sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: yarn test:install
|
||||
continue-on-error: true
|
||||
|
||||
24
README.md
24
README.md
@@ -35,3 +35,27 @@ Quasar es una aplicación web para al gestión de una biblioteca personal de vid
|
||||
| Fireshare | Distribución de Juegos | Comparte clips de juegos, videos u otros medios mediante enlaces únicos. | Comparte contenido multimedia de forma sencilla. | Compartir capturas o videos de juegos con amigos. | [GitHub - Fireshare](https://github.com/fireshare/fireshare) |
|
||||
| Crafty Controller | Herramientas para Minecraft | Panel de control y lanzador para servidores de Minecraft. | Gestión simplificada de servidores de Minecraft. | Administrar servidores de Minecraft de forma visual. | [GitHub - Crafty Controller](https://github.com/crafty-controller/crafty-controller) |
|
||||
| Steam Headless | Herramientas para Steam | Servidor remoto de Steam sin cabeza (headless) mediante Docker. | Permite gestionar juegos de Steam en un servidor remoto. | Usuarios que quieren acceder a su biblioteca de Steam desde un servidor. | [GitHub - Steam Headless](https://github.com/steamheadless/steamheadless) |
|
||||
|
||||
## Dependencias nativas para tests de integración
|
||||
|
||||
Algunos tests de integración (p. ej. verificación de DATs, lectura de CHD/7z)
|
||||
requieren herramientas nativas instaladas en el sistema donde se ejecuten
|
||||
los tests (local o CI). A continuación está la lista mínima y cómo instalarlas:
|
||||
|
||||
- `7z` / `p7zip` — necesario para extraer/leer ZIP y 7z.
|
||||
- Debian/Ubuntu: `sudo apt update && sudo apt install -y p7zip-full p7zip-rar`
|
||||
- macOS (Homebrew): `brew install p7zip`
|
||||
|
||||
- `chdman` — herramienta de MAME para manejar archivos CHD (opcional,
|
||||
requerida para tests que trabajen con imágenes CHD).
|
||||
- Debian/Ubuntu: intentar `sudo apt install -y mame-tools` o `sudo apt install -y mame`.
|
||||
- macOS (Homebrew): `brew install mame`
|
||||
- Si no hay paquete disponible, descargar o compilar MAME/CHDTools desde
|
||||
las fuentes oficiales.
|
||||
|
||||
Notas:
|
||||
- En CI se intentará instalar estas herramientas cuando sea posible; si no
|
||||
están disponibles los tests de integración que dependan de ellas pueden
|
||||
configurarse para ejecutarse condicionalmente.
|
||||
- La variable de entorno `INTEGRATION=1` controla si se ejecutan pruebas
|
||||
más pesadas y dependientes de binarios.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"fast-xml-parser": "^5.3.5",
|
||||
"prettier": "^2.8.0",
|
||||
"prisma": "6.19.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { runner } from '../jobs/importRunner';
|
||||
import { importDirectory } from '../services/importService';
|
||||
|
||||
export default async function importRoutes(app: FastifyInstance) {
|
||||
app.post('/import/scan', async (request, reply) => {
|
||||
const body = request.body as any;
|
||||
|
||||
// Encolar el job en background (placeholder)
|
||||
// Encolar el job en background
|
||||
setImmediate(() => {
|
||||
// placeholder task: no persistencia, trabajo ligero en background
|
||||
runner
|
||||
.enqueue(async () => {
|
||||
// usar body en caso necesario; aquí sólo un placeholder
|
||||
void body;
|
||||
return true;
|
||||
// no await here; background task. Pasamos el logger de Fastify para
|
||||
// que los mensajes de advertencia se integren con el sistema de logs.
|
||||
return importDirectory({ dir: body?.dir, persist: body?.persist }, app.log);
|
||||
})
|
||||
.catch((err) => {
|
||||
app.log.warn({ err }, 'Background import task failed');
|
||||
|
||||
89
backend/src/services/archiveReader.ts
Normal file
89
backend/src/services/archiveReader.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
import path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
export type ArchiveEntry = { name: string; size: number };
|
||||
|
||||
export async function listArchiveEntries(
|
||||
filePath: string,
|
||||
logger: { warn?: (...args: any[]) => void } = console
|
||||
): Promise<ArchiveEntry[]> {
|
||||
const ext = path.extname(filePath).toLowerCase().replace(/^\./, '');
|
||||
|
||||
if (!['zip', '7z'].includes(ext)) return [];
|
||||
|
||||
const execCmd = (cmd: string) =>
|
||||
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
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: ArchiveEntry[] = [];
|
||||
|
||||
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: ArchiveEntry[] = [];
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
export default { listArchiveEntries };
|
||||
@@ -1,3 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
|
||||
@@ -1,11 +1,100 @@
|
||||
export function parseDat(_xml: string): any {
|
||||
// Stub: el parseo completo no se implementa en esta fase.
|
||||
return {};
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
export type DatRom = {
|
||||
name: string;
|
||||
size?: number;
|
||||
crc?: string;
|
||||
md5?: string;
|
||||
sha1?: string;
|
||||
};
|
||||
|
||||
export type DatGame = {
|
||||
name: string;
|
||||
roms: DatRom[];
|
||||
};
|
||||
|
||||
export type DatDatabase = {
|
||||
games: DatGame[];
|
||||
};
|
||||
|
||||
function ensureArray<T>(v: T | T[] | undefined): T[] {
|
||||
if (v === undefined || v === null) return [];
|
||||
return Array.isArray(v) ? v : [v];
|
||||
}
|
||||
|
||||
export async function verifyRomAgainstDat(_romMeta: any, _parsedDat: any): Promise<any> {
|
||||
// Stub: verificación mínima para que los tests de integración puedan ser saltados.
|
||||
return {};
|
||||
function normalizeHex(v?: string): string | undefined {
|
||||
if (!v) return undefined;
|
||||
return v.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export default { parseDat, verifyRomAgainstDat };
|
||||
export function parseDat(xml: string): DatDatabase {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '',
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
const parsed = parser.parse(xml as any) as any;
|
||||
const datafile = parsed?.datafile ?? parsed;
|
||||
|
||||
const rawGames = ensureArray(datafile?.game);
|
||||
|
||||
const games: DatGame[] = rawGames.map((g: any) => {
|
||||
// game name may be an attribute or a child node
|
||||
const nameAttr = g?.name ?? g?.['@_name'] ?? (g?.$?.name as any);
|
||||
|
||||
const romNodes = ensureArray(g?.rom);
|
||||
|
||||
const roms: DatRom[] = romNodes.map((r: any) => {
|
||||
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 };
|
||||
}
|
||||
|
||||
export function verifyHashesAgainstDat(
|
||||
datDb: DatDatabase,
|
||||
hashes: { crc?: string; md5?: string; sha1?: string; size?: number }
|
||||
): { gameName: string; romName: string; matchedOn: 'crc' | 'md5' | 'sha1' | 'size' } | null {
|
||||
const cmp = {
|
||||
crc: normalizeHex(hashes.crc),
|
||||
md5: normalizeHex(hashes.md5),
|
||||
sha1: normalizeHex(hashes.sha1),
|
||||
size: hashes.size,
|
||||
} as any;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { detectFormat } from '../lib/fileTypeDetector';
|
||||
|
||||
111
backend/src/services/importService.ts
Normal file
111
backend/src/services/importService.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Servicio: importService
|
||||
*
|
||||
* Orquesta el proceso de importación de ROMs 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.
|
||||
*
|
||||
* `importDirectory` devuelve un resumen con contadores `{ processed, createdCount, upserted }`.
|
||||
*/
|
||||
import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { scanDirectory } from './fsScanner';
|
||||
import { computeHashes } from './checksumService';
|
||||
import prisma from '../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.
|
||||
*/
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toString()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
export async function importDirectory(
|
||||
options?: { dir?: string; persist?: boolean },
|
||||
logger: { warn?: (msg: any, ...args: any[]) => void } = console
|
||||
) {
|
||||
const providedDir = options?.dir;
|
||||
const dir = providedDir ?? process.env.ROMS_PATH ?? path.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 fsPromises.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: any[] = [];
|
||||
try {
|
||||
files = await scanDirectory(dir as string);
|
||||
} 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 {
|
||||
const hashes = await computeHashes(file.path);
|
||||
const checksum = hashes.md5;
|
||||
const size = hashes.size;
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
upserted++;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn?.({ err, file }, 'importDirectory: error procesando fichero, se continúa con el siguiente');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { processed, createdCount, upserted };
|
||||
}
|
||||
|
||||
export default importDirectory;
|
||||
17
backend/tests/fixtures/sample.dat.xml
vendored
Normal file
17
backend/tests/fixtures/sample.dat.xml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<datafile>
|
||||
<header>
|
||||
<name>Sample DAT for tests</name>
|
||||
</header>
|
||||
|
||||
<game name="Game Alpha">
|
||||
<rom name="alpha1.bin" size="1000" crc="ABCD1234" md5="11111111111111111111111111111111" sha1="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" />
|
||||
<rom name="alpha2.bin" size="2000" crc="BEEFCAFE" md5="22222222222222222222222222222222" sha1="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" />
|
||||
</game>
|
||||
|
||||
<game name="Game Beta">
|
||||
<rom name="beta1.rom" size="3000" crc="DEADBEEF" md5="33333333333333333333333333333333" sha1="cccccccccccccccccccccccccccccccccccccccc" />
|
||||
<rom name="beta2.rom" size="4000" md5="44444444444444444444444444444444" sha1="dddddddddddddddddddddddddddddddddddddddd" />
|
||||
</game>
|
||||
|
||||
</datafile>
|
||||
47
backend/tests/services/archiveReader.spec.ts
Normal file
47
backend/tests/services/archiveReader.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mockeamos el módulo `child_process` para controlar las llamadas a `exec`.
|
||||
vi.mock('child_process', () => ({ exec: vi.fn() }));
|
||||
import * as child_process from 'child_process';
|
||||
import { listArchiveEntries } from '../../src/services/archiveReader';
|
||||
|
||||
describe('services/archiveReader', () => {
|
||||
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 as any).exec.mockImplementation((cmd: any, cb: any) => {
|
||||
cb(null, stdout, '');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
const entries = await listArchiveEntries('/roms/archive.7z', console);
|
||||
expect(entries.length).toBe(2);
|
||||
expect(entries[0].name).toBe('file1.txt');
|
||||
expect(entries[0].size).toBe(123);
|
||||
(child_process as any).exec.mockRestore?.();
|
||||
});
|
||||
|
||||
it('usa unzip como fallback para zip cuando 7z falla', async () => {
|
||||
(child_process as any).exec
|
||||
.mockImplementationOnce((cmd: any, cb: any) => {
|
||||
// simular fallo de 7z
|
||||
cb(new Error('7z not found'), '', '');
|
||||
return {} as any;
|
||||
})
|
||||
.mockImplementationOnce((cmd: any, cb: any) => {
|
||||
// salida simulada de unzip -l
|
||||
cb(null, ' 123 file1.txt\n 456 file2.bin\n', '');
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
const entries = await listArchiveEntries('/roms/archive.zip', console);
|
||||
expect(entries.length).toBe(2);
|
||||
expect(entries[0].name).toBe('file1.txt');
|
||||
(child_process as any).exec.mockRestore?.();
|
||||
});
|
||||
|
||||
it('retorna vacío para formatos no soportados', async () => {
|
||||
const entries = await listArchiveEntries('/roms/simple.bin');
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { parseDat, verifyRomAgainstDat } from '../../src/services/datVerifier';
|
||||
import path from 'path';
|
||||
|
||||
const fixturesDir = path.join(__dirname, '..', 'fixtures');
|
||||
const datPath = path.join(fixturesDir, 'dats', 'sample-no-intro.dat.xml');
|
||||
const simpleRom = path.join(fixturesDir, 'simple-rom.bin');
|
||||
import { parseDat, verifyHashesAgainstDat, DatDatabase } from '../../src/services/datVerifier';
|
||||
|
||||
const runIntegration = !!process.env.INTEGRATION;
|
||||
const describeIf = runIntegration ? describe : describe.skip;
|
||||
const FIXTURE = path.resolve('tests/fixtures/sample.dat.xml');
|
||||
|
||||
describeIf('services/datVerifier', () => {
|
||||
it('parsea DAT xml', () => {
|
||||
const xml = fs.readFileSync(datPath, 'utf8');
|
||||
const parsed = parseDat(xml);
|
||||
expect(parsed).toBeDefined();
|
||||
describe('services/datVerifier', () => {
|
||||
it('parseDat parses simple DAT XML', () => {
|
||||
const xml = fs.readFileSync(FIXTURE, 'utf8');
|
||||
const dat: DatDatabase = parseDat(xml);
|
||||
|
||||
expect(dat).toBeTruthy();
|
||||
expect(Array.isArray(dat.games)).toBe(true);
|
||||
expect(dat.games.length).toBe(2);
|
||||
|
||||
const g0 = dat.games[0];
|
||||
expect(g0.name).toBe('Game Alpha');
|
||||
expect(g0.roms.length).toBeGreaterThan(0);
|
||||
expect(g0.roms[0].name).toBe('alpha1.bin');
|
||||
expect(g0.roms[0].crc).toBeDefined();
|
||||
expect(g0.roms[0].md5).toBeDefined();
|
||||
|
||||
const g1 = dat.games[1];
|
||||
expect(g1.name).toBe('Game Beta');
|
||||
expect(g1.roms.some((r) => r.name === 'beta2.rom')).toBe(true);
|
||||
});
|
||||
|
||||
it('verifica rom contra DAT', async () => {
|
||||
const stats = fs.statSync(simpleRom);
|
||||
const romMeta = {
|
||||
filename: 'simple-rom.bin',
|
||||
size: stats.size,
|
||||
md5: 'placeholder',
|
||||
sha1: 'placeholder',
|
||||
crc32: 'placeholder',
|
||||
} as any;
|
||||
it('verifyHashesAgainstDat finds match by CRC', () => {
|
||||
const xml = fs.readFileSync(FIXTURE, 'utf8');
|
||||
const dat = parseDat(xml);
|
||||
|
||||
const xml = fs.readFileSync(datPath, 'utf8');
|
||||
const parsed = parseDat(xml);
|
||||
const res = await verifyRomAgainstDat(romMeta, parsed);
|
||||
expect(res).toBeDefined();
|
||||
const match = verifyHashesAgainstDat(dat, { crc: 'DEADBEEF' });
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.gameName).toBe('Game Beta');
|
||||
expect(match?.romName).toBe('beta1.rom');
|
||||
expect(match?.matchedOn).toBe('crc');
|
||||
});
|
||||
|
||||
it('verifyHashesAgainstDat finds match by MD5, SHA1 and size', () => {
|
||||
const xml = fs.readFileSync(FIXTURE, 'utf8');
|
||||
const dat = parseDat(xml);
|
||||
|
||||
const md5match = verifyHashesAgainstDat(dat, { md5: '11111111111111111111111111111111' });
|
||||
expect(md5match).not.toBeNull();
|
||||
expect(md5match?.gameName).toBe('Game Alpha');
|
||||
expect(md5match?.romName).toBe('alpha1.bin');
|
||||
expect(md5match?.matchedOn).toBe('md5');
|
||||
|
||||
const sha1match = verifyHashesAgainstDat(dat, {
|
||||
sha1: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
});
|
||||
expect(sha1match).not.toBeNull();
|
||||
expect(sha1match?.gameName).toBe('Game Alpha');
|
||||
expect(sha1match?.romName).toBe('alpha2.bin');
|
||||
expect(sha1match?.matchedOn).toBe('sha1');
|
||||
|
||||
const sizematch = verifyHashesAgainstDat(dat, { size: 4000 });
|
||||
expect(sizematch).not.toBeNull();
|
||||
expect(sizematch?.gameName).toBe('Game Beta');
|
||||
expect(sizematch?.romName).toBe('beta2.rom');
|
||||
expect(sizematch?.matchedOn).toBe('size');
|
||||
});
|
||||
|
||||
it('verifyHashesAgainstDat returns null when no match', () => {
|
||||
const xml = fs.readFileSync(FIXTURE, 'utf8');
|
||||
const dat = parseDat(xml);
|
||||
|
||||
const noMatch = verifyHashesAgainstDat(dat, { md5: 'ffffffffffffffffffffffffffffffff' });
|
||||
expect(noMatch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
79
backend/tests/services/importService.spec.ts
Normal file
79
backend/tests/services/importService.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../src/services/fsScanner', () => ({
|
||||
scanDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/services/checksumService', () => ({
|
||||
computeHashes: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/plugins/prisma', () => ({
|
||||
default: {
|
||||
game: { findUnique: vi.fn(), create: vi.fn() },
|
||||
romFile: { upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { importDirectory, createSlug } from '../../src/services/importService';
|
||||
import { scanDirectory } from '../../src/services/fsScanner';
|
||||
import { computeHashes } from '../../src/services/checksumService';
|
||||
import prisma from '../../src/plugins/prisma';
|
||||
|
||||
describe('services/importService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('exporta createSlug e importDirectory', () => {
|
||||
expect(typeof createSlug).toBe('function');
|
||||
expect(typeof importDirectory).toBe('function');
|
||||
});
|
||||
|
||||
it('cuando hay un archivo y persist:true crea Game y hace romFile.upsert, 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' };
|
||||
|
||||
(scanDirectory as unknown as vi.Mock).mockResolvedValue(files);
|
||||
(computeHashes as unknown as vi.Mock).mockResolvedValue(hashes);
|
||||
|
||||
(prisma.game.findUnique as unknown as vi.Mock).mockResolvedValue(null);
|
||||
(prisma.game.create as unknown as vi.Mock).mockResolvedValue({
|
||||
id: 77,
|
||||
title: 'Sonic',
|
||||
slug: 'sonic',
|
||||
});
|
||||
(prisma.romFile.upsert as unknown as vi.Mock).mockResolvedValue({ id: 1 });
|
||||
|
||||
const summary = await importDirectory({ dir: '/roms', persist: true });
|
||||
|
||||
expect((scanDirectory as unknown as vi.Mock).mock.calls[0][0]).toBe('/roms');
|
||||
expect((computeHashes as unknown as vi.Mock).mock.calls[0][0]).toBe('/roms/Sonic.bin');
|
||||
|
||||
expect((prisma.game.findUnique as unknown as vi.Mock).mock.calls[0][0]).toEqual({
|
||||
where: { slug: 'sonic' },
|
||||
});
|
||||
expect((prisma.game.create as unknown as vi.Mock).mock.calls[0][0]).toEqual({
|
||||
data: { title: 'Sonic', slug: 'sonic' },
|
||||
});
|
||||
|
||||
expect((prisma.romFile.upsert as unknown as vi.Mock).mock.calls.length).toBe(1);
|
||||
const upsertArgs = (prisma.romFile.upsert as unknown as vi.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 });
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,38 @@
|
||||
## Phase 3 Complete: ArchiveReader
|
||||
|
||||
TL;DR: Implementado `archiveReader` para listar entradas dentro de contenedores ZIP y 7z usando utilidades del sistema (`7z` y `unzip` como fallback). Añadidos tests unitarios que mockean las llamadas a `child_process.exec` para validar parsing y comportamiento de fallback.
|
||||
|
||||
**Files created/changed:**
|
||||
|
||||
- backend/src/services/archiveReader.ts
|
||||
- backend/tests/services/archiveReader.spec.ts
|
||||
|
||||
**Functions created/changed:**
|
||||
|
||||
- `listArchiveEntries(filePath, logger)` — lista entradas de ZIP/7z usando `7z -slt` y `unzip -l` como fallback.
|
||||
|
||||
**Tests created/changed:**
|
||||
|
||||
- `backend/tests/services/archiveReader.spec.ts` — cubre:
|
||||
- listado con salida simulada de `7z -slt`
|
||||
- fallback a `unzip -l` si `7z` falla
|
||||
- comportamiento para formatos no soportados
|
||||
|
||||
**Review Status:** APPROVED
|
||||
|
||||
**Git Commit Message:**
|
||||
feat: add archive reader and tests
|
||||
|
||||
- Añade `archiveReader` que lista entradas en ZIP/7z con fallback a `unzip`
|
||||
- Añade tests unitarios que mockean `child_process.exec` para validar parsing
|
||||
- Documenta dependencia de binarios en README y CI (pasos previos)
|
||||
|
||||
## Phase 3 Complete: Backend base y modelo de datos
|
||||
|
||||
Fase completada: configuré el backend mínimo (dependencias, Prisma schema), generé el cliente Prisma y aseguré que los tests TDD de backend pasan.
|
||||
|
||||
**Files created/changed:**
|
||||
|
||||
- backend/package.json
|
||||
- backend/prisma/schema.prisma
|
||||
- backend/tests/models/game.spec.ts
|
||||
@@ -11,18 +41,22 @@ Fase completada: configuré el backend mínimo (dependencias, Prisma schema), ge
|
||||
- prisma-client/package.json
|
||||
|
||||
**Files generados por herramientas (no necesariamente versionadas):**
|
||||
- prisma-client/client/* (Prisma Client generado)
|
||||
- node_modules/.prisma/client/* (artefacto runtime generado)
|
||||
|
||||
- prisma-client/client/\* (Prisma Client generado)
|
||||
- node_modules/.prisma/client/\* (artefacto runtime generado)
|
||||
|
||||
**Functions / cambios clave:**
|
||||
|
||||
- Ajustes en `backend/tests/models/game.spec.ts` para fallback de carga del cliente Prisma generado.
|
||||
- `backend/prisma/schema.prisma`: definición de modelos (Game, RomFile, Platform, Purchase, Artwork, Tag, PriceHistory) ya presente; ajustado el `generator client` para flujo de generación local.
|
||||
|
||||
**Tests created/changed:**
|
||||
|
||||
- backend/tests/models/game.spec.ts (modificado: mejor manejo de require/generación del cliente)
|
||||
- backend/tests/server.spec.ts (existente — pase verificable)
|
||||
|
||||
**Migraciones aplicadas durante pruebas:**
|
||||
|
||||
- `backend/prisma/migrations/20260208102247_init/migration.sql` (aplicada en DB temporal de test)
|
||||
|
||||
**Review Status:** APPROVED
|
||||
|
||||
29
plans/gestor-coleccion-plan-phase-4-complete.md
Normal file
29
plans/gestor-coleccion-plan-phase-4-complete.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## Phase 4 Complete: DAT verifier
|
||||
|
||||
TL;DR: Implementado `datVerifier` para parsear archivos DAT (XML) y verificar hashes de ROMs (CRC/MD5/SHA1/size). Se añadieron tests TDD y una fixture XML; los tests específicos pasan y se aplicó un parche menor de calidad.
|
||||
|
||||
**Files created/changed:**
|
||||
|
||||
- backend/src/services/datVerifier.ts
|
||||
- backend/tests/services/datVerifier.spec.ts
|
||||
- backend/tests/fixtures/sample.dat.xml
|
||||
- backend/package.json (se añadió `fast-xml-parser` en devDependencies)
|
||||
|
||||
**Functions created/changed:**
|
||||
|
||||
- `parseDat(xml: string): DatDatabase` — parsea y normaliza la estructura DAT a un modelo en memoria.
|
||||
- `verifyHashesAgainstDat(datDb: DatDatabase, hashes): {gameName, romName, matchedOn} | null` — verifica hashes contra el DAT y devuelve la coincidencia.
|
||||
|
||||
**Tests created/changed:**
|
||||
|
||||
- `backend/tests/services/datVerifier.spec.ts` — cubre parsing, match por CRC/MD5/SHA1/size y ausencia de match.
|
||||
- `backend/tests/fixtures/sample.dat.xml` — fixture usada por las pruebas.
|
||||
|
||||
**Review Status:** APPROVED with minor recommendations
|
||||
|
||||
**Git Commit Message:**
|
||||
feat: add datVerifier and tests
|
||||
|
||||
- Añade `datVerifier` con `parseDat` y `verifyHashesAgainstDat`
|
||||
- Añade tests y fixture XML para validar matching por CRC/MD5/SHA1/size
|
||||
- Añade `fast-xml-parser` en `backend/package.json` (devDependency)
|
||||
19
yarn.lock
19
yarn.lock
@@ -1992,6 +1992,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-xml-parser@npm:^5.3.5":
|
||||
version: 5.3.5
|
||||
resolution: "fast-xml-parser@npm:5.3.5"
|
||||
dependencies:
|
||||
strnum: "npm:^2.1.2"
|
||||
bin:
|
||||
fxparser: src/cli/cli.js
|
||||
checksum: 10c0/ac6232de821c8292436c53a58fc583f073cc5a73d14310b956391512e325e1ef65b950a1d41f5f2715b0d4d363fac2e483d7df1748b344647ea7c7f219a5d2f4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fastify-plugin@npm:^4.0.0, fastify-plugin@npm:^4.2.1":
|
||||
version: 4.5.1
|
||||
resolution: "fastify-plugin@npm:4.5.1"
|
||||
@@ -3335,6 +3346,7 @@ __metadata:
|
||||
"@types/node": "npm:^18.0.0"
|
||||
dotenv: "npm:^16.0.0"
|
||||
eslint: "npm:^8.0.0"
|
||||
fast-xml-parser: "npm:^5.3.5"
|
||||
fastify: "npm:^4.28.0"
|
||||
pino: "npm:^8.0.0"
|
||||
prettier: "npm:^2.8.0"
|
||||
@@ -3773,6 +3785,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"strnum@npm:^2.1.2":
|
||||
version: 2.1.2
|
||||
resolution: "strnum@npm:2.1.2"
|
||||
checksum: 10c0/4e04753b793540d79cd13b2c3e59e298440477bae2b853ab78d548138385193b37d766d95b63b7046475d68d44fb1fca692f0a3f72b03f4168af076c7b246df9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"supports-color@npm:^7.1.0":
|
||||
version: 7.2.0
|
||||
resolution: "supports-color@npm:7.2.0"
|
||||
|
||||
Reference in New Issue
Block a user