src/history/paginate.test.ts
v0.3.0 · 4.3 KB
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();
});
});