// 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: 'videos@test.local', 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: 'autre@test.local', 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`);