diff --git a/backend/package.json b/backend/package.json index c05c284..231341d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/services/datVerifier.ts b/backend/src/services/datVerifier.ts index 3919935..6da1756 100644 --- a/backend/src/services/datVerifier.ts +++ b/backend/src/services/datVerifier.ts @@ -1,23 +1,100 @@ -/** - * Servicio: datVerifier - * - * Encargado de parsear ficheros DAT (XML de listas de ROMs) y de verificar si - * un ROM (por tamaño / CRC / MD5 / SHA1) coincide con una entrada del DAT. - * - * Actualmente este archivo contiene stubs mínimos: `parseDat` y - * `verifyRomAgainstDat` devuelven valores vacíos para permitir que las pruebas - * unitarias/integración opcionales se salten cuando `INTEGRATION` no está set. - * En fases posteriores se sustituirá por un parseador XML completo y lógica de - * matching detallada. - */ -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(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 { - // 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; +} diff --git a/backend/tests/fixtures/sample.dat.xml b/backend/tests/fixtures/sample.dat.xml new file mode 100644 index 0000000..696b716 --- /dev/null +++ b/backend/tests/fixtures/sample.dat.xml @@ -0,0 +1,17 @@ + + +
+ Sample DAT for tests +
+ + + + + + + + + + + +
diff --git a/backend/tests/services/datVerifier.spec.ts b/backend/tests/services/datVerifier.spec.ts index 5241546..bd5ab27 100644 --- a/backend/tests/services/datVerifier.spec.ts +++ b/backend/tests/services/datVerifier.spec.ts @@ -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 FIXTURE = path.resolve('tests/fixtures/sample.dat.xml'); -// Ejecutar siempre las pruebas de datVerifier. Dependencias externas (p.ej. -// binarios para formatos específicos) deben estar instaladas en el entorno -// donde se intente ejecutar las pruebas completas. describe('services/datVerifier', () => { - it('parsea DAT xml', () => { - const xml = fs.readFileSync(datPath, 'utf8'); - const parsed = parseDat(xml); - expect(parsed).toBeDefined(); + 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(); }); }); diff --git a/plans/gestor-coleccion-plan-phase-4-complete.md b/plans/gestor-coleccion-plan-phase-4-complete.md new file mode 100644 index 0000000..51f74aa --- /dev/null +++ b/plans/gestor-coleccion-plan-phase-4-complete.md @@ -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) diff --git a/yarn.lock b/yarn.lock index d9a831f..4facefd 100644 --- a/yarn.lock +++ b/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"