Files
obsidian-foundry-sync/tests/e4-2-mode.test.ts
Kaysser Kayyali d7b06d7071 feat(E4.2): PREP / RUN-THE-MATCH mode flag gating AutoSyncController
In PREP, auto-sync is blocked (the DM is still curating/seeding/linking — must
not push half-curated notes to live Foundry). In RUN-THE-MATCH, auto-sync is
unblocked. The toggle is the only writer of `mode` in sync-state.json. Gated by
features.syncStatus (default off → existing behavior unchanged).

- src/server.ts setEnabled: PREP gate — if features.syncStatus + mode===PREP →
  throw "auto-sync is blocked in PREP mode — switch to RUN-THE-MATCH first".
  Stacks on top of the E1b.5 apply-mode gate (both must pass).
- src/server.ts POST /api/sync-state/mode (gated by features.syncStatus; 404 when
  off): flips mode in sync-state.json; switching to PREP while auto-sync is ON
  tears down the watcher (stop()) + flips autoSyncOn=false first. /api/status
  exposes syncMode + featuresSyncStatus.
- src/server.ts boot reconcile: if mode===PREP + autoSyncOn=true → autoSyncOn
  flipped to false + "PREP mode auto-sync disabled on boot" event (doesn't call
  setEnabled, which would throw the PREP message).
- src/dashboard.html: PREP⇄RUN toggle button in the header (gated by
  featuresSyncStatus from /api/status); autoSyncBtn disabled in PREP with a
  tooltip "Switch to RUN-THE-MATCH mode first"; toggleSyncMode() POSTs the flip.
- tests/e4-2-mode.test.ts: 8 tests — setEnabled in PREP throws / in RUN proceeds
  / flag-off no gate; POST mode flip; switching to PREP while ON → stop + off;
  boot reconcile PREP + autoSyncOn → off + event; invalid mode → 400; features
  off → 404.

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

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

138 lines
6.9 KiB
TypeScript

