Files
NewTube/server/tests/videos_catalog.test.mjs
T
bruno 665a0f0ebd
CI / build-and-test (push) Successful in 14m43s
feat(providers): phases 7.3/7.4/7.6/8.1 — provenance, health, NDJSON, contrat unique
7.3: capturedAt/source au registre + 6 adaptateurs + module provenance.ts + ?debug=1 (search-transport.mjs). 7.4: ProviderHealthService + badge source degradee. 7.6: squelettes par provider + snapshots progressifs + transport NDJSON /api/search. 8.1: ProviderAdapter unifie (search enveloppe + channelContent/channelMeta/capabilities) via getProviderAdapter + test de contrat offline.
2026-09-30 07:57:11 -04:00

198 lines
12 KiB
JavaScript

// Phase 5 : catalogue `videos` — upsert, trou des likes, backfill, captured_at.
// Run with: npm run test:videos
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import Database from 'better-sqlite3';
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-videos-test-'));
// DOIT etre defini avant l'import de db.mjs (cf. NEWTUBE_DB_FILE).
process.env.NEWTUBE_DB_FILE = path.join(tmpDir, 'test.db');
const db = await import('../db.mjs');
const {
insertUser, likeVideo, listLikedVideos, isVideoLiked, unlikeVideo,
upsertWatchHistory, upsertVideoRow, getVideoRow, countVideos, backfillVideosFromLegacy,
createPlaylist, addPlaylistVideo, listWatchHistory,
} = db;
// Seconde connexion sur le meme fichier, uniquement pour simuler des lignes
// pre-migration (que les helpers de production ne savent pas produire).
const raw = new Database(process.env.NEWTUBE_DB_FILE);
let pass = 0;
const ok = (c, m) => { if (!c) throw new Error(`ECHEC: ${m}`); pass++; console.log(` V ${m}`); };
// `insertUser` ne retourne rien : on garde l'id explicite.
insertUser({ id: 'u-vid-1', username: 'vidtester', email: '[email protected]', passwordHash: 'x' });
const userId = 'u-vid-1';
// --- 5.1 : la table existe et demarre vide ---
ok(typeof countVideos() === 'number', 'countVideos() repond');
ok(countVideos() === 0, 'la table videos demarre vide');
// --- 5.2 : upsert minimal ---
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v1', title: 'Titre 1', thumbnail: 'https://t/1.jpg' }) === true, 'upsert minimal ecrit');
let row = getVideoRow('youtube', 'v1');
ok(row && row.title === 'Titre 1' && row.thumbnail === 'https://t/1.jpg', 'lecture de la ligne');
ok(!!row.captured_at, 'captured_at renseigne');
ok(row.created_at === row.captured_at, 'created_at = captured_at a la creation');
// --- provider normalise dans les deux sens ---
ok(getVideoRow('yt', 'v1')?.title === 'Titre 1', 'getVideoRow accepte le nom court');
ok(upsertVideoRow({ provider: 'yt', videoId: 'v2', title: 'Titre 2' }) === true, 'upsert accepte le nom court');
ok(getVideoRow('youtube', 'v2') !== null, 'le nom court est stocke en nom long');
ok(countVideos() === 2, 'les deux formes ne creent pas deux lignes');
// --- entree invalide refusee ---
ok(upsertVideoRow({ provider: 'youtube', videoId: '' }) === false, 'videoId vide refuse');
ok(upsertVideoRow({ provider: '', videoId: 'x' }) === false, 'provider vide refuse');
ok(upsertVideoRow({}) === false, 'dto vide refuse');
ok(upsertVideoRow(null) === false, 'null refuse (best-effort, pas de throw)');
// --- COALESCE : un appel minimal n'ecrase pas ---
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v1', kind: 'short', durationSeconds: 42 }) === true, 'appel minimal (kind + duree)');
row = getVideoRow('youtube', 'v1');
ok(row.kind === 'short' && row.duration_seconds === 42, 'les nouveaux champs sont ecrits');
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v1' }) === true, 'appel vide sur une ligne existante');
row = getVideoRow('youtube', 'v1');
ok(row.title === 'Titre 1' && row.kind === 'short' && row.duration_seconds === 42,
'un appel sans titre n\'efface ni le titre ni le kind');
// --- valeurs nulles / invalides rejetees (jamais de 0) ---
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v3', title: 'T', views: 0, durationSeconds: 0, width: -5 }) === true, 'upsert avec zeros');
row = getVideoRow('youtube', 'v3');
ok(row.views === null && row.duration_seconds === null && row.width === null,
'0 et negatif ne sont jamais persistes (coherent avec la regle undefined)');
// --- captured_at rafraichi a chaque re-observation (5.5) ---
const before = getVideoRow('youtube', 'v1').captured_at;
await new Promise((r) => setTimeout(r, 5));
upsertVideoRow({ provider: 'youtube', videoId: 'v1', title: 'Titre 1' });
ok(getVideoRow('youtube', 'v1').captured_at > before, 'captured_at est rafraichi a chaque re-observation');
ok(getVideoRow('youtube', 'v1').created_at !== null, 'created_at n\'est pas ecrase');
// --- raw_json tronque a 4 Ko ---
ok(upsertVideoRow({ provider: 'youtube', videoId: 'v4', title: 'T', raw: { big: 'x'.repeat(20000) } }) === true, 'upsert avec raw');
const rawRow = getVideoRow('youtube', 'v4');
ok(rawRow.raw_json && rawRow.raw_json.length <= 4096, 'raw_json tronque a 4 Ko');
// --- 5.3 : LE TROU DES LIKES ---
// Le vrai cas : l'utilisateur B like une video SANS l'avoir jamais vue et SANS
// titre ni vignette (le front n'a pas la fiche). `likeVideo` ne cree alors
// aucune ligne `watch_history` pour B -> l'ancien JOIN renvoyait un titre vide.
// C'est le scenario inter-utilisateurs : A a regarde, donc `videos` est rempli.
insertUser({ id: 'u-vid-2', username: 'autre', email: '[email protected]', passwordHash: 'x' });
upsertWatchHistory({ userId: 'u-vid-2', provider: 'youtube', videoId: 'shared1', title: 'Partagee', thumbnail: 'https://t/s.jpg' });
ok(getVideoRow('youtube', 'shared1')?.title === 'Partagee', 'l\'observation de A alimente videos');
likeVideo({ userId, provider: 'youtube', videoId: 'shared1' }); // like SANS titre ni vignette
ok(!listWatchHistory({ userId, limit: 200 }).some((h) => h.video_id === 'shared1'),
'aucun historique cree pour B par le like');
let likes = listLikedVideos({ userId, limit: 100 });
const shared = likes.find((l) => l.video_id === 'shared1');
ok(!!shared, 'le like sans historique est liste');
ok(shared && shared.title === 'Partagee', 'titre retrouve via `videos` malgre l\'absence d\'historique (le bug d\'origine)');
ok(shared && shared.thumbnail === 'https://t/s.jpg', 'vignette retrouve via `videos`');
ok(shared && shared.last_watched_at === null, 'aucun historique de visionnage pour B, et c\'est normal');
ok(shared && shared.captured_at !== null, 'captured_at expose pour l\'indicateur de fraicheur');
// --- provider court cote video_tags ---
likeVideo({ userId, provider: 'yt', videoId: 'shortform' });
upsertVideoRow({ provider: 'yt', videoId: 'shortform', title: 'Forme courte', thumbnail: 'https://t/s.jpg' });
likes = listLikedVideos({ userId, limit: 100 });
const short = likes.find((l) => l.video_id === 'shortform');
ok(!!short && short.title === 'Forme courte',
'un like emis avec le provider court retrouve bien ses metadonnees (normalisation du JOIN)');
// --- pas de doublon : une video dans 3 playlists ---
const pl = createPlaylist({ userId, title: 'PL' });
addPlaylistVideo({ userId, playlistId: pl.id, provider: 'youtube', videoId: 'multi', title: 'Dans 3 playlists' });
const pl2 = createPlaylist({ userId, title: 'PL2' });
addPlaylistVideo({ userId, playlistId: pl2.id, provider: 'youtube', videoId: 'multi', title: 'Dans 3 playlists' });
const pl3 = createPlaylist({ userId, title: 'PL3' });
addPlaylistVideo({ userId, playlistId: pl3.id, provider: 'youtube', videoId: 'multi', title: 'Dans 3 playlists' });
likeVideo({ userId, provider: 'youtube', videoId: 'multi' }); // like SANS titre ni vignette
upsertVideoRow({ provider: 'youtube', videoId: 'multi' });
upsertVideoRow({ provider: 'youtube', videoId: 'multi', title: 'Depuis videos' });
likes = listLikedVideos({ userId, limit: 100 });
const multi = likes.filter((l) => l.video_id === 'multi');
ok(multi.length === 1, 'une video dans 3 playlists apparait UNE seule fois dans les likes');
ok(multi[0]?.title === 'Depuis videos', '`videos` prime sur le repli playlist');
// --- repli playlist quand `videos` n'a pas la ligne (donnees pre-migration) ---
addPlaylistVideo({ userId, playlistId: pl.id, provider: 'yt', videoId: 'legacypl', title: 'Titre via playlist', thumbnail: 'https://t/lp.jpg' });
raw.prepare('DELETE FROM videos WHERE provider = ? AND video_id = ?').run('youtube', 'legacypl');
likeVideo({ userId, provider: 'yt', videoId: 'legacypl' });
ok(getVideoRow('yt', 'legacypl') !== null, 'le like a recree la ligne videos');
raw.prepare('DELETE FROM videos WHERE provider = ? AND video_id = ?').run('youtube', 'legacypl');
ok(getVideoRow('youtube', 'legacypl') === null, 'ligne videos retiree pour forcer le repli');
likes = listLikedVideos({ userId, limit: 100 });
const fb = likes.find((l) => l.video_id === 'legacypl');
ok(!!fb, 'repli playlist : le like reste visible');
ok(fb && fb.title === 'Titre via playlist', 'repli playlist : titre retrouve sans ligne `videos`');
ok(fb && fb.thumbnail === 'https://t/lp.jpg', 'repli playlist : vignette retrouvee sans ligne `videos`');
// --- repli watch_history quand `videos` est vide ---
likeVideo({ userId, provider: 'youtube', videoId: 'legacywh', title: 'Via historique', thumbnail: 'https://t/lw.jpg' });
raw.prepare('DELETE FROM videos WHERE provider = ? AND video_id = ?').run('youtube', 'legacywh');
likes = listLikedVideos({ userId, limit: 100 });
const fbwh = likes.find((l) => l.video_id === 'legacywh');
ok(!!fbwh && fbwh.title === 'Via historique', 'repli watch_history conserve le titre');
ok(fbwh && fbwh.captured_at === null, 'captured_at reste null si la video n\'est pas au catalogue');
// --- recherche texte ---
likeVideo({ userId, provider: 'youtube', videoId: 'q1', title: 'Recette de crepes' });
upsertVideoRow({ provider: 'youtube', videoId: 'q1', title: 'Recette de crepes' });
const found = listLikedVideos({ userId, q: 'crepes' });
ok(found.some((l) => l.video_id === 'q1'), 'la recherche filtre sur le titre de `videos`');
// --- unlike ---
unlikeVideo({ userId, provider: 'youtube', videoId: 'q1' });
ok(!isVideoLiked({ userId, provider: 'youtube', videoId: 'q1' }), 'unlike retire le like');
ok(!listLikedVideos({ userId, limit: 100 }).some((l) => l.video_id === 'q1'), 'le like retire n apparait plus');
// --- 5.2 : watch_history alimente aussi `videos` ---
const countBefore = countVideos();
upsertWatchHistory({ userId, provider: 'youtube', videoId: 'hist1', title: 'Historique', thumbnail: 'https://t/h.jpg' });
ok(getVideoRow('youtube', 'hist1')?.title === 'Historique', 'upsertWatchHistory alimente videos');
ok(countVideos() === countBefore + 1, 'une seule ligne ajoutee');
// --- 5.3 : backfill, normalisation provider + idempotence ---
// Lignes legacy ecrites a la main, provider court, hors de tout helper.
raw.prepare(`INSERT INTO watch_history (user_id, provider, video_id, title, thumbnail, watched_at, last_watched_at)
VALUES (?, 'yt', 'bf1', 'BF histoire', 'https://t/bf1.jpg', ?, ?)`)
.run(userId, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z');
raw.prepare(`INSERT INTO playlist_items (id, playlist_id, provider, video_id, title, thumbnail, added_at, position)
VALUES ('pi-legacy', ?, 'yt', 'bf2', 'BF playlist', 'https://t/bf2.jpg', ?, 1)`)
.run(pl.id, '2026-01-02T00:00:00.000Z');
ok(getVideoRow('youtube', 'bf1') === null, 'la ligne legacy watch_history n\'est pas encore au catalogue');
const back = backfillVideosFromLegacy();
ok(typeof back.watchHistory === 'number' && typeof back.playlistItems === 'number', 'le backfill retourne un rapport');
ok(back.watchHistory >= 1 && back.playlistItems >= 1, 'le backfill a bien insere les lignes legacy');
ok(getVideoRow('youtube', 'bf1')?.title === 'BF histoire', 'watch_history backfillee');
ok(getVideoRow('youtube', 'bf2')?.title === 'BF playlist', 'playlist_items backfillee');
ok(raw.prepare('SELECT provider FROM videos WHERE video_id = ?').get('bf1').provider === 'youtube',
'le provider court n\'a PAS ete stocke tel quel (normalise en nom long)');
ok(raw.prepare('SELECT COUNT(1) AS n FROM videos WHERE video_id = ?').get('bf1').n === 1,
'aucune ligne dupliquee pour (yt, bf1)');
ok(raw.prepare('SELECT COUNT(1) AS n FROM videos WHERE video_id = ?').get('bf2').n === 1,
'aucune ligne dupliquee pour (yt, bf2)');
// Le provider `yt` de watch_history ET de playlist_items doivent fusionner.
raw.prepare(`INSERT OR IGNORE INTO watch_history (user_id, provider, video_id, title, thumbnail, watched_at, last_watched_at)
VALUES (?, 'youtube', 'bf2', 'BF fusion', 'https://t/bf2.jpg', ?, ?)`)
.run(userId, '2026-01-03T00:00:00.000Z', '2026-01-03T00:00:00.000Z');
backfillVideosFromLegacy();
ok(raw.prepare('SELECT COUNT(1) AS n FROM videos WHERE video_id = ?').get('bf2').n === 1,
'les deux formes du meme provider fusionnent en une seule ligne');
// Rejouable : une deuxieme passe n'ajoute rien.
const again = backfillVideosFromLegacy();
ok(again.watchHistory === 0 && again.playlistItems === 0, 'backfill rejouable : 0 insertion au second passage');
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
console.log(`\n videos: ${pass} assertions OK`);