Files
gitmost/packages/mcp/test/mock/footnote-write.test.mjs
a a77a0bc92b fix(footnotes): re-review #232 — refuse footnoteRef into codeBlock/definition, deep-strip nested lists, docs + cross-copy guard (#228)
Must-fix:
- REAL BUG: insertInlineFootnote could splice a footnoteReference (inline atom)
  into a codeBlock or an existing footnoteDefinition, persisting a schema-invalid
  doc (insert_footnote skips validateDocStructure). Now the search is bounded to
  the BODY (before the first footnotesList) and the insertNodesAfterAnchor core
  refuses textblocks that can't hold the atom (codeBlock); when the only match is
  in such a place the insert returns inserted:false and the write aborts cleanly.
  Reachable via docmost_transform too. Added codeBlock / definition / fall-through
  tests.
- Fixed the deepEqualJson doc comment in both copies: arrays are order-SENSITIVE
  (correctness depends on it), only object keys are order-insensitive.
- README.ru.md MCP tool count 38 -> 39 (lines 36/47/63), matching README.md/AGENTS.
- CHANGELOG [Unreleased] Added entry for insert_footnote + server-side footnote
  canonicalization on non-editor write paths (#228).

Suggestions:
- canonicalize step 5/7 now strips footnotesList at ANY depth (both copies), so a
  schema-valid list nested in a callout/blockquote can't leave duplicate defs.
- Exclude the test-only footnote-corpus.ts fixture from the editor-ext build
  (tsconfig), so it no longer ships in dist/.
- Removed the duplicate manual canonicalize cases from the MCP unit test (the
  shared corpus covers them via full deepEqual); kept idempotence + immutability.
- insertInlineFootnote dedup key now keys off the inline array directly
  (footnoteContentKey({ content: inline })) instead of a throwaway node.

Tests / architecture:
- New client-wrapper test (#9): overrides a small mutatePage seam to assert the
  not-found path throws and persists NOTHING, and the success path shapes
  footnoteId/reused/message/verify and writes the right content. Fixed the
  misleading comment in footnote-write.test.mjs.
- B: cross-copy corpus parity guard test (loads both corpora, asserts deep-equal)
  so a typo in one copy can't pass both suites green.
- A: declined — the full-vs-fragment decision lives at the call site, so a
  prepareDocForPersist wrapper would be a bare alias for canonicalizeFootnotes;
  kept the existing per-call-site comments instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:41:10 +03:00

154 lines
6.2 KiB
JavaScript

// Mock-HTTP orchestration tests for the footnote WRITE wrappers on DocmostClient
// (issue #228):
// - insertFootnote (#11): the required-argument guards reject BEFORE any write,
// and never touch the collab/mutate path.
// - transformPage / docmost_transform (#13): the auto-canonicalize step
// (`result = canonicalizeFootnotes(raw)`) runs after every transform, so a
// transform that introduces an orphan footnote definition is silently tidied
// away — observable as an EMPTY diff in a dryRun preview.
//
// These stand a local http.createServer in for Docmost and only exercise plain
// HTTP routes (login / comments / pages.info), deliberately avoiding the live
// Hocuspocus collab WebSocket: the insertFootnote guards short-circuit before it,
// and docmost_transform's dryRun preview never opens it. The collab mutate path
// itself — abort-via-throw on a missing anchor with NO persisted write, and the
// reused-vs-new response shaping — is covered in
// test/mock/insert-footnote-wrapper.test.mjs (which overrides the mutatePage
// seam to drive the transform), not here.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
function sendJson(res, status, obj, extraHeaders = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
res.end(JSON.stringify(obj));
}
const openServers = [];
async function spawn(handler) {
const { server, baseURL } = await startServer(handler);
openServers.push(server);
return { baseURL };
}
after(async () => {
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
});
const ref = (id) => ({ type: "footnoteReference", attrs: { id } });
const def = (id, text) => ({
type: "footnoteDefinition",
attrs: { id },
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
// ---------------------------------------------------------------------------
// #11 insertFootnote guards: missing anchorText / text reject and never write.
// ---------------------------------------------------------------------------
test("insertFootnote rejects a missing anchorText before any write", async () => {
const otherRoutes = [];
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
return sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
}
otherRoutes.push(req.url);
sendJson(res, 404, { message: "not found" });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.insertFootnote("page-1", " ", "a note"),
/anchorText is required/i,
);
assert.deepEqual(otherRoutes, [], "must not hit any write route");
});
test("insertFootnote rejects an empty text before any write", async () => {
const otherRoutes = [];
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
return sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
}
otherRoutes.push(req.url);
sendJson(res, 404, { message: "not found" });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.insertFootnote("page-1", "anchor", " "),
/text is required/i,
);
assert.deepEqual(otherRoutes, [], "must not hit any write route");
});
// ---------------------------------------------------------------------------
// #13 docmost_transform auto-canonicalization: a transform that adds an orphan
// footnote definition produces NO net change (the canonicalizer drops it), so a
// dryRun preview reports an empty diff. Without the auto-canonicalize step the
// orphan would survive and the diff would be non-empty.
// ---------------------------------------------------------------------------
test("transformPage dryRun auto-canonicalizes footnotes (orphan def is dropped)", async () => {
// A page already in canonical footnote state (refs b,a; defs b,a).
const pageContent = {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "x" }, ref("b"), ref("a")] },
{ type: "footnotesList", content: [def("b", "B"), def("a", "A")] },
],
};
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
return sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
}
if (req.url === "/api/comments") {
return sendJson(res, 200, { data: { items: [], meta: { nextCursor: null } } });
}
if (req.url === "/api/pages/info") {
return sendJson(res, 200, {
data: { id: "page-1", slugId: "s", title: "P", spaceId: "sp", content: pageContent },
});
}
sendJson(res, 404, { message: "not found" });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
// The transform appends an ORPHAN definition (id "z", no matching reference).
const transformJs = `(doc) => {
const list = doc.content.find((n) => n.type === "footnotesList");
list.content.push({
type: "footnoteDefinition",
attrs: { id: "z" },
content: [{ type: "paragraph", content: [{ type: "text", text: "orphan" }] }],
});
return doc;
}`;
const result = await client.transformPage("page-1", transformJs, { dryRun: true });
assert.equal(result.pushed, false);
// Auto-canonicalize dropped the orphan, so the doc is unchanged => empty diff.
assert.equal(result.diff.summary.inserted, 0, "orphan def must be canonicalized away");
assert.equal(result.diff.summary.deleted, 0);
});