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'); }); });