// Couverture HTTP volet 2 : routes non couvertes par api_coverage.test.mjs. // 100 % offline-safe : uniquement validations, formes d'erreur stables et // chemins SQLite (aucun yt-dlp, aucun réseau provider, aucune clé requise). // Run: npm run test:api (inclus) 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-cov2-')); const dbPath = path.join(tmpDir, 'cov2.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: 'cov2-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; } assert.ok(await waitUp(), `API ne démarre pas:\n${logs.slice(-3000)}`); const J = async (url, opts = {}) => { const r = await fetch(url, opts); const ct = r.headers.get('content-type') || ''; const body = ct.includes('json') ? await r.json().catch(() => ({})) : await r.text(); return { status: r.status, body }; }; const auth = (t) => ({ Authorization: `Bearer ${t}` }); const uniq = (p) => `${p}_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; const H = (t) => ({ ...auth(t), 'content-type': 'application/json' }); let token; before(async () => { const u = uniq('cov2user'); await J(`${base}/api/auth/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: u, password: 'Passw0rd!' }) }); const login = await J(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: u, password: 'Passw0rd!' }) }); assert.equal(login.status, 200); token = login.body.accessToken; }); after(() => { try { server.kill(); } catch {} }); describe('oauth (sans clés : formes 503/404/400)', () => { it('GET /api/oauth/status -> {google,twitch}', async () => { const r = await J(`${base}/api/oauth/status`); assert.equal(r.status, 200); assert.ok(r.body.google && r.body.twitch); }); it('GET /api/oauth/bogus/url (auth) -> 400 invalid_oauth_provider', async () => { const r = await J(`${base}/api/oauth/bogus/url`, { headers: auth(token) }); assert.equal(r.status, 400); assert.equal(r.body.error, 'invalid_oauth_provider'); }); it('GET /api/oauth/google/url sans config -> 503', async () => { const r = await J(`${base}/api/oauth/google/url`, { headers: auth(token) }); assert.ok([200, 503].includes(r.status)); // 200 si clés présentes dans l'env CI if (r.status === 503) assert.match(r.body.error, /not_configured/); }); it('GET /api/oauth/connections -> []', async () => { const r = await J(`${base}/api/oauth/connections`, { headers: auth(token) }); assert.equal(r.status, 200); assert.ok(Array.isArray(r.body.connections)); }); it('GET /api/oauth/google/preview sans connexion -> 404 oauth_not_connected', async () => { const r = await J(`${base}/api/oauth/google/preview`, { headers: auth(token) }); assert.equal(r.status, 404); assert.equal(r.body.error, 'oauth_not_connected'); }); it('DELETE /api/oauth/twitch sans connexion -> 204', async () => { const r = await fetch(`${base}/api/oauth/twitch`, { method: 'DELETE', headers: auth(token) }); assert.equal(r.status, 204); }); it('GET /api/auth/google/url sans config -> 503', async () => { const r = await J(`${base}/api/auth/google/url`); assert.ok([200, 503].includes(r.status)); }); }); describe('channels (validations, sans réseau)', () => { it('POST /api/channels/resolve sans auth -> 401', async () => { const r = await J(`${base}/api/channels/resolve`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }); assert.equal(r.status, 401); }); it('POST /api/channels/resolve provider bogus -> 400 invalid_provider', async () => { const r = await J(`${base}/api/channels/resolve`, { method: 'POST', headers: H(token), body: JSON.stringify({ provider: 'xx', externalId: 'a' }) }); assert.equal(r.status, 400); }); it('GET /api/channels/yt/x/content?type=bogus -> 400 invalid_type', async () => { const r = await J(`${base}/api/channels/yt/x/content?type=bogus`); assert.equal(r.status, 400); assert.equal(r.body.error, 'invalid_type'); }); }); describe('subscriptions batch + groupes', () => { it('POST /api/subscriptions/batch vide -> 400, puis import offline OK', async () => { const bad = await J(`${base}/api/subscriptions/batch`, { method: 'POST', headers: H(token), body: JSON.stringify({ items: [] }) }); assert.equal(bad.status, 400); const ok = await J(`${base}/api/subscriptions/batch`, { method: 'POST', headers: H(token), body: JSON.stringify({ items: [{ provider: 'youtube', externalId: 'ch-cov2', title: 'C' }] }) }); assert.equal(ok.status, 200); assert.equal(ok.body.imported, 1); }); it('groupes CRUD + DELETE abonnement', async () => { const bad = await J(`${base}/api/subscription-groups`, { method: 'POST', headers: H(token), body: JSON.stringify({}) }); assert.equal(bad.status, 400); const c = await J(`${base}/api/subscription-groups`, { method: 'POST', headers: H(token), body: JSON.stringify({ name: 'G1' }) }); assert.equal(c.status, 201); const g = await J(`${base}/api/subscription-groups`, { headers: auth(token) }); assert.equal(g.status, 200); assert.ok(g.body.groups.some((x) => x.id === c.body.id)); const p = await J(`${base}/api/subscription-groups/${c.body.id}`, { method: 'PATCH', headers: H(token), body: JSON.stringify({ name: 'G2' }) }); assert.equal(p.status, 200); const ls = await J(`${base}/api/subscriptions`, { headers: auth(token) }); const sub = ls.body.items.find((s) => s?.channel?.externalId === 'ch-cov2' || s?.externalId === 'ch-cov2'); assert.ok(sub, 'abonnement batch listé'); const subId = sub.subscriptionId ?? sub.id; const del = await fetch(`${base}/api/subscriptions/${subId}`, { method: 'DELETE', headers: auth(token) }); assert.ok([204, 404].includes(del.status)); const delG = await fetch(`${base}/api/subscription-groups/${c.body.id}`, { method: 'DELETE', headers: auth(token) }); assert.equal(delG.status, 204); }); }); describe('transcripts history + likes DELETE + history DELETE', () => { it('transcripts history : 400 sans lignes, 201, GET 404 puis 200, DELETE all=1', async () => { const bad = await J(`${base}/api/user/history/transcripts`, { method: 'POST', headers: H(token), body: JSON.stringify({ provider: 'youtube', videoId: 'v9', lang: 'fr' }) }); assert.equal(bad.status, 400); const vid = uniq('v'); const c = await J(`${base}/api/user/history/transcripts`, { method: 'POST', headers: H(token), body: JSON.stringify({ provider: 'youtube', videoId: vid, lang: 'fr', title: 'T', lines: [{ t: 0, dur: 1, text: 'hi' }] }) }); assert.equal(c.status, 201); const miss = await J(`${base}/api/user/history/transcripts/youtube/${uniq('nope')}`); void miss; // sans auth -> 401 (forme) ; on vérifie la route authentifiée ci-dessous const one = await J(`${base}/api/user/history/transcripts/youtube/${vid}`, { headers: auth(token) }); assert.equal(one.status, 200); assert.equal(one.body.available, true); const list = await J(`${base}/api/user/history/transcripts`, { headers: auth(token) }); assert.equal(list.status, 200); assert.ok(Array.isArray(list.body)); const del = await fetch(`${base}/api/user/history/transcripts?all=1`, { method: 'DELETE', headers: auth(token) }); assert.equal(del.status, 204); }); it('likes DELETE + history DELETE all=1', async () => { await J(`${base}/api/user/likes`, { method: 'POST', headers: H(token), body: JSON.stringify({ provider: 'youtube', videoId: 'vdel', title: 'T', thumbnail: 'http://x/t.jpg' }) }); const d = await J(`${base}/api/user/likes?provider=youtube&videoId=vdel`, { method: 'DELETE', headers: auth(token) }); assert.equal(d.status, 200); const st = await J(`${base}/api/user/likes/status?provider=youtube&videoId=vdel`, { headers: auth(token) }); assert.equal(st.body.liked, false); for (const u of [`${base}/api/user/history/search?all=1`, `${base}/api/user/history/watch?all=1`]) { const r = await fetch(u, { method: 'DELETE', headers: auth(token) }); assert.equal(r.status, 204, u); } }); }); describe('playlists public/view/reorder + telemetry summary', () => { it('public + view + reorder validation', async () => { const c = await J(`${base}/api/playlists`, { method: 'POST', headers: H(token), body: JSON.stringify({ title: 'PubCov' }) }); assert.equal(c.status, 201); const pub = await J(`${base}/api/playlists/public?limit=5`); assert.equal(pub.status, 200); assert.ok(Array.isArray(pub.body)); const view = await J(`${base}/api/playlists/${c.body.id}/view`); assert.ok([200, 401, 404].includes(view.status)); const badReorder = await J(`${base}/api/playlists/${c.body.id}/reorder`, { method: 'PUT', headers: H(token), body: JSON.stringify({ order: [] }) }); assert.equal(badReorder.status, 400); await fetch(`${base}/api/playlists/${c.body.id}`, { method: 'DELETE', headers: auth(token) }); }); it('telemetry events + summary (auth)', async () => { const post = await J(`${base}/api/telemetry/events`, { method: 'POST', headers: H(token), body: JSON.stringify({ event: 'search_submit' }) }); assert.ok([200, 201].includes(post.status)); const sum = await J(`${base}/api/telemetry/summary`, { headers: auth(token) }); assert.equal(sum.status, 200); }); }); describe('downloads (validations, sans yt-dlp) + ia/twitch/config + rumble', () => { it('sans auth -> 401', async () => { const r = await J(`${base}/api/download/youtube/v1/formats`); assert.equal(r.status, 401); }); it('provider interdit -> 403 (aucun spawn yt-dlp)', async () => { const r = await J(`${base}/api/download/bogus/v1/formats`, { headers: auth(token) }); assert.equal(r.status, 403); const p = await J(`${base}/api/download/bogus/v1`, { method: 'POST', headers: H(token), body: '{}' }); assert.equal(p.status, 403); }); it('peertube sans instance -> 400 formats_failed', async () => { const r = await J(`${base}/api/download/peertube/v1/formats`, { headers: auth(token) }); assert.equal(r.status, 400); assert.equal(r.body.error, 'formats_failed'); }); it('POST /api/ai/summarize sans clé -> 503', async () => { const r = await J(`${base}/api/ai/summarize`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ comments: ['a'] }) }); assert.ok([400, 503].includes(r.status)); if (r.status === 503) assert.equal(r.body.error, 'gemini_not_configured'); }); it('GET /api/twitch-token sans clés -> 502', async () => { const r = await J(`${base}/api/twitch-token`); assert.ok([200, 502, 503].includes(r.status)); }); it('GET /assets/config.js -> 200 JS', async () => { const r = await J(`${base}/assets/config.js`); assert.equal(r.status, 200); }); it('GET /api/rumble/search sans q -> 400', async () => { const r = await J(`${base}/api/rumble/search`); assert.equal(r.status, 400); }); });