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