Files
NewTube/server/tests/suggest.test.mjs
T

191 lines
8.3 KiB
JavaScript

// Step 15 — Suggest tests: unit parsing/dedup (offline) + API contract (isolated server).
// Run with: npm run test:suggest
// Scenario coverage (todo Step 15):
// - providers=yt returns only yt suggestions
// - unknown provider falls back to the full registry
// Assertions are on fan-out shape, never on external provider content.
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
import net from 'node:net';
function expect(cond, msg) {
if (!cond) throw new Error(`Assertion failed: ${msg}`);
}
function logOk(msg) { console.log(`✓ ${msg}`); }
// ---------- 1) Offline unit tests: parsers + graceful degradation ----------
const yt = (await import('../providers/youtube.mjs')).default;
const { parseYoutubeSuggestResponse } = await import('../providers/youtube.mjs');
const dm = (await import('../providers/dailymotion.mjs')).default;
const { dedupeSuggestGroups } = await import('../suggest.mjs');
const { parseOsjsonList, parseLighthouseList, humanizeClaimName } = await import('../suggest-web.mjs');
// Bare JSON payload
expect(
JSON.stringify(parseYoutubeSuggestResponse('["tut",["tutoriel angular","tutoriel android"],[],{}]')) ===
JSON.stringify(['tutoriel angular', 'tutoriel android']),
'bare JSON suggest payload parses',
);
logOk('parseYoutubeSuggestResponse bare JSON');
// window.google.ac.h(...) wrapped payload
expect(
JSON.stringify(
parseYoutubeSuggestResponse('window.google.ac.h(["tut",["tutoriel angular"],[],{}])'),
) === JSON.stringify(['tutoriel angular']),
'wrapped JSONP suggest payload parses',
);
logOk('parseYoutubeSuggestResponse wrapped payload');
// Array-wrapped suggestions (newer format: [text, type, ...])
expect(
JSON.stringify(parseYoutubeSuggestResponse('["tut",[["tutoriel angular",0],["tutoriel android",0]]]')) ===
JSON.stringify(['tutoriel angular', 'tutoriel android']),
'array-wrapped suggestions keep text only',
);
logOk('parseYoutubeSuggestResponse array-wrapped entries');
// Garbage / empty payloads never throw
expect(parseYoutubeSuggestResponse('not json at all').length === 0, 'garbage payload yields []');
expect(parseYoutubeSuggestResponse('').length === 0, 'empty payload yields []');
logOk('parseYoutubeSuggestResponse graceful degradation');
// min-length guard: no network below 2 chars (would throw offline otherwise)
expect(JSON.stringify(await yt.suggest('a')) === '[]', 'yt.suggest short query returns [] without network');
expect(JSON.stringify(await dm.suggest('x')) === '[]', 'dm.suggest short query returns [] without network');
logOk('suggest min-length guard (no network)');
// Cross-provider dedup: the same occurrence is only returned once (first provider wins)
{
const groups = dedupeSuggestGroups(
{ yt: ['Tutoriel Angular', 'tutoriel android'], dm: ['TUTORIEL angular', 'autre', ''] },
['yt', 'dm'],
);
expect(JSON.stringify(groups.yt) === JSON.stringify(['Tutoriel Angular', 'tutoriel android']), 'first provider keeps its occurrences');
expect(JSON.stringify(groups.dm) === JSON.stringify(['autre']), 'duplicates removed from later providers');
logOk('dedupeSuggestGroups cross-provider (no duplicates)');
}
// Web enrichment parsers (offline): Bing/Google OSJSON + Odysee Lighthouse
{
expect(
JSON.stringify(parseOsjsonList('["tut",["tutoriel angular","tutoriel android"],[],{}]')) ===
JSON.stringify(['tutoriel angular', 'tutoriel android']),
'OSJSON payload parses',
);
expect(parseOsjsonList('garbage').length === 0, 'OSJSON garbage yields []');
expect(parseOsjsonList('["tut","notalist"]').length === 0, 'OSJSON non-list yields []');
logOk('parseOsjsonList (Bing/Google)');
expect(humanizeClaimName('tutoriel-express_nettoyage--moulinet') === 'tutoriel express nettoyage moulinet', 'claim slug humanized');
expect(
JSON.stringify(parseLighthouseList([{ name: 'tutoriel-express' }, { name: 'x' }, { name: '' }], 10)) ===
JSON.stringify(['tutoriel express']),
'lighthouse list keeps readable titles only',
);
logOk('parseLighthouseList (Odysee)');
}
// ---------- 2) API contract against a real isolated server ----------
const PORT = await new Promise((resolve) => {
const srv = net.createServer();
srv.listen(0, '127.0.0.1', () => {
const p = srv.address().port;
srv.close(() => resolve(p));
});
});
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-suggest-test-'));
const server = spawn(process.execPath, ['./server/index.mjs'], {
env: { ...process.env, PORT: String(PORT), NEWTUBE_DB_FILE: path.join(tmpDir, 'suggest.db'), JWT_SECRET: 'suggest-test-secret', NODE_ENV: 'test' },
stdio: ['ignore', 'pipe', 'pipe'],
cwd: path.resolve(import.meta.dirname, '..', '..'),
});
let serverLogs = '';
server.stdout.on('data', (d) => { serverLogs += d.toString(); });
server.stderr.on('data', (d) => { serverLogs += d.toString(); });
const baseUrl = `http://127.0.0.1:${PORT}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitForServer(timeoutMs = 20000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(`${baseUrl}/`);
if (res.status < 500) return true;
} catch {}
await sleep(300);
}
return false;
}
let failures = 0;
async function scenario(name, fn) {
try { await fn(); logOk(name); }
catch (e) { failures += 1; console.error(`✗ ${name}`); console.error(e?.stack || String(e)); }
}
try {
const up = await waitForServer();
expect(up, 'isolated server starts');
await scenario('q too short -> 400', async () => {
const res = await fetch(`${baseUrl}/api/search/suggest?q=a`);
expect(res.status === 400, `expected 400, got ${res.status}`);
});
await scenario('providers=yt returns only yt suggestions (+optional web enrichment)', async () => {
const res = await fetch(`${baseUrl}/api/search/suggest?q=tutoriel&providers=yt&limit=5`);
expect(res.status === 200, `expected 200, got ${res.status}`);
const body = await res.json();
expect(body.q === 'tutoriel', 'q echoed');
const keys = Object.keys(body.groups);
expect(keys.includes('yt'), `yt group present (got ${keys})`);
expect(keys.every((k) => k === 'yt' || k === 'web'), `only yt (+web) groups (got ${keys})`);
expect(Array.isArray(body.groups.yt), 'yt group is an array');
expect(body.groups.yt.length <= 5, 'limit respected');
});
await scenario('unknown provider falls back to the full registry', async () => {
const res = await fetch(`${baseUrl}/api/search/suggest?q=tutoriel&providers=xx,yy&limit=3`);
expect(res.status === 200, `expected 200, got ${res.status}`);
const body = await res.json();
const keys = Object.keys(body.groups).sort();
const withoutWeb = keys.filter((k) => k !== 'web');
expect(JSON.stringify(withoutWeb) === JSON.stringify(['dm', 'od', 'pt', 'ru', 'tw', 'yt']), `full registry fallback (got ${keys})`);
});
await scenario('served under both /api and /proxy/api (front apiBase)', async () => {
for (const prefix of ['/api', '/proxy/api']) {
const res = await fetch(`${baseUrl}${prefix}/search/suggest?q=tutoriel&providers=yt&limit=3`);
expect(res.status === 200, `expected 200 on ${prefix}, got ${res.status}`);
const body = await res.json();
expect(body.q === 'tutoriel' && Array.isArray(body.groups?.yt), `${prefix} returns suggest JSON`);
}
});
await scenario('no duplicate occurrence across groups (web enrichment included)', async () => {
const res = await fetch(`${baseUrl}/api/search/suggest?q=tutoriel&limit=10`);
expect(res.status === 200, `expected 200, got ${res.status}`);
const body = await res.json();
const seen = new Set();
for (const list of Object.values(body.groups)) {
for (const raw of Array.isArray(list) ? list : []) {
const k = String(raw || '').trim().toLowerCase().replace(/\s+/g, ' ');
expect(!seen.has(k), `duplicate occurrence across groups: "${raw}"`);
seen.add(k);
}
}
const total = Object.values(body.groups).reduce((n, l) => n + (Array.isArray(l) ? l.length : 0), 0);
expect(total > 0, 'enrichment yields at least one suggestion');
});
} finally {
server.kill();
}
if (failures > 0) {
console.error(`\n${failures} suggest scenario(s) failed. Server logs:\n${serverLogs}`);
process.exit(1);
}
console.log('\nAll suggest tests passed.');