Compare commits
2 Commits
a702310da4
...
97a7f74685
| Author | SHA1 | Date | |
|---|---|---|---|
| 97a7f74685 | |||
| ab63361e66 |
@@ -15,7 +15,7 @@
|
|||||||
* las llamadas a `child_process.exec`.
|
* las llamadas a `child_process.exec`.
|
||||||
*/
|
*/
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { exec } from 'child_process';
|
import { exec, spawn } from 'child_process';
|
||||||
|
|
||||||
export type ArchiveEntry = { name: string; size: number };
|
export type ArchiveEntry = { name: string; size: number };
|
||||||
|
|
||||||
@@ -86,4 +86,92 @@ export async function listArchiveEntries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default { listArchiveEntries };
|
export async function streamArchiveEntry(
|
||||||
|
filePath: string,
|
||||||
|
entryPath: string,
|
||||||
|
logger: { warn?: (...args: any[]) => void } = console
|
||||||
|
): Promise<NodeJS.ReadableStream | null> {
|
||||||
|
const ext = path.extname(filePath).toLowerCase().replace(/^\./, '');
|
||||||
|
|
||||||
|
if (!['zip', '7z'].includes(ext)) return null;
|
||||||
|
|
||||||
|
const waitForStreamOrError = (proc: any): Promise<NodeJS.ReadableStream | null> =>
|
||||||
|
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: any;
|
||||||
|
try {
|
||||||
|
proc = 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: any = 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { listArchiveEntries, streamArchiveEntry };
|
||||||
|
|||||||
@@ -13,9 +13,13 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { promises as fsPromises } from 'fs';
|
import { promises as fsPromises } from 'fs';
|
||||||
import { detectFormat } from '../lib/fileTypeDetector';
|
import { detectFormat } from '../lib/fileTypeDetector';
|
||||||
|
import { listArchiveEntries } from './archiveReader';
|
||||||
|
|
||||||
|
const ARCHIVE_MAX_ENTRIES = Number(process.env.ARCHIVE_MAX_ENTRIES) || 1000;
|
||||||
|
|
||||||
export async function scanDirectory(dirPath: string): Promise<any[]> {
|
export async function scanDirectory(dirPath: string): Promise<any[]> {
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
|
let archiveEntriesAdded = 0;
|
||||||
|
|
||||||
async function walk(dir: string) {
|
async function walk(dir: string) {
|
||||||
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
||||||
@@ -43,6 +47,35 @@ export async function scanDirectory(dirPath: string): Promise<any[]> {
|
|||||||
format,
|
format,
|
||||||
isArchive,
|
isArchive,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isArchive) {
|
||||||
|
try {
|
||||||
|
const entries = await listArchiveEntries(full);
|
||||||
|
|
||||||
|
for (const e of entries) {
|
||||||
|
if (archiveEntriesAdded >= ARCHIVE_MAX_ENTRIES) break;
|
||||||
|
if (!e || !e.name) continue;
|
||||||
|
// avoid path traversal or absolute paths
|
||||||
|
if (e.name.includes('..') || path.isAbsolute(e.name)) continue;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
path: `${full}::${e.name}`,
|
||||||
|
containerPath: full,
|
||||||
|
entryPath: e.name,
|
||||||
|
filename: path.basename(e.name),
|
||||||
|
name: e.name,
|
||||||
|
size: e.size,
|
||||||
|
format: detectFormat(e.name),
|
||||||
|
isArchive: false,
|
||||||
|
isArchiveEntry: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
archiveEntriesAdded++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// ignore archive listing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
backend/tests/services/archiveReader.stream.spec.ts
Normal file
69
backend/tests/services/archiveReader.stream.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { PassThrough } from 'stream';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
vi.mock('child_process', () => ({ spawn: vi.fn() }));
|
||||||
|
import * as child_process from 'child_process';
|
||||||
|
import { streamArchiveEntry } from '../../src/services/archiveReader';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('services/archiveReader streamArchiveEntry', () => {
|
||||||
|
it('streams entry using 7z stdout', async () => {
|
||||||
|
const pass = new PassThrough();
|
||||||
|
const proc = new EventEmitter() as any;
|
||||||
|
proc.stdout = pass;
|
||||||
|
|
||||||
|
(child_process.spawn as any).mockImplementation(() => proc as any);
|
||||||
|
|
||||||
|
// Emular producción de datos de forma asíncrona
|
||||||
|
setImmediate(() => {
|
||||||
|
pass.write(Buffer.from('content-from-7z'));
|
||||||
|
pass.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await streamArchiveEntry('/roms/archive.7z', 'path/file.txt');
|
||||||
|
expect(stream).not.toBeNull();
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream as any) {
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Buffer.concat(chunks).toString()).toBe('content-from-7z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to unzip -p when 7z throws', async () => {
|
||||||
|
const pass = new PassThrough();
|
||||||
|
const proc2 = new EventEmitter() as any;
|
||||||
|
proc2.stdout = pass;
|
||||||
|
|
||||||
|
(child_process.spawn as any)
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
throw new Error('spawn ENOENT');
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(() => proc2 as any);
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
pass.write(Buffer.from('fallback-content'));
|
||||||
|
pass.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await streamArchiveEntry('/roms/archive.zip', 'file.dat');
|
||||||
|
expect(stream).not.toBeNull();
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream as any) {
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Buffer.concat(chunks).toString()).toBe('fallback-content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unsupported formats', async () => {
|
||||||
|
const res = await streamArchiveEntry('/roms/archive.bin', 'entry');
|
||||||
|
expect(res).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
35
backend/tests/services/fsScanner.archiveEntries.spec.ts
Normal file
35
backend/tests/services/fsScanner.archiveEntries.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import { afterEach, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vi.fn() }));
|
||||||
|
|
||||||
|
import scanDirectory from '../../src/services/fsScanner';
|
||||||
|
import { listArchiveEntries } from '../../src/services/archiveReader';
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('expone entradas internas de archivos como items virtuales', async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fsScanner-test-'));
|
||||||
|
const collectionFile = path.join(tmpDir, 'collection.zip');
|
||||||
|
await fs.writeFile(collectionFile, '');
|
||||||
|
|
||||||
|
(listArchiveEntries as unknown as vi.Mock).mockResolvedValue([
|
||||||
|
{ name: 'inner/rom1.bin', size: 1234 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = await scanDirectory(tmpDir);
|
||||||
|
|
||||||
|
const expectedPath = `${collectionFile}::inner/rom1.bin`;
|
||||||
|
const found = results.find((r: any) => r.path === expectedPath);
|
||||||
|
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found.isArchiveEntry).toBe(true);
|
||||||
|
expect(found.containerPath).toBe(collectionFile);
|
||||||
|
expect(found.entryPath).toBe('inner/rom1.bin');
|
||||||
|
expect(found.filename).toBe('rom1.bin');
|
||||||
|
expect(found.format).toBe('bin');
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
## Plan: Importador de ROMs (Fase 4)
|
|
||||||
|
|
||||||
TL;DR: Estabilizar el entorno de tests y, mediante TDD, implementar la pipeline de importación de ROMs: escaneo de ficheros, cálculo de checksums, verificación contra DATs, persistencia en Prisma y un runner en background. Se comenzará con un runner en memoria y se dejará la puerta abierta para migrar a Redis posteriormente.
|
|
||||||
|
|
||||||
**Phases**
|
|
||||||
|
|
||||||
1. **Phase 1: Estabilizar entorno y ejecutar tests**
|
|
||||||
- **Objective:** Obtener una línea base reproducible donde `yarn --cwd backend test` se ejecute y muestre resultados claros.
|
|
||||||
- **Files/Functions to Modify/Create:** `backend/tsconfig.json`, `backend/package.json`, `backend/prisma/schema.prisma`, `backend/src/plugins/prisma.ts`.
|
|
||||||
- **Tests to Write:** Ninguno nuevo; ejecutar y capturar los tests existentes (`backend/tests/**`).
|
|
||||||
- **Steps:**
|
|
||||||
1. Ejecutar `yarn install` en la raíz y generar el cliente Prisma (`prisma generate`) en `backend/`.
|
|
||||||
2. Ejecutar `yarn --cwd backend test` y documentar fallos.
|
|
||||||
3. Corregir problemas de `prisma generate` o `tsconfig` y validar que los tests relevantes pasan.
|
|
||||||
|
|
||||||
2. **Phase 2: Persistencia básica e integración con la ruta de import**
|
|
||||||
- **Objective:** Implementar `importService` que use `scanDirectory` y `computeHashes` para persistir `RomFile` (upsert por checksum) y, cuando sea posible, vincular/crear `Game`.
|
|
||||||
- **Files/Functions to Modify/Create:** `backend/src/services/importService.ts`, actualizar `backend/src/routes/import.ts` para invocar el servicio.
|
|
||||||
- **Tests to Write:** `backend/tests/services/importService.spec.ts`, actualizar `backend/tests/routes/import.spec.ts` para escenarios `persist: true/false`.
|
|
||||||
- **Steps:**
|
|
||||||
1. Escribir tests (falla roja).
|
|
||||||
2. Implementar mínimo para pasar tests (green).
|
|
||||||
3. Refactor y asegurar idempotencia (re-run tests).
|
|
||||||
|
|
||||||
3. **Phase 3: ArchiveReader — soportar zip/7z/chd**
|
|
||||||
- **Objective:** Leer/listar contenido de contenedores (ZIP, 7z, CHD) sin extracción completa para indexar ROMs internos.
|
|
||||||
- **Files/Functions to Modify/Create:** `backend/src/services/archiveReader.ts`; adaptar `backend/src/services/fsScanner.ts` para delegar en `archiveReader` cuando `isArchive`.
|
|
||||||
- **Tests to Write:** `backend/tests/services/archiveReader.spec.ts` (fixtures: zips/7z/CHD bajo `backend/tests/fixtures`).
|
|
||||||
- **Steps:**
|
|
||||||
1. Añadir tests que describan el comportamiento esperado (falla roja).
|
|
||||||
2. Implementar con librería elegida y validar en CI con binarios instalados.
|
|
||||||
|
|
||||||
4. **Phase 4: DAT parsing y verificación**
|
|
||||||
- **Objective:** Parsear DAT XML y comprobar si un ROM coincide con una entrada DAT (por checksums/size/name).
|
|
||||||
- **Files/Functions to Modify/Create:** `backend/src/services/datVerifier.ts` (completar), añadir utilidades de parseo XML.
|
|
||||||
- **Tests to Write:** `backend/tests/services/datVerifier.spec.ts` (unidad + integración, usar `INTEGRATION=1` para pruebas que dependan de binarios/fixtures grandes).
|
|
||||||
- **Steps:**
|
|
||||||
1. Implementar parseo y matching (falla roja).
|
|
||||||
2. Integrarlo en `importService` para sugerir o asociar `Game`.
|
|
||||||
|
|
||||||
5. **Phase 5: Job runner en memoria (inicio) — migrable a Redis**
|
|
||||||
- **Objective:** Implementar un runner en memoria que procese jobs de import con control de concurrencia (`IMPORT_CONCURRENCY`), estado básico y capacidad de encolar tareas desde la ruta `/api/import/scan`.
|
|
||||||
- **Files/Functions to Modify/Create:** `backend/src/config.ts`, `backend/src/jobs/importRunner.ts`, tests en `backend/tests/jobs/importRunner.spec.ts`, actualizar `backend/src/routes/import.ts` para encolar jobs.
|
|
||||||
- **Tests to Write:** `backend/tests/jobs/importRunner.spec.ts` (enqueue/resolución, concurrencia, getStatus).
|
|
||||||
- **Steps:**
|
|
||||||
1. Escribir tests (falla roja).
|
|
||||||
2. Implementar runner in-memory (green).
|
|
||||||
3. Integrar con la ruta de import y validar comportamiento en tests.
|
|
||||||
|
|
||||||
6. **Phase 6: CI e integración de binarios**
|
|
||||||
- **Objective:** Preparar workflows para ejecutar pruebas de integración que dependan de binarios (`7z`, `chdman`) y asegurar `prisma generate` en CI.
|
|
||||||
- **Files/Functions to Modify/Create:** `.github/workflows/ci.yml`, documentación en `README.md`.
|
|
||||||
- **Steps:**
|
|
||||||
1. Crear workflow que haga `yarn install`, `yarn --cwd backend prisma generate`, instale binarios (o use contenedor preparado) y ejecute `yarn --cwd backend test` con `INTEGRATION=1` cuando corresponda.
|
|
||||||
|
|
||||||
**Open Questions**
|
|
||||||
|
|
||||||
1. ¿Persistimos `ImportJob` en DB desde el inicio (útil para resume/retry) o lo dejamos para una futura migración a Redis? (Persistir ahora / Posponer)
|
|
||||||
2. ¿En CI preferís instalar `7z`/`chdman` o marcar tests de archive/CHD como opcionales con `INTEGRATION=1` y ejecutarlos solo si runner proporciona binarios? (Instalar / Opcional)
|
|
||||||
3. ¿Creación automática de `Game` al no encontrar DAT match? (Crear placeholder con `slug` / Solo guardar `RomFile`)
|
|
||||||
4. Política de colas a largo plazo: ¿Redis desde el inicio o in-memory ahora y migrar luego? (Usuario eligió: in-memory ahora)
|
|
||||||
|
|
||||||
## Plan: Importadores y gestión de ROMs (Fase 4)
|
|
||||||
|
|
||||||
Implementar servicios para escanear directorios de ROMs, detectar formatos (ZIP/7z/CHD), calcular checksums (CRC32/MD5/SHA1), verificar contra DATs (No-Intro/Redump) y persistir `RomFile` en la BD. El escaneo se lanzará desde el frontend, correrá como job en background y la ruta a las ROMs será una configuración del sistema (no se envía en cada request). Se añadirá la variable `IMPORT_CONCURRENCY` con cálculo por defecto.
|
|
||||||
|
|
||||||
**Phases 4**
|
|
||||||
|
|
||||||
1. **Phase 4.1: Tests y fixtures (TDD)**
|
|
||||||
- **Objective:** Escribir tests y fixtures que guiarán la implementación; los tests deben fallar inicialmente.
|
|
||||||
- **Files a crear:**
|
|
||||||
- `backend/tests/fixtures/simple-rom.bin` — fixture ROM sintética
|
|
||||||
- `backend/tests/fixtures/nested/nested-rom.bin` — fixture en subdirectorio
|
|
||||||
- `backend/tests/fixtures/dats/sample-no-intro.dat.xml` — DAT XML mínimo
|
|
||||||
- `backend/tests/services/fsScanner.spec.ts`
|
|
||||||
- `backend/tests/services/checksumService.spec.ts`
|
|
||||||
- `backend/tests/services/datVerifier.spec.ts` (marcar integración para binarios cuando aplique)
|
|
||||||
- `backend/tests/routes/import.spec.ts`
|
|
||||||
- **Criterio de aceptación:** Los tests existen y fallan por falta de implementación (TDD).
|
|
||||||
|
|
||||||
2. **Phase 4.2: Core — Scanner y checksums**
|
|
||||||
- **Objective:** Implementar `fsScanner` y `checksumService` con streaming y control de concurrencia.
|
|
||||||
- **Files a crear:**
|
|
||||||
- `backend/src/services/fsScanner.ts`
|
|
||||||
- `backend/src/services/checksumService.ts`
|
|
||||||
- `backend/src/lib/fileTypeDetector.ts`
|
|
||||||
- **Criterio de aceptación:** Tests unitarios de Phase 4.1 pasan para los casos no relacionados con archives/CHD.
|
|
||||||
|
|
||||||
3. **Phase 4.3: Archives y DAT verification**
|
|
||||||
- **Objective:** Implementar `archiveReader` (ZIP, 7z opcional) y `datVerifier`.
|
|
||||||
- **Files a crear:**
|
|
||||||
- `backend/src/services/archiveReader.ts`
|
|
||||||
- `backend/src/services/datVerifier.ts`
|
|
||||||
- **Notas:** CHD será soportado opcionalmente mediante `chdman` (si está instalado en el sistema); en la MVP se tratará CHD como blob para checksums si `chdman` no está presente.
|
|
||||||
|
|
||||||
4. **Phase 4.4: API, job en background y E2E**
|
|
||||||
- **Objective:** Añadir endpoint `POST /api/import/scan` (sin recibir path; usa la ruta preconfigurada), job runner en background, endpoints `GET /api/import/status/:taskId` y `GET /api/import/result/:taskId`, y pruebas E2E que verifiquen persistencia en Prisma.
|
|
||||||
- **Files a crear:**
|
|
||||||
- `backend/src/routes/import.ts`
|
|
||||||
- `backend/src/controllers/importController.ts`
|
|
||||||
- `backend/src/plugins/importJobs.ts` (cola en proceso; migrable a Redis/BullMQ)
|
|
||||||
- **Criterio de aceptación:** E2E de import completo pasa en CI (con binarios instalados según sea necesario).
|
|
||||||
|
|
||||||
**Decisiones tomadas**
|
|
||||||
|
|
||||||
- `chdman` soporte: opcional. Si está instalado, lo usaremos; si no, se calcula checksum y se trata como blob.
|
|
||||||
- Endpoint `POST /api/import/scan`: no recibe `path`; la ruta a las ROMs se configura en el sistema (env var `ROMS_PATH`).
|
|
||||||
- El job se ejecuta en background; el frontend lanza el job a petición del usuario (botón "Scan").
|
|
||||||
- Añadir `IMPORT_CONCURRENCY` (env var). Valor por defecto: `min(8, max(1, os.cpus().length - 1))` si no se configura.
|
|
||||||
- Tests que dependen de binarios (7z/chdman): se incluirán como tests de integración y se habilitarán en CI (instalación de binarios en workflow).
|
|
||||||
|
|
||||||
**Open Questions (resueltas por el usuario):**
|
|
||||||
|
|
||||||
1. Soporte `chdman` opcional — Aprobado.
|
|
||||||
2. No enviar `path` en payload; usar ruta preconfigurada — Aprobado (`ROMS_PATH`).
|
|
||||||
3. Job en background — Aprobado.
|
|
||||||
4. `IMPORT_CONCURRENCY` variable y fórmula por defecto — Aprobado.
|
|
||||||
5. Incluir tests dependientes de binarios y instalarlos en CI — Aprobado.
|
|
||||||
|
|
||||||
**Siguientes pasos (Phase 4.1 - TDD):**
|
|
||||||
|
|
||||||
1. Crear fixtures y tests unitarios marcados en Phase 4.1.
|
|
||||||
2. Ejecutar `yarn --cwd backend test` y observar tests fallidos.
|
|
||||||
3. Implementar servicios mínimos en Phase 4.2 para pasar tests básicos.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Metadatos:
|
|
||||||
|
|
||||||
- Autor: GitHub Copilot
|
|
||||||
- Fecha: 2026-02-08
|
|
||||||
28
plans/integrar-archive-entries-plan-phase-1-complete.md
Normal file
28
plans/integrar-archive-entries-plan-phase-1-complete.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
## Phase 1 Complete: Contracto de ArchiveReader (list + stream)
|
||||||
|
|
||||||
|
TL;DR: Añadida `streamArchiveEntry` a `archiveReader` y tests unitarios que cubren streaming con `7z`, fallback `unzip -p` y formato no soportado. Los tests unitarios específicos pasan y la implementación es mockeable.
|
||||||
|
|
||||||
|
**Files created/changed:**
|
||||||
|
|
||||||
|
- backend/src/services/archiveReader.ts
|
||||||
|
- backend/tests/services/archiveReader.stream.spec.ts
|
||||||
|
- backend/tests/services/archiveReader.spec.ts
|
||||||
|
|
||||||
|
**Functions created/changed:**
|
||||||
|
|
||||||
|
- `streamArchiveEntry(containerPath, entryPath)` — nueva función que retorna un `Readable` con el contenido de una entrada interna (o `null` para formatos no soportados).
|
||||||
|
- `listArchiveEntries(filePath)` — sin cambios funcionales (pruebas de listado existentes siguen pasando).
|
||||||
|
|
||||||
|
**Tests created/changed:**
|
||||||
|
|
||||||
|
- `backend/tests/services/archiveReader.stream.spec.ts` — tests unitarios para `streamArchiveEntry` (7z success, unzip fallback, unsupported formats).
|
||||||
|
- `backend/tests/services/archiveReader.spec.ts` — tests de listado existentes (sin cambios funcionales relevantes).
|
||||||
|
|
||||||
|
**Review Status:** APPROVED with minor recommendations
|
||||||
|
|
||||||
|
**Git Commit Message:**
|
||||||
|
feat: add streamArchiveEntry to archiveReader and tests
|
||||||
|
|
||||||
|
- Añade `streamArchiveEntry` que devuelve un stream para entradas internas de ZIP/7z
|
||||||
|
- Añade tests unitarios que mockean `child_process.spawn` (7z + unzip fallback)
|
||||||
|
- Mantiene `listArchiveEntries` y documenta dependencia de binarios en CI
|
||||||
28
plans/integrar-archive-entries-plan-phase-2-complete.md
Normal file
28
plans/integrar-archive-entries-plan-phase-2-complete.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
## Phase 2 Complete: Exponer entradas internas en el escáner
|
||||||
|
|
||||||
|
TL;DR: `scanDirectory` ahora lista entradas internas de contenedores ZIP/7z como items virtuales codificados usando `::`. Se añadieron tests unitarios que mockean `archiveReader.listArchiveEntries` y se introdujo un límite configurable `ARCHIVE_MAX_ENTRIES`.
|
||||||
|
|
||||||
|
**Files created/changed:**
|
||||||
|
|
||||||
|
- backend/src/services/fsScanner.ts
|
||||||
|
- backend/tests/services/fsScanner.archiveEntries.spec.ts
|
||||||
|
|
||||||
|
**Functions created/changed:**
|
||||||
|
|
||||||
|
- `scanDirectory(dirPath)` — ahora, al detectar un archivo contenedor, invoca `listArchiveEntries(container)` y añade items virtuales con:
|
||||||
|
- `path: "${containerPath}::${entryPath}"`
|
||||||
|
- `containerPath`, `entryPath`, `filename`, `size`, `format`, `isArchiveEntry: true`
|
||||||
|
- Añadido `ARCHIVE_MAX_ENTRIES` (configurable via `process.env.ARCHIVE_MAX_ENTRIES`, default 1000).
|
||||||
|
|
||||||
|
**Tests created/changed:**
|
||||||
|
|
||||||
|
- `backend/tests/services/fsScanner.archiveEntries.spec.ts` — valida que `scanDirectory` incluya la entrada interna codificada y que los metadatos (`filename`, `format`, `containerPath`, `entryPath`, `isArchiveEntry`) sean correctos.
|
||||||
|
|
||||||
|
**Review Status:** APPROVED
|
||||||
|
|
||||||
|
**Git Commit Message:**
|
||||||
|
feat: expose archive entries in fsScanner
|
||||||
|
|
||||||
|
- Añade `scanDirectory` support para listar entradas internas de ZIP/7z
|
||||||
|
- Añade test unitario que mockea `archiveReader.listArchiveEntries`
|
||||||
|
- Añade límite configurable `ARCHIVE_MAX_ENTRIES` y comprobación básica de seguridad
|
||||||
89
plans/integrar-archive-entries-plan.md
Normal file
89
plans/integrar-archive-entries-plan.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
## Plan: Integrar entradas de archivo en el escáner
|
||||||
|
|
||||||
|
TL;DR: Añadir soporte para listar y procesar entradas dentro de contenedores (ZIP/7z) en el pipeline de importación. Empezamos sin migración de base de datos (usando `::` para codificar `path`), no soportamos archives anidados por ahora, y añadimos límites configurables de tamaño y entradas por archivo. CI instalará `7z` y `unzip` para tests de integración.
|
||||||
|
|
||||||
|
**Phases 4**
|
||||||
|
|
||||||
|
1. **Phase 1: Contracto de ArchiveReader (list + stream)**
|
||||||
|
- **Objective:** Definir y probar la API de `archiveReader` con dos funciones públicas: `listArchiveEntries(containerPath): Promise<Entry[]>` y `streamArchiveEntry(containerPath, entryPath): Readable`.
|
||||||
|
- **Files/Functions to Modify/Create:** `backend/src/services/archiveReader.ts` (añadir `streamArchiveEntry`, documentar comportamiento y fallback a librería JS para ZIP si falta `7z`).
|
||||||
|
- **Tests to Write:**
|
||||||
|
- `backend/tests/services/archiveReader.list.spec.ts` — unit: mockear `child_process.exec` para simular salida de `7z -slt` y `unzip -l`.
|
||||||
|
- `backend/tests/services/archiveReader.stream.spec.ts` — unit: mockear `child_process` y stream; integration opcional (ver Fase 4).
|
||||||
|
- **Steps:**
|
||||||
|
1. Escribir tests (fallando) que describan la API y el formato de `Entry` (`{ name, size }`).
|
||||||
|
2. Implementar `streamArchiveEntry` usando `7z x -so` o `unzip -p` y devolver un `Readable`.
|
||||||
|
3. Añadir fallback para ZIP mediante librería JS si `7z` no está disponible.
|
||||||
|
4. Ejecutar y hacer pasar tests unitarios.
|
||||||
|
- **Acceptance:** Tests unitarios pasan; `streamArchiveEntry` es mockeable y devuelve stream.
|
||||||
|
|
||||||
|
2. **Phase 2: Extender `fsScanner` para exponer entradas (virtual files)**
|
||||||
|
- **Objective:** `scanDirectory(dir)` debe incluir entradas internas de archivos contenedor como items virtuales con `path` codificado (`/abs/archive.zip::inner/path.rom`), `filename` = basename(inner), `isArchiveEntry = true`.
|
||||||
|
- **Files/Functions to Modify/Create:** `backend/src/services/fsScanner.ts` (usar `archiveReader.listArchiveEntries`).
|
||||||
|
- **Tests to Write:**
|
||||||
|
- `backend/tests/services/fsScanner.archiveEntries.spec.ts` — unit: mockear `archiveReader.listArchiveEntries` y verificar formato.
|
||||||
|
- **Steps:**
|
||||||
|
1. Escribir test unitario (fallando) que verifica que `scanDirectory` invoca `archiveReader` y añade entradas codificadas.
|
||||||
|
2. Implementar la integración mínima en `fsScanner` (sin extracción, solo listar entradas).
|
||||||
|
3. Ejecutar tests y ajustar.
|
||||||
|
- **Acceptance:** `scanDirectory` devuelve objetos virtuales estandarizados; tests unitarios pasan.
|
||||||
|
|
||||||
|
3. **Phase 3: Hashing por stream y soporte en `importService` (unit)**
|
||||||
|
- **Objective:** Añadir `computeHashesFromStream(stream)` y hacer que `importDirectory` pueda procesar entradas internas usando `archiveReader.streamArchiveEntry` para obtener hashes sin escribir ficheros temporales.
|
||||||
|
- **Files/Functions to Modify/Create:** `backend/src/services/checksumService.ts` (añadir `computeHashesFromStream`), `backend/src/services/importService.ts` (aceptar `isArchiveEntry` y usar `archiveReader.streamArchiveEntry`).
|
||||||
|
- **Tests to Write:**
|
||||||
|
- `backend/tests/services/checksumService.stream.spec.ts` — unit: hashing desde un `Readable` creado desde un fixture (`backend/tests/fixtures/simple-rom.bin`).
|
||||||
|
- `backend/tests/services/importService.archiveEntry.spec.ts` — unit: mockear `scanDirectory` para devolver entry codificada, mockear `archiveReader.streamArchiveEntry` para devolver stream desde fixture, mockear Prisma y verificar `upsert` con `path` codificado.
|
||||||
|
- **Steps:**
|
||||||
|
1. Escribir tests (fallando) que describan el comportamiento.
|
||||||
|
2. Implementar `computeHashesFromStream(stream)` (MD5/SHA1/CRC32) y refactorizar `computeHashes` para delegar cuando se dispone de stream.
|
||||||
|
3. Hacer `importDirectory` soportar entries internas: obtener stream, calcular hashes, persistir con `path` codificado.
|
||||||
|
4. Ejecutar y pasar tests unitarios.
|
||||||
|
- **Acceptance:** Unit tests pasan; `importDirectory` hace upsert con `path` codificado y hashes correctos.
|
||||||
|
|
||||||
|
4. **Phase 4: Integración real y CI opt-in**
|
||||||
|
- **Objective:** Validar flujo end-to-end con binarios nativos (`7z` y `unzip`) usando fixtures reales en `backend/tests/fixtures/archives/`. CI instalará estos binarios para ejecutar integration tests.
|
||||||
|
- **Files/Functions to Modify/Create:** tests de integración (ej. `backend/tests/services/integration/archive-to-import.spec.ts`), posibles ajustes en `archiveReader` para robustez.
|
||||||
|
- **Tests to Write:**
|
||||||
|
- `backend/tests/services/archiveReader.stream.spec.ts` (integration) — usa `simple.zip` fixture y verifica hashes.
|
||||||
|
- `backend/tests/services/integration/archive-to-import.spec.ts` — E2E: `importDirectory` sobre carpeta con `simple.zip`, verificar DB upsert.
|
||||||
|
- **Steps:**
|
||||||
|
1. Añadir fixtures de archive en `backend/tests/fixtures/archives/` (`simple.zip`, `nested.zip`, `traversal.zip`).
|
||||||
|
2. Marcar tests de integración opt-in mediante `INTEGRATION=1` o detectando binarios con helper (`tests/helpers/requireBinaries.ts`).
|
||||||
|
3. Ejecutar integraciones en local con `INTEGRATION=1` y en CI asegurando que `7z`/`unzip` se instalen.
|
||||||
|
- **Acceptance:** Integration tests pasan en entornos con binarios; fallback JS para ZIP pasa cuando faltan binarios.
|
||||||
|
|
||||||
|
**Decisiones concretas ya tomadas**
|
||||||
|
|
||||||
|
- Representación en DB: usar `path` codificado con `::` (ej. `/abs/archive.zip::dir/inner.rom`) y no tocar Prisma inicialmente.
|
||||||
|
- No soportar archives anidados por ahora (configurable en futuro).
|
||||||
|
- Límites configurables (con valores por defecto razonables):
|
||||||
|
- `ARCHIVE_MAX_ENTRY_SIZE` — tamaño máximo por entrada (por defecto: 200 MB).
|
||||||
|
- `ARCHIVE_MAX_ENTRIES` — máximo de entradas a listar por archive (por defecto: 1000).
|
||||||
|
- CI: instalar `7z` (`p7zip-full`) y `unzip` en runners para ejecutar tests de integración.
|
||||||
|
|
||||||
|
**Riesgos y mitigaciones**
|
||||||
|
|
||||||
|
- Path traversal: sanitizar `entryPath` y rechazar entradas que suban fuera del contenedor.
|
||||||
|
- Zip bombs / entradas gigantes: respetar `ARCHIVE_MAX_ENTRY_SIZE` y abortar hashing si se excede.
|
||||||
|
- Recursos por spawn: imponer timeouts y límites, cerrar streams correctamente.
|
||||||
|
- Archivos cifrados/password: detectar y registrar como `status: 'encrypted'` o saltar.
|
||||||
|
|
||||||
|
**Comandos recomendados para pruebas**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn --cwd backend test
|
||||||
|
yarn --cwd backend test tests/services/archiveReader.list.spec.ts
|
||||||
|
INTEGRATION=1 yarn --cwd backend test tests/services/integration/archive-to-import.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Open Questions (resueltas por ti)**
|
||||||
|
|
||||||
|
1. Usar encoding `::` para `path` — Confirmado.
|
||||||
|
2. Soporte de archives anidados — Dejar fuera por ahora.
|
||||||
|
3. Límite por defecto por entrada — Configurable; por defecto 200 MB.
|
||||||
|
4. CI debe instalar `7z` y `unzip` — Confirmado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Si apruebas este plan, empezaré la Phase 1: escribiré los tests unitarios para `archiveReader` y delegaré la implementación al subagente implementador siguiendo TDD. ¿Procedo?
|
||||||
Reference in New Issue
Block a user