feat(docs): documentation web des API (Swagger UI) liée depuis le menu
CI / build-and-test (push) Successful in 15m9s
CI / build-and-test (push) Successful in 15m9s
Le lien « API » du menu ouvre /api/docs (et /proxy/api/docs) : Swagger UI
auto-hébergé (swagger-ui-dist, aucun CDN, thème sombre), spécification chargée
depuis `${base}/openapi.json`.
/openapi.json devient exhaustif :
- inventaire généré depuis l'arbre Express (collectApiRoutes) : `:param` →
`{param}`, `*` → `{path}`, miroirs /proxy/* et métadonnées écartés, préfixe
/api retiré (le document porte les serveurs /api et /proxy/api) ;
- `app.all()` réduit à GET — ses 35 méthodes HTTP (acl, propfind, m-search…)
entraient dans le document et figaient Swagger au bout de 2 groupes ;
- authentification marquée automatiquement (middlewares de route ET de préfixe,
ex. `r.use('/download', …)`), route publique laissée publique ;
- tags + descriptions de groupe, résumés FR pour les routes courantes ;
- 3 schémas d'auth : bearerAuth (JWT), apiKeyAuth (X-API-Key), cookieAuth ;
- description : méthodes d'auth avec exemples, codes d'erreur et seuils de
débit, section « Serveur MCP » avec snippet `mcpServers` et la liste des
outils LUE dans mcp/server.mjs au démarrage (aucune liste à maintenir).
Dockerfile : `mcp/` copié dans l'image (cette lecture doit exister en prod).
package.json → 1.0.61 (tag de cette livraison).
Tests : api_coverage +3 cas (page /docs + assets, exhaustivité/méthodes/tags/
sécurité, 3 schémas + outils MCP dans la description) → 51/51 verts.
Vérifs instance locale : lien menu = /proxy/api/docs, 114 opérations rendues
sur 12 groupes, bouton Authorize + section MCP visibles, zéro erreur console.
This commit is contained in:
@@ -310,3 +310,59 @@ describe('production-ready (P0/P1)', () => {
|
||||
assert.equal(r.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
// Documentation web : la page /docs (Swagger UI) et l'exhaustivité du contrat.
|
||||
describe('documentation web (/docs)', () => {
|
||||
it('GET /api/docs sert la page Swagger et ses assets', async () => {
|
||||
const r = await fetch(`${base}/api/docs`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.headers.get('content-type') || '', /html/);
|
||||
const html = await r.text();
|
||||
assert.ok(html.includes('swagger-ui-bundle.js'), 'bundle swagger absent de la page');
|
||||
assert.ok(html.includes('/api/openapi.json'), 'point d\'entrée spec absent');
|
||||
for (const asset of ['swagger-ui.css', 'swagger-ui-bundle.js', 'swagger-ui-standalone-preset.js']) {
|
||||
const a = await fetch(`${base}/api/docs/assets/${asset}`);
|
||||
assert.equal(a.status, 200, `asset ${asset}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('openapi : inventaire exhaustif, méthodes valides, tags partout', async () => {
|
||||
const r = await J(`${base}/api/openapi.json`);
|
||||
const paths = Object.keys(r.body.paths);
|
||||
assert.ok(paths.length >= 80, `seulement ${paths.length} chemins documentés`);
|
||||
for (const p of ['/download/jobs', '/user/likes/status', '/rumble/browse', '/auth/sessions',
|
||||
'/metrics', '/keys', '/yt/{path}', '/playlists/export', '/providers/health', '/twitch-token']) {
|
||||
assert.ok(r.body.paths[p], `${p} absent du contrat`);
|
||||
}
|
||||
assert.ok(!paths.some((p) => p.includes('/proxy')), 'chemin /proxy/ présent (miroir non documenté)');
|
||||
// Régression app.all() : 35 méthodes HTTP (acl, propfind…) ne doivent pas
|
||||
// entrer dans le document — Swagger plante et n'affiche plus rien.
|
||||
const VALID = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
||||
const invalid = paths.flatMap((p) => Object.keys(r.body.paths[p]).filter((m) => !VALID.includes(m)));
|
||||
assert.deepEqual(invalid, [], `méthodes invalides: ${invalid.slice(0, 5).join(', ')}`);
|
||||
const noTag = paths.filter((p) => !Object.values(r.body.paths[p]).some((op) => Array.isArray(op.tags) && op.tags.length));
|
||||
assert.deepEqual(noTag, [], `opérations sans tag: ${noTag.slice(0, 5).join(', ')}`);
|
||||
// Sécurité : route couverte par un middleware de préfixe marquée protégée,
|
||||
// route publique laissée telle quelle.
|
||||
assert.ok(r.body.paths['/download/jobs'].get.security, '/download/jobs doit être marqué protégé');
|
||||
assert.ok(!r.body.paths['/search'].get.security, '/search doit rester public');
|
||||
assert.ok(Array.isArray(r.body.tags) && r.body.tags.length >= 10, 'descriptions de tags absentes');
|
||||
});
|
||||
|
||||
it('openapi : 3 schémas d\'authentification + MCP décrit avec ses outils', async () => {
|
||||
const r = await J(`${base}/api/openapi.json`);
|
||||
const schemes = r.body.components.securitySchemes;
|
||||
assert.ok(schemes.bearerAuth && schemes.apiKeyAuth && schemes.cookieAuth, '3 schémas d\'auth attendus');
|
||||
const desc = r.body.info.description;
|
||||
assert.ok(desc.includes('Authorization: Bearer'), 'méthode JWT non décrite');
|
||||
assert.ok(desc.includes('X-API-Key'), 'méthode clé d\'API non décrite');
|
||||
assert.ok(desc.includes('Serveur MCP') && desc.includes('mcp/server.mjs'), 'section MCP absente');
|
||||
// La liste des outils est lue dans mcp/server.mjs : aucun décalage possible.
|
||||
const mcp = fs.readFileSync(path.resolve(import.meta.dirname, '..', '..', 'mcp', 'server.mjs'), 'utf8');
|
||||
const start = mcp.indexOf('const TOOLS = [');
|
||||
assert.ok(start > 0, 'bloc TOOLS introuvable dans mcp/server.mjs');
|
||||
const names = [...mcp.slice(start, mcp.indexOf('\n];', start)).matchAll(/name:\s*'([a-z0-9_]+)'/g)].map((m) => m[1]);
|
||||
assert.ok(names.length >= 10, `outils MCP trouvés: ${names.length}`);
|
||||
for (const n of names) assert.ok(desc.includes(n), `outil MCP ${n} absent de la description`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user