Skip to content
markpaper

src/history/paginate.test.ts

v0.3.0 · 4.3 KB

Download file
import { describe, expect, it } from 'vitest';
import { paginateForward, sleep } from './paginate.js';

interface Row {
  id: number;
  time: number;
}

/** A chronological feed returning the oldest `cap` rows from the cursor. */
function feed(rows: Row[], cap: number) {
  const cursors: number[] = [];
  const fetchPage = async (cursor: number): Promise<unknown> => {
    cursors.push(cursor);
    return rows.filter((r) => r.time >= cursor).slice(0, cap);
  };
  return { fetchPage, cursors };
}

const base = { timeOf: (r: Row) => r.time, keyOf: (r: Row) => String(r.id), label: 'test' };

describe('paginateForward', () => {
  it('re-reads the newest millisecond so a page cut inside a millisecond loses nothing', async () => {
    // 3 rows per ms; a cap of 10 cuts ms 3 after its first row.
    const rows = Array.from({ length: 30 }, (_, i) => ({ id: i, time: Math.floor(i / 3) }));
    const f = feed(rows, 10);
    const res = await paginateForward<Row>({ ...base, startTime: 0, pageLimit: 10, fetchPage: f.fetchPage });
    expect(res.items.map((r) => r.id).sort((a, b) => a - b)).toEqual(rows.map((r) => r.id));
    expect(res.complete).toBe(true);
    expect(f.cursors[1]).toBe(3); // newest ms, not newest + 1
  });

  it('reports and skips a millisecond that fills a whole capped page', async () => {
    const dense = Array.from({ length: 15 }, (_, i) => ({ id: i, time: 7 }));
    const rows = [...dense, { id: 100, time: 8 }, { id: 101, time: 9 }];
    const f = feed(rows, 10);
    const res = await paginateForward<Row>({ ...base, startTime: 0, pageLimit: 10, fetchPage: f.fetchPage });
    expect(res.denseMillis).toEqual([7]);
    expect(res.items).toHaveLength(12); // 10 of ms 7 are reachable, 5 are not
    expect(res.items.some((r) => r.id === 101)).toBe(true);
    expect(res.complete).toBe(true);
  });

  it('with an unknown cap stops on a page with nothing new', async () => {
    const rows = Array.from({ length: 25 }, (_, i) => ({ id: i, time: i }));
    const f = feed(rows, 10);
    const res = await paginateForward<Row>({ ...base, startTime: 0, fetchPage: f.fetchPage });
    expect(res.items).toHaveLength(25);
    expect(res.complete).toBe(true);
  });

  it('stops at endTime and filters rows outside the window', async () => {
    const rows = Array.from({ length: 50 }, (_, i) => ({ id: i, time: i }));
    const fetchPage = async (cursor: number) => rows.filter((r) => r.time >= cursor - 5).slice(0, 10);
    const res = await paginateForward<Row>({ ...base, startTime: 10, endTime: 20, pageLimit: 10, fetchPage });
    expect(res.items.every((r) => r.time >= 10 && r.time <= 20)).toBe(true);
  });

  it('throws on a non-array response instead of returning []', async () => {
    await expect(
      paginateForward<Row>({ ...base, startTime: 0, fetchPage: async () => ({ error: 'x' }) }),
    ).rejects.toThrow(/non-array/);
  });

  it('propagates request errors', async () => {
    await expect(
      paginateForward<Row>({
        ...base,
        startTime: 0,
        fetchPage: async () => {
          throw new Error('HTTP 429');
        },
      }),
    ).rejects.toThrow('HTTP 429');
  });

  it('stops without looping when the server ignores the cursor', async () => {
    const rows = Array.from({ length: 10 }, (_, i) => ({ id: i, time: i }));
    let calls = 0;
    const res = await paginateForward<Row>({
      ...base,
      startTime: 0,
      pageLimit: 10,
      fetchPage: async () => {
        calls += 1;
        return rows;
      },
    });
    expect(calls).toBeLessThanOrEqual(3);
    expect(res.complete).toBe(false);
  });

  it('honours maxPages and maxItems', async () => {
    const rows = Array.from({ length: 100 }, (_, i) => ({ id: i, time: i }));
    const a = await paginateForward<Row>({ ...base, startTime: 0, pageLimit: 10, maxPages: 2, fetchPage: feed(rows, 10).fetchPage });
    expect(a.pages).toBe(2);
    expect(a.complete).toBe(false);
    const b = await paginateForward<Row>({ ...base, startTime: 0, pageLimit: 10, maxItems: 15, fetchPage: feed(rows, 10).fetchPage });
    expect(b.aborted).toBe(true);
    expect(b.complete).toBe(false);
  });

  it('sleep rejects on abort', async () => {
    const ac = new AbortController();
    const p = sleep(10_000, ac.signal);
    ac.abort(new Error('stop'));
    await expect(p).rejects.toThrow('stop');
    await expect(sleep(0)).resolves.toBeUndefined();
  });
});
All files