import { test } from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; import { persistSourceVideo } from "../lib/savedVideo-server"; import { loadSavedVideo } from "../lib/savedVideo-server"; import { BACKUP_MANIFEST_FILENAME, parseBackupManifest, } from "../lib/savedVideoBackup"; import { backupSavedVideos, verifySavedVideoBackup, } from "./backupSavedVideos"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test controller/backupSavedVideos.test.ts // // These exercise the real rsync binary (resolved via paths.rsyncBin -> "rsync"). async function withPaths( fn: (paths: Paths, dest: string) => Promise, ): Promise { const dir = await mkdtemp(path.join(tmpdir(), "ttb-backup-")); const paths = { channelsDir: path.join(dir, "channels"), savedVideosDir: path.join(dir, "saved"), rsyncBin: "rsync", } as Paths; const dest = path.join(dir, "backup"); try { await fn(paths, dest); } finally { await rm(dir, { recursive: true, force: true }); } } async function seed( paths: Paths, slug: string, id: string, content: string, ): Promise { const videoDir = path.join(paths.channelsDir, slug, "data", id); await mkdir(videoDir, { recursive: true }); await writeFile(path.join(videoDir, "source-media.mp4"), content); await persistSourceVideo({ videoDir, sourceFilename: "source-media.mp4", storeDir: path.join(paths.savedVideosDir, slug, id), keepReason: "keep-latest", }); } function sha256(s: string): string { return createHash("sha256").update(s).digest("hex"); } test("backup mirrors the store + writes a manifest with sizes and hashes", async () => { await withPaths(async (paths, dest) => { await seed(paths, "alpha", "v1", "hello video one"); await seed(paths, "beta", "v2", "second"); const res = await backupSavedVideos({ paths, dest }); assert.equal(res.entries, 2); assert.equal(res.backedUp, 2); // Containers landed at ///source-media.mp4. assert.equal( await readFile(path.join(dest, "alpha", "v1", "source-media.mp4"), "utf8"), "hello video one", ); assert.equal( await readFile(path.join(dest, "beta", "v2", "source-media.mp4"), "utf8"), "second", ); // Manifest records each entry with the right size + streamed sha256. const manifest = parseBackupManifest( JSON.parse( await readFile(path.join(dest, BACKUP_MANIFEST_FILENAME), "utf8"), ), ); assert.ok(manifest); assert.equal(manifest.entries.length, 2); const alpha = manifest.entries.find((e) => e.slug === "alpha"); assert.ok(alpha); assert.equal(alpha.bytes, "hello video one".length); assert.equal(alpha.sha256, sha256("hello video one")); // The checksum was cached back onto the live pointer. const pointer = await loadSavedVideo( path.join(paths.channelsDir, "alpha", "data", "v1"), ); assert.equal(pointer?.sha256, sha256("hello video one")); // A fresh verify is clean. const verify = await verifySavedVideoBackup({ paths, dest }); assert.equal(verify.ok, true); assert.equal(verify.checked, 2); }); }); test("verify reports size, checksum, missing, and extra drift", async () => { await withPaths(async (paths, dest) => { await seed(paths, "alpha", "v1", "original-one"); await seed(paths, "alpha", "v2", "original-two"); await seed(paths, "beta", "v3", "original-three"); await backupSavedVideos({ paths, dest }); // Grow one dest file -> size mismatch. await appendFile(path.join(dest, "alpha", "v1", "source-media.mp4"), "X"); // Same-size edit of another -> checksum mismatch (replace with equal length). const v2 = path.join(dest, "alpha", "v2", "source-media.mp4"); const v2bytes = (await stat(v2)).size; await writeFile(v2, "Z".repeat(v2bytes)); // Delete the third -> missing. await rm(path.join(dest, "beta", "v3", "source-media.mp4")); // Drop an unknown container into the dest -> extra. await mkdir(path.join(dest, "ghost", "v9"), { recursive: true }); await writeFile(path.join(dest, "ghost", "v9", "source-media.mp4"), "boo"); const drift = await verifySavedVideoBackup({ paths, dest }); assert.equal(drift.ok, false); assert.deepEqual( drift.sizeMismatch.map((e) => e.videoId), ["v1"], ); assert.deepEqual( drift.checksumMismatch.map((e) => e.videoId), ["v2"], ); assert.deepEqual( drift.missing.map((e) => e.videoId), ["v3"], ); assert.deepEqual(drift.extra, [path.join("ghost", "v9", "source-media.mp4")]); }); }); test("verify throws when no manifest is present", async () => { await withPaths(async (paths, dest) => { await mkdir(dest, { recursive: true }); await assert.rejects(verifySavedVideoBackup({ paths, dest })); }); });