CI / build-and-test (push) Successful in 14m43s
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.
198 lines
12 KiB
JavaScript
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`);
|