Files
obsidian-foundry-sync/tests/e2-3-pull.test.ts
Kaysser Kayyali 2457596f26 feat(E2.3): F→O pull for F-changed + O-unchanged notes
When the deep poll detects a F-changed note AND the Obsidian side is unchanged,
the note is pulled from Foundry into the vault: /get → entryToObsidian (via
rePullRow) → writeWithBackup → dual re-baseline (contentHash + ccHash). Removes
from fPending. If O-side also changed → "conflict" (left in fPending for E2.4).

- src/server.ts AutoSyncController.pullFChanged(uuid, liveEntry): finds the
  matching row, checks O-side unchanged (contentHash(body) === foundry.contentHash),
  acquires the per-uuid "pull" lock, calls rePullRow (batch.ts — converts the live
  entry to refined markdown + injects the foundry block), writes via
  writeWithBackup (dev: mirror; apply: real vault + .bak), re-baselines both
  contentHash + ccHash(liveEntry) via baselineNote, removes from fPending +
  updates parity, logs "pulled ← <uuid> · baselined (content+cc)". Returns
  "pulled" | "conflict" | "skipped".
- src/foundry-poll.ts deepPoll: for each F-changed note, calls
  pullFChanged(uuid, liveEntry). "pulled" → removed from fPending (no change
  recorded). "conflict"/"skipped" → recorded in fPending for E2.4 / retry.
  Guard: if autosync isn't available (E2.2 tests), falls back to recording only.
- tests/e2-3-pull.test.ts: 5 tests — F-changed + O-unchanged → pulled +
  re-baselined + fPending cleared; F-changed + O-changed → "conflict" (note
  untouched); no matching row → "skipped"; note missing → "skipped"; unseeded →
  "skipped".

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

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

131 lines
6.1 KiB
TypeScript

// E2.3 — F→O pull for F-changed + O-unchanged notes.
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 { AutoSyncController } from "../src/server.js";
import type { State, ServerConfig } from "../src/server.js";
import { ccHash } from "../src/cchash.js";
import { contentHash } from "../src/normalize.js";
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
import { saveSyncState, defaultSyncState, type SyncState } from "../src/sync-state.js";
import type { JournalEntry } from "../src/types.js";
import type { FileRow } from "../src/batch.js";
const UUID = "JournalEntry.aaa";
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: "" } } } };
}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "e2-3-"));
await mkdir(join(dir, "refined"), { recursive: true });
await mkdir(join(dir, "out"), { recursive: true });
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 },
};
state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
state.syncState = { ...defaultSyncState(cfg.refinedDir) } as SyncState;
await saveSyncState(cfg.outDir, state.syncState);
});
afterEach(async () => { globalThis.fetch = realFetch; await rm(dir, { recursive: true, force: true }); });
async function writeNote(body: string, contentHashBaseline: string, ccHashBaseline: string): Promise<void> {
const lines = [
"---", "type: npc", "foundry:", ` cc_uuid: ${UUID}`, " cc_type: npc",
` folder_path: Folder.test`, ` contentHash: ${contentHashBaseline}`,
` ccHash: ${ccHashBaseline}`, " syncedAt: 2026-06-22T00:00:00.000Z",
"---", body, "",
];
await writeFile(join(dir, "refined", "Roland.md"), lines.join("\n"), "utf8");
}
function setupIndex(): void {
const row: FileRow = {
name: "Roland", basename: "Roland", status: "matched" as never,
refinedPath: join(dir, "refined", "Roland.md"), ccPath: null, ccId: UUID, ccType: "npc",
curatedType: null, entry: { _id: "aaa", name: "Roland" } as JournalEntry, recommendation: "in-sync" as never,
refinedChanged: false, ccChanged: false, refinedMtime: null, ccMtime: null,
storedRefinedHash: null, storedCcHash: null,
};
(state as any).index = { matched: [row], ccOnly: [], refinedOnly: [], counts: { matched: 1, ccOnly: 0, refinedOnly: 0, unlinked: 0 } };
}
describe("E2.3 pullFChanged", () => {
it("F-changed + O-unchanged → pulls, re-baselines, removes from fPending", async () => {
const live = liveEntry("<p>Foundry-edited body.</p>");
const liveCc = ccHash(live);
// Note's contentHash = contentHash("Roland body.\n") (the O-side is unchanged —
// the body hasn't been edited in Obsidian since the last sync).
const body = "Roland body.\n";
await writeNote(body, contentHash(body), "0".repeat(64)); // ccHash = stale (≠ liveCc)
setupIndex();
const controller = new AutoSyncController(state);
(controller as any).retryBackoffs = [1, 1, 1];
const result = await controller.pullFChanged(UUID, live);
expect(result).toBe("pulled");
// The note was re-baselined: contentHash = the pulled body hash, ccHash = ccHash(live).
const md = await readFile(join(dir, "refined", "Roland.md"), "utf8");
const fb = readFoundryBlock(splitFrontmatter(md).fm);
expect(fb?.ccHash).toBe(liveCc);
// The body was pulled from Foundry (entryToObsidian converts the live entry to markdown).
// The contentHash should match the NEW body (the pulled content).
const { body: pulledBody } = splitFrontmatter(md);
expect(fb?.contentHash).toBe(contentHash(pulledBody));
// Removed from fPending.
const saved = JSON.parse(await readFile(join(dir, "out", "sync-state.json"), "utf8")) as SyncState & { fPending?: unknown[] };
expect((saved.fPending ?? []).length).toBe(0);
});
it("F-changed + O-CHANGED → 'conflict' (left in fPending for E2.4)", async () => {
const live = liveEntry("<p>Foundry-edited body.</p>");
// Note's contentHash = "0"*64 (stale) — the body was edited in Obsidian too
// (bodyHash ≠ contentHash → O-side changed).
await writeNote("Edited in Obsidian.\n", "0".repeat(64), "0".repeat(64));
setupIndex();
const controller = new AutoSyncController(state);
const result = await controller.pullFChanged(UUID, live);
expect(result).toBe("conflict");
// The note was NOT pulled (content unchanged).
const md = await readFile(join(dir, "refined", "Roland.md"), "utf8");
const fb = readFoundryBlock(splitFrontmatter(md).fm);
expect(fb?.ccHash).toBe("0".repeat(64)); // unchanged — not baselined
});
it("no matching row → 'skipped'", async () => {
// No index → no matching row.
const controller = new AutoSyncController(state);
const result = await controller.pullFChanged(UUID, liveEntry("<p>x</p>"));
expect(result).toBe("skipped");
});
it("note missing from disk → 'skipped'", async () => {
setupIndex(); // index has a row pointing at a non-existent file
const controller = new AutoSyncController(state);
const result = await controller.pullFChanged(UUID, liveEntry("<p>x</p>"));
expect(result).toBe("skipped");
});
it("unseeded note (no contentHash) → 'skipped'", async () => {
// Write a note without contentHash.
await writeFile(join(dir, "refined", "Roland.md"), "---\ntype: npc\nfoundry:\n cc_uuid: JournalEntry.aaa\n---\nbody\n", "utf8");
setupIndex();
const controller = new AutoSyncController(state);
const result = await controller.pullFChanged(UUID, liveEntry("<p>x</p>"));
expect(result).toBe("skipped");
});
});