diff --git a/docs/API_MCP_GUIDE.md b/docs/API_MCP_GUIDE.md index 1b3710b..bf5c5f8 100644 --- a/docs/API_MCP_GUIDE.md +++ b/docs/API_MCP_GUIDE.md @@ -78,7 +78,7 @@ curl http://localhost:4000/api/search?q=lofi&providers=yt&limit=5 | jq - JSON partout (même les 429 : `{ error, retryAfterSec }` ou `{ available:false, error }`). - Auth : `Authorization: Bearer ` **ou** cookies `sid`+`refreshToken` (routes `authMiddlewareCookieAware` : downloads, likes, subscriptions, playlists). - Pagination : `page` (défaut 1), `pageSize`/`limit` (max 50). -- OpenAPI partiel : `GET /api/openapi.json` (playlists uniquement, à étendre). +- OpenAPI : `GET /api/openapi.json` (v1.1 : santé, recherche, détails, trending, transcript, auth, préférences, likes, abonnements, downloads, OAuth, playlists). ## 4. Authentification @@ -229,7 +229,7 @@ NEWTUBE_API_URL=https://mon-serveur/api NEWTUBE_TOKEN= npm run mcp } } } ``` -**VS Code** (`.vscode/mcp.json`) / **opencode** (`opencode.json`) : même `command`/`args`/`env`. Redémarrer le client, vérifier `tools/list` → 7 outils. +**VS Code** (`.vscode/mcp.json`) / **opencode** (`opencode.json`) : même `command`/`args`/`env`. Redémarrer le client, vérifier `tools/list` → 11 outils. Pour les 4 outils authentifiés, ajoutez `"NEWTUBE_TOKEN": ""` (obtenu via `POST /api/auth/login`) dans `env`. ## 12. Serveur MCP — outils @@ -242,6 +242,10 @@ NEWTUBE_API_URL=https://mon-serveur/api NEWTUBE_TOKEN= npm run mcp | `get_transcript` | `provider*`, `videoId*`, `lang=fr`, `instance`, `slug`, `sourceUrl` | `GET /transcript/:p/:id` (JSON brut) | | `get_transcript_text` | + `format=text\|srt`, `withTimestamps=true`, `maxChars=12000` | idem + mise en forme LLM | | `health_check` | — | `GET /healthz` | +| `list_playlists` 🔒 | `limit`, `offset`, `q` | `GET /playlists` (NEWTUBE_TOKEN) | +| `list_likes` 🔒 | `limit`, `q` | `GET /user/likes` (NEWTUBE_TOKEN) | +| `list_subscriptions` 🔒 | — | `GET /subscriptions` (NEWTUBE_TOKEN) | +| `list_watch_history` 🔒 | `limit` | `GET /user/history/watch` (NEWTUBE_TOKEN) | Réponse MCP : `{ content:[{ type:"text", text:"\n\n\n\n--- JSON ---\n" }], isError? }`. Les erreurs API (connexion refusée, 429, 502 retryable) remontent en `isError:true` avec message actionnable (`npm run api`, réessayer…). diff --git a/mcp/server.mjs b/mcp/server.mjs index 0407246..4a5c9a9 100644 --- a/mcp/server.mjs +++ b/mcp/server.mjs @@ -27,7 +27,7 @@ const API_BASE = (process.env.NEWTUBE_API_URL || 'http://localhost:4000/api').replace(/\/+$/, ''); const TOKEN = (process.env.NEWTUBE_TOKEN || '').trim(); const TIMEOUT_MS = Number(process.env.MCP_TOOL_TIMEOUT_MS || 60000); -const SERVER_VERSION = '1.0.0'; +const SERVER_VERSION = '1.1.0'; const PROVIDERS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru']; const TRANSCRIPT_PROVIDERS = ['youtube', 'yt', 'dailymotion', 'dm', 'peertube', 'pt', 'twitch', 'tw', 'odysee', 'od', 'rumble', 'ru']; @@ -187,8 +187,48 @@ const TOOLS = [ description: "Santé API : mode YouTube (YT_SEARCH_MODE), binaire yt-dlp, cache, quota jour, clés. Équivalent GET /healthz.", inputSchema: { type: 'object', properties: {} }, }, + { + name: 'list_playlists', + description: "Playlists de l'utilisateur connecté (NEWTUBE_TOKEN requis). Équivalent GET /api/playlists.", + inputSchema: { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 }, + offset: { type: 'integer', minimum: 0, default: 0 }, + q: { type: 'string', description: 'Recherche dans les titres' }, + }, + }, + }, + { + name: 'list_likes', + description: "Vidéos likées de l'utilisateur connecté (NEWTUBE_TOKEN requis). Équivalent GET /api/user/likes.", + inputSchema: { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 500, default: 50 }, + q: { type: 'string', description: 'Recherche serveur' }, + }, + }, + }, + { + name: 'list_subscriptions', + description: "Abonnements aux chaînes de l'utilisateur connecté (NEWTUBE_TOKEN requis). Équivalent GET /api/subscriptions.", + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'list_watch_history', + description: "Historique de visionnage de l'utilisateur connecté (NEWTUBE_TOKEN requis). Équivalent GET /api/user/history/watch.", + inputSchema: { + type: 'object', + properties: { limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 } }, + }, + }, ]; +function requireAuth() { + if (!TOKEN) throw new Error('NEWTUBE_TOKEN manquant — connectez-vous (POST /api/auth/login) puis exportez NEWTUBE_TOKEN='); +} + async function callTool(name, args = {}) { switch (name) { case 'search_videos': { @@ -265,6 +305,39 @@ async function callTool(name, args = {}) { const data = await apiFetch('/healthz'); return { summary: `API ${data.status} — YT mode=${data.youtube?.mode}, yt-dlp=${data.youtube?.ytdlp?.version || 'n/a'}`, data }; } + case 'list_playlists': { + requireAuth(); + const qs = new URLSearchParams({ + limit: String(args.limit ?? 50), offset: String(args.offset ?? 0), + ...(args.q ? { q: String(args.q) } : {}), + }); + const data = await apiFetch(`/playlists?${qs}`, { auth: true }); + const n = Array.isArray(data) ? data.length : 0; + return { summary: `${n} playlist(s)`, data }; + } + case 'list_likes': { + requireAuth(); + const qs = new URLSearchParams({ + limit: String(args.limit ?? 50), + ...(args.q ? { q: String(args.q) } : {}), + }); + const data = await apiFetch(`/user/likes?${qs}`, { auth: true }); + const n = Array.isArray(data) ? data.length : 0; + return { summary: `${n} vidéo(s) likée(s)`, data }; + } + case 'list_subscriptions': { + requireAuth(); + const data = await apiFetch('/subscriptions', { auth: true }); + const n = Array.isArray(data?.items) ? data.items.length : 0; + return { summary: `${n} abonnement(s)`, data }; + } + case 'list_watch_history': { + requireAuth(); + const qs = new URLSearchParams({ limit: String(args.limit ?? 50) }); + const data = await apiFetch(`/user/history/watch?${qs}`, { auth: true }); + const n = Array.isArray(data) ? data.length : 0; + return { summary: `${n} entrée(s) d'historique`, data }; + } default: throw new Error(`unknown_tool: ${name}`); } diff --git a/mcp/server.test.mjs b/mcp/server.test.mjs index f545ff8..378a449 100644 --- a/mcp/server.test.mjs +++ b/mcp/server.test.mjs @@ -23,7 +23,7 @@ function rpc(proc, obj) { } describe('NewTube MCP server (stdio)', () => { - it('initialize + tools/list expose 7 outils dont transcript', async () => { + it('initialize + tools/list expose 11 outils dont transcript', async () => { const proc = spawn(process.execPath, ['./mcp/server.mjs'], { env: { ...process.env, NEWTUBE_API_URL: 'http://127.0.0.1:9/api' } }); try { const init = await rpc(proc, { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }); @@ -32,7 +32,7 @@ describe('NewTube MCP server (stdio)', () => { const list = await rpc(proc, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const names = list.result.tools.map((t) => t.name); - for (const expected of ['search_videos', 'get_video_details', 'get_trending', 'get_transcript', 'get_transcript_text', 'health_check']) { + for (const expected of ['search_videos', 'suggest_queries', 'get_video_details', 'get_trending', 'get_transcript', 'get_transcript_text', 'health_check', 'list_playlists', 'list_likes', 'list_subscriptions', 'list_watch_history']) { assert.ok(names.includes(expected), `outil manquant: ${expected}`); } const tr = list.result.tools.find((t) => t.name === 'get_transcript'); diff --git a/mcp/tools.test.mjs b/mcp/tools.test.mjs index e74000c..0bf1778 100644 --- a/mcp/tools.test.mjs +++ b/mcp/tools.test.mjs @@ -23,6 +23,10 @@ const stub = http.createServer((req, res) => { if (u.pathname === '/api/trending') return json(200, { provider: 'yt', items: [{ id: 't1' }] }); if (u.pathname === '/api/transcript/youtube/v1') return json(200, { lang: 'fr', available: true, languages: ['fr', 'en'], lines: LINES }); if (u.pathname === '/api/transcript/youtube/none') return json(200, { lang: null, available: false, languages: [], lines: [], reason: 'no_subtitles' }); + if (u.pathname === '/api/playlists') return json(200, [{ id: 'p1', title: 'Ma playlist' }]); + if (u.pathname === '/api/user/likes') return json(200, [{ provider: 'youtube', videoId: 'v1' }]); + if (u.pathname === '/api/subscriptions') return json(200, { items: [{ subscriptionId: 1 }], ttl: 1 }); + if (u.pathname === '/api/user/history/watch') return json(200, [{ provider: 'youtube', videoId: 'v1' }]); return json(404, { error: 'not_found' }); }); await new Promise((r) => stub.listen(0, '127.0.0.1', r)); @@ -98,4 +102,27 @@ describe('MCP tools (stub API)', () => { assert.ok(m.error); assert.equal(m.error.code, -32602); }); + it('outils authentifiés sans token -> isError NEWTUBE_TOKEN', async () => { + const m = await rpc(proc, 100, 'tools/call', { name: 'list_playlists', arguments: {} }); + assert.equal(m.result.isError, true); + assert.match(m.result.content[0].text, /NEWTUBE_TOKEN/); + }); + it('outils authentifiés avec token (stub)', async () => { + const authed = spawn(process.execPath, ['./mcp/server.mjs'], { env: { ...process.env, NEWTUBE_API_URL: STUB_URL, NEWTUBE_TOKEN: 'dummy-jwt' } }); + try { + await rpc(authed, 1, 'initialize'); + for (const [id, name, args, re] of [ + [201, 'list_playlists', {}, /playlist/], + [202, 'list_likes', {}, /likée/], + [203, 'list_subscriptions', {}, /abonnement/], + [204, 'list_watch_history', {}, /historique/], + ]) { + const m = await rpc(authed, id, 'tools/call', { name, arguments: args }); + assert.equal(m.result.isError, undefined, `${name}: ${m.result.content?.[0]?.text?.slice(0, 200)}`); + assert.match(m.result.content[0].text, re); + } + } finally { + authed.kill(); + } + }); }); diff --git a/package.json b/package.json index ad7d0aa..2ff3dd4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "mcp": "node ./mcp/server.mjs", "mcp:dev": "node --env-file=.env.local ./mcp/server.mjs", "test:mcp": "node --test ./mcp/server.test.mjs ./mcp/tools.test.mjs", - "test:api": "node --test ./server/tests/api_coverage.test.mjs", + "test:api": "node --test ./server/tests/api_coverage.test.mjs ./server/tests/api_coverage2.test.mjs", "test:playlists": "node ./server/tests/playlist_visibility.test.mjs", "test:preferences": "node ./server/tests/preferences.test.mjs", "test:search-e2e": "node ./server/tests/search.e2e.test.mjs", diff --git a/server/index.mjs b/server/index.mjs index c1874ea..9c27833 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -3779,12 +3779,81 @@ r.put('/playlists/:id/reorder', authMiddlewareCookieAware, (req, res) => { } }); -// --- OpenAPI (minimal for playlists) --- +// --- OpenAPI (référence machine : lecture publique + espace utilisateur) --- r.get('/openapi.json', (_req, res) => { + const bearer = [{ bearerAuth: [] }]; const doc = { openapi: '3.0.0', - info: { title: 'NewTube API', version: '1.0.0' }, + info: { title: 'NewTube API', version: '1.1.0' }, paths: { + '/healthz': { + get: { summary: 'Santé API (mode YT, yt-dlp, cache, quota)', responses: { 200: { description: 'ok' } } }, + }, + '/search': { + get: { summary: 'Recherche unifiée multi-providers', parameters: [ + { name: 'q', in: 'query', required: true, schema: { type: 'string', minLength: 2 } }, + { name: 'providers', in: 'query', schema: { type: 'string', example: 'yt,dm' } }, + { name: 'page', in: 'query', schema: { type: 'integer', default: 1 } }, + { name: 'pageSize', in: 'query', schema: { type: 'integer', maximum: 50 } }, + { name: 'sort', in: 'query', schema: { type: 'string', enum: ['relevance', 'date', 'views'] } }, + ], responses: { 200: { description: '{ q, providers, groups, errors, page, pageSize, sort }' } } }, + }, + '/search/suggest': { + get: { summary: 'Typeahead groupé par provider', parameters: [ + { name: 'q', in: 'query', required: true, schema: { type: 'string', minLength: 2 } }, + { name: 'providers', in: 'query', schema: { type: 'string' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', maximum: 20 } }, + ], responses: { 200: { description: '{ q, groups }' } } }, + }, + '/details/{provider}/{videoId}': { + get: { summary: 'Métadonnées vidéo + connexes YouTube', parameters: [ + { name: 'provider', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'videoId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'instance', in: 'query', schema: { type: 'string' } }, + { name: 'related', in: 'query', schema: { type: 'string', enum: ['0'] } }, + ], responses: { 200: { description: 'video + related[]' } } }, + }, + '/trending': { + get: { summary: 'Tendances YouTube sans clé (phase 1 : yt)', parameters: [ + { name: 'provider', in: 'query', schema: { type: 'string', default: 'yt' } }, + { name: 'limit', in: 'query', schema: { type: 'integer', maximum: 50 } }, + ], responses: { 200: { description: '{ provider, items }' } } }, + }, + '/transcript/{provider}/{videoId}': { + get: { summary: 'Transcript { lang, available, languages, lines }', parameters: [ + { name: 'provider', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'videoId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'lang', in: 'query', schema: { type: 'string', default: 'fr' } }, + { name: 'instance', in: 'query', schema: { type: 'string' } }, + ], responses: { 200: { description: '200 available:true|false ; 502 retryable:true si 429 YouTube' } } }, + }, + '/auth/register': { + post: { summary: 'Créer un compte', responses: { 201: { description: '{ user, accessToken }' } } }, + }, + '/auth/login': { + post: { summary: 'Connexion (retourne accessToken + cookies)', responses: { 200: { description: '{ user, accessToken }' } } }, + }, + '/user/me': { + get: { summary: 'Profil courant', security: bearer, responses: { 200: { description: 'user' } } }, + }, + '/user/preferences': { + get: { summary: 'Lire préférences', security: bearer, responses: { 200: { description: 'prefs' } } }, + patch: { summary: 'Modifier préférences (defaultProviders…)', security: bearer, responses: { 200: { description: 'prefs' } } }, + }, + '/user/likes': { + get: { summary: 'Vidéos likées', security: bearer, responses: { 200: { description: 'likes[]' } } }, + post: { summary: 'Liker', security: bearer, responses: { 201: { description: 'like' } } }, + }, + '/subscriptions': { + get: { summary: 'Abonnements { items, ttl }', security: bearer, responses: { 200: { description: 'subs' } } }, + post: { summary: 'S abonner (résolution chaîne)', security: bearer, responses: { 201: { description: 'sub' } } }, + }, + '/download/{provider}/{videoId}/formats': { + get: { summary: 'Formats téléchargeables (cache 10 min)', security: bearer, responses: { 200: { description: '{ url, formats }' } } }, + }, + '/oauth/status': { + get: { summary: 'Connecteurs OAuth configurés', responses: { 200: { description: '{ google, twitch }' } } }, + }, '/playlists': { get: { summary: 'List user playlists', security: [{ bearerAuth: [] }], parameters: [ { name: 'limit', in: 'query', schema: { type: 'integer' } }, diff --git a/server/tests/api_coverage2.test.mjs b/server/tests/api_coverage2.test.mjs new file mode 100644 index 0000000..0032d13 --- /dev/null +++ b/server/tests/api_coverage2.test.mjs @@ -0,0 +1,224 @@ +// 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); + }); +});