import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; import type { ChannelConfig } from "../lib/channelConfig"; import { relocatedDataDir, RELOCATION_MARKER_FILENAME, } from "../lib/channelMedia"; import { relocatedMediaDir } from "../lib/mediaTier-server"; import { channelConfigPath, channelExists, deleteChannel, patchChannelConfig, readChannelConfig, writeChannelConfig, } from "./channels"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test controller/channels.test.ts // // Deleting a RELOCATED channel is the one place `rm -r` on the channel dir is // not the whole job: node's rm does not follow symlinks, so it takes the link // and leaves the media behind as an orphan nothing in the app can see. const config: ChannelConfig = { handling: "youtube", name: "A channel" }; async function withPaths( fn: (paths: Paths, mediaRoot: string) => Promise, ): Promise { const dir = await mkdtemp(path.join(tmpdir(), "ttb-channels-")); const paths = { channelsDir: path.join(dir, "channels") } as Paths; const mediaRoot = path.join(dir, "platter"); try { await fn(paths, mediaRoot); } finally { await rm(dir, { recursive: true, force: true }); } } async function there(p: string): Promise { try { await stat(p); return true; } catch { return false; } } test("deleting an in-place channel removes its directory", async () => { await withPaths(async (paths) => { await writeChannelConfig(paths, "alpha", config); await mkdir(path.join(paths.channelsDir, "alpha", "data", "v1"), { recursive: true, }); await deleteChannel(paths, "alpha"); assert.equal(await channelExists(paths, "alpha"), false); }); }); test("deleting a relocated channel reclaims the media on the other drive", async () => { await withPaths(async (paths, mediaRoot) => { const target = relocatedMediaDir(mediaRoot, "alpha"); await writeChannelConfig(paths, "alpha", { ...config, mediaDir: target }); await mkdir(path.join(target, "v1"), { recursive: true }); await writeFile(path.join(target, "v1", "audio.m4a"), "BYTES"); await symlink(target, path.join(paths.channelsDir, "alpha", "media")); await deleteChannel(paths, "alpha"); assert.equal(await channelExists(paths, "alpha"), false); assert.equal(await there(target), false); // / goes too, once it holds nothing. assert.equal(await there(path.join(mediaRoot, "alpha")), false); // ...but the root itself is the operator's and is never touched. assert.equal(await there(mediaRoot), true); }); }); test("a non-empty / survives the delete", async () => { await withPaths(async (paths, mediaRoot) => { const target = relocatedMediaDir(mediaRoot, "alpha"); await writeChannelConfig(paths, "alpha", { ...config, mediaDir: target }); await mkdir(target, { recursive: true }); await symlink(target, path.join(paths.channelsDir, "alpha", "media")); // Something of the operator's, alongside the media dir we own. await writeFile(path.join(mediaRoot, "alpha", "NOTES.txt"), "mine"); await deleteChannel(paths, "alpha"); assert.equal(await there(target), false); assert.equal(await there(path.join(mediaRoot, "alpha", "NOTES.txt")), true); }); }); // THE RETIRED LAYOUT (release 17): a channel never migrated off the whole- // directory move still carries `dataDir`; deleting it reclaims that tree too. test("deleting a legacy channel reclaims its retired data dir", async () => { await withPaths(async (paths, mediaRoot) => { const target = relocatedDataDir(mediaRoot, "alpha"); await writeChannelConfig(paths, "alpha", { ...config, dataDir: target }); await mkdir(path.join(target, "v1"), { recursive: true }); await writeFile(path.join(target, "v1", "transcript.json"), "{}"); await symlink(target, path.join(paths.channelsDir, "alpha", "data")); await deleteChannel(paths, "alpha"); assert.equal(await channelExists(paths, "alpha"), false); assert.equal(await there(target), false); assert.equal(await there(path.join(mediaRoot, "alpha")), false); }); }); test("deleting is refused while a relocation is in flight", async () => { await withPaths(async (paths, mediaRoot) => { const target = relocatedMediaDir(mediaRoot, "alpha"); await writeChannelConfig(paths, "alpha", config); await mkdir(path.join(paths.channelsDir, "alpha", "data"), { recursive: true, }); await writeFile( path.join(paths.channelsDir, "alpha", RELOCATION_MARKER_FILENAME), JSON.stringify({ target, direction: "out", startedAt: new Date().toISOString(), phase: "copy", }), ); await assert.rejects( () => deleteChannel(paths, "alpha"), /relocation in progress/, ); assert.equal(await channelExists(paths, "alpha"), true); }); }); test("an unmounted target does not make the channel undeletable", async () => { await withPaths(async (paths, mediaRoot) => { // `force: true` makes a missing target a no-op, so the channel goes and its // media stays on the unmounted platter. Deliberate: refusing here would // make a channel whose drive is gone for good impossible to remove. const target = relocatedMediaDir(mediaRoot, "alpha"); await writeChannelConfig(paths, "alpha", { ...config, mediaDir: target }); await symlink(target, path.join(paths.channelsDir, "alpha", "media")); await deleteChannel(paths, "alpha"); assert.equal(await channelExists(paths, "alpha"), false); }); }); // --------------------------------------------------------------------------- // One reader, one strict writer, one patcher (one-core phase 3 slice 4b). // --------------------------------------------------------------------------- test("patchChannelConfig sets one key and leaves the rest", async () => { await withPaths(async (paths) => { await writeChannelConfig(paths, "a", { ...config, lastSyncedAt: "t0", keepLatest: 3 }); const out = await patchChannelConfig(paths, "a", { lastSyncedAt: "t1" }); assert.deepEqual(out, { handling: "youtube", name: "A channel", keepLatest: 3, lastSyncedAt: "t1" }); assert.deepEqual(await readChannelConfig(paths, "a"), out); }); }); test("patchChannelConfig unsets before it assigns", async () => { await withPaths(async (paths) => { await writeChannelConfig(paths, "a", { ...config, dataDir: "/x", keepLatest: 3 }); await patchChannelConfig(paths, "a", {}, { unset: ["dataDir"] }); assert.deepEqual(await readChannelConfig(paths, "a"), { ...config, keepLatest: 3 }); // unset + set of the same key = set. await patchChannelConfig(paths, "a", { keepLatest: 5 }, { unset: ["keepLatest"] }); assert.equal((await readChannelConfig(paths, "a"))?.keepLatest, 5); }); }); test("patchChannelConfig on a missing or non-channel file writes nothing and answers null", async () => { await withPaths(async (paths) => { assert.equal(await patchChannelConfig(paths, "ghost", { lastSyncedAt: "t" }), null); await assert.rejects(stat(channelConfigPath(paths, "ghost"))); await mkdir(path.join(paths.channelsDir, "junk"), { recursive: true }); await writeFile(channelConfigPath(paths, "junk"), "{ torn"); assert.equal(await patchChannelConfig(paths, "junk", { lastSyncedAt: "t" }), null); assert.equal(await readFile(channelConfigPath(paths, "junk"), "utf8"), "{ torn"); }); }); test("writeChannelConfig is strict: a non-channel throws, unknown keys are dropped", async () => { await withPaths(async (paths) => { await assert.rejects( writeChannelConfig(paths, "a", { handling: "x" } as unknown as ChannelConfig), /not a channel config/, ); await assert.rejects(stat(channelConfigPath(paths, "a"))); await writeChannelConfig(paths, "a", { ...config, bogus: 1, excludeFromSync: true, keepLatest: -4, } as unknown as ChannelConfig); assert.equal( await readFile(channelConfigPath(paths, "a"), "utf8"), JSON.stringify(config, null, 2) + "\n", ); }); }); test("two concurrent patches to one channel both land", async () => { await withPaths(async (paths) => { await writeChannelConfig(paths, "a", config); await Promise.all([ patchChannelConfig(paths, "a", { lastSyncedAt: "sync" }), patchChannelConfig(paths, "a", { keepLatest: 7 }), patchChannelConfig(paths, "a", { lastFullSweepAt: "sweep" }), ]); const out = await readChannelConfig(paths, "a"); assert.equal(out?.lastSyncedAt, "sync"); assert.equal(out?.keepLatest, 7); assert.equal(out?.lastFullSweepAt, "sweep"); }); });