Follow-up to the first e2e fix: with nanoid/editRes.edits resolved, the suites failed one layer deeper. Both layers were never green since the e2e jobs were added (non-blocking in CI), so the failures had stacked up. server e2e (jest-e2e.json) — align module resolution/transform with the working unit/integration jest configs so AppModule's full import graph loads: - moduleFileExtensions: add "tsx" (React-Email .tsx templates are pulled in via the auth controller chain). - transform: ^.+\.(t|j)s$ -> ^.+\.(t|j)sx?$ so .tsx is transformed. - moduleNameMapper: add ^src/(.*)$ -> <rootDir>/../src/$1 (code imports via the absolute 'src/...' alias). Verified locally: the module graph now fully resolves (only env vars, supplied by CI, remain). mcp e2e (test-e2e.mjs) — insert_image/replace_image accept only http(s) URLs the server fetches; the test passed local file paths and died with "Invalid image URL". Serve the PNG bytes over a throwaway 127.0.0.1 HTTP server (the Docmost server runs on the same CI host) and pass URLs. The featPng negative test is untouched: replaceImage checks the attachmentId and throws before fetching, so its local path is never validated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
488 lines
26 KiB
JavaScript
488 lines
26 KiB
JavaScript
// End-to-end test of the docmost-mcp client against a live Docmost server.
|
|
// Creates a throwaway page, exercises every code path, cleans up after itself.
|
|
// Usage: DOCMOST_API_URL=... DOCMOST_EMAIL=... DOCMOST_PASSWORD=... node test-e2e.mjs
|
|
import { DocmostClient } from "./build/client.js";
|
|
import axios from "axios";
|
|
import { writeFileSync, unlinkSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { deflateSync } from "node:zlib";
|
|
import { createServer } from "node:http";
|
|
|
|
const API = process.env.DOCMOST_API_URL;
|
|
if (!API || !process.env.DOCMOST_EMAIL || !process.env.DOCMOST_PASSWORD) {
|
|
console.error("Set DOCMOST_API_URL, DOCMOST_EMAIL and DOCMOST_PASSWORD env variables.");
|
|
process.exit(2);
|
|
}
|
|
const APP = API.replace(/\/api\/?$/, "");
|
|
const client = new DocmostClient(API, process.env.DOCMOST_EMAIL, process.env.DOCMOST_PASSWORD);
|
|
|
|
let failed = 0;
|
|
const check = (name, cond, extra = "") => {
|
|
console.log(`${cond ? "OK " : "FAIL"} ${name}${extra ? " — " + extra : ""}`);
|
|
if (!cond) failed++;
|
|
};
|
|
|
|
// Minimal solid-color PNG encoder using Node built-ins only (no dependencies).
|
|
// Returns a valid PNG buffer for a 1x1 image of the given RGB color.
|
|
const crc32 = (buf) => {
|
|
let crc = 0xffffffff;
|
|
for (let i = 0; i < buf.length; i++) {
|
|
crc ^= buf[i];
|
|
for (let k = 0; k < 8; k++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
}
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
};
|
|
const pngChunk = (type, data) => {
|
|
const len = Buffer.alloc(4);
|
|
len.writeUInt32BE(data.length, 0);
|
|
const typeBuf = Buffer.from(type, "ascii");
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
|
return Buffer.concat([len, typeBuf, data, crc]);
|
|
};
|
|
const makePng = (r, g, b) => {
|
|
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(1, 0); // width
|
|
ihdr.writeUInt32BE(1, 4); // height
|
|
ihdr[8] = 8; // bit depth
|
|
ihdr[9] = 2; // color type: truecolor RGB
|
|
ihdr[10] = 0; // compression
|
|
ihdr[11] = 0; // filter
|
|
ihdr[12] = 0; // interlace
|
|
// One scanline: filter byte 0 followed by one RGB pixel.
|
|
const raw = Buffer.from([0, r, g, b]);
|
|
const idat = deflateSync(raw);
|
|
return Buffer.concat([
|
|
sig,
|
|
pngChunk("IHDR", ihdr),
|
|
pngChunk("IDAT", idat),
|
|
pngChunk("IEND", Buffer.alloc(0)),
|
|
]);
|
|
};
|
|
|
|
const MD = `:::info
|
|
**Тестовый callout.** Он должен стать узлом callout, а не blockquote.
|
|
:::
|
|
|
|
Первый абзац с **жирным** и [ссылкой](https://example.com). Маркер тут [1] стоит.
|
|
|
|
## Раздел два
|
|
|
|
| Колонка А | Колонка Б |
|
|
| --- | --- |
|
|
| раз | два |
|
|
| три | четыре |
|
|
|
|
Последний абзац со словом БУКВОЕД для замены.
|
|
`;
|
|
|
|
async function main() {
|
|
const spaces = await client.getSpaces();
|
|
const spaceId = spaces[0].id;
|
|
let pageId = null;
|
|
|
|
try {
|
|
// 1. create_page: title with spaces must survive (was: underscores bug)
|
|
const created = await client.createPage("Тест апгрейда MCP сервера", MD, spaceId);
|
|
pageId = created.data.id;
|
|
check("create_page: title keeps spaces", created.data.title === "Тест апгрейда MCP сервера", created.data.title);
|
|
check("create_page: slugId exposed", typeof created.data.slugId === "string" && created.data.slugId.length > 0, created.data.slugId);
|
|
|
|
// 2. get_page_json: raw ProseMirror with callout + table
|
|
const pj = await client.getPageJson(pageId);
|
|
const types = pj.content.content.map((n) => n.type);
|
|
check("get_page_json: callout node present", types.includes("callout"), types.join(","));
|
|
check("get_page_json: table node present", types.includes("table"));
|
|
check("get_page_json: slugId present", !!pj.slugId);
|
|
|
|
// 3. edit_page_text: surgical replace, ids preserved
|
|
const idsBefore = JSON.stringify(
|
|
pj.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
|
|
);
|
|
const editRes = await client.editPageText(pageId, [
|
|
{ find: "БУКВОЕД", replace: "КНИГОЛЮБ" },
|
|
{ find: "[1]", replace: "[42]" },
|
|
]);
|
|
check("edit_page_text: both edits applied", editRes.applied.every((e) => e.replacements === 1));
|
|
await new Promise((r) => setTimeout(r, 16000)); // wait for server persistence
|
|
const pj2 = await client.getPageJson(pageId);
|
|
const text2 = JSON.stringify(pj2.content);
|
|
check("edit_page_text: replacement visible", text2.includes("КНИГОЛЮБ") && text2.includes("[42]"));
|
|
check("edit_page_text: old text gone", !text2.includes("БУКВОЕД"));
|
|
const idsAfter = JSON.stringify(
|
|
pj2.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
|
|
);
|
|
check("edit_page_text: block ids preserved", idsBefore === idsAfter);
|
|
check("edit_page_text: callout survived", JSON.stringify(pj2.content).includes('"callout"'));
|
|
check("edit_page_text: table survived", pj2.content.content.some((n) => n.type === "table"));
|
|
|
|
// 4. error reporting: ambiguous and missing finds
|
|
let err1 = "";
|
|
try { await client.editPageText(pageId, [{ find: "Колонка", replace: "X" }]); } catch (e) { err1 = e.message; }
|
|
check("edit_page_text: ambiguous match rejected", err1.includes("matches"), err1);
|
|
let err2 = "";
|
|
try { await client.editPageText(pageId, [{ find: "НЕСУЩЕСТВУЮЩЕЕ", replace: "X" }]); } catch (e) { err2 = e.message; }
|
|
check("edit_page_text: missing text reported", err2.includes("not found"), err2);
|
|
|
|
// 5. update_page (markdown): table + callout must survive the re-import
|
|
await client.updatePage(pageId, MD + "\nДобавленный абзац.\n");
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const pj3 = await client.getPageJson(pageId);
|
|
const types3 = pj3.content.content.map((n) => n.type);
|
|
check("update_page md: callout survives re-import", types3.includes("callout"), types3.join(","));
|
|
check("update_page md: table survives re-import", types3.includes("table"));
|
|
const tableNode = pj3.content.content.find((n) => n.type === "table");
|
|
const cellText = JSON.stringify(tableNode);
|
|
check("update_page md: table cells intact", cellText.includes("четыре") && cellText.includes("Колонка А"));
|
|
|
|
// 6. update_page_json: lossless write round-trip
|
|
pj3.content.content.push({
|
|
type: "paragraph",
|
|
attrs: { id: "testidjsonpush", indent: 0, textAlign: null },
|
|
content: [{ type: "text", text: "Абзац, добавленный через update_page_json." }],
|
|
});
|
|
await client.updatePageJson(pageId, pj3.content);
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const pj4 = await client.getPageJson(pageId);
|
|
const lastNode = pj4.content.content[pj4.content.content.length - 1];
|
|
check("update_page_json: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через update_page_json"));
|
|
check("update_page_json: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id);
|
|
|
|
// 6b. images: upload / insert / replace (clean src, fresh attachment on replace).
|
|
// insert_image / replace_image take an http(s) URL that the SERVER fetches;
|
|
// local file paths are intentionally unsupported. The Docmost server runs on
|
|
// the same host as this test, so serve the PNG bytes over a throwaway
|
|
// localhost HTTP server it can reach.
|
|
const bytesA = makePng(255, 0, 0); // red
|
|
const bytesB = makePng(0, 0, 255); // blue (a DIFFERENT valid PNG)
|
|
const imgServer = createServer((req, res) => {
|
|
res.writeHead(200, { "Content-Type": "image/png" });
|
|
res.end(req.url === "/b.png" ? bytesB : bytesA);
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
imgServer.once("error", reject);
|
|
imgServer.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const imgPort = imgServer.address().port;
|
|
const urlA = `http://127.0.0.1:${imgPort}/a.png`;
|
|
const urlB = `http://127.0.0.1:${imgPort}/b.png`;
|
|
try {
|
|
// Independent login to fetch file bytes with the same cookie the editor uses.
|
|
const login = await axios.post(
|
|
`${API}/auth/login`,
|
|
{ email: process.env.DOCMOST_EMAIL, password: process.env.DOCMOST_PASSWORD },
|
|
{ validateStatus: () => true },
|
|
);
|
|
const token = (login.headers["set-cookie"] || [])
|
|
.find((c) => c.startsWith("authToken="))
|
|
?.split(";")[0]
|
|
.split("=")[1];
|
|
const fetchFile = (src) =>
|
|
axios.get(`${APP}${src}`, {
|
|
headers: { Cookie: `authToken=${token}` },
|
|
responseType: "arraybuffer",
|
|
validateStatus: () => true,
|
|
});
|
|
|
|
// insert_image: append the first PNG, src must be clean (no ?v=) and fetchable.
|
|
const ins = await client.insertImage(pageId, urlA);
|
|
check("insert_image: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src);
|
|
const fileA = await fetchFile(ins.src);
|
|
check("insert_image: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`);
|
|
check(
|
|
"insert_image: content-type is image/*",
|
|
String(fileA.headers["content-type"] || "").startsWith("image/"),
|
|
String(fileA.headers["content-type"]),
|
|
);
|
|
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const pjImg = await client.getPageJson(pageId);
|
|
const findImage = (nodes, id) => {
|
|
for (const n of nodes || []) {
|
|
if (n.type === "image" && (!id || n.attrs?.attachmentId === id)) return n;
|
|
const found = findImage(n.content, id);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
};
|
|
const imgNode = findImage(pjImg.content.content);
|
|
const oldAttachmentId = imgNode?.attrs?.attachmentId;
|
|
check("insert_image: image node present after persist", !!oldAttachmentId, oldAttachmentId);
|
|
|
|
// replace_image: must create a NEW attachment with a clean, fetchable URL.
|
|
// The 200 fetch is the assertion that catches the in-place-overwrite HTTP 500 regression.
|
|
const rep = await client.replaceImage(pageId, oldAttachmentId, urlB);
|
|
check("replace_image: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`);
|
|
check("replace_image: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src);
|
|
const fileB = await fetchFile(rep.src);
|
|
check("replace_image: new file fetch returns 200", fileB.status === 200, `status=${fileB.status}`);
|
|
check(
|
|
"replace_image: new content-type is image/*",
|
|
String(fileB.headers["content-type"] || "").startsWith("image/"),
|
|
String(fileB.headers["content-type"]),
|
|
);
|
|
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const pjImg2 = await client.getPageJson(pageId);
|
|
check("replace_image: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId);
|
|
check("replace_image: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId);
|
|
} finally {
|
|
imgServer.close();
|
|
}
|
|
|
|
// 6c. rich formatting: callout type, task list, inline marks, table alignment,
|
|
// and literal $-pattern edits. Runs on its own throwaway page so it does not
|
|
// disturb the markdown-export assumptions of later sections.
|
|
{
|
|
const findNodes = (n, t, acc = []) => {
|
|
if (!n) return acc;
|
|
if (n.type === t) acc.push(n);
|
|
for (const ch of n.content || []) findNodes(ch, t, acc);
|
|
return acc;
|
|
};
|
|
const marksOf = (n, acc = new Set()) => {
|
|
if (!n) return acc;
|
|
for (const m of n.marks || []) acc.add(m.type);
|
|
for (const ch of n.content || []) marksOf(ch, acc);
|
|
return acc;
|
|
};
|
|
const FMD = [
|
|
":::warning", "Warning callout with СЛОВО.", ":::", "",
|
|
"- [x] done", "- [ ] todo", "",
|
|
"Marks: <mark>hl</mark> <sub>lo</sub> <sup>hi</sup>.", "",
|
|
"| L | C | R |", "|:--|:-:|--:|", "| a | b | c |", "",
|
|
"Edit anchor PRICEMARK.",
|
|
].join("\n");
|
|
const featPng = join(tmpdir(), `mcp-e2e-feat-${Date.now()}.png`);
|
|
writeFileSync(featPng, makePng(0, 255, 0));
|
|
const fp = await client.createPage("E2E features " + Date.now(), "init", spaceId);
|
|
const fid = fp.data.id;
|
|
try {
|
|
await client.updatePage(fid, FMD);
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const fj = (await client.getPageJson(fid)).content;
|
|
check("feature: callout type 'warning' preserved (was coerced to info)", findNodes(fj, "callout").some((n) => n.attrs?.type === "warning"), JSON.stringify(findNodes(fj, "callout").map((n) => n.attrs?.type)));
|
|
check("feature: task list imported (taskList + 2 taskItems)", findNodes(fj, "taskList").length >= 1 && findNodes(fj, "taskItem").length === 2, `tl=${findNodes(fj, "taskList").length} ti=${findNodes(fj, "taskItem").length}`);
|
|
check("feature: task checked states preserved", findNodes(fj, "taskItem").some((n) => n.attrs?.checked === true) && findNodes(fj, "taskItem").some((n) => n.attrs?.checked === false));
|
|
const mk = [...marksOf(fj)];
|
|
check("feature: highlight/subscript/superscript marks imported", ["highlight", "subscript", "superscript"].every((m) => mk.includes(m)), mk.join(","));
|
|
check("feature: table cell alignment imported", JSON.stringify(findNodes(fj, "tableHeader").map((n) => n.attrs?.align)) === '["left","center","right"]', JSON.stringify(findNodes(fj, "tableHeader").map((n) => n.attrs?.align)));
|
|
const fmd = (await client.getPage(fid)).data.content;
|
|
check("feature: md export emits task checkboxes", fmd.includes("- [x]") && fmd.includes("- [ ]"));
|
|
check("feature: md export emits table alignment markers", /:--|:-:|--:/.test(fmd));
|
|
await client.editPageText(fid, [{ find: "PRICEMARK", replace: "$& costs $100" }]);
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const ftext = JSON.stringify((await client.getPageJson(fid)).content);
|
|
check("feature: edit_page_text inserts $-pattern literally (no $& expansion)", ftext.includes("$& costs $100") && !ftext.includes("PRICEMARK costs"));
|
|
let badThrew = false;
|
|
try { await client.replaceImage(fid, "00000000-0000-0000-0000-000000000000", featPng); } catch (e) { badThrew = /no image with attachmentId/.test(e.message); }
|
|
check("feature: replace_image with unknown id throws (no orphan upload)", badThrew);
|
|
} finally {
|
|
try { await client.deletePage(fid); } catch {}
|
|
try { unlinkSync(featPng); } catch {}
|
|
}
|
|
}
|
|
|
|
// 6d. node ops: patch / insert / delete a block by id on a throwaway page.
|
|
// Three paragraphs are written with KNOWN ids via update_page_json so the
|
|
// ids can be targeted directly; each op is verified via getPageJson after
|
|
// the standard 16s persistence wait.
|
|
{
|
|
const np = await client.createPage("E2E node-ops " + Date.now(), "init", spaceId);
|
|
const nid = np.data.id;
|
|
try {
|
|
const mkPara = (id, text) => ({
|
|
type: "paragraph",
|
|
attrs: { id, indent: 0, textAlign: null },
|
|
content: [{ type: "text", text }],
|
|
});
|
|
// Seed three paragraphs with known ids.
|
|
await client.updatePageJson(nid, {
|
|
type: "doc",
|
|
content: [
|
|
mkPara("nodeops-a", "Alpha paragraph."),
|
|
mkPara("nodeops-b", "Bravo paragraph."),
|
|
mkPara("nodeops-c", "Charlie paragraph."),
|
|
],
|
|
});
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
|
|
// Read back the ids the server actually assigned.
|
|
const seed = (await client.getPageJson(nid)).content;
|
|
const seedIds = seed.content.map((n) => n.attrs?.id);
|
|
check("node_ops: three seed paragraphs present", seed.content.length === 3, seedIds.join(","));
|
|
const [idA, idB, idC] = seedIds;
|
|
|
|
// patchNode: replace the middle paragraph; siblings' ids must be unchanged.
|
|
await client.patchNode(nid, idB, mkPara(idB, "Bravo PATCHED."));
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const afterPatch = (await client.getPageJson(nid)).content;
|
|
const patchText = JSON.stringify(afterPatch);
|
|
check("node_ops: patchNode applied new text", patchText.includes("Bravo PATCHED.") && !patchText.includes("Bravo paragraph."));
|
|
const patchIds = afterPatch.content.map((n) => n.attrs?.id);
|
|
check("node_ops: patchNode kept sibling ids", patchIds[0] === idA && patchIds[2] === idC, patchIds.join(","));
|
|
|
|
// insertNode: place a new block after the first paragraph.
|
|
await client.insertNode(
|
|
nid,
|
|
mkPara("nodeops-ins", "Inserted paragraph."),
|
|
{ position: "after", anchorNodeId: idA },
|
|
);
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const afterIns = (await client.getPageJson(nid)).content;
|
|
const insIds = afterIns.content.map((n) => n.attrs?.id);
|
|
const insText = afterIns.content.map((n) => JSON.stringify(n.content)).join("|");
|
|
check("node_ops: insertNode added a block", afterIns.content.length === 4 && insText.includes("Inserted paragraph."));
|
|
check("node_ops: insertNode placed block right after anchor", insIds[0] === idA && insIds[1] !== idB && insIds[2] === idB, insIds.join(","));
|
|
|
|
// deleteNode: remove the last (Charlie) paragraph.
|
|
await client.deleteNode(nid, idC);
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const afterDel = (await client.getPageJson(nid)).content;
|
|
const delText = JSON.stringify(afterDel);
|
|
check("node_ops: deleteNode removed the block", !delText.includes("Charlie paragraph.") && !afterDel.content.some((n) => n.attrs?.id === idC));
|
|
} finally {
|
|
try { await client.deletePage(nid); } catch {}
|
|
}
|
|
}
|
|
|
|
// 6e. rename_page: title-only update must leave the content untouched.
|
|
{
|
|
const rp = await client.createPage("E2E rename before " + Date.now(), "Rename body marker RENAMEBODY.", spaceId);
|
|
const rid = rp.data.id;
|
|
try {
|
|
const beforeJson = (await client.getPageJson(rid)).content;
|
|
const beforeContent = JSON.stringify(beforeJson);
|
|
const newTitle = "E2E rename AFTER " + Date.now();
|
|
const rr = await client.renamePage(rid, newTitle);
|
|
check("rename_page: returns success+title", rr.success === true && rr.title === newTitle, JSON.stringify(rr));
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const afterJson = await client.getPageJson(rid);
|
|
check("rename_page: title changed", afterJson.title === newTitle, afterJson.title);
|
|
check("rename_page: content unchanged", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("RENAMEBODY"));
|
|
const afterMd = (await client.getPage(rid)).data;
|
|
check("rename_page: get_page reflects new title", afterMd.title === newTitle, afterMd.title);
|
|
} finally {
|
|
try { await client.deletePage(rid); } catch {}
|
|
}
|
|
}
|
|
|
|
// 6f. update_page_json title-only: omitting content updates the title and
|
|
// leaves the body intact; supplying neither content nor title throws.
|
|
{
|
|
const up = await client.createPage("E2E upj-title before " + Date.now(), "Title-only body marker UPJTITLEBODY.", spaceId);
|
|
const uid = up.data.id;
|
|
try {
|
|
const beforeContent = JSON.stringify((await client.getPageJson(uid)).content);
|
|
const newTitle = "E2E upj-title AFTER " + Date.now();
|
|
const ur = await client.updatePageJson(uid, undefined, newTitle);
|
|
check("update_page_json title-only: succeeds", ur.success === true, JSON.stringify(ur));
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
const afterJson = await client.getPageJson(uid);
|
|
check("update_page_json title-only: title updated", afterJson.title === newTitle, afterJson.title);
|
|
check("update_page_json title-only: content intact", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("UPJTITLEBODY"));
|
|
let upjErr = "";
|
|
try { await client.updatePageJson(uid); } catch (e) { upjErr = e.message; }
|
|
check("update_page_json: neither content nor title throws", upjErr.includes("nothing to update"), upjErr);
|
|
} finally {
|
|
try { await client.deletePage(uid); } catch {}
|
|
}
|
|
}
|
|
|
|
// 6g. copy_page_content: B's body becomes a copy of A's body, server-side,
|
|
// while B's title/slugId stay put. Both pages are throwaways.
|
|
{
|
|
let aid = null;
|
|
let bid = null;
|
|
try {
|
|
const aPage = await client.createPage("E2E copy SOURCE " + Date.now(), "Source marker COPYSOURCE only here.\n\nSecond source paragraph.", spaceId);
|
|
aid = aPage.data.id;
|
|
const bPage = await client.createPage("E2E copy TARGET " + Date.now(), "Target marker COPYTARGET only here.", spaceId);
|
|
bid = bPage.data.id;
|
|
|
|
const aJson = await client.getPageJson(aid);
|
|
const bBefore = await client.getPageJson(bid);
|
|
const bTitleBefore = bBefore.title;
|
|
const bSlugBefore = bBefore.slugId;
|
|
const aNodeCount = aJson.content.content.length;
|
|
|
|
const cr = await client.copyPageContent(aid, bid);
|
|
check("copy_page_content: returns success + node count", cr.success === true && cr.copiedNodes === aNodeCount, JSON.stringify(cr));
|
|
await new Promise((r) => setTimeout(r, 16000));
|
|
|
|
const bAfter = await client.getPageJson(bid);
|
|
const bText = JSON.stringify(bAfter.content);
|
|
check("copy_page_content: B now has A's marker", bText.includes("COPYSOURCE"));
|
|
check("copy_page_content: B's old marker gone", !bText.includes("COPYTARGET"));
|
|
check("copy_page_content: B node count equals A's", bAfter.content.content.length === aNodeCount, `${bAfter.content.content.length} vs ${aNodeCount}`);
|
|
check("copy_page_content: B title unchanged", bAfter.title === bTitleBefore, bAfter.title);
|
|
check("copy_page_content: B slugId unchanged", bAfter.slugId === bSlugBefore, bAfter.slugId);
|
|
|
|
// Source must be left untouched by the copy.
|
|
const aAfter = JSON.stringify((await client.getPageJson(aid)).content);
|
|
check("copy_page_content: source page unchanged", aAfter === JSON.stringify(aJson.content) && aAfter.includes("COPYSOURCE"));
|
|
|
|
let copyErr = "";
|
|
try { await client.copyPageContent(aid, aid); } catch (e) { copyErr = e.message; }
|
|
check("copy_page_content: self-copy rejected", copyErr.includes("same page"), copyErr);
|
|
} finally {
|
|
try { if (bid) await client.deletePage(bid); } catch {}
|
|
try { if (aid) await client.deletePage(aid); } catch {}
|
|
}
|
|
}
|
|
|
|
// 7. shares: create (idempotent), public access, list, unshare
|
|
const share = await client.sharePage(pageId);
|
|
check("share_page: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl);
|
|
const share2 = await client.sharePage(pageId);
|
|
check("share_page: idempotent", share2.key === share.key);
|
|
const anon = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
|
|
check("share_page: anonymous access works", anon.status === 200);
|
|
const shares = await client.listShares();
|
|
check("list_shares: contains our page", shares.some((s) => s.pageId === pageId && s.publicUrl === share.publicUrl));
|
|
const un = await client.unsharePage(pageId);
|
|
check("unshare_page: success", un.success === true);
|
|
const anon2 = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
|
|
check("unshare_page: public access revoked", anon2.status !== 200, `status=${anon2.status}`);
|
|
|
|
// 8. get_page markdown round-trip sanity (table separator present)
|
|
const md = await client.getPage(pageId);
|
|
check("get_page md: table separator emitted", md.data.content.includes("| --- |"), "");
|
|
check("get_page md: callout exported as :::", md.data.content.includes(":::info"));
|
|
|
|
// 9. comments: create / list / reply / update / check_new / delete
|
|
const beforeComments = new Date(Date.now() - 1000).toISOString();
|
|
const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).");
|
|
check("create_comment: created", !!c1.data.id, c1.data.id);
|
|
check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
|
|
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);
|
|
check("create_comment: reply has parent", reply.data.parentCommentId === c1.data.id);
|
|
const list = await client.listComments(pageId);
|
|
check("list_comments: both visible", list.length === 2, `count=${list.length}`);
|
|
await client.updateComment(c1.data.id, "Обновлённый текст комментария.");
|
|
const got = await client.getComment(c1.data.id);
|
|
check("update_comment + get_comment: content updated", got.data.content.includes("Обновлённый"), got.data.content);
|
|
const news = await client.checkNewComments(spaceId, beforeComments, pageId);
|
|
check("check_new_comments: finds new comments in subtree", news.totalNewComments >= 2, `total=${news.totalNewComments}`);
|
|
await client.deleteComment(reply.data.id);
|
|
await client.deleteComment(c1.data.id);
|
|
const listAfter = await client.listComments(pageId);
|
|
check("delete_comment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
|
|
} finally {
|
|
if (pageId) {
|
|
await client.deletePage(pageId);
|
|
console.log("cleanup: test page deleted");
|
|
}
|
|
}
|
|
|
|
console.log(failed === 0 ? "\nALL TESTS PASSED" : `\n${failed} TESTS FAILED`);
|
|
process.exit(failed === 0 ? 0 : 1);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("FATAL:", e.message);
|
|
process.exit(2);
|
|
});
|