Files
NewTube/server/tests/search_stream_provider.test.mjs
T
bruno 665a0f0ebd
CI / build-and-test (push) Successful in 14m43s
feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
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.
2026-09-30 07:57:11 -04:00

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)');
});
});