Lock the access-layer decision (REST only) and start implementation per SPEC. - monorepo (npm workspaces): packages/docmost-client = DocmostClient + lib/* copied 1:1 from docmost-mcp/src (backport target), plus bannered sync methods (listTrash, restorePage, listAllSpacePages, exportPageBody, listRecentSince / collectRecentSince cursor scan) - engine stays the root app per AGENTS.md (src/, test/, build/, data/, settings.ts); add roundtrip.ts (SPEC §11 idempotency harness), pull.ts (SPEC §6 read-only Docmost->FS mirror), sanitize.ts (SPEC §12 filenames, path-traversal-safe) - Dockerfile builds the workspace lib before the app; vitest gates CI - exportPageBody never touches /comments (SPEC §3); serializeDocmostMarkdownBody emits meta + body only - SPEC: resolve access-layer (REST), reflect root-engine layout + REST pagination - tests: sanitize (incl. dot-traversal), collectRecentSince (cutoff/dedup/cap), stripBlockIds, markdown round-trip byte-stability Note: raw ProseMirror round-trip is byte-stable in Markdown but not yet attribute- idempotent (SPEC §11 Задача №0, before Phase 2).
179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { collectRecentSince } from 'docmost-client';
|
|
|
|
/**
|
|
* Unit tests for the pure cursor-pagination helper behind listRecentSince.
|
|
* `fetchPage` is faked (no network); each test models a different server
|
|
* behaviour to exercise one stop condition.
|
|
*/
|
|
|
|
type Item = { id: string; updatedAt: string };
|
|
|
|
/**
|
|
* Build a fake `fetchPage` from a list of pages. Each page is served in order;
|
|
* the nextCursor of page i points at page i+1 (the last page has no cursor).
|
|
* The handed-back cursor is asserted to match what we previously emitted so a
|
|
* caller that mis-threads the cursor would fail loudly. Tracks the call count.
|
|
*/
|
|
function fakeServer(pages: Item[][]) {
|
|
let calls = 0;
|
|
const cursorFor = (i: number) => (i < pages.length - 1 ? `c${i}` : null);
|
|
const fetchPage = async (cursor: string | null) => {
|
|
// Resolve which page this cursor selects: null -> page 0, "cN" -> page N+1.
|
|
const idx = cursor === null ? 0 : Number(cursor.slice(1)) + 1;
|
|
calls++;
|
|
const items = pages[idx] ?? [];
|
|
return { items, nextCursor: cursorFor(idx) };
|
|
};
|
|
return {
|
|
fetchPage,
|
|
get calls() {
|
|
return calls;
|
|
},
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('collectRecentSince', () => {
|
|
it('stops at the cutoff page and does not fetch beyond it', async () => {
|
|
// Page 0: all newer than the cutoff. Page 1: contains the cutoff item, so
|
|
// the walk must stop here and never request page 2.
|
|
const server = fakeServer([
|
|
[
|
|
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
|
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
|
],
|
|
[
|
|
{ id: 'c', updatedAt: '2026-06-16T08:00:00Z' },
|
|
{ id: 'd', updatedAt: '2026-06-16T05:00:00Z' }, // <= cutoff -> stop
|
|
{ id: 'e', updatedAt: '2026-06-16T04:00:00Z' },
|
|
],
|
|
[{ id: 'f', updatedAt: '2026-06-16T03:00:00Z' }], // must NOT be fetched
|
|
]);
|
|
|
|
const out = await collectRecentSince(
|
|
server.fetchPage,
|
|
'2026-06-16T05:00:00Z',
|
|
);
|
|
|
|
// Only strictly-newer items, in server order; the cutoff item 'd' and
|
|
// everything after it is excluded.
|
|
expect(out.map((i) => i.id)).toEqual(['a', 'b', 'c']);
|
|
// Fetched page 0 and page 1 only — stopped at the cutoff page.
|
|
expect(server.calls).toBe(2);
|
|
});
|
|
|
|
it('dedups ids that overlap across pages', async () => {
|
|
// The cursor advances, but page boundaries overlap: 'b' appears on both
|
|
// pages. The dedup-by-id Set must keep it exactly once.
|
|
const server = fakeServer([
|
|
[
|
|
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
|
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
|
],
|
|
[
|
|
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' }, // overlap
|
|
{ id: 'c', updatedAt: '2026-06-16T08:00:00Z' },
|
|
],
|
|
]);
|
|
|
|
const out = await collectRecentSince(
|
|
server.fetchPage,
|
|
'2026-06-16T01:00:00Z',
|
|
);
|
|
|
|
expect(out.map((i) => i.id)).toEqual(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('terminates when the server ignores the cursor (zero new items)', async () => {
|
|
// A broken server that returns the SAME first page on every call and always
|
|
// claims a nextCursor. Without the zero-new-items guard this loops to the
|
|
// cap; with it, the second fetch contributes nothing and the walk stops.
|
|
let calls = 0;
|
|
const fetchPage = async (_cursor: string | null) => {
|
|
calls++;
|
|
return {
|
|
items: [
|
|
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
|
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
|
] as Item[],
|
|
nextCursor: 'always', // server always claims another page
|
|
};
|
|
};
|
|
|
|
const out = await collectRecentSince(fetchPage, '2026-06-16T01:00:00Z');
|
|
|
|
// The newer items are returned exactly once (no hang, no duplicates).
|
|
expect(out.map((i) => i.id)).toEqual(['a', 'b']);
|
|
// First page collects, second page is all-seen -> stop. Capped well below
|
|
// the default hardPageCap, proving the loop terminated.
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it('returns only the first page when sinceIso is null', async () => {
|
|
const server = fakeServer([
|
|
[
|
|
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
|
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
|
],
|
|
[{ id: 'c', updatedAt: '2026-06-16T08:00:00Z' }],
|
|
]);
|
|
|
|
const out = await collectRecentSince(server.fetchPage, null);
|
|
|
|
expect(out.map((i) => i.id)).toEqual(['a', 'b']);
|
|
// Exactly one page fetched.
|
|
expect(server.calls).toBe(1);
|
|
});
|
|
|
|
it('stops at hardPageCap and warns when results may be truncated', async () => {
|
|
// Every page is all-newer-than-cutoff, every item is unique, and there is
|
|
// always a nextCursor: the only thing that can stop the walk is the cap.
|
|
let n = 0;
|
|
const fetchPage = async (_cursor: string | null) => {
|
|
const id = `id${n++}`;
|
|
return {
|
|
items: [{ id, updatedAt: '2026-06-16T10:00:00Z' }] as Item[],
|
|
nextCursor: 'next', // never runs out
|
|
};
|
|
};
|
|
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
const cap = 5;
|
|
const out = await collectRecentSince(
|
|
fetchPage,
|
|
'2020-01-01T00:00:00Z',
|
|
cap,
|
|
);
|
|
|
|
// Exactly `cap` pages were collected (one unique item each).
|
|
expect(out).toHaveLength(cap);
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(String(warn.mock.calls[0][0])).toContain('hardPageCap=5');
|
|
});
|
|
|
|
it('preserves server (descending) order across pages', async () => {
|
|
const server = fakeServer([
|
|
[
|
|
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
|
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
|
],
|
|
[
|
|
{ id: 'c', updatedAt: '2026-06-16T08:00:00Z' },
|
|
{ id: 'd', updatedAt: '2026-06-16T07:00:00Z' },
|
|
],
|
|
]);
|
|
|
|
const out = await collectRecentSince(
|
|
server.fetchPage,
|
|
'2026-06-16T01:00:00Z',
|
|
);
|
|
|
|
expect(out.map((i) => i.id)).toEqual(['a', 'b', 'c', 'd']);
|
|
});
|
|
});
|