test: add full test suite for docmost-client and remaining modules
Raise coverage from 2.6% to 68% statements by adding 19 test files (~480 tests) covering every module in test-strategy-report.md. No production code changed — tests reach private logic via (client as any), mock HTTP with axios-mock-adapter on the real axios instance (interceptors intact), and mock the Hocuspocus provider with vi.mock + real yjs + fake timers. Coverage: auth-utils/filters/page-lock/json-edit 100%, diff 99%, node-ops 96%, transforms 95%, collaboration 86%, layout 91%, client.ts 41% (transport). - node-ops/transforms/json-edit/page-lock/filters: pure tree/text ops, immutability + clone guarantees, throw-vs-noop contracts - markdown-converter + markdown-document envelope + fast-check round-trip property test - diff, docmost-schema (sanitizeCssColor/clampCalloutType security guards) - collaboration: pure (buildCollabWsUrl/buildYDoc) + write-path (mutatePageContent read-transform-write, false-success suppression) - client.ts: isSafeUrl/validateDoc* XSS guards, vm-sandbox, REST pagination, 401 re-auth interceptor, login dedup, uploadImage/createPage multipart guards - collectRecentSince edge cases; loadSettingsOrExit invalid-value branch - env-gated E2E skeleton (DOCMOST_E2E) Two genuine markdown round-trip non-idempotency bugs are documented as it.fails (code-mark excludes other marks; block-image injects a blank line). Latent: isSafeUrl allows file:// on link context. Adds dev-deps: fast-check, @vitest/coverage-v8, axios-mock-adapter; adds the "coverage" npm script.
This commit is contained in:
236
test/recent-since-edges.test.ts
Normal file
236
test/recent-since-edges.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
// Import from source (not the built barrel/dist) for consistency with the rest
|
||||
// of the docmost-client test wave and to avoid running stale compiled output.
|
||||
import { collectRecentSince } from '../packages/docmost-client/src/client.js';
|
||||
|
||||
/**
|
||||
* Edge-case unit tests for `collectRecentSince`, complementing
|
||||
* `test/recent-since.test.ts` (which covers the happy / cap / dedup / order
|
||||
* paths). These tests target the defensive `!= null` guards, the exact cutoff
|
||||
* boundary, and a mid-walk null cursor — none of which the existing suite
|
||||
* exercises. `fetchPage` is always faked; no network is involved.
|
||||
*
|
||||
* Imported `from 'docmost-client'` (the built barrel) to match the behaviour
|
||||
* the existing suite asserts against.
|
||||
*/
|
||||
|
||||
// An item may be malformed (missing id and/or updatedAt), so every field is
|
||||
// optional here on purpose — that is exactly what these tests probe.
|
||||
type Item = { id?: string; updatedAt?: string };
|
||||
|
||||
/**
|
||||
* Build a fake `fetchPage` from an ordered list of pages. Page i's nextCursor
|
||||
* points at page i+1; the last page has no cursor (null). Tracks the call
|
||||
* count and the sequence of cursors the function asked for, so we can assert
|
||||
* the walk stopped where we expect.
|
||||
*/
|
||||
function fakeServer(pages: Item[][]) {
|
||||
let calls = 0;
|
||||
const cursorsRequested: (string | null)[] = [];
|
||||
const cursorFor = (i: number) => (i < pages.length - 1 ? `c${i}` : null);
|
||||
const fetchPage = async (cursor: string | null) => {
|
||||
// null -> page 0, "cN" -> page N+1 (mirrors the existing suite's helper).
|
||||
const idx = cursor === null ? 0 : Number(cursor.slice(1)) + 1;
|
||||
calls++;
|
||||
cursorsRequested.push(cursor);
|
||||
const items = pages[idx] ?? [];
|
||||
return { items, nextCursor: cursorFor(idx) };
|
||||
};
|
||||
return {
|
||||
fetchPage,
|
||||
get calls() {
|
||||
return calls;
|
||||
},
|
||||
get cursorsRequested() {
|
||||
return cursorsRequested;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('collectRecentSince — malformed items (the `!= null` guards)', () => {
|
||||
it('keeps an item with no updatedAt without tripping the cutoff', async () => {
|
||||
// The item with no `updatedAt` must skip the `<= cutoff` comparison
|
||||
// entirely (guard: item.updatedAt != null) and therefore be collected
|
||||
// rather than terminating the scan. The genuinely-old 'old' item that
|
||||
// follows it is what stops the walk.
|
||||
const server = fakeServer([
|
||||
[
|
||||
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
||||
{ id: 'no-ts' }, // no updatedAt -> cutoff check skipped, still collected
|
||||
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
||||
{ id: 'old', updatedAt: '2026-06-16T01:00:00Z' }, // <= cutoff -> stop
|
||||
{ id: 'after', updatedAt: '2026-06-16T00:30:00Z' },
|
||||
],
|
||||
]);
|
||||
|
||||
const out = await collectRecentSince(
|
||||
server.fetchPage,
|
||||
'2026-06-16T05:00:00Z',
|
||||
);
|
||||
|
||||
// 'no-ts' survives between the two newer items; 'old' and everything after
|
||||
// it is excluded once the cutoff is hit.
|
||||
expect(out.map((i) => i.id)).toEqual(['a', 'no-ts', 'b']);
|
||||
});
|
||||
|
||||
it('keeps an item with no id and never dedups it', async () => {
|
||||
// Items without an `id` are never added to the `seen` set (guard:
|
||||
// item.id != null), so they are never deduped: every no-id item — even
|
||||
// identical ones — is collected. A no-id item must not throw on the
|
||||
// `seen.has(undefined)` path either.
|
||||
const server = fakeServer([
|
||||
[
|
||||
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
||||
{ updatedAt: '2026-06-16T09:30:00Z' }, // no id
|
||||
{ updatedAt: '2026-06-16T09:30:00Z' }, // identical, no id -> NOT deduped
|
||||
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
||||
],
|
||||
]);
|
||||
|
||||
const out = await collectRecentSince(
|
||||
server.fetchPage,
|
||||
'2026-06-16T01:00:00Z',
|
||||
);
|
||||
|
||||
// Both no-id items are kept (length 4), proving no-id items bypass dedup.
|
||||
expect(out).toHaveLength(4);
|
||||
expect(out.map((i) => i.id)).toEqual(['a', undefined, undefined, 'b']);
|
||||
});
|
||||
|
||||
it('does not let a malformed item break id-based dedup of real items', async () => {
|
||||
// A malformed (no id, no updatedAt) item appears alongside a real id that
|
||||
// overlaps across pages. The real id 'b' must still be deduped exactly
|
||||
// once, and the malformed item must not interfere with that bookkeeping.
|
||||
const server = fakeServer([
|
||||
[
|
||||
{ id: 'a', updatedAt: '2026-06-16T10:00:00Z' },
|
||||
{}, // fully malformed: no id, no updatedAt
|
||||
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' },
|
||||
],
|
||||
[
|
||||
{ id: 'b', updatedAt: '2026-06-16T09:00:00Z' }, // overlap -> deduped
|
||||
{ id: 'c', updatedAt: '2026-06-16T08:00:00Z' },
|
||||
],
|
||||
]);
|
||||
|
||||
const out = await collectRecentSince(
|
||||
server.fetchPage,
|
||||
'2026-06-16T01:00:00Z',
|
||||
);
|
||||
|
||||
// Real ids appear once each; the malformed item is kept once (it counts as
|
||||
// a new item, so it does not stall progress between pages).
|
||||
expect(out.map((i) => i.id)).toEqual(['a', undefined, 'b', 'c']);
|
||||
expect(server.calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectRecentSince — exact cutoff boundary (updatedAt === sinceIso)', () => {
|
||||
it('EXCLUDES an item whose updatedAt equals sinceIso (<= is inclusive on the boundary)', async () => {
|
||||
// The comparison is `item.updatedAt <= sinceIso`, so an item exactly AT
|
||||
// the cutoff satisfies it and is treated as the stop point: that item and
|
||||
// everything after it is excluded.
|
||||
const server = fakeServer([
|
||||
[
|
||||
{ id: 'a', updatedAt: '2026-06-16T06:00:00Z' },
|
||||
{ id: 'boundary', updatedAt: '2026-06-16T05:00:00Z' }, // === sinceIso
|
||||
{ id: 'after', updatedAt: '2026-06-16T04:00:00Z' },
|
||||
],
|
||||
[{ id: 'never', updatedAt: '2026-06-16T03:00:00Z' }], // must not be fetched
|
||||
]);
|
||||
|
||||
const out = await collectRecentSince(
|
||||
server.fetchPage,
|
||||
'2026-06-16T05:00:00Z',
|
||||
);
|
||||
|
||||
// Only the strictly-newer 'a'; the boundary item is the cutoff and is
|
||||
// excluded, and the walk stops without fetching page 1.
|
||||
expect(out.map((i) => i.id)).toEqual(['a']);
|
||||
expect(server.calls).toBe(1);
|
||||
});
|
||||
|
||||
it('returns an empty array when the very first item is exactly at the cutoff', async () => {
|
||||
// Boundary item is first on the page: the loop hits the cutoff immediately
|
||||
// and collects nothing.
|
||||
const server = fakeServer([
|
||||
[
|
||||
{ id: 'boundary', updatedAt: '2026-06-16T05:00:00Z' }, // === sinceIso
|
||||
{ id: 'after', updatedAt: '2026-06-16T04:00:00Z' },
|
||||
],
|
||||
]);
|
||||
|
||||
const out = await collectRecentSince(
|
||||
server.fetchPage,
|
||||
'2026-06-16T05:00:00Z',
|
||||
);
|
||||
|
||||
expect(out).toEqual([]);
|
||||
expect(server.calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectRecentSince — nextCursor null mid-walk before cutoff', () => {
|
||||
it('stops and returns what it has when the cursor runs out before the cutoff', async () => {
|
||||
// Both pages are entirely newer than the cutoff (so `reachedCutoff` stays
|
||||
// false), but the last page has nextCursor === null. The `if
|
||||
// (!data.nextCursor) break` must end the walk gracefully and return the
|
||||
// accumulated items — no extra fetch, no warning (cap not hit).
|
||||
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' }, // still all newer
|
||||
],
|
||||
]);
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const out = await collectRecentSince(
|
||||
server.fetchPage,
|
||||
'2026-06-16T01:00:00Z',
|
||||
50, // generous cap: the null cursor, not the cap, must stop the walk
|
||||
);
|
||||
|
||||
// Everything from both pages, in server order; the null cursor ends it.
|
||||
expect(out.map((i) => i.id)).toEqual(['a', 'b', 'c', 'd']);
|
||||
// Exactly the two real pages were fetched (page 0 via null, page 1 via c0).
|
||||
expect(server.calls).toBe(2);
|
||||
expect(server.cursorsRequested).toEqual([null, 'c0']);
|
||||
// The cutoff was never reached, but we exhausted the feed naturally, so no
|
||||
// truncation warning is emitted.
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectRecentSince — hardPageCap warning (re-confirm)', () => {
|
||||
it('warns once when the cap is hit before the cutoff is reached', async () => {
|
||||
// Endless feed of unique, all-newer items with a perpetual nextCursor: the
|
||||
// only stop condition is the cap, which must emit exactly one truncation
|
||||
// warning naming the cap value.
|
||||
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 = 3;
|
||||
const out = await collectRecentSince(fetchPage, '2020-01-01T00:00:00Z', cap);
|
||||
|
||||
expect(out).toHaveLength(cap);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(String(warn.mock.calls[0][0])).toContain('hardPageCap=3');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user