import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "./paths"; import { assertSavedVideosStoreWritable, isInCorpusSavedVideoStore, readSavedVideosMarker, savedVideosMarkerPath, SavedVideosStoreInTransitionError, } from "./savedVideoStore"; import { persistSourceVideo } from "./savedVideo-server"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test lib/savedVideoStore.test.ts // // THE GUARD AT THE MOMENT OF THE WRITE. The relocation queue key serialises // relocations against each other and says nothing about a download; this is // what actually stops a container landing in a store that is being moved — and // it has to live in lib/, because `savedVideo-server.ts` is what does the // landing and lib may not import controller. async function withTmp( fn: (paths: Paths, videoDir: string) => Promise, ): Promise { const dir = await mkdtemp(path.join(tmpdir(), "ttb-store-guard-")); const transcriptsDir = path.join(dir, "corpus"); const paths = { transcriptsDir, channelsDir: path.join(transcriptsDir, "channels"), savedVideosDir: path.join(transcriptsDir, "saved-videos"), } as Paths; const videoDir = path.join(paths.channelsDir, "chan", "data", "vid1"); await mkdir(videoDir, { recursive: true }); await mkdir(paths.savedVideosDir, { recursive: true }); try { await fn(paths, videoDir); } finally { await rm(dir, { recursive: true, force: true }); } } async function writeMarker(paths: Paths, phase = "copy"): Promise { await writeFile( savedVideosMarkerPath(paths), JSON.stringify({ target: "/mnt/platter/saved-videos", direction: "out", startedAt: new Date().toISOString(), phase, }), ); } test("no marker, no refusal", async () => { await withTmp(async (paths) => { assert.equal(await readSavedVideosMarker(paths), null); await assertSavedVideosStoreWritable(paths, paths.savedVideosDir); }); }); test("a marker refuses a write into the corpus store, by name", async () => { await withTmp(async (paths) => { await writeMarker(paths, "swap"); await assert.rejects( assertSavedVideosStoreWritable( paths, path.join(paths.savedVideosDir, "chan", "vid1"), ), (err: Error) => { assert.ok(err instanceof SavedVideosStoreInTransitionError); assert.match(err.message, /being moved/); assert.match(err.message, /phase "swap"/); return true; }, ); }); }); // A CHANNEL MAY POINT ITS OWN STORE SOMEWHERE ELSE ENTIRELY // (`ChannelConfig.savedVideosDir`), and that store is not the one being moved. // Refusing a write to it would decline a persist for a move that has nothing to // do with it. test("a store outside the corpus is not this move's business", async () => { await withTmp(async (paths) => { await writeMarker(paths); assert.equal( isInCorpusSavedVideoStore(paths, "/somewhere/else/chan/vid1"), false, ); await assertSavedVideosStoreWritable(paths, "/somewhere/else/chan/vid1"); }); }); // THE WHOLE POINT, end to end: a persist that fires while the store is being // moved does not move the container. The three ways it ends badly are in // savedVideoStore.ts's header; the worst is the container landing in the // directory the swap is about to park, which `reclaimParked` then rm -rf's // while `saved-video.json` still points at it. test("persistSourceVideo refuses while the store is in transition, and moves nothing", async () => { await withTmp(async (paths, videoDir) => { const source = "source-media.mp4"; await writeFile(path.join(videoDir, source), "the only copy"); const storeDir = path.join(paths.savedVideosDir, "chan", "vid1"); await writeMarker(paths); await assert.rejects( persistSourceVideo({ videoDir, sourceFilename: source, storeDir, paths, }), /being moved/, ); // The container is still where it was, with no pointer beside it and // nothing created in the store. assert.equal( await readFile(path.join(videoDir, source), "utf8"), "the only copy", ); assert.equal(await exists(path.join(videoDir, "saved-video.json")), false); assert.equal(await exists(storeDir), false); // Clear the marker and the same call succeeds — the guard is the marker // and nothing else. await rm(savedVideosMarkerPath(paths), { force: true }); const pointer = await persistSourceVideo({ videoDir, sourceFilename: source, storeDir, paths, }); assert.equal(pointer.dir, storeDir); assert.equal( await readFile(path.join(storeDir, source), "utf8"), "the only copy", ); }); }); // A caller that passes no paths opts out. That is right for a store that is not // the corpus one, and it is what keeps this change from touching every existing // call site. test("persistSourceVideo without paths does not consult the marker", async () => { await withTmp(async (paths, videoDir) => { await writeMarker(paths); await writeFile(path.join(videoDir, "source-media.mp4"), "x"); const storeDir = path.join(paths.savedVideosDir, "chan", "vid1"); const pointer = await persistSourceVideo({ videoDir, sourceFilename: "source-media.mp4", storeDir, }); assert.equal(pointer.file, "source-media.mp4"); }); }); async function exists(p: string): Promise { try { await readFile(p); return true; } catch (err) { return (err as NodeJS.ErrnoException).code === "EISDIR"; } }