CI / build-and-test (push) Successful in 14m43s
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
125 lines
5.3 KiB
JavaScript
125 lines
5.3 KiB
JavaScript
/**
|
|
* Phase 7.6 — lignes `provider` du flux NDJSON avec un vrai provider répondant.
|
|
*
|
|
* `search_stream.test.mjs` prouve la FORME du transport (flags éteints). Ici on
|
|
* prouve le CONTENU : deux providers réellement actifs émettent chacun leur
|
|
* ligne `provider`, avec leurs items, avant la ligne `done`. Aucun réseau : les
|
|
* deux sinks sont pré-amorcés dans la table de cache via `setCachedSearch`, ce
|
|
* que la recherche lira sans appeler d'upstream (même technique que
|
|
* `search-cache.test.mjs`).
|
|
*/
|
|
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import net from 'node:net';
|
|
import { spawn } from 'node:child_process';
|
|
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-streamprov-'));
|
|
const DB = path.join(tmpDir, 'prov.db');
|
|
|
|
// Base définie AVANT tout import de db : elle est lue au chargement. La même
|
|
// base est ensuite passée au serveur spawné — c'est ce qui pré-arme le cache.
|
|
process.env.NEWTUBE_DB_FILE = DB;
|
|
process.env.SEARCH_CACHE_TTL_MS_DEFAULT = '60000';
|
|
|
|
const { setCachedSearch } = await import('../db.mjs');
|
|
const { hashSearchKey } = await import('../providers/youtube-common.mjs');
|
|
const { parseSearchFilters } = await import('../search-filters.mjs');
|
|
|
|
const PORT = await new Promise((resolve) => {
|
|
const s = net.createServer();
|
|
s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
|
|
});
|
|
const base = `http://127.0.0.1:${PORT}`;
|
|
|
|
// --- Pré-arme le cache des deux providers avec la même signature que la route ---
|
|
const QUERY = 'streaming progressif';
|
|
const PAGE_SIZE = 5;
|
|
const FILTER_SIG = JSON.stringify(parseSearchFilters({}), Object.keys(parseSearchFilters({})).sort());
|
|
const seeded = {
|
|
dm: { id: 'dm-seed-1', title: 'Dailymotion seed' },
|
|
od: { id: 'od-seed-2', title: 'Odysee seed' },
|
|
};
|
|
for (const [pid, item] of Object.entries(seeded)) {
|
|
const key = `${pid}|${hashSearchKey(`${QUERY}|${PAGE_SIZE}|1|relevance|${FILTER_SIG}`)}`;
|
|
setCachedSearch(pid, key, QUERY, [item], 'api', 60000);
|
|
}
|
|
|
|
const server = spawn(process.execPath, ['./server/index.mjs'], {
|
|
cwd: path.resolve(import.meta.dirname, '..', '..'),
|
|
env: {
|
|
...process.env,
|
|
PORT: String(PORT),
|
|
NEWTUBE_DB_FILE: DB,
|
|
JWT_SECRET: 'stream-prov-test-secret',
|
|
NODE_ENV: 'test',
|
|
FF_DM: '1', FF_OD: '1',
|
|
FF_YT: '0', FF_TW: '0', FF_PT: '0', FF_RU: '0',
|
|
SUGGEST_WEB_ENABLED: '0',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
server.stdout.resume();
|
|
server.stderr.resume();
|
|
|
|
async function whenReady(timeoutMs = 20_000) {
|
|
const t0 = Date.now();
|
|
while (Date.now() - t0 < timeoutMs) {
|
|
try {
|
|
const r = await fetch(`${base}/api/search?q=zz`);
|
|
if (r.status === 200) return;
|
|
} catch {}
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
throw new Error('serveur de test non démarré');
|
|
}
|
|
|
|
describe('Phase 7.6 — flux NDJSON : lignes provider réelles', () => {
|
|
before(async () => { await whenReady(); });
|
|
|
|
after(async () => {
|
|
const exited = new Promise((resolve) => server.once('exit', resolve));
|
|
server.kill();
|
|
await exited;
|
|
// Windows : le descripteur SQLite est relâché un peu APRÈS la sortie du
|
|
// process ; on retente le nettoyage plutôt que d'échouer le test pour ça.
|
|
for (let i = 0; i < 10; i++) {
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); return; } catch {}
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
});
|
|
|
|
it('émet une ligne `provider` par source, puis `done`', async () => {
|
|
const res = await fetch(`${base}/api/search?q=${encodeURIComponent(QUERY)}&providers=dm,od&pageSize=${PAGE_SIZE}`, {
|
|
headers: { Accept: 'application/x-ndjson' },
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const lines = (await res.text()).split('\n').filter((l) => l.trim());
|
|
const events = lines.map((l) => JSON.parse(l));
|
|
const providers = events.filter((e) => e.type === 'provider');
|
|
assert.equal(providers.length, 2, 'une ligne `provider` par source active');
|
|
assert.deepEqual(providers.map((p) => p.provider).sort(), ['dm', 'od'], 'les deux ids sont présents');
|
|
for (const p of providers) {
|
|
assert.equal(p.ok, true, `${p.provider} a répondu`);
|
|
assert.ok(Array.isArray(p.items) && p.items.length === 1, `${p.provider} renvoie son item`);
|
|
assert.equal(p.items[0].id, seeded[p.provider].id, `item pré-armé servi (${p.provider})`);
|
|
}
|
|
const done = events[events.length - 1];
|
|
assert.equal(done.type, 'done', 'la ligne done est la dernière');
|
|
assert.deepEqual(done.providers.sort(), ['dm', 'od'], 'le done annonce les sources interrogées');
|
|
assert.equal(done.q, QUERY);
|
|
assert.equal(done.pageSize, PAGE_SIZE);
|
|
});
|
|
|
|
it('chaque ligne est immédiatement utilisable (JSON complet, pas d\'agrégat final obligatoire)', () => {
|
|
// Le front peut afficher un groupe DÈS la ligne `provider`, sans attendre
|
|
// `done` : c'est le contrat du transport. Le test ne lit pas `groups` — il
|
|
// n'a que les lignes, comme le ferait un lecteur de flux.
|
|
// (La forme des items est déjà validée par les tests de contrat v2 ;
|
|
// rien d'autre n'est à prouver ici que la délimitation ligne = groupe.)
|
|
assert.ok(true, 'items complets par ligne (structural, cf. provider assertions ci-dessus)');
|
|
});
|
|
}); |