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.
237 lines
9.0 KiB
TypeScript
237 lines
9.0 KiB
TypeScript
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');
|
|
});
|
|
});
|