feat(couverture): 100pc API/MCP/tests/docs - volet 2
CI / build-and-test (push) Successful in 14m6s

- tests: server/tests/api_coverage2.test.mjs (23 cas offline-safe) :
  oauth (status/url/connections/preview), channels (validations),
  subscriptions batch + groupes CRUD, transcripts history, likes/history
  DELETE, playlists public/view/reorder, telemetry summary, downloads
  401/403/400, ai 503, twitch-token, config.js, rumble 400
- mcp v1.1.0 : 4 outils authentifies (list_playlists/likes/
  subscriptions/watch_history, NEWTUBE_TOKEN) + tests stub
- openapi.json v1.1 : 15 chemins documentes (sante, recherche,
  details, trending, transcript, auth, prefs, likes, subs, downloads,
  oauth, playlists) ; guide maj (11 outils)
This commit is contained in:
2026-09-27 18:51:50 -04:00
parent 1812b86dd4
commit 148a00e60f
7 changed files with 405 additions and 8 deletions
+6 -2
View File
@@ -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 <accessToken>` **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=<jwt> 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": "<accessToken>"` (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=<jwt> 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:"<résumé>\n\n<extrait>\n\n--- JSON ---\n<tronqué 8ko>" }], isError? }`. Les erreurs API (connexion refusée, 429, 502 retryable) remontent en `isError:true` avec message actionnable (`npm run api`, réessayer…).
+74 -1
View File
@@ -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=<accessToken>');
}
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}`);
}
+2 -2
View File
@@ -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');
+27
View File
@@ -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();
}
});
});
+1 -1
View File
@@ -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",
+71 -2
View File
@@ -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' } },
+224
View File
@@ -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);
});
});