CI / build-and-test (push) Successful in 14m55s
Nouvelle liste dans la section Vous de la barre latérale, avec un bouton horloge a cote du coeur sur toutes les cartes (accueil, themes, provider- themes, recherche). - db : les 4 fonctions de listes (like/unlike/isLiked/list) prennent un paramètre `tag` — la table `tags` sert déjà de mot-clés génériques, donc zéro migration. - api : les 4 routes likes sont générées par registerVideoTagRoutes(prefix, tagName) ; /api/user/watch-later = même fabrique, même enrichissement yt-dlp, même middleware cookie-aware. Entrée OpenAPI ajoutée. - front : LikesService prend un VideoTag, like-button un @Input() list (icône horloge, ambre quand enregistré, libellés a11y dédiés) — un seul composant pour les deux boutons, pas de copie. - cartes de recherche : la grille partagée n'avait AUCUN bouton ; cœur + horloge ajoutés, masqués sur les lives et cartes chaîne (coin bas-gauche déjà occupé par le compteur de spectateurs / pas des vidéos). - route /library/watch-later = LikedComponent via data.list (titre, icône, placeholder et état vide pilotés par la route), clés nav.watchLater FR/EN. Tests : npm run test:api 41/41 (nouveau test watch-later : ajout, statut, indépendance vis-à-vis des likes, liste, retrait, 400, 401) + 21 autres suites vertes. Build OK, instance locale 4200 rebuildée. Sondage Playwright desktop+mobile : 12/14 (2 faux positifs du sondage : accordéon replié sur une route hors /library, 3×401 console pendant la phase déconnectée).
220 lines
12 KiB
JavaScript
220 lines
12 KiB
JavaScript
// Couverture HTTP des routes API non couvertes par les autres suites :
|
|
// health, search/suggest (400 + fallback), transcript (400), trending (400 + shape),
|
|
// ai/status, openapi, auth (register/login/me), preferences, playlists CRUD,
|
|
// likes, history search/watch, subscriptions, telemetry, download jobs.
|
|
// Offline-safe : aucune assertion sur le contenu provider externe, uniquement
|
|
// les formes stables (status, clés JSON). yt-dlp n'est jamais invoqué avec succès.
|
|
// Run: npm run test:api
|
|
|
|
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-cov-'));
|
|
const dbPath = path.join(tmpDir, 'cov.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: 'cov-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;
|
|
}
|
|
const up = await waitUp();
|
|
assert.ok(up, `API ne démarre pas:\n${logs.slice(-3000)}`);
|
|
|
|
const J = async (url, opts = {}) => {
|
|
const r = await fetch(url, opts);
|
|
const body = await r.json().catch(() => ({}));
|
|
return { status: r.status, body };
|
|
};
|
|
const auth = (t) => ({ Authorization: `Bearer ${t}` });
|
|
const uniq = (p) => `${p}_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
|
|
|
|
let token;
|
|
const user = uniq('covuser');
|
|
|
|
before(async () => {
|
|
const reg = await J(`${base}/api/auth/register`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ username: user, password: 'Passw0rd!' }),
|
|
});
|
|
assert.ok([201, 409].includes(reg.status), `register ${reg.status}`);
|
|
const login = await J(`${base}/api/auth/login`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ username: user, password: 'Passw0rd!' }),
|
|
});
|
|
assert.equal(login.status, 200);
|
|
assert.ok(login.body.accessToken);
|
|
token = login.body.accessToken;
|
|
});
|
|
after(() => { try { server.kill(); } catch {} });
|
|
|
|
describe('lecture publique / santé', () => {
|
|
it('GET /api/health -> ok', async () => {
|
|
const r = await J(`${base}/api/health`);
|
|
assert.equal(r.status, 200); assert.equal(r.body.status, 'ok');
|
|
});
|
|
it('GET /healthz expose youtube.mode sans secret', async () => {
|
|
const r = await J(`${base}/healthz`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.status, 'ok');
|
|
assert.ok(r.body.youtube?.mode);
|
|
assert.ok(!JSON.stringify(r.body).includes('GEMINI'));
|
|
});
|
|
it('GET /api/search?q=x -> 400', async () => {
|
|
const r = await J(`${base}/api/search?q=x`);
|
|
assert.equal(r.status, 400);
|
|
});
|
|
it('GET /api/search providers inconnus -> fallback registre', async () => {
|
|
const r = await J(`${base}/api/search?q=test&providers=xx,yy&pageSize=2`);
|
|
assert.equal(r.status, 200);
|
|
assert.ok(r.body.providers.length >= 6);
|
|
assert.ok(r.body.groups && typeof r.body.groups === 'object');
|
|
});
|
|
it('GET /api/search/suggest?q=x -> 400', async () => {
|
|
const r = await J(`${base}/api/search/suggest?q=x`);
|
|
assert.equal(r.status, 400);
|
|
});
|
|
it('GET /api/transcript provider inconnu -> 400 available:false', async () => {
|
|
const r = await J(`${base}/api/transcript/bogus/vid`);
|
|
assert.equal(r.status, 400);
|
|
assert.equal(r.body.available, false);
|
|
});
|
|
it('GET /api/details provider inconnu -> 500 details_failed (sans yt-dlp)', async () => {
|
|
const r = await J(`${base}/api/details/bogus/vid`);
|
|
assert.equal(r.status, 500);
|
|
assert.equal(r.body.error, 'details_failed');
|
|
});
|
|
it('GET /api/trending provider non-yt -> 400', async () => {
|
|
const r = await J(`${base}/api/trending?provider=dm`);
|
|
assert.equal(r.status, 400);
|
|
});
|
|
it('GET /api/trending?provider=yt -> shape {provider,items[]}', async () => {
|
|
const r = await J(`${base}/api/trending?provider=yt&limit=2`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.provider, 'yt');
|
|
assert.ok(Array.isArray(r.body.items));
|
|
});
|
|
it('GET /api/ai/status -> {ready:boolean}', async () => {
|
|
const r = await J(`${base}/api/ai/status`);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(typeof r.body.ready, 'boolean');
|
|
});
|
|
it('GET /api/openapi.json documente playlists', async () => {
|
|
const r = await J(`${base}/api/openapi.json`);
|
|
assert.equal(r.status, 200);
|
|
assert.ok(r.body.paths?.['/playlists']);
|
|
});
|
|
});
|
|
|
|
describe('auth + espace utilisateur', () => {
|
|
it('register/login 400 sans password, 401 mauvais password', async () => {
|
|
const r1 = await J(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user }) });
|
|
assert.equal(r1.status, 400);
|
|
const r2 = await J(`${base}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: user, password: 'mauvais' }) });
|
|
assert.equal(r2.status, 401);
|
|
});
|
|
it('protégées sans token -> 401', async () => {
|
|
for (const u of ['/api/user/me', '/api/playlists', '/api/user/likes', '/api/user/watch-later', '/api/subscriptions', '/api/download/jobs']) {
|
|
const r = await J(`${base}${u}`);
|
|
assert.equal(r.status, 401, u);
|
|
}
|
|
});
|
|
it('GET /api/user/me + preferences round-trip', async () => {
|
|
const me = await J(`${base}/api/user/me`, { headers: auth(token) });
|
|
assert.equal(me.status, 200); assert.ok(me.body.id);
|
|
const p = await J(`${base}/api/user/preferences`, { headers: auth(token) });
|
|
assert.equal(p.status, 200);
|
|
const w = await J(`${base}/api/user/preferences`, { method: 'PATCH', headers: { ...auth(token), 'content-type': 'application/json' }, body: JSON.stringify({ defaultProviders: ['yt'] }) });
|
|
assert.equal(w.status, 200);
|
|
});
|
|
it('playlists CRUD complet', async () => {
|
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
|
const c = await J(`${base}/api/playlists`, { method: 'POST', headers: h, body: JSON.stringify({ title: 'Cov' }) });
|
|
assert.equal(c.status, 201); const id = c.body.id; assert.ok(id);
|
|
const bad = await J(`${base}/api/playlists`, { method: 'POST', headers: h, body: JSON.stringify({}) });
|
|
assert.equal(bad.status, 400);
|
|
const g = await J(`${base}/api/playlists/${id}`, { headers: auth(token) });
|
|
assert.equal(g.status, 200);
|
|
const add = await J(`${base}/api/playlists/${id}/videos`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1', title: 'T', thumbnail: 'http://x/t.jpg' }) });
|
|
assert.equal(add.status, 201);
|
|
const del = await J(`${base}/api/playlists/${id}/videos/v1?provider=youtube`, { method: 'DELETE', headers: auth(token) });
|
|
assert.equal(del.status, 200);
|
|
const u = await J(`${base}/api/playlists/${id}`, { method: 'PUT', headers: h, body: JSON.stringify({ title: 'Cov2' }) });
|
|
assert.equal(u.status, 200); assert.equal(u.body.title, 'Cov2');
|
|
const rm = await fetch(`${base}/api/playlists/${id}`, { method: 'DELETE', headers: auth(token) });
|
|
assert.equal(rm.status, 204);
|
|
});
|
|
it('likes + history search/watch', async () => {
|
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
|
// title + thumbnail fournis -> pas d'appel yt-dlp d'enrichissement (offline-safe)
|
|
const like = await J(`${base}/api/user/likes`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1', title: 'T', thumbnail: 'http://x/t.jpg' }) });
|
|
assert.ok([200, 201].includes(like.status));
|
|
const st = await J(`${base}/api/user/likes/status?provider=youtube&videoId=v1`, { headers: auth(token) });
|
|
assert.equal(st.status, 200);
|
|
const hs = await J(`${base}/api/user/history/search`, { method: 'POST', headers: h, body: JSON.stringify({ query: 'cov' }) });
|
|
assert.ok([200, 201].includes(hs.status));
|
|
const ls = await J(`${base}/api/user/history/search`, { headers: auth(token) });
|
|
assert.equal(ls.status, 200);
|
|
const hw = await J(`${base}/api/user/history/watch`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'v1' }) });
|
|
assert.ok([200, 201].includes(hw.status));
|
|
const lw = await J(`${base}/api/user/history/watch`, { headers: auth(token) });
|
|
assert.equal(lw.status, 200);
|
|
});
|
|
it('watch-later : ajout, statut, liste et retrait (indépendant des likes)', async () => {
|
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
|
const add = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl1', title: 'WL', thumbnail: 'http://x/wl.jpg' }) });
|
|
assert.equal(add.status, 201, JSON.stringify(add.body));
|
|
const st = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) });
|
|
assert.equal(st.status, 200);
|
|
assert.equal(st.body.liked, true, 'wl1 doit etre dans la liste watch-later');
|
|
// La meme video ajoutee aux likes ne doit PAS apparaitre dans watch-later
|
|
const like = await J(`${base}/api/user/likes`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl2', title: 'L', thumbnail: 'http://x/l.jpg' }) });
|
|
assert.ok([200, 201].includes(like.status));
|
|
const st2 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl2`, { headers: auth(token) });
|
|
assert.equal(st2.body.liked, false, 'un like ne doit pas se retrouver dans watch-later');
|
|
const list = await J(`${base}/api/user/watch-later?limit=500`, { headers: auth(token) });
|
|
assert.equal(list.status, 200);
|
|
assert.ok(Array.isArray(list.body), 'watch-later repond une liste');
|
|
assert.ok(list.body.some((r) => r.video_id === 'wl1'), 'wl1 present dans la liste');
|
|
assert.ok(!list.body.some((r) => r.video_id === 'wl2'), 'wl2 absent de la liste watch-later');
|
|
const rm = await J(`${base}/api/user/watch-later?provider=youtube&videoId=wl1`, { method: 'DELETE', headers: auth(token) });
|
|
assert.equal(rm.status, 200);
|
|
assert.equal(rm.body.removed, true);
|
|
const st3 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) });
|
|
assert.equal(st3.body.liked, false, 'wl1 retire de la liste');
|
|
const bad = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube' }) });
|
|
assert.equal(bad.status, 400);
|
|
});
|
|
it('subscriptions + telemetry + download jobs', async () => {
|
|
const h = { ...auth(token), 'content-type': 'application/json' };
|
|
const sub = await J(`${base}/api/subscriptions`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'yt', externalId: 'ch1', title: 'C' }) });
|
|
assert.ok([200, 201].includes(sub.status));
|
|
const ls = await J(`${base}/api/subscriptions`, { headers: auth(token) });
|
|
assert.equal(ls.status, 200);
|
|
const tel = await J(`${base}/api/telemetry/events`, { method: 'POST', headers: h, body: JSON.stringify({ event: 'search_submit' }) });
|
|
assert.ok([200, 201].includes(tel.status));
|
|
const dj = await J(`${base}/api/download/jobs`, { headers: auth(token) });
|
|
assert.equal(dj.status, 200);
|
|
});
|
|
});
|