// E4.2 — PREP / RUN-THE-MATCH mode flag (gates AutoSyncController).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { ClassicLevel } from "classic-level";
import { mkdtemp, mkdir, rm, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Server } from "node:http";
import { AutoSyncController } from "../src/server.js";
import type { State, ServerConfig } from "../src/server.js";
import { saveSyncState, defaultSyncState, type SyncState } from "../src/sync-state.js";
import { startServer } from "../src/server.js";
let dir: string;
beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "e4-2-")); await mkdir(join(dir, "refined"), { recursive: true }); });
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
function makeState(opts: { syncStatus?: boolean; syncMode?: string; mode?: "dev" | "apply" } = {}): State {
const cfg: ServerConfig = {
journal: "", refinedDir: join(dir, "refined"), ccDir: "", outDir: join(dir, "out"),
mode: opts.mode ?? "apply", port: 0, host: "127.0.0.1",
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
features: { syncStatus: opts.syncStatus ?? true },
};
const state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
state.syncState = { ...defaultSyncState(cfg.refinedDir), mode: opts.syncMode ?? "PREP" } as SyncState;
return state;
}
describe("E4.2 setEnabled PREP/RUN gate", () => {
it("setEnabled(true) in PREP mode (features.syncStatus on) → throws 'blocked in PREP mode'", async () => {
const state = makeState({ syncStatus: true, syncMode: "PREP", mode: "apply" });
const controller = new AutoSyncController(state);
await expect(controller.setEnabled(true)).rejects.toThrow(/blocked in PREP mode/);
expect(controller.enabled).toBe(false);
});
it("setEnabled(true) in RUN-THE-MATCH mode → proceeds (enabled=true)", async () => {
const state = makeState({ syncStatus: true, syncMode: "RUN-THE-MATCH", mode: "apply" });
const controller = new AutoSyncController(state);
await controller.setEnabled(true);
expect(controller.enabled).toBe(true);
controller.stop();
});
it("features.syncStatus OFF → no PREP gate (setEnabled works in apply mode even with mode=PREP)", async () => {
const state = makeState({ syncStatus: false, syncMode: "PREP", mode: "apply" });
const controller = new AutoSyncController(state);
await controller.setEnabled(true); // no PREP gate (flag off)
expect(controller.enabled).toBe(true);
controller.stop();
});
});
describe("E4.2 POST /api/sync-state/mode + boot reconcile", () => {
async function bootWithState(stateOverrides: Partial<SyncState>): Promise<{ server: Server; state: State }> {
const outDir = join(dir, "out");
await mkdir(outDir, { recursive: true });
const full = { ...defaultSyncState(join(dir, "refined")), ...stateOverrides } as SyncState;
await saveSyncState(outDir, full);
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
await jdb.open(); await jdb.close();
const { server, state } = await startServer({
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
outDir, mode: "apply", port: 0, host: "127.0.0.1",
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
features: { syncStatus: true },
});
return { server, state };
}
async function postMode(server: Server, mode: string): Promise<{ code: number; body: unknown }> {
const port = (server.address() as { port: number }).port;
const r = await fetch(`http://127.0.0.1:${port}/api/sync-state/mode`, {
method: "POST", headers: { "content-type": "application/json", origin: `http://127.0.0.1:${port}` },
body: JSON.stringify({ mode }),
});
return { code: r.status, body: await r.json().catch(() => null) };
}
it("POST /api/sync-state/mode flips PREP → RUN-THE-MATCH", async () => {
const { server, state } = await bootWithState({ mode: "PREP" });
const r = await postMode(server, "RUN-THE-MATCH");
expect(r.code).toBe(200);
expect((r.body as { mode: string }).mode).toBe("RUN-THE-MATCH");
// Persisted to sync-state.json.
const saved = JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8")) as SyncState;
expect(saved.mode).toBe("RUN-THE-MATCH");
state.autosync.stop();
await new Promise<void>((res) => server.close(() => res()));
});
it("switching to PREP while auto-sync is ON → stop() + autoSyncOn=false", async () => {
const { server, state } = await bootWithState({ mode: "RUN-THE-MATCH", autoSyncOn: true });
expect(state.autosync.enabled).toBe(true); // auto-sync restored ON in RUN mode
const r = await postMode(server, "PREP");
expect(r.code).toBe(200);
expect(state.autosync.enabled).toBe(false); // stopped
const saved = JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8")) as SyncState;
expect(saved.autoSyncOn).toBe(false);
expect(saved.mode).toBe("PREP");
await new Promise<void>((res) => server.close(() => res()));
});
it("boot reconcile: PREP + autoSyncOn=true → autoSyncOn=false + 'PREP mode auto-sync disabled on boot' event", async () => {
const { server, state } = await bootWithState({ mode: "PREP", autoSyncOn: true });
expect(state.autosync.enabled).toBe(false); // not restored (PREP blocks)
const saved = JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8")) as SyncState;
expect(saved.autoSyncOn).toBe(false);
expect(saved.activity.some((e) => e.message.includes("PREP mode auto-sync disabled on boot"))).toBe(true);
await new Promise<void>((res) => server.close(() => res()));
});
it("invalid mode → 400", async () => {
const { server } = await bootWithState({ mode: "PREP" });
const r = await postMode(server, "INVALID");
expect(r.code).toBe(400);
await new Promise<void>((res) => server.close(() => res()));
});
it("features.syncStatus OFF → POST /api/sync-state/mode → 404", async () => {
const outDir = join(dir, "out");
await mkdir(outDir, { recursive: true });
await saveSyncState(outDir, { ...defaultSyncState(join(dir, "refined")), mode: "PREP" } as SyncState);
const jdb = new ClassicLevel<string, string>(join(dir, "journal"));
await jdb.open(); await jdb.close();
const { server } = await startServer({
journal: join(dir, "journal"), refinedDir: join(dir, "refined"), ccDir: join(dir, "cc"),
outDir, mode: "apply", port: 0, host: "127.0.0.1",
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
features: { syncStatus: false }, // OFF → endpoint 404
});
const r = await postMode(server, "RUN-THE-MATCH");
expect(r.code).toBe(404);
await new Promise<void>((res) => server.close(() => res()));
});
});