feat: import job runner in-memory
- Añade ImportRunner en memoria con concurrencia configurable - Tests TDD para enqueue, concurrencia y comportamiento tras stop - Actualiza /api/import/scan para encolar jobs y registrar errores - Ajusta tsconfig.json para incluir tests en comprobaciones de tipo
This commit is contained in:
9
backend/tests/fixtures/dats/sample-no-intro.dat.xml
vendored
Normal file
9
backend/tests/fixtures/dats/sample-no-intro.dat.xml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<datafile>
|
||||
<header>
|
||||
<name>Sample No-Intro DAT</name>
|
||||
</header>
|
||||
<game name="Simple ROM">
|
||||
<rom name="simple-rom.bin" size="16" crc="DEADBEEF" md5="00000000000000000000000000000000" sha1="0000000000000000000000000000000000000000" />
|
||||
</game>
|
||||
</datafile>
|
||||
1
backend/tests/fixtures/empty/.gitkeep
vendored
Normal file
1
backend/tests/fixtures/empty/.gitkeep
vendored
Normal file
@@ -0,0 +1 @@
|
||||
// placeholder to ensure directory exists; scanner ignores dotfiles
|
||||
1
backend/tests/fixtures/nested/nested-rom.bin
vendored
Normal file
1
backend/tests/fixtures/nested/nested-rom.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
NESTED-ROM-TEST
|
||||
1
backend/tests/fixtures/simple-rom.bin
vendored
Normal file
1
backend/tests/fixtures/simple-rom.bin
vendored
Normal file
@@ -0,0 +1 @@
|
||||
SIMPLE-ROM-TEST
|
||||
119
backend/tests/jobs/importRunner.spec.ts
Normal file
119
backend/tests/jobs/importRunner.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ImportRunner } from '../../src/jobs/importRunner';
|
||||
|
||||
describe('jobs/importRunner', () => {
|
||||
it('enqueue rechaza después de stop', async () => {
|
||||
const runner = new ImportRunner(1);
|
||||
runner.start();
|
||||
runner.stop();
|
||||
|
||||
await expect(runner.enqueue(() => 'x')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rechaza tareas en cola tras stop', async () => {
|
||||
const r = new 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 expect(t1).resolves.toBe('ok1');
|
||||
await expect(t2).rejects.toThrow(/ImportRunner stopped/);
|
||||
|
||||
const s = r.getStatus();
|
||||
expect(s.completed).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('completed incrementa en rechazo', async () => {
|
||||
const runner = new ImportRunner(1);
|
||||
runner.start();
|
||||
|
||||
const p = runner.enqueue(() => Promise.reject(new Error('boom')));
|
||||
|
||||
await expect(p).rejects.toThrow('boom');
|
||||
|
||||
const status = runner.getStatus();
|
||||
expect(status.completed).toBeGreaterThanOrEqual(1);
|
||||
|
||||
runner.stop();
|
||||
});
|
||||
|
||||
it('enqueue resuelve con el resultado de la tarea', async () => {
|
||||
const runner = new ImportRunner(2);
|
||||
runner.start();
|
||||
|
||||
const result = await runner.enqueue(async () => 'ok');
|
||||
expect(result).toBe('ok');
|
||||
|
||||
const status = runner.getStatus();
|
||||
expect(status.completed).toBe(1);
|
||||
expect(status.running).toBe(0);
|
||||
expect(status.queued).toBe(0);
|
||||
expect(status.concurrency).toBe(2);
|
||||
|
||||
runner.stop();
|
||||
});
|
||||
|
||||
it('respeta la concurrencia configurada', async () => {
|
||||
const concurrency = 2;
|
||||
const runner = new ImportRunner(concurrency);
|
||||
runner.start();
|
||||
|
||||
let active = 0;
|
||||
const observed: number[] = [];
|
||||
|
||||
const makeTask = (delay: number) => 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);
|
||||
|
||||
expect(Math.max(...observed)).toBeLessThanOrEqual(concurrency);
|
||||
|
||||
runner.stop();
|
||||
});
|
||||
|
||||
it('getStatus reporta queued, running, completed y concurrency', async () => {
|
||||
const concurrency = 2;
|
||||
const runner = new 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();
|
||||
expect(statusNow.concurrency).toBe(concurrency);
|
||||
expect(statusNow.running).toBeLessThanOrEqual(concurrency);
|
||||
expect(statusNow.queued).toBeGreaterThanOrEqual(0);
|
||||
|
||||
await Promise.all([p1, p2, p3]);
|
||||
|
||||
const statusAfter = runner.getStatus();
|
||||
expect(statusAfter.queued).toBe(0);
|
||||
expect(statusAfter.running).toBe(0);
|
||||
expect(statusAfter.completed).toBe(3);
|
||||
|
||||
runner.stop();
|
||||
});
|
||||
});
|
||||
19
backend/tests/routes/import.spec.ts
Normal file
19
backend/tests/routes/import.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildApp } from '../../src/app';
|
||||
|
||||
describe('routes/import', () => {
|
||||
it('POST /api/import/scan devuelve 202 o 200', async () => {
|
||||
const app = buildApp();
|
||||
await app.ready();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/import/scan',
|
||||
payload: { persist: false },
|
||||
});
|
||||
|
||||
expect([200, 202]).toContain(res.statusCode);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
21
backend/tests/services/checksumService.spec.ts
Normal file
21
backend/tests/services/checksumService.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import { computeHashes } from '../../src/services/checksumService';
|
||||
|
||||
const fixturesDir = path.join(__dirname, '..', 'fixtures');
|
||||
const simpleRom = path.join(fixturesDir, 'simple-rom.bin');
|
||||
|
||||
describe('services/checksumService', () => {
|
||||
it('exporta computeHashes', () => {
|
||||
expect(typeof computeHashes).toBe('function');
|
||||
});
|
||||
|
||||
it('calcula hashes', async () => {
|
||||
const meta = await computeHashes(simpleRom);
|
||||
expect(meta).toBeDefined();
|
||||
expect(meta.size).toBeGreaterThan(0);
|
||||
expect(meta.md5).toBeDefined();
|
||||
expect(meta.sha1).toBeDefined();
|
||||
expect(meta.crc32).toBeDefined();
|
||||
});
|
||||
});
|
||||
35
backend/tests/services/datVerifier.spec.ts
Normal file
35
backend/tests/services/datVerifier.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { parseDat, verifyRomAgainstDat } from '../../src/services/datVerifier';
|
||||
|
||||
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');
|
||||
|
||||
const runIntegration = !!process.env.INTEGRATION;
|
||||
const describeIf = runIntegration ? describe : describe.skip;
|
||||
|
||||
describeIf('services/datVerifier', () => {
|
||||
it('parsea DAT xml', () => {
|
||||
const xml = fs.readFileSync(datPath, 'utf8');
|
||||
const parsed = parseDat(xml);
|
||||
expect(parsed).toBeDefined();
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const xml = fs.readFileSync(datPath, 'utf8');
|
||||
const parsed = parseDat(xml);
|
||||
const res = await verifyRomAgainstDat(romMeta, parsed);
|
||||
expect(res).toBeDefined();
|
||||
});
|
||||
});
|
||||
28
backend/tests/services/fsScanner.spec.ts
Normal file
28
backend/tests/services/fsScanner.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import { scanDirectory } from '../../src/services/fsScanner';
|
||||
|
||||
const fixturesDir = path.join(__dirname, '..', 'fixtures');
|
||||
const emptyDir = path.join(fixturesDir, 'empty');
|
||||
|
||||
describe('services/fsScanner', () => {
|
||||
it('exporta scanDirectory', () => {
|
||||
expect(typeof scanDirectory).toBe('function');
|
||||
});
|
||||
|
||||
it('carpeta vacía devuelve array', async () => {
|
||||
const res = await scanDirectory(emptyDir);
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
expect((res as any[]).length).toBe(0);
|
||||
});
|
||||
|
||||
it('detecta simple-rom.bin', async () => {
|
||||
const res = await scanDirectory(fixturesDir);
|
||||
const found = (res as any[]).find(
|
||||
(r: any) => r.filename === 'simple-rom.bin' || r.name === 'simple-rom.bin'
|
||||
);
|
||||
expect(found).toBeTruthy();
|
||||
expect(found.size).toBeGreaterThan(0);
|
||||
expect(found.format).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user