Files
obsidian-foundry-sync/tests/e2-2-deep-poll.test.ts
Kaysser Kayyali 19a76a0c01 feat(E2.2): deep poll — per-note /get + ccHash compare + folder move detection
The deep poll fetches each linked note's full /get document and compares a
derived ccHash to the stored foundry.ccHash baseline, detecting content edits
and folder moves. Supersedes ADR-005's ~800-calls/min concern: mapPool bounds
concurrency to 4, cadence 5min → ≤48 calls/min regardless of N. Gated by
foundryPoll (default off).

- src/foundry-poll.ts: deep poll on a separate timer (5min cadence, ±20% jitter,
  FOUNDRY_DEEP_POLL_CADENCE_MS / FOUNDRY_DEEP_POLL_CONCURRENCY env). Candidate
  list = intersection of shallow-poll snapshot uuids + linked index uuids.
  mapPool concurrency 4, each: relay.getEntry(uuid) → ccHash(liveEntry) vs
  foundry.ccHash baseline (from the note's frontmatter) + liveEntry.folder vs
  foundry.folder_path. ccHash mismatch → "edited"; folder change on a legacy
  note (no ccHash) → "moved". Changes recorded to sync-state.json.fPending.
  Persistent error (404 No connected clients) → abort round + halt timer.
  Transient error → inline retry (3 attempts, backoff 500/1500/4500ms ±20%,
  retryBackoffs field for test override); final failure → fPending recorded
  (round continues). Overlap guard (deepSkipCounter). Load ceiling documented
  in status() (loadCeilingCallsPerMin = concurrency / round_seconds ≈ 48/min).
  stop()/setEnabled manage BOTH shallow + deep timers.
- tests/e2-2-deep-poll.test.ts: 6 tests — ccHash mismatch → "edited"; ccHash
  match → no change; folder move on legacy note → "moved"; persistent 404 →
  halts; transient 504 → retries + fPending recorded; status() exposes load
  ceiling + deep poll info.

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

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

151 lines
7.0 KiB
TypeScript

// E2.2 — deep poll: per-linked-note /get + ccHash compare + folder move detection.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtemp, mkdir, rm, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { FoundryPollController, type PendingFChange } from "../src/foundry-poll.js";
import type { State, ServerConfig } from "../src/server.js";
import { defaultSyncState, saveSyncState, type SyncState } from "../src/sync-state.js";
import { ccHash } from "../src/cchash.js";
import type { JournalEntry } from "../src/types.js";
let dir: string;
let state: State;
const realFetch = globalThis.fetch;
function liveEntry(description: string, folder = "Folder.test"): JournalEntry {
return { name: "Roland", _id: "aaa", folder, flags: { "campaign-codex": { type: "npc", data: { description, notes: "" } } } };
}
function makeState(): State {
const cfg: ServerConfig = {
journal: "", refinedDir: join(dir, "refined"), ccDir: "", outDir: join(dir, "out"),
mode: "apply", port: 0, host: "127.0.0.1",
relayCfg: { url: "http://relay.test", apiKey: "k", clientId: "c" },
features: { syncStatus: true, foundryPoll: true },
};
const s = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
s.syncState = { ...defaultSyncState(cfg.refinedDir) } as SyncState;
return s;
}
async function writeNote(ccHashBaseline: string | null, folderPath: string): Promise<void> {
const lines = ["---", "type: npc", "foundry:", " cc_uuid: JournalEntry.aaa", " cc_type: npc", ` folder_path: ${folderPath}`, ` contentHash: ${"0".repeat(64)}`];
if (ccHashBaseline !== null) lines.push(` ccHash: ${ccHashBaseline}`);
lines.push(" syncedAt: 2026-06-22T00:00:00.000Z", "---", "Roland body.", "");
await writeFile(join(dir, "refined", "Roland.md"), lines.join("\n"), "utf8");
}
function mockGetEntry(entry: JournalEntry): void {
globalThis.fetch = vi.fn(async (url: string) => {
if (String(url).includes("/get?")) return { ok: true, status: 200, text: async () => JSON.stringify({ data: entry }) } as unknown as Response;
if (String(url).includes("/search")) return { ok: true, status: 200, text: async () => JSON.stringify({ results: [{ uuid: "JournalEntry.aaa", id: "aaa", name: "Roland", documentType: "JournalEntry" }] }) } as unknown as Response;
return { ok: false, status: 404, text: async () => '{"error":"not found"}' } as unknown as Response;
}) as unknown as typeof fetch;
}
function mockGetError(status: number, error: string): void {
globalThis.fetch = vi.fn(async () => ({ ok: false, status, text: async () => JSON.stringify({ error }) }) as unknown as Response) as unknown as typeof fetch;
}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "e2-2-"));
await mkdir(join(dir, "refined"), { recursive: true });
await mkdir(join(dir, "out"), { recursive: true });
state = makeState();
await saveSyncState(state.cfg.outDir, state.syncState!);
});
afterEach(async () => {
globalThis.fetch = realFetch;
await rm(dir, { recursive: true, force: true });
});
function setupController(): FoundryPollController {
const controller = new FoundryPollController(state);
// Set the prevSnapshot so the deep poll's candidate list includes the uuid.
(controller as any).prevSnapshot.set("JournalEntry.aaa", { name: "Roland", img: null });
// Mock the index with a linked entry.
(state as any).index = {
matched: [{ entry: { _id: "aaa", name: "Roland" }, refinedPath: join(dir, "refined", "Roland.md"), name: "Roland" }],
ccOnly: [], refinedOnly: [],
};
(controller as any).retryBackoffs = [1, 1, 1]; // fast retries for tests
return controller;
}
async function readFPending(): Promise<PendingFChange[]> {
const saved = JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8")) as SyncState & { fPending?: PendingFChange[] };
return saved.fPending ?? [];
}
describe("E2.2 deep poll detection", () => {
it("ccHash mismatch → F-changed 'edited'", async () => {
const live = liveEntry("<p>Foundry-edited body.</p>");
mockGetEntry(live);
// Note's ccHash baseline = a DIFFERENT value (the original body's ccHash).
await writeNote("0".repeat(64), "Folder.test");
const controller = setupController();
await (controller as any).deepPoll();
const fPending = await readFPending();
expect(fPending.some((e) => e.uuid === "JournalEntry.aaa" && e.change === "edited")).toBe(true);
controller.stop();
});
it("ccHash match → no change (left untouched)", async () => {
const live = liveEntry("<p>Original body.</p>");
mockGetEntry(live);
// Note's ccHash baseline = ccHash(live) (matches).
await writeNote(ccHash(live), "Folder.test");
const controller = setupController();
await (controller as any).deepPoll();
const fPending = await readFPending();
expect(fPending.length).toBe(0); // no changes
controller.stop();
});
it("folder move on a legacy note (no ccHash) → 'moved'", async () => {
const live = liveEntry("<p>Original body.</p>", "Folder.new");
mockGetEntry(live);
// Note has NO ccHash baseline (legacy) but has folder_path = "Folder.test".
// Live folder = "Folder.new" → "moved".
await writeNote(null, "Folder.test");
const controller = setupController();
await (controller as any).deepPoll();
const fPending = await readFPending();
expect(fPending.some((e) => e.uuid === "JournalEntry.aaa" && e.change === "moved")).toBe(true);
controller.stop();
});
it("persistent error (404 No connected Foundry clients) → aborts + halts", async () => {
mockGetError(404, "No connected Foundry clients found");
await writeNote(ccHash(liveEntry("<p>x</p>")), "Folder.test");
const controller = setupController();
await controller.setEnabled(true);
// deepTick catches the persistent error from deepPoll + calls stop() (halt).
await (controller as any).deepTick();
expect(controller.enabled).toBe(false); // halted by deepTick's catch
controller.stop();
});
it("transient error (504) → retries, final failure → fPending recorded (round continues)", async () => {
// Mock /get to always 504 (transient → retries → exhaustion).
mockGetError(504, "Request timed out");
await writeNote(ccHash(liveEntry("<p>x</p>")), "Folder.test");
const controller = setupController();
await (controller as any).deepPoll(); // should NOT throw (transient exhaustion is recorded, not thrown)
const fPending = await readFPending();
expect(fPending.some((e) => e.uuid === "JournalEntry.aaa" && e.change === "edited")).toBe(true); // recorded as fPending
controller.stop();
});
it("status() exposes loadCeilingCallsPerMin + deep poll info", () => {
const controller = new FoundryPollController(state);
const s = controller.status();
expect(s.loadCeilingCallsPerMin).toBeGreaterThan(0);
expect(s.deepCadenceMs).toBeGreaterThanOrEqual(30000);
expect(s.deepInFlight).toBe(false);
});
});