90168eb926
Апстрим 78b1c1a4 перевёл серверные эндпоинты на КУРСОРНУЮ пагинацию, а
ValidationPipe({whitelist:true}) молча вырезает неизвестное поле `page`.
MCP-клиент так и слал офсетный `page` → сервер отдавал ту же первую двадцатку
с hasNextPage:true, цикл выкручивался до MAX_PAGES=50 одинаковых запросов, дети
№21+ не выгружались (с поддеревьями). Дедуп `visited` гасил дубли → «дырявое»
дерево без ошибок. Netmap: 20/299 страниц терялось, 160 запросов вместо 62.
- A: enumerateSpacePages → один POST /pages/tree (весь спейс/поддерево разом);
fallback на курсорный BFS при 404/405 (stock upstream). Возврат {pages,
truncated}; truncated честный — true только при реальном упоре fallback-BFS в
MAX_NODES.
- B: listSidebarPages → курсорный цикл, limit:100, guard на неподвижный курсор
(!next || next===cursor → break) — если протокол снова разойдётся, не крутит
дубли молча; warn при упоре в MAX_PAGES.
- C: paginateAll (/spaces, /shares) → та же курсорная миграция + guard.
- D: check_new_comments — /pages/tree поддерева включает корень
(getPageAndDescendants), убран лишний getPageRaw; в fallback корень
засевается явно (иначе его комменты терялись — регрессия того же класса).
- listComments: do/while → for с MAX_PAGES + guard неподвижного курсора
(был безлимитный — тот же сценарий #442 дал бы бесконечный цикл).
Внутренний цикл: 2 прохода. Первый нашёл потерю комментов корня в fallback
поддерева (data-loss) → засев корня; догрёб honest-truncated, warn в
listSidebarPages, guard в listComments. Второй проход — APPROVE, форма возврата
{pages,truncated} распространена на оба вызова без пропусков.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
441 lines
16 KiB
JavaScript
441 lines
16 KiB
JavaScript
// Mock-HTTP tests for the cursor-pagination migration in DocmostClient (#442).
|
|
//
|
|
// The server switched its list endpoints from OFFSET (`page`) to CURSOR
|
|
// (`cursor`/`nextCursor`) pagination, and the global ValidationPipe silently
|
|
// strips the obsolete `page` field — so the old offset loops re-fetched page
|
|
// one forever (hasNextPage stuck true), dropping every item past the first
|
|
// page. These tests pin the new cursor behaviour and the immovable-cursor
|
|
// guard that prevents a silent spin/duplication if the protocol drifts again.
|
|
//
|
|
// A local http.createServer stands in for Docmost so everything stays
|
|
// deterministic and offline (same harness style as reauth.test.mjs).
|
|
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", (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
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 closeServer(server) {
|
|
return new Promise((resolve) => server.close(resolve));
|
|
}
|
|
|
|
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 { server, baseURL };
|
|
}
|
|
|
|
after(async () => {
|
|
await Promise.all(openServers.map((s) => closeServer(s)));
|
|
});
|
|
|
|
// A login handler shared by every server below.
|
|
function handleLogin(req, res) {
|
|
if (req.url === "/api/auth/login") {
|
|
sendJson(res, 200, { success: true }, {
|
|
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 1) listSidebarPages: collects every cursor page; #requests == #pages.
|
|
// -----------------------------------------------------------------------------
|
|
test("listSidebarPages walks all cursor pages and collects every item", async () => {
|
|
// Three pages keyed by the cursor the client sends back.
|
|
const PAGES = {
|
|
"": { items: [{ id: "a" }, { id: "b" }], nextCursor: "c1" },
|
|
c1: { items: [{ id: "c" }, { id: "d" }], nextCursor: "c2" },
|
|
c2: { items: [{ id: "e" }], nextCursor: null },
|
|
};
|
|
let requests = 0;
|
|
const sentLimits = [];
|
|
|
|
const { baseURL } = await spawn(async (req, res) => {
|
|
const raw = await readBody(req);
|
|
if (handleLogin(req, res)) return;
|
|
if (req.url === "/api/pages/sidebar-pages") {
|
|
requests++;
|
|
const body = JSON.parse(raw || "{}");
|
|
sentLimits.push(body.limit);
|
|
const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null };
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
data: {
|
|
items: page.items,
|
|
meta: {
|
|
hasNextPage: page.nextCursor != null,
|
|
nextCursor: page.nextCursor,
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
sendJson(res, 404, {});
|
|
});
|
|
|
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
|
const all = await client.listSidebarPages("space-1");
|
|
|
|
assert.equal(requests, 3, "one request per cursor page");
|
|
assert.deepEqual(
|
|
all.map((p) => p.id),
|
|
["a", "b", "c", "d", "e"],
|
|
"all items across all pages collected in order",
|
|
);
|
|
assert.ok(
|
|
sentLimits.every((l) => l === 100),
|
|
"requests limit:100 (server-side max)",
|
|
);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 2) REGRESSION on the bug class: server IGNORES the cursor param and always
|
|
// returns page one with hasNextPage:true -> the immovable-cursor guard must
|
|
// terminate the loop with no duplicates, NOT spin to MAX_PAGES.
|
|
// -----------------------------------------------------------------------------
|
|
test("listSidebarPages terminates (no dups) when the server ignores the cursor", async () => {
|
|
let requests = 0;
|
|
|
|
const { baseURL } = await spawn(async (req, res) => {
|
|
await readBody(req);
|
|
if (handleLogin(req, res)) return;
|
|
if (req.url === "/api/pages/sidebar-pages") {
|
|
requests++;
|
|
// Always the SAME first page with hasNextPage:true and the SAME cursor,
|
|
// exactly as a server that no longer understands our pagination param.
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
data: {
|
|
items: [{ id: "x1" }, { id: "x2" }],
|
|
meta: { hasNextPage: true, nextCursor: "stuck" },
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
sendJson(res, 404, {});
|
|
});
|
|
|
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
|
const all = await client.listSidebarPages("space-1");
|
|
|
|
// Request 1 (no cursor) gets "stuck"; request 2 (cursor "stuck") gets "stuck"
|
|
// again -> guard trips. Far below the MAX_PAGES=50 ceiling; no runaway dups.
|
|
assert.equal(requests, 2, "stops as soon as the cursor stops moving");
|
|
assert.equal(all.length, 4, "no runaway accumulation / duplication");
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 3a) enumerateSpacePages happy path: a SINGLE /pages/tree request.
|
|
// -----------------------------------------------------------------------------
|
|
test("enumerateSpacePages (via list_pages tree) uses one /pages/tree request", async () => {
|
|
let treeRequests = 0;
|
|
let sidebarRequests = 0;
|
|
let treeBody = null;
|
|
|
|
const NODES = [
|
|
{ id: "r1", slugId: "r1s", title: "Root 1", parentPageId: null, hasChildren: true, spaceId: "space-1", position: "a", icon: null, canEdit: true },
|
|
{ id: "c1", slugId: "c1s", title: "Child 1", parentPageId: "r1", hasChildren: false, spaceId: "space-1", position: "a", icon: null, canEdit: true },
|
|
{ id: "r2", slugId: "r2s", title: "Root 2", parentPageId: null, hasChildren: false, spaceId: "space-1", position: "b", icon: null, canEdit: true },
|
|
];
|
|
|
|
const { baseURL } = await spawn(async (req, res) => {
|
|
const raw = await readBody(req);
|
|
if (handleLogin(req, res)) return;
|
|
if (req.url === "/api/pages/tree") {
|
|
treeRequests++;
|
|
treeBody = JSON.parse(raw || "{}");
|
|
sendJson(res, 200, { success: true, data: { items: NODES } });
|
|
return;
|
|
}
|
|
if (req.url === "/api/pages/sidebar-pages") {
|
|
sidebarRequests++;
|
|
sendJson(res, 200, { success: true, data: { items: [], meta: {} } });
|
|
return;
|
|
}
|
|
sendJson(res, 404, {});
|
|
});
|
|
|
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
|
// list_pages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree.
|
|
const tree = await client.listPages("space-1", 50, true);
|
|
|
|
assert.equal(treeRequests, 1, "exactly one /pages/tree request for the space");
|
|
assert.equal(sidebarRequests, 0, "no per-node sidebar BFS requests");
|
|
assert.deepEqual(treeBody, { spaceId: "space-1" }, "space scope posts spaceId only");
|
|
// buildPageTree nests c1 under r1; two roots at the top level.
|
|
assert.equal(tree.length, 2, "two root nodes");
|
|
const r1 = tree.find((n) => n.id === "r1");
|
|
assert.equal(r1.children.length, 1, "child nested under its root");
|
|
assert.equal(r1.children[0].id, "c1");
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 3b) enumerateSpacePages fallback: /pages/tree 404 -> cursor BFS via sidebar.
|
|
// -----------------------------------------------------------------------------
|
|
test("enumerateSpacePages falls back to the cursor BFS on /pages/tree 404", async () => {
|
|
let treeRequests = 0;
|
|
const sidebarCalls = [];
|
|
|
|
// Root level: one root with children. Child level (pageId=r1): one leaf.
|
|
const { baseURL } = await spawn(async (req, res) => {
|
|
const raw = await readBody(req);
|
|
if (handleLogin(req, res)) return;
|
|
if (req.url === "/api/pages/tree") {
|
|
treeRequests++;
|
|
// Stock upstream Docmost has no /pages/tree.
|
|
sendJson(res, 404, { message: "Not Found" });
|
|
return;
|
|
}
|
|
if (req.url === "/api/pages/sidebar-pages") {
|
|
const body = JSON.parse(raw || "{}");
|
|
sidebarCalls.push(body.pageId ?? "<root>");
|
|
if (!body.pageId) {
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
data: {
|
|
items: [
|
|
{ id: "r1", title: "Root", parentPageId: null, hasChildren: true },
|
|
],
|
|
meta: { hasNextPage: false, nextCursor: null },
|
|
},
|
|
});
|
|
} else if (body.pageId === "r1") {
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
data: {
|
|
items: [
|
|
{ id: "c1", title: "Leaf", parentPageId: "r1", hasChildren: false },
|
|
],
|
|
meta: { hasNextPage: false, nextCursor: null },
|
|
},
|
|
});
|
|
} else {
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
sendJson(res, 404, {});
|
|
});
|
|
|
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
|
const tree = await client.listPages("space-1", 50, true);
|
|
|
|
assert.ok(treeRequests >= 1, "the tree endpoint was attempted first");
|
|
assert.deepEqual(
|
|
sidebarCalls,
|
|
["<root>", "r1"],
|
|
"fell back to the sidebar BFS: roots then the root's children",
|
|
);
|
|
assert.equal(tree.length, 1, "one root in the built tree");
|
|
assert.equal(tree[0].children[0].id, "c1", "leaf nested via the BFS");
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 3c) enumerateSpacePages fallback SUBTREE: /pages/tree 404 + a rootPageId ->
|
|
// the ROOT page itself must be seeded (via getPageRaw) so its own comments
|
|
// aren't dropped. listSidebarPages(spaceId, root) returns only the root's
|
|
// CHILDREN, so without the seed the root would be absent. (Finding 1.)
|
|
// -----------------------------------------------------------------------------
|
|
test("enumerateSpacePages fallback subtree seeds the ROOT page itself", async () => {
|
|
const sidebarCalls = [];
|
|
let infoRequests = 0;
|
|
const commentedPages = [];
|
|
|
|
const { baseURL } = await spawn(async (req, res) => {
|
|
const raw = await readBody(req);
|
|
if (handleLogin(req, res)) return;
|
|
if (req.url === "/api/pages/tree") {
|
|
// Stock upstream Docmost -> fall back to the BFS.
|
|
sendJson(res, 404, { message: "Not Found" });
|
|
return;
|
|
}
|
|
if (req.url === "/api/pages/info") {
|
|
// getPageRaw for the root seed. Shape mirrors a real page-info response.
|
|
infoRequests++;
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
data: { id: "root", title: "Root", spaceId: "space-1", hasChildren: true },
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === "/api/pages/sidebar-pages") {
|
|
const body = JSON.parse(raw || "{}");
|
|
sidebarCalls.push(body.pageId ?? "<root>");
|
|
// Children of the root: one leaf. (Root itself is NOT in this list.)
|
|
const items =
|
|
body.pageId === "root"
|
|
? [{ id: "leaf", title: "Leaf", parentPageId: "root", hasChildren: false }]
|
|
: [];
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
data: { items, meta: { hasNextPage: false, nextCursor: null } },
|
|
});
|
|
return;
|
|
}
|
|
if (req.url === "/api/comments") {
|
|
const body = JSON.parse(raw || "{}");
|
|
commentedPages.push(body.pageId);
|
|
const items =
|
|
body.pageId === "root"
|
|
? [{ id: "cm1", createdAt: "2030-01-01T00:00:00.000Z", content: null }]
|
|
: [];
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
data: { items, meta: { nextCursor: null } },
|
|
});
|
|
return;
|
|
}
|
|
sendJson(res, 404, {});
|
|
});
|
|
|
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
|
// checkNewComments(space, since, parentPageId) exercises the subtree fallback.
|
|
const result = await client.checkNewComments(
|
|
"space-1",
|
|
"2020-01-01T00:00:00.000Z",
|
|
"root",
|
|
);
|
|
|
|
assert.equal(infoRequests, 1, "root was seeded via one getPageRaw");
|
|
assert.equal(sidebarCalls[0], "root", "BFS walked the root's children");
|
|
assert.ok(
|
|
commentedPages.includes("root"),
|
|
"the ROOT page is in scope (its comments were fetched) — not dropped",
|
|
);
|
|
assert.ok(commentedPages.includes("leaf"), "the descendant is in scope too");
|
|
assert.equal(result.checkedPages, 2, "root + one descendant scanned");
|
|
assert.equal(result.totalNewComments, 1, "the root's fresh comment found");
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 5) listComments immovable-cursor guard: the server IGNORES the cursor and
|
|
// keeps returning the same nextCursor -> the loop must terminate (no
|
|
// infinite loop, no duplicates), not spin forever. (Finding 4.)
|
|
// -----------------------------------------------------------------------------
|
|
test("listComments terminates (no dups) when the server ignores the cursor", async () => {
|
|
let requests = 0;
|
|
|
|
const { baseURL } = await spawn(async (req, res) => {
|
|
await readBody(req);
|
|
if (handleLogin(req, res)) return;
|
|
if (req.url === "/api/comments") {
|
|
requests++;
|
|
// Always the SAME page with the SAME nextCursor, as a server that no
|
|
// longer advances the cursor would.
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
data: {
|
|
items: [{ id: "cm1", createdAt: "2030-01-01T00:00:00.000Z", content: null }],
|
|
meta: { nextCursor: "stuck" },
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
sendJson(res, 404, {});
|
|
});
|
|
|
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
|
const { items } = await client.listComments("page-1", true);
|
|
|
|
// Request 1 (no cursor) gets "stuck"; request 2 (cursor "stuck") gets "stuck"
|
|
// again -> guard trips. Bounded far below MAX_PAGES=50, no runaway dups.
|
|
assert.equal(requests, 2, "stops as soon as the cursor stops moving");
|
|
assert.equal(items.length, 2, "no runaway accumulation / duplication");
|
|
});
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// 4) check_new_comments subtree: the root is included in scope WITHOUT a
|
|
// separate getPageRaw (/pages/info) request for the parent.
|
|
// -----------------------------------------------------------------------------
|
|
test("checkNewComments subtree includes the root without a separate getPageRaw", async () => {
|
|
let pageInfoRequests = 0;
|
|
let treeBody = null;
|
|
const commentedPages = [];
|
|
|
|
// /pages/tree (subtree) returns the parent itself plus a descendant, exactly
|
|
// as getPageAndDescendants seeds with id = parentPageId.
|
|
const NODES = [
|
|
{ id: "parent", title: "Parent", parentPageId: null, hasChildren: true },
|
|
{ id: "kid", title: "Kid", parentPageId: "parent", hasChildren: false },
|
|
];
|
|
|
|
const { baseURL } = await spawn(async (req, res) => {
|
|
const raw = await readBody(req);
|
|
if (handleLogin(req, res)) return;
|
|
if (req.url === "/api/pages/tree") {
|
|
treeBody = JSON.parse(raw || "{}");
|
|
sendJson(res, 200, { success: true, data: { items: NODES } });
|
|
return;
|
|
}
|
|
if (req.url === "/api/pages/info") {
|
|
// If checkNewComments still fetched the parent separately this would fire.
|
|
pageInfoRequests++;
|
|
sendJson(res, 200, { success: true, data: { id: "parent" } });
|
|
return;
|
|
}
|
|
if (req.url === "/api/comments") {
|
|
const body = JSON.parse(raw || "{}");
|
|
commentedPages.push(body.pageId);
|
|
// One fresh comment on the parent, none elsewhere.
|
|
const items =
|
|
body.pageId === "parent"
|
|
? [{ id: "cm1", createdAt: "2030-01-01T00:00:00.000Z", content: null }]
|
|
: [];
|
|
sendJson(res, 200, {
|
|
success: true,
|
|
data: { items, meta: { nextCursor: null } },
|
|
});
|
|
return;
|
|
}
|
|
sendJson(res, 404, {});
|
|
});
|
|
|
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
|
const result = await client.checkNewComments(
|
|
"space-1",
|
|
"2020-01-01T00:00:00.000Z",
|
|
"parent",
|
|
);
|
|
|
|
assert.equal(pageInfoRequests, 0, "no separate getPageRaw for the root");
|
|
assert.deepEqual(treeBody, { pageId: "parent" }, "subtree scope posts pageId");
|
|
assert.ok(
|
|
commentedPages.includes("parent"),
|
|
"the root itself is in scope (comments fetched for it)",
|
|
);
|
|
assert.ok(commentedPages.includes("kid"), "descendants are in scope too");
|
|
assert.equal(result.checkedPages, 2, "root + one descendant scanned");
|
|
assert.equal(result.totalNewComments, 1, "the root's fresh comment found");
|
|
});
|