Files
obsidian-foundry-sync/tests/e1b5-applymode.test.ts
Kaysser Kayyali 3202115735 feat(E1b.5): apply-mode gating + OFF-by-default + dev banner + watcher skips
Auto-sync refuses to go live unless the server is in --apply mode, starts OFF
every restart (opt-in per session until E3), and the watcher never pushes the
status-note / wiki-structure paths.

- src/server.ts setEnabled: throws "auto-sync requires --apply mode..." when
  enabled in dev mode (defense in depth). POST /api/autosync handler pre-checks
  mode → 400 with that message (leaves enabled=false). Apply-mode gating is a
  HARD safety floor (not feature-flagged): in dev mode the refined dir is the
  --out mirror, so enabling would push mirror edits to live Foundry.
- OFF-by-default: constructor sets enabled=false; no startup path auto-enables
  (verified — the only setEnabled call is the POST toggle). Per-session: a
  restart always returns to OFF (no persisted-on state until E3).
- src/server.ts watcher: STATUS_NOTE_PATHS = ["_meta/", "wiki/", ".raw/"]
  (FR-4.3 status-note exclusion); onChange skips rel under these with a logged
  "status-note path" reason, no debounce timer. (Unlinked/unseeded frontmatter
  skips stay in process — already logged there; a hot-path read in onChange
  conflicts with the single-read design and yields no net savings — deferred.)
- src/dashboard.html: dev-mode banner (yellow strip above the auto-sync panel
  when /api/status mode==="dev"); "Auto-sync is opt-in per session — resets to
  OFF on restart" note near the toggle when enabled in apply mode.
- tests/e1b5-applymode.test.ts: 6 tests — constructor enabled=false; dev-mode
  setEnabled throws + stays off; apply-mode setEnabled starts/stops; onChange
  skips _meta/ + wiki/ + .raw/ with logged reason + no timer; a normal note arms
  the debounce.

tsc clean; 141 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 00:08:50 +00:00

96 lines
4.6 KiB
TypeScript

// E1b.5 — apply-mode gating + OFF-by-default + watcher status-note skip.
//
// Covers:
// 1. setEnabled(true) in dev mode → throws "requires --apply mode"; enabled
// stays false (the POST handler pre-checks mode → 400; this tests the
// defense-in-depth throw).
// 2. setEnabled(true) in apply mode → starts (enabled=true); setEnabled(false)
// → stops.
// 3. constructor sets enabled=false (OFF-by-default / opt-in per session).
// 4. onChange skips STATUS_NOTE_PATHS (_meta/, wiki/, .raw/) with a logged
// "status-note path" reason and does NOT arm a debounce timer.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AutoSyncController } from "../src/server.js";
import type { State, ServerConfig } from "../src/server.js";
const UUID = "JournalEntry.abc1";
function makeState(mode: "dev" | "apply", refinedDir: string, outDir: string): State {
const cfg: ServerConfig = {
journal: "", refinedDir, ccDir: "", outDir, mode, port: 0, host: "",
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
};
return { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
}
let dir: string;
const realFetch = globalThis.fetch;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "e1b5-"));
await mkdir(join(dir, "refined"), { recursive: true });
// No relay calls expected; keep fetch mocked to a no-op so RelayClient
// construction (if it happens) doesn't hit the network.
globalThis.fetch = vi.fn(async () => ({ ok: true, status: 200, text: async () => "{}" })) as unknown as typeof fetch;
});
afterEach(async () => {
globalThis.fetch = realFetch;
await rm(dir, { recursive: true, force: true });
});
describe("E1b.5 apply-mode gating + OFF-by-default", () => {
it("constructor sets enabled=false (OFF-by-default / opt-in per session)", () => {
const state = makeState("apply", join(dir, "refined"), join(dir, "out"));
const controller = new AutoSyncController(state);
expect(controller.enabled).toBe(false);
});
it("setEnabled(true) in dev mode throws 'requires --apply mode' and leaves enabled=false", async () => {
const state = makeState("dev", join(dir, "refined"), join(dir, "out"));
const controller = new AutoSyncController(state);
await expect(controller.setEnabled(true)).rejects.toThrow(/requires --apply mode/);
expect(controller.enabled).toBe(false); // not enabled
});
it("setEnabled(true) in apply mode starts; setEnabled(false) stops", async () => {
const state = makeState("apply", join(dir, "refined"), join(dir, "out"));
const controller = new AutoSyncController(state);
await controller.setEnabled(true);
expect(controller.enabled).toBe(true);
await controller.setEnabled(false);
expect(controller.enabled).toBe(false);
});
});
describe("E1b.5 watcher status-note path skip", () => {
it("onChange skips a save under _meta/ with a 'status-note path' log and no debounce timer", () => {
const state = makeState("apply", join(dir, "refined"), join(dir, "out"));
const controller = new AutoSyncController(state);
(controller as any).onChange("change", "hot.md", "_meta"); // rel = "_meta/hot.md"
expect(controller.events.some((e) => e.status === "skipped" && e.message === "status-note path")).toBe(true);
expect((controller as any).timers.has("_meta/hot.md")).toBe(false);
});
it("onChange skips wiki/ and .raw/ paths too", () => {
const state = makeState("apply", join(dir, "refined"), join(dir, "out"));
const controller = new AutoSyncController(state);
(controller as any).onChange("change", "modules/server.md", "wiki");
(controller as any).onChange("change", "README.md", ".raw");
expect(controller.events.filter((e) => e.message === "status-note path").length).toBe(2);
});
it("onChange does NOT skip a normal vault note (arms the debounce timer)", () => {
const state = makeState("apply", join(dir, "refined"), join(dir, "out"));
const controller = new AutoSyncController(state);
// A normal note under a content dir — should NOT be skipped at the watcher
// (it may be skipped later in process if unlinked/unseeded, but the watcher
// arms the debounce). Write a file so statSync in the debounce pre-check works.
(controller as any).onChange("change", "Roland.md", "npcs"); // rel = "npcs/Roland.md"
expect((controller as any).timers.has("npcs/Roland.md")).toBe(true);
expect(controller.events.some((e) => e.message === "status-note path")).toBe(false);
});
});