150 lines
5.6 KiB
TypeScript
150 lines
5.6 KiB
TypeScript
/**
|
|
* Property-based tests using fast-check.
|
|
*
|
|
* Covers:
|
|
* - decodeCursor(encodeCursor(s)) === s for all non-empty strings and numbers
|
|
* - decodeKeysetCursor(encodeKeysetCursor(keys)) deep-equals keys
|
|
* - decodePageToken(encodePageToken(payload)) deep-equals payload
|
|
* - paginateArray always returns ≤ limit items
|
|
* - offsetToPage(pageToOffset(p, l), l) === p for all positive p and l
|
|
*/
|
|
|
|
import { describe, it } from "@suaveplan/testing/runner";
|
|
import * as fc from "fast-check";
|
|
import { decodeCursor, encodeCursor } from "../src/cursor/cursor-pagination.js";
|
|
import {
|
|
decodeKeysetCursor,
|
|
encodeKeysetCursor,
|
|
} from "../src/keyset/keyset-pagination.js";
|
|
import {
|
|
offsetToPage,
|
|
pageToOffset,
|
|
paginateArray,
|
|
} from "../src/offset/offset.js";
|
|
import { decodePageToken, encodePageToken } from "../src/tokens/page-tokens.js";
|
|
import type {
|
|
KeysetSortKey,
|
|
PageTokenPayload,
|
|
} from "../src/types/pagination.types.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Cursor encode/decode round-trip
|
|
// ---------------------------------------------------------------------------
|
|
describe("property: cursor encode/decode round-trip", () => {
|
|
it("round-trips any non-empty string", { timeout: 10000 }, () => {
|
|
fc.assert(
|
|
fc.property(fc.string({ minLength: 1 }), (s) => {
|
|
return decodeCursor(encodeCursor(s)) === s;
|
|
}),
|
|
{ numRuns: 200 }
|
|
);
|
|
});
|
|
|
|
it("round-trips any safe integer", { timeout: 10000 }, () => {
|
|
fc.assert(
|
|
fc.property(fc.integer(), (n) => {
|
|
return decodeCursor(encodeCursor(n)) === n;
|
|
}),
|
|
{ numRuns: 200 }
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keyset cursor round-trip
|
|
// ---------------------------------------------------------------------------
|
|
describe("property: keyset cursor encode/decode round-trip", () => {
|
|
const keysetArb: fc.Arbitrary<KeysetSortKey[]> = fc.array(
|
|
fc.record({
|
|
field: fc.string({ minLength: 1, maxLength: 20 }),
|
|
direction: fc.constantFrom("asc" as const, "desc" as const),
|
|
value: fc.oneof(
|
|
fc.string({ minLength: 0, maxLength: 50 }),
|
|
fc.integer()
|
|
),
|
|
}),
|
|
{ minLength: 1, maxLength: 5 }
|
|
);
|
|
|
|
it("round-trips arbitrary keyset keys", { timeout: 10000 }, () => {
|
|
fc.assert(
|
|
fc.property(keysetArb, (keys) => {
|
|
const decoded = decodeKeysetCursor(encodeKeysetCursor(keys));
|
|
return JSON.stringify(decoded) === JSON.stringify(keys);
|
|
}),
|
|
{ numRuns: 200 }
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page token round-trip
|
|
// ---------------------------------------------------------------------------
|
|
describe("property: page token encode/decode round-trip", () => {
|
|
const payloadArb: fc.Arbitrary<PageTokenPayload> = fc.record(
|
|
{
|
|
v: fc.oneof(fc.string({ minLength: 1 }), fc.integer()),
|
|
s: fc.option(fc.string({ minLength: 1 }), { nil: undefined }),
|
|
d: fc.option(fc.constantFrom("asc" as const, "desc" as const), {
|
|
nil: undefined,
|
|
}),
|
|
f: fc.option(fc.string({ minLength: 1 }), { nil: undefined }),
|
|
},
|
|
{ withDeletedKeys: false }
|
|
);
|
|
|
|
it("round-trips arbitrary page token payloads", { timeout: 10000 }, () => {
|
|
fc.assert(
|
|
fc.property(payloadArb, (payload) => {
|
|
const filtered: PageTokenPayload = { v: payload.v };
|
|
if (payload.s !== undefined) filtered.s = payload.s;
|
|
if (payload.d !== undefined) filtered.d = payload.d;
|
|
if (payload.f !== undefined) filtered.f = payload.f;
|
|
const decoded = decodePageToken(encodePageToken(filtered));
|
|
return JSON.stringify(decoded) === JSON.stringify(filtered);
|
|
}),
|
|
{ numRuns: 200 }
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// paginateArray: always returns ≤ limit items
|
|
// ---------------------------------------------------------------------------
|
|
describe("property: paginateArray respects limit", () => {
|
|
it("always returns ≤ limit items", { timeout: 10000 }, () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.array(fc.integer(), { minLength: 0, maxLength: 100 }),
|
|
fc.integer({ min: 1, max: 20 }),
|
|
fc.integer({ min: 1, max: 10 }),
|
|
(items, page, limit) => {
|
|
const res = paginateArray(items, page, limit);
|
|
return res.data.length <= limit;
|
|
}
|
|
),
|
|
{ numRuns: 300 }
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// offsetToPage / pageToOffset round-trip
|
|
// ---------------------------------------------------------------------------
|
|
describe("property: offsetToPage(pageToOffset(p, l), l) === p", () => {
|
|
it("round-trips for positive page and limit", { timeout: 10000 }, () => {
|
|
fc.assert(
|
|
fc.property(
|
|
fc.integer({ min: 1, max: 10000 }),
|
|
fc.integer({ min: 1, max: 1000 }),
|
|
(page, limit) => {
|
|
return (
|
|
offsetToPage(pageToOffset(page, limit), limit) === page
|
|
);
|
|
}
|
|
),
|
|
{ numRuns: 300 }
|
|
);
|
|
});
|
|
});
|