- 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:
@@ -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 }`).
|
- 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).
|
- Auth : `Authorization: Bearer <accessToken>` **ou** cookies `sid`+`refreshToken` (routes `authMiddlewareCookieAware` : downloads, likes, subscriptions, playlists).
|
||||||
- Pagination : `page` (défaut 1), `pageSize`/`limit` (max 50).
|
- 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
|
## 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
|
## 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` | `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 |
|
| `get_transcript_text` | + `format=text\|srt`, `withTimestamps=true`, `maxChars=12000` | idem + mise en forme LLM |
|
||||||
| `health_check` | — | `GET /healthz` |
|
| `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…).
|
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
@@ -27,7 +27,7 @@
|
|||||||
const API_BASE = (process.env.NEWTUBE_API_URL || 'http://localhost:4000/api').replace(/\/+$/, '');
|
const API_BASE = (process.env.NEWTUBE_API_URL || 'http://localhost:4000/api').replace(/\/+$/, '');
|
||||||
const TOKEN = (process.env.NEWTUBE_TOKEN || '').trim();
|
const TOKEN = (process.env.NEWTUBE_TOKEN || '').trim();
|
||||||
const TIMEOUT_MS = Number(process.env.MCP_TOOL_TIMEOUT_MS || 60000);
|
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 PROVIDERS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
|
||||||
const TRANSCRIPT_PROVIDERS = ['youtube', 'yt', 'dailymotion', 'dm', 'peertube', 'pt', 'twitch', 'tw', 'odysee', 'od', 'rumble', '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.",
|
description: "Santé API : mode YouTube (YT_SEARCH_MODE), binaire yt-dlp, cache, quota jour, clés. Équivalent GET /healthz.",
|
||||||
inputSchema: { type: 'object', properties: {} },
|
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 = {}) {
|
async function callTool(name, args = {}) {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'search_videos': {
|
case 'search_videos': {
|
||||||
@@ -265,6 +305,39 @@ async function callTool(name, args = {}) {
|
|||||||
const data = await apiFetch('/healthz');
|
const data = await apiFetch('/healthz');
|
||||||
return { summary: `API ${data.status} — YT mode=${data.youtube?.mode}, yt-dlp=${data.youtube?.ytdlp?.version || 'n/a'}`, data };
|
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:
|
default:
|
||||||
throw new Error(`unknown_tool: ${name}`);
|
throw new Error(`unknown_tool: ${name}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ function rpc(proc, obj) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('NewTube MCP server (stdio)', () => {
|
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' } });
|
const proc = spawn(process.execPath, ['./mcp/server.mjs'], { env: { ...process.env, NEWTUBE_API_URL: 'http://127.0.0.1:9/api' } });
|
||||||
try {
|
try {
|
||||||
const init = await rpc(proc, { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
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 list = await rpc(proc, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
||||||
const names = list.result.tools.map((t) => t.name);
|
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}`);
|
assert.ok(names.includes(expected), `outil manquant: ${expected}`);
|
||||||
}
|
}
|
||||||
const tr = list.result.tools.find((t) => t.name === 'get_transcript');
|
const tr = list.result.tools.find((t) => t.name === 'get_transcript');
|
||||||
|
|||||||
@@ -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/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/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/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' });
|
return json(404, { error: 'not_found' });
|
||||||
});
|
});
|
||||||
await new Promise((r) => stub.listen(0, '127.0.0.1', r));
|
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.ok(m.error);
|
||||||
assert.equal(m.error.code, -32602);
|
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
@@ -12,7 +12,7 @@
|
|||||||
"mcp": "node ./mcp/server.mjs",
|
"mcp": "node ./mcp/server.mjs",
|
||||||
"mcp:dev": "node --env-file=.env.local ./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: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:playlists": "node ./server/tests/playlist_visibility.test.mjs",
|
||||||
"test:preferences": "node ./server/tests/preferences.test.mjs",
|
"test:preferences": "node ./server/tests/preferences.test.mjs",
|
||||||
"test:search-e2e": "node ./server/tests/search.e2e.test.mjs",
|
"test:search-e2e": "node ./server/tests/search.e2e.test.mjs",
|
||||||
|
|||||||
+71
-2
@@ -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) => {
|
r.get('/openapi.json', (_req, res) => {
|
||||||
|
const bearer = [{ bearerAuth: [] }];
|
||||||
const doc = {
|
const doc = {
|
||||||
openapi: '3.0.0',
|
openapi: '3.0.0',
|
||||||
info: { title: 'NewTube API', version: '1.0.0' },
|
info: { title: 'NewTube API', version: '1.1.0' },
|
||||||
paths: {
|
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': {
|
'/playlists': {
|
||||||
get: { summary: 'List user playlists', security: [{ bearerAuth: [] }], parameters: [
|
get: { summary: 'List user playlists', security: [{ bearerAuth: [] }], parameters: [
|
||||||
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
|
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user