Files
NewTube/mcp/tools.test.mjs
T
bruno 1812b86dd4
CI / build-and-test (push) Successful in 14m3s
feat(mcp): serveur MCP stdio 7 outils + guide API/MCP + couverture tests
- mcp/server.mjs : search, suggest, details, trending, transcript,
  transcript_text, health_check (client fin vers API REST)
- docs/API_MCP_GUIDE.md : reference complete REST + MCP + transcript
- tests : server/tests/api_coverage.test.mjs (17 cas HTTP),
  mcp/tools.test.mjs (7 outils sur stub), scripts test:mcp/test:api
- fix(db): DELETE playlist 500 FK -> metrique avant delete + cleanup
  enfants + recordPlaylistMetric tolerant (observabilite)
2026-09-27 18:39:26 -04:00

102 lines
5.0 KiB
JavaScript

// Tests des 7 outils MCP contre un stub HTTP (aucune API réelle, aucun réseau).
// Stub -> NEWTUBE_API_URL, puis pilotage stdio JSON-RPC (tools/call).
// Run: npm run test:mcp (inclus via --test mcp/*.test.mjs si appelé en glob,
// sinon exécution directe node --test mcp/tools.test.mjs)
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { spawn } from 'node:child_process';
const LINES = [
{ t: 1, dur: 2, text: 'Bonjour' },
{ t: 65, dur: 2.5, text: 'deuxième ligne' },
{ t: 120, dur: 2, text: `remplissage ${'x'.repeat(900)}` },
];
const stub = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const json = (code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
if (u.pathname === '/api/healthz') return json(200, { status: 'ok', youtube: { mode: 'test' } });
if (u.pathname === '/api/search') return json(200, { q: 'q', providers: ['yt'], groups: { yt: [{ id: 'v1', title: 'T' }] }, page: 1, pageSize: 10 });
if (u.pathname === '/api/search/suggest') return json(200, { q: 'q', groups: { yt: ['abc'] } });
if (u.pathname === '/api/details/youtube/v1') return json(200, { videoId: 'v1', title: 'Demo', uploaderName: 'U', duration: 60, views: 10 });
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' });
return json(404, { error: 'not_found' });
});
await new Promise((r) => stub.listen(0, '127.0.0.1', r));
const STUB_URL = `http://127.0.0.1:${stub.address().port}/api`;
function startMcp() {
return spawn(process.execPath, ['./mcp/server.mjs'], { env: { ...process.env, NEWTUBE_API_URL: STUB_URL } });
}
function rpc(proc, id, method, params = {}) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`timeout ${method}`)), 15000);
const onData = (buf) => {
for (const l of String(buf).split('\n')) {
if (!l.trim()) continue;
try {
const m = JSON.parse(l);
if (m.id === id) { clearTimeout(timer); proc.stdout.off('data', onData); resolve(m); return; }
} catch {}
}
};
proc.stdout.on('data', onData);
proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
});
}
async function call(proc, id, name, args) {
const m = await rpc(proc, id, 'tools/call', { name, arguments: args });
assert.ok(!m.error, `RPC error: ${JSON.stringify(m.error)}`);
assert.equal(m.result.isError, undefined, `tool ${name} isError: ${m.result.content?.[0]?.text?.slice(0, 300)}`);
return m.result.content[0].text;
}
let proc;
before(async () => {
proc = startMcp();
await rpc(proc, 1, 'initialize');
});
after(() => { try { proc.kill(); } catch {} stub.close(); });
describe('MCP tools (stub API)', () => {
it('search_videos / suggest_queries / get_video_details / get_trending / health_check', async () => {
const t1 = await call(proc, 11, 'search_videos', { q: 'test' });
assert.match(t1, /r.sultats|résultats|1 r/);
const t2 = await call(proc, 12, 'suggest_queries', { q: 'ab' });
assert.match(t2, /Suggestions/);
const t3 = await call(proc, 13, 'get_video_details', { provider: 'youtube', videoId: 'v1' });
assert.match(t3, /Demo/);
const t4 = await call(proc, 14, 'get_trending', { provider: 'yt', limit: 5 });
assert.match(t4, /tendances/);
const t5 = await call(proc, 15, 'health_check', {});
assert.match(t5, /test/);
});
it('get_transcript JSON brut', async () => {
const t = await call(proc, 21, 'get_transcript', { provider: 'youtube', videoId: 'v1', lang: 'fr' });
assert.match(t, /3 lignes/);
assert.match(t, /Bonjour/);
});
it('get_transcript indisponible -> message clair, pas d erreur', async () => {
const t = await call(proc, 22, 'get_transcript', { provider: 'youtube', videoId: 'none' });
assert.match(t, /indisponible/);
assert.match(t, /no_subtitles/);
});
it('get_transcript_text horodaté + srt + troncature', async () => {
const txt = await call(proc, 23, 'get_transcript_text', { provider: 'youtube', videoId: 'v1', lang: 'fr' });
assert.match(txt, /\[00:00:01\] Bonjour/);
assert.match(txt, /\[00:01:05\]/);
const srt = await call(proc, 24, 'get_transcript_text', { provider: 'youtube', videoId: 'v1', format: 'srt' });
assert.match(srt, /-->/);
const cut = await call(proc, 25, 'get_transcript_text', { provider: 'youtube', videoId: 'v1', maxChars: 500 });
assert.match(cut, /tronqu/);
});
it('outil inconnu -> erreur JSON-RPC -32602', async () => {
const m = await rpc(proc, 99, 'tools/call', { name: 'nope', arguments: {} });
assert.ok(m.error);
assert.equal(m.error.code, -32602);
});
});