b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
118 lines
4.0 KiB
TypeScript
118 lines
4.0 KiB
TypeScript
import type { Kysely } from "kysely";
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
|
import { ContentRepository } from "../../../src/database/repositories/content.js";
|
|
import { encodeCursor } from "../../../src/database/repositories/types.js";
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import { SchemaRegistry } from "../../../src/schema/registry.js";
|
|
import { FTSManager } from "../../../src/search/fts-manager.js";
|
|
import { searchCollection, searchWithDb } from "../../../src/search/query.js";
|
|
import { createPostFixture } from "../../utils/fixtures.js";
|
|
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js";
|
|
|
|
/**
|
|
* `search()` / `searchCollection()` advertise keyset pagination via
|
|
* `options.cursor` and `SearchResponse.nextCursor`. These tests pin that the
|
|
* advertised contract actually works: a cursor walks disjoint pages that cover
|
|
* every match exactly once, and the cursor stops at the end.
|
|
*/
|
|
describe("search pagination", () => {
|
|
let db: Kysely<Database>;
|
|
let repo: ContentRepository;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabaseWithCollections();
|
|
repo = new ContentRepository(db);
|
|
|
|
const registry = new SchemaRegistry(db);
|
|
const ftsManager = new FTSManager(db);
|
|
await registry.updateField("post", "title", { searchable: true });
|
|
await ftsManager.enableSearch("post");
|
|
|
|
// Five published posts that all match the query "report".
|
|
for (let i = 1; i <= 5; i++) {
|
|
await repo.create(
|
|
createPostFixture({
|
|
slug: `report-${i}`,
|
|
status: "published",
|
|
data: { title: `Quarterly report number ${i}` },
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
it("returns a nextCursor when more results exist beyond the limit", async () => {
|
|
const page = await searchWithDb(db, "report", { collections: ["post"], limit: 2 });
|
|
|
|
expect(page.items).toHaveLength(2);
|
|
expect(page.nextCursor).toBeTruthy();
|
|
});
|
|
|
|
it("omits nextCursor on the final page", async () => {
|
|
const page = await searchWithDb(db, "report", { collections: ["post"], limit: 10 });
|
|
|
|
expect(page.items).toHaveLength(5);
|
|
expect(page.nextCursor).toBeUndefined();
|
|
});
|
|
|
|
it("walks disjoint pages covering every match exactly once", async () => {
|
|
const seen: string[] = [];
|
|
let cursor: string | undefined;
|
|
let guard = 0;
|
|
|
|
do {
|
|
const page: { items: { id: string }[]; nextCursor?: string } = await searchWithDb(
|
|
db,
|
|
"report",
|
|
{ collections: ["post"], limit: 2, cursor },
|
|
);
|
|
expect(page.items.length).toBeLessThanOrEqual(2);
|
|
seen.push(...page.items.map((r) => r.id));
|
|
cursor = page.nextCursor;
|
|
} while (cursor && ++guard < 10);
|
|
|
|
expect(seen).toHaveLength(5);
|
|
expect(new Set(seen).size).toBe(5);
|
|
});
|
|
|
|
it("rejects a malformed cursor instead of silently restarting", async () => {
|
|
await expect(
|
|
searchWithDb(db, "report", { collections: ["post"], limit: 2, cursor: "not-a-cursor" }),
|
|
).rejects.toThrow(/Invalid pagination cursor/);
|
|
});
|
|
|
|
it("rejects a cursor from another endpoint even if its orderValue is numeric", async () => {
|
|
const foreignCursor = encodeCursor("1", "content-list");
|
|
|
|
await expect(
|
|
searchWithDb(db, "report", { collections: ["post"], limit: 2, cursor: foreignCursor }),
|
|
).rejects.toThrow(/Invalid pagination cursor/);
|
|
});
|
|
|
|
it("rejects a cursor whose offset exceeds the max search offset", async () => {
|
|
const hugeCursor = encodeCursor("999999999", "search");
|
|
|
|
await expect(
|
|
searchWithDb(db, "report", { collections: ["post"], limit: 2, cursor: hugeCursor }),
|
|
).rejects.toThrow(/Invalid pagination cursor/);
|
|
});
|
|
|
|
it("paginates a single-collection search the same way", async () => {
|
|
const seen: string[] = [];
|
|
let cursor: string | undefined;
|
|
let guard = 0;
|
|
|
|
do {
|
|
const page = await searchCollection(db, "post", "report", { limit: 2, cursor });
|
|
seen.push(...page.items.map((r) => r.id));
|
|
cursor = page.nextCursor;
|
|
} while (cursor && ++guard < 10);
|
|
|
|
expect(new Set(seen).size).toBe(5);
|
|
});
|
|
});
|