import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "./paths"; import { applyTagAssignments, assignmentFor, effectiveSiteTags, emptyTagsConfig, readGlobalTags, readSiteTags, writeGlobalTags, writeSiteTags, } from "./curatedTagsStore"; import { siteTagsFile } from "./site"; import type { CuratedTagsConfig } from "./curatedTags"; function tempPaths(): { paths: Paths; dir: string; cleanup: () => void } { const dir = mkdtempSync(path.join(tmpdir(), "curated-tags-store-")); // Only the fields curatedTagsStore touches need to be real. const paths = { sitesDir: path.join(dir, "sites"), globalTagsFile: path.join(dir, "tags.json"), } as Paths; return { paths, dir, cleanup: () => rmSync(dir, { recursive: true, force: true }), }; } function withPaths(fn: (paths: Paths, dir: string) => void): void { const { paths, dir, cleanup } = tempPaths(); try { fn(paths, dir); } finally { cleanup(); } } const COLLAB: CuratedTagsConfig = { version: 1, tags: [ { id: "eva-collab", label: "Collab", group: "eva", groupLabel: "Eva", order: 1, rules: [ { id: "meta", kind: "metadata", pattern: "elfpire", enabled: true }, ], }, ], assignments: {}, }; // applyTagAssignments refuses to pin or suppress a tag the vocabulary does not // define, so most of these tests need one. Kept explicit per test rather than // seeded globally — the refusal itself is a test below. function seedVocab(paths: Paths, ...ids: string[]): void { writeGlobalTags(paths, { version: 1, tags: ids.map((id) => ({ id, label: id })), assignments: {}, }); } const V1 = { channelSlug: "legal-mindset", id: "XZqL6k9IHGA" }; const V2 = { channelSlug: "legal-mindset", id: "AAAAAAAAAAA" }; // ─── read / write ─── test("readGlobalTags of an absent file is EMPTY — no seeded defaults", () => { withPaths((paths) => { assert.deepEqual(readGlobalTags(paths), emptyTagsConfig()); assert.deepEqual(readGlobalTags(paths).tags, []); }); }); test("readGlobalTags of unreadable junk is empty, not a throw", () => { withPaths((paths) => { writeFileSync(paths.globalTagsFile, "{not json"); assert.deepEqual(readGlobalTags(paths).tags, []); }); }); test("global tags round-trip and are sanitized on the way out", () => { withPaths((paths) => { writeGlobalTags(paths, { ...COLLAB, tags: [ ...COLLAB.tags, { id: "BAD ID", label: "dropped" } as never, ], }); const back = readGlobalTags(paths); assert.deepEqual( back.tags.map((t) => t.id), ["eva-collab"], ); // Written pretty so the file stays hand-readable (never hand-EDITED). assert.match(readFileSync(paths.globalTagsFile, "utf8"), /\n {2}"tags"/); }); }); test("site tags land beside site.json and default to empty", () => { withPaths((paths) => { assert.deepEqual(readSiteTags(paths, "anilyzer").tags, []); writeSiteTags(paths, "anilyzer", { version: 1, tags: [{ id: "eva-collab", label: "On mic" }], assignments: {}, }); assert.equal( siteTagsFile(paths, "anilyzer"), path.join(paths.sitesDir, "anilyzer", "tags.json"), ); assert.equal(readSiteTags(paths, "anilyzer").tags[0].label, "On mic"); }); }); // ─── layering ─── test("effectiveSiteTags layers the site overlay over the corpus vocabulary", () => { withPaths((paths) => { writeGlobalTags(paths, COLLAB); writeSiteTags(paths, "anilyzer", { version: 1, tags: [ { id: "eva-collab", label: "On mic", color: "#b48ead" }, { id: "site-only", label: "Site only" }, ], assignments: {}, }); const defs = effectiveSiteTags(paths, "anilyzer"); assert.deepEqual( defs.map((d) => d.id), ["eva-collab", "site-only"], ); assert.equal(defs[0].label, "On mic"); assert.equal(defs[0].color, "#b48ead"); // Untouched global fields survive the overlay. assert.equal(defs[0].group, "eva"); assert.deepEqual( (defs[0].rules ?? []).map((r) => r.id), ["meta"], ); }); }); test("a colour-only overlay survives the store's two sanitize passes", () => { // writeSiteTags sanitizes, readSiteTags sanitizes again — both at the SITE // layer, so the row that set only a colour still carries no label and the // site keeps publishing the corpus wording. A later corpus rename follows // with no site edit. withPaths((paths) => { writeGlobalTags(paths, COLLAB); writeSiteTags(paths, "anilyzer", { version: 1, tags: [{ id: "eva-collab", color: "#b48ead" }], assignments: {}, }); assert.equal("label" in readSiteTags(paths, "anilyzer").tags[0], false); const defs = effectiveSiteTags(paths, "anilyzer"); assert.equal(defs[0].label, "Collab"); assert.equal(defs[0].color, "#b48ead"); writeGlobalTags(paths, { ...COLLAB, tags: [{ ...COLLAB.tags[0], label: "On mic" }], }); assert.equal(effectiveSiteTags(paths, "anilyzer")[0].label, "On mic"); }); }); test("a site with no overlay ships the corpus vocabulary unchanged", () => { withPaths((paths) => { writeGlobalTags(paths, COLLAB); assert.deepEqual(effectiveSiteTags(paths, "anilyzer"), COLLAB.tags); }); }); test("the site layer cannot delete a global rule, tag or assignment", () => { withPaths((paths) => { writeGlobalTags(paths, COLLAB); applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1], source: "operator", at: "2026-09-21T18:00:00Z", }); // A site file that tries to empty the rules, and to carry an assignment. mkdirSync(path.join(paths.sitesDir, "anilyzer"), { recursive: true }); writeFileSync( siteTagsFile(paths, "anilyzer"), JSON.stringify({ version: 1, tags: [{ id: "eva-collab", label: "x", rules: [] }], assignments: { "legal-mindset/XZqL6k9IHGA": { suppressed: ["eva-collab"] } }, }), ); const defs = effectiveSiteTags(paths, "anilyzer"); assert.deepEqual( (defs[0].rules ?? []).map((r) => r.id), ["meta"], ); // Assignments are global facts: the site file's copy is simply not consulted. const global = readGlobalTags(paths); assert.deepEqual(assignmentFor(global, V1.channelSlug, V1.id)?.manual, [ "eva-collab", ]); assert.equal( assignmentFor(global, V1.channelSlug, V1.id)?.suppressed, undefined, ); }); }); // ─── applyTagAssignments: validation ─── test("applyTagAssignments rejects a bad op, tag id or video ref", () => { withPaths((paths) => { assert.throws( () => applyTagAssignments(paths, { op: "nope" as never, tag: "t", videos: [V1] }), /unknown tag op/, ); assert.throws( () => applyTagAssignments(paths, { op: "add", tag: "Bad Id", videos: [V1] }), /invalid tag id/, ); assert.throws( () => applyTagAssignments(paths, { op: "add", tag: "t", videos: [{ channelSlug: "a/b", id: "v" }], }), /invalid video ref/, ); assert.throws( () => applyTagAssignments(paths, { op: "add", tag: "t", videos: "x" as never }), /videos must be an array/, ); // Nothing was written by any of those. assert.deepEqual(readGlobalTags(paths).assignments, {}); }); }); test("add and suppress refuse a tag the vocabulary does not define", () => { // The typo guard. Without it `eva-collabs` would ride on records for ever: // no /tags.json can show a tag that has no definition, so nothing would // surface the mistake. withPaths((paths) => { seedVocab(paths, "eva-collab"); for (const op of ["add", "suppress"] as const) { assert.throws( () => applyTagAssignments(paths, { op, tag: "eva-collabs", videos: [V1] }), /unknown tag "eva-collabs" — define it first/, op, ); } assert.deepEqual(readGlobalTags(paths).assignments, {}); }); }); test("remove and unsuppress still work for a tag whose def was deleted", () => { // The other half of the guard: those two only ever take a claim away, and an // operator must be able to clean up after deleting a definition. withPaths((paths) => { writeGlobalTags(paths, { version: 1, tags: [], assignments: { "legal-mindset/XZqL6k9IHGA": { manual: ["gone"] }, "legal-mindset/AAAAAAAAAAA": { suppressed: ["gone"] }, }, }); applyTagAssignments(paths, { op: "remove", tag: "gone", videos: [V1] }); applyTagAssignments(paths, { op: "unsuppress", tag: "gone", videos: [V2] }); assert.deepEqual(readGlobalTags(paths).assignments, {}); }); }); test("applyTagAssignments lowercases the tag id it stores", () => { withPaths((paths) => { seedVocab(paths, "eva-collab"); applyTagAssignments(paths, { op: "add", tag: " EVA-Collab ", videos: [V1] }); assert.deepEqual(readGlobalTags(paths).assignments["legal-mindset/XZqL6k9IHGA"].manual, [ "eva-collab", ]); }); }); // ─── applyTagAssignments: the four ops ─── test("add pins with provenance and is idempotent", () => { withPaths((paths) => { seedVocab(paths, "eva-collab"); const first = applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1, V2, V1], // duplicate ref collapses source: "umtool:elfpire-eva", at: "2026-09-21T18:04:11Z", }); assert.deepEqual(first.changed, [ "legal-mindset/XZqL6k9IHGA", "legal-mindset/AAAAAAAAAAA", ]); assert.equal(first.touched.length, 2); const cfg = readGlobalTags(paths); const a = assignmentFor(cfg, V1.channelSlug, V1.id)!; assert.deepEqual(a.manual, ["eva-collab"]); assert.deepEqual(a.sources?.["eva-collab"], { source: "umtool:elfpire-eva", setAt: "2026-09-21T18:04:11Z", }); // Re-adding changes nothing and does not re-stamp provenance. const again = applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1], source: "operator", at: "2026-09-22T00:00:00Z", }); assert.deepEqual(again.changed, []); assert.equal( readGlobalTags(paths).assignments["legal-mindset/XZqL6k9IHGA"].sources?.[ "eva-collab" ].source, "umtool:elfpire-eva", ); }); }); test("add clears a suppression of the same tag", () => { withPaths((paths) => { seedVocab(paths, "eva-collab"); applyTagAssignments(paths, { op: "suppress", tag: "eva-collab", videos: [V1] }); applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1] }); const a = assignmentFor(readGlobalTags(paths), V1.channelSlug, V1.id)!; assert.deepEqual(a.manual, ["eva-collab"]); assert.equal(a.suppressed, undefined); }); }); test("remove unpins only — a rule hit survives it, which is why suppress exists", () => { withPaths((paths) => { seedVocab(paths, "eva-collab"); applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1] }); applyTagAssignments(paths, { op: "remove", tag: "eva-collab", videos: [V1] }); const cfg = readGlobalTags(paths); // No pin, and crucially NO suppression: the rule may still tag this video. assert.equal(assignmentFor(cfg, V1.channelSlug, V1.id), undefined); assert.deepEqual(cfg.assignments, {}); }); }); test("suppress rejects a rule-derived tag and records provenance", () => { withPaths((paths) => { seedVocab(paths, "eva-topic"); const res = applyTagAssignments(paths, { op: "suppress", tag: "eva-topic", videos: [V1], source: "operator", at: "2026-09-21T18:05:02Z", }); assert.deepEqual(res.changed, ["legal-mindset/XZqL6k9IHGA"]); const a = assignmentFor(readGlobalTags(paths), V1.channelSlug, V1.id)!; assert.deepEqual(a.suppressed, ["eva-topic"]); assert.equal(a.manual, undefined); assert.deepEqual(a.sources?.["eva-topic"], { source: "operator", setAt: "2026-09-21T18:05:02Z", }); }); }); test("suppress also unpins, and unsuppress clears the rejection", () => { withPaths((paths) => { seedVocab(paths, "eva-collab"); applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1] }); applyTagAssignments(paths, { op: "suppress", tag: "eva-collab", videos: [V1] }); let a = assignmentFor(readGlobalTags(paths), V1.channelSlug, V1.id)!; assert.equal(a.manual, undefined); assert.deepEqual(a.suppressed, ["eva-collab"]); applyTagAssignments(paths, { op: "unsuppress", tag: "eva-collab", videos: [V1] }); // Both claims gone: the key itself is dropped rather than left as a husk. assert.deepEqual(readGlobalTags(paths).assignments, {}); }); }); test("an op that changes nothing writes no file at all", () => { withPaths((paths) => { const res = applyTagAssignments(paths, { op: "remove", tag: "eva-collab", videos: [V1], }); assert.deepEqual(res.changed, []); assert.deepEqual(res.touched, ["legal-mindset/XZqL6k9IHGA"]); assert.throws(() => readFileSync(paths.globalTagsFile, "utf8")); }); }); test("other tags on the same video, and other videos, are untouched", () => { withPaths((paths) => { seedVocab(paths, "eva-collab", "eva-topic"); applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1, V2] }); applyTagAssignments(paths, { op: "add", tag: "eva-topic", videos: [V1] }); applyTagAssignments(paths, { op: "suppress", tag: "eva-collab", videos: [V1] }); const cfg = readGlobalTags(paths); const a = assignmentFor(cfg, V1.channelSlug, V1.id)!; assert.deepEqual(a.manual, ["eva-topic"]); assert.deepEqual(a.suppressed, ["eva-collab"]); assert.deepEqual(Object.keys(a.sources ?? {}).sort(), ["eva-collab", "eva-topic"]); assert.deepEqual(assignmentFor(cfg, V2.channelSlug, V2.id)?.manual, ["eva-collab"]); }); }); test("the tag vocabulary survives an assignment write", () => { withPaths((paths) => { writeGlobalTags(paths, COLLAB); applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1] }); const cfg = readGlobalTags(paths); assert.deepEqual(cfg.tags, COLLAB.tags); }); }); test("a bulk call is ONE write, and reports exactly the keys it changed", () => { withPaths((paths) => { seedVocab(paths, "eva-collab"); const many = Array.from({ length: 50 }, (_, i) => ({ channelSlug: "legal-mindset", id: `v${i}`, })); applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: many }); assert.equal(Object.keys(readGlobalTags(paths).assignments).length, 50); // Half already pinned: only the new half is reported as changed. const mixed = applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [...many.slice(0, 25), { channelSlug: "other", id: "z1" }], }); assert.deepEqual(mixed.changed, ["other/z1"]); assert.equal(mixed.touched.length, 26); }); }); test("provenance for a tag that is neither pinned nor suppressed is pruned", () => { withPaths((paths) => { // A hand-authored file with a stale source entry for an unassigned tag. writeFileSync( paths.globalTagsFile, JSON.stringify({ version: 1, tags: [ { id: "eva-collab", label: "Collab" }, { id: "eva-topic", label: "Discussed" }, ], assignments: { "legal-mindset/XZqL6k9IHGA": { manual: ["eva-collab"], sources: { "eva-collab": { source: "operator", setAt: "2026-01-01T00:00:00Z" }, }, }, }, }), ); applyTagAssignments(paths, { op: "add", tag: "eva-topic", videos: [V1] }); const a = assignmentFor(readGlobalTags(paths), V1.channelSlug, V1.id)!; assert.deepEqual(a.manual, ["eva-collab", "eva-topic"]); assert.deepEqual(Object.keys(a.sources ?? {}).sort(), ["eva-collab", "eva-topic"]); }); });