Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

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