// Couverture HTTP des routes API non couvertes par les autres suites : // health, search/suggest (400 + fallback), transcript (400), trending (400 + shape), // ai/status, openapi, auth (register/login/me), preferences, playlists CRUD, // likes, history search/watch, subscriptions, telemetry, download jobs. // Offline-safe : aucune assertion sur le contenu provider externe, uniquement // les formes stables (status, clés JSON). yt-dlp n'est jamais invoqué avec succès. // Run: npm run test:api 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-api-cov-')); const dbPath = path.join(tmpDir, 'cov.db'); 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}`; const server = spawn(process.execPath, ['./server/index.mjs'], { cwd: path.resolve(import.meta.dirname, '..', '..'), env: { ...process.env, PORT: String(PORT), NEWTUBE_DB_FILE: dbPath, JWT_SECRET: 'cov-test-secret', NODE_ENV: 'test' }, stdio: ['ignore', 'pipe', 'pipe'], }); let logs = ''; server.stdout.on('data', (d) => { logs += d; }); server.stderr.on('data', (d) => { logs += d; }); async function waitUp(timeout = 25000) { const t0 = Date.now(); while (Date.now() - t0 < timeout) { try { const r = await fetch(`${base}/api/health`); if (r.status < 500) return true; } catch {} await new Promise((r) => setTimeout(r, 300)); } return false; } const up = await waitUp(); assert.ok(up, `API ne démarre pas:\n${logs.slice(-3000)}`); const J = async (url, opts = {}) => { const r = await fetch(url, opts); const body = await r.json().catch(() => ({})); return { status: r.status, body }; }; const auth = (t) => ({ Authorization: `Bearer ${t}` }); const uniq = (p) => `${p}_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; let token; const user = uniq('covuser'); before(async () => { const reg = await J(`${base}/api/auth/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user, password: 'Passw0rd!' }), }); assert.ok([201, 409].includes(reg.status), `register ${reg.status}`); const login = await J(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user, password: 'Passw0rd!' }), }); assert.equal(login.status, 200); assert.ok(login.body.accessToken); token = login.body.accessToken; }); after(() => { try { server.kill(); } catch {} }); describe('lecture publique / santé', () => { it('GET /api/health -> ok', async () => { const r = await J(`${base}/api/health`); assert.equal(r.status, 200); assert.equal(r.body.status, 'ok'); }); it('GET /healthz expose youtube.mode sans secret', async () => { const r = await J(`${base}/healthz`); assert.equal(r.status, 200); assert.equal(r.body.status, 'ok'); assert.ok(r.body.youtube?.mode); assert.ok(!JSON.stringify(r.body).includes('GEMINI')); }); it('GET /api/search?q=x -> 400', async () => { const r = await J(`${base}/api/search?q=x`); assert.equal(r.status, 400); }); it('GET /api/search providers inconnus -> fallback registre', async () => { const r = await J(`${base}/api/search?q=test&providers=xx,yy&pageSize=2`); assert.equal(r.status, 200); assert.ok(r.body.providers.length >= 6); assert.ok(r.body.groups && typeof r.body.groups === 'object'); }); it('GET /api/search/suggest?q=x -> 400', async () => { const r = await J(`${base}/api/search/suggest?q=x`); assert.equal(r.status, 400); }); it('GET /api/transcript provider inconnu -> 400 available:false', async () => { const r = await J(`${base}/api/transcript/bogus/vid`); assert.equal(r.status, 400); assert.equal(r.body.available, false); }); it('GET /api/details provider inconnu -> 500 details_failed (sans yt-dlp)', async () => { const r = await J(`${base}/api/details/bogus/vid`); assert.equal(r.status, 500); assert.equal(r.body.error, 'details_failed'); }); it('GET /api/trending provider non-yt -> 400', async () => { const r = await J(`${base}/api/trending?provider=dm`); assert.equal(r.status, 400); }); it('GET /api/trending?provider=yt -> shape {provider,items[]}', async () => { const r = await J(`${base}/api/trending?provider=yt&limit=2`); assert.equal(r.status, 200); assert.equal(r.body.provider, 'yt'); assert.ok(Array.isArray(r.body.items)); }); it('GET /api/ai/status -> {ready:boolean}', async () => { const r = await J(`${base}/api/ai/status`); assert.equal(r.status, 200); assert.equal(typeof r.body.ready, 'boolean'); }); it('GET /api/openapi.json documente playlists', async () => { const r = await J(`${base}/api/openapi.json`); assert.equal(r.status, 200); assert.ok(r.body.paths?.['/playlists']); }); }); describe('auth + espace utilisateur', () => { it('register/login 400 sans password, 401 mauvais password', async () => { const r1 = await J(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user }) }); assert.equal(r1.status, 400); const r2 = await J(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user, password: 'mauvais' }) }); assert.equal(r2.status, 401); }); it('protégées sans token -> 401', async () => { for (const u of ['/api/user/me', '/api/playlists', '/api/user/likes', '/api/user/watch-later', '/api/subscriptions', '/api/download/jobs']) { const r = await J(`${base}${u}`); assert.equal(r.status, 401, u); } }); it('GET /api/user/me + preferences round-trip', async () => { const me = await J(`${base}/api/user/me`, { headers: auth(token) }); assert.equal(me.status, 200); assert.ok(me.body.id); const p = await J(`${base}/api/user/preferences`, { headers: auth(token) }); assert.equal(p.status, 200); const w = await J(`${base}/api/user/preferences`, { method: 'PATCH', headers: { ...auth(token), 'content-type': 'application/json' }, body: JSON.stringify({ defaultProviders: ['yt'] }) }); assert.equal(w.status, 200); }); it('playlists CRUD complet', async () => { const h = { ...auth(token), 'content-type': 'application/json' }; const c = await J(`${base}/api/playlists`, { method: 'POST', headers: h, body: JSON.stringify({ title: 'Cov' }) }); assert.equal(c.status, 201); const id = c.body.id; assert.ok(id); const bad = await J(`${base}/api/playlists`, { method: 'POST', headers: h, body: JSON.stringify({}) }); assert.equal(bad.status, 400); const g = await J(`${base}/api/playlists/${id}`, { headers: auth(token) }); assert.equal(g.status, 200); const add = await J(`${base}/api/playlists/${id}/videos`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1', title: 'T', thumbnail: 'http://x/t.jpg' }) }); assert.equal(add.status, 201); const del = await J(`${base}/api/playlists/${id}/videos/v1?provider=youtube`, { method: 'DELETE', headers: auth(token) }); assert.equal(del.status, 200); const u = await J(`${base}/api/playlists/${id}`, { method: 'PUT', headers: h, body: JSON.stringify({ title: 'Cov2' }) }); assert.equal(u.status, 200); assert.equal(u.body.title, 'Cov2'); const rm = await fetch(`${base}/api/playlists/${id}`, { method: 'DELETE', headers: auth(token) }); assert.equal(rm.status, 204); }); it('likes + history search/watch', async () => { const h = { ...auth(token), 'content-type': 'application/json' }; // title + thumbnail fournis -> pas d'appel yt-dlp d'enrichissement (offline-safe) const like = await J(`${base}/api/user/likes`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1', title: 'T', thumbnail: 'http://x/t.jpg' }) }); assert.ok([200, 201].includes(like.status)); const st = await J(`${base}/api/user/likes/status?provider=youtube&videoId=v1`, { headers: auth(token) }); assert.equal(st.status, 200); const hs = await J(`${base}/api/user/history/search`, { method: 'POST', headers: h, body: JSON.stringify({ query: 'cov' }) }); assert.ok([200, 201].includes(hs.status)); const ls = await J(`${base}/api/user/history/search`, { headers: auth(token) }); assert.equal(ls.status, 200); const hw = await J(`${base}/api/user/history/watch`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1' }) }); assert.ok([200, 201].includes(hw.status)); const lw = await J(`${base}/api/user/history/watch`, { headers: auth(token) }); assert.equal(lw.status, 200); }); it('watch-later : ajout, statut, liste et retrait (indépendant des likes)', async () => { const h = { ...auth(token), 'content-type': 'application/json' }; const add = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl1', title: 'WL', thumbnail: 'http://x/wl.jpg' }) }); assert.equal(add.status, 201, JSON.stringify(add.body)); const st = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) }); assert.equal(st.status, 200); assert.equal(st.body.liked, true, 'wl1 doit etre dans la liste watch-later'); // La meme video ajoutee aux likes ne doit PAS apparaitre dans watch-later const like = await J(`${base}/api/user/likes`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl2', title: 'L', thumbnail: 'http://x/l.jpg' }) }); assert.ok([200, 201].includes(like.status)); const st2 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl2`, { headers: auth(token) }); assert.equal(st2.body.liked, false, 'un like ne doit pas se retrouver dans watch-later'); const list = await J(`${base}/api/user/watch-later?limit=500`, { headers: auth(token) }); assert.equal(list.status, 200); assert.ok(Array.isArray(list.body), 'watch-later repond une liste'); assert.ok(list.body.some((r) => r.video_id === 'wl1'), 'wl1 present dans la liste'); assert.ok(!list.body.some((r) => r.video_id === 'wl2'), 'wl2 absent de la liste watch-later'); const rm = await J(`${base}/api/user/watch-later?provider=youtube&videoId=wl1`, { method: 'DELETE', headers: auth(token) }); assert.equal(rm.status, 200); assert.equal(rm.body.removed, true); const st3 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) }); assert.equal(st3.body.liked, false, 'wl1 retire de la liste'); const bad = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube' }) }); assert.equal(bad.status, 400); }); it('subscriptions + telemetry + download jobs', async () => { const h = { ...auth(token), 'content-type': 'application/json' }; const sub = await J(`${base}/api/subscriptions`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'yt', externalId: 'ch1', title: 'C' }) }); assert.ok([200, 201].includes(sub.status)); const ls = await J(`${base}/api/subscriptions`, { headers: auth(token) }); assert.equal(ls.status, 200); const tel = await J(`${base}/api/telemetry/events`, { method: 'POST', headers: h, body: JSON.stringify({ event: 'search_submit' }) }); assert.ok([200, 201].includes(tel.status)); const dj = await J(`${base}/api/download/jobs`, { headers: auth(token) }); assert.equal(dj.status, 200); }); });