Public: anyone can read this; the crew can change it.
A test pins a repository's events page: hidden events never shift a page, and a mostly hidden outbox comes back short with a cursor that resumes #6719
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6699-a-test-pins-a-repository-s-events-page-h → main 1 commit fork
1 file
changed +118
−0
test/events-page.test.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** | |
| 2 | * A repository's events page. The audience filter runs before the page is | |
| 3 | * cut, so hidden events never shift a page; one request reads at most 1,000 | |
| 4 | * rows, so a mostly hidden outbox comes back short with a cursor that resumes. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { eventsLimit, eventsPage, validEventsCursor } from "../src/core/events_page.ts"; | |
| 13 | import type { Repo } from "../src/core/repos.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | ||
| 16 | describe("events limit and cursor", () => { | |
| 17 | test("limit defaults to 50 and accepts 1 to 200", () => { | |
| 18 | expect(eventsLimit(undefined)).toBe(50); | |
| 19 | expect(eventsLimit(null)).toBe(50); | |
| 20 | expect(eventsLimit("1")).toBe(1); | |
| 21 | expect(eventsLimit(200)).toBe(200); | |
| 22 | for (const bad of [0, 201, "1.5", "-3", "12345", "abc"]) { | |
| 23 | expect(eventsLimit(bad)).toBeUndefined(); | |
| 24 | } | |
| 25 | }); | |
| 26 | ||
| 27 | test("a cursor is 1 to 64 id characters", () => { | |
| 28 | expect(validEventsCursor("abc_DEF-123")).toBe(true); | |
| 29 | expect(validEventsCursor("a".repeat(64))).toBe(true); | |
| 30 | expect(validEventsCursor("")).toBe(false); | |
| 31 | expect(validEventsCursor("a".repeat(65))).toBe(false); | |
| 32 | expect(validEventsCursor("/")).toBe(false); | |
| 33 | }); | |
| 34 | }); | |
| 35 | ||
| 36 | describe("a repository's events page", () => { | |
| 37 | let dataDir: string; | |
| 38 | let app: App; | |
| 39 | let alice: User; | |
| 40 | let bob: User; | |
| 41 | let repo: Repo; | |
| 42 | ||
| 43 | const emit = (kind: string, n: number) => | |
| 44 | app.bus.append({ kind, repoId: repo.id, actorId: alice.id, payload: { n } }); | |
| 45 | ||
| 46 | const user = async (username: string, first = false) => { | |
| 47 | const made = await createUser(app.db, { | |
| 48 | origin: "seed", | |
| 49 | trusted: true, | |
| 50 | username, | |
| 51 | email: `${username}@forge.local`, | |
| 52 | password: "a-good-passphrase", | |
| 53 | adminIfFirst: first, | |
| 54 | }); | |
| 55 | if (!made.ok) throw new Error(made.error); | |
| 56 | return made.value; | |
| 57 | }; | |
| 58 | ||
| 59 | const fresh = async (name: string) => { | |
| 60 | const made = await createRepo(app, alice, { name, visibility: "public" }); | |
| 61 | if (!made.ok) throw new Error(String(made.error)); | |
| 62 | repo = made.value; | |
| 63 | }; | |
| 64 | ||
| 65 | beforeAll(async () => { | |
| 66 | dataDir = await mkdtemp(join(tmpdir(), "forge-events-page-")); | |
| 67 | app = createApp({ dataDir, port: 0 }); | |
| 68 | alice = await user("alice", true); | |
| 69 | bob = await user("bob"); | |
| 70 | }, 30_000); | |
| 71 | ||
| 72 | afterAll(async () => { | |
| 73 | app.close(); | |
| 74 | await rm(dataDir, { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | ||
| 77 | test("hidden events never shift a page, and paging lists each visible event once", async () => { | |
| 78 | await fresh("mixed"); | |
| 79 | const visible: number[] = []; | |
| 80 | for (let i = 0; i < 12; i++) { | |
| 81 | emit("push", i); | |
| 82 | visible.push(i); | |
| 83 | emit("webhook.created", i); | |
| 84 | emit("webhook.created", i + 100); | |
| 85 | } | |
| 86 | const seen: number[] = []; | |
| 87 | let cursor: string | undefined; | |
| 88 | const sizes: number[] = []; | |
| 89 | for (let guard = 0; guard < 10; guard++) { | |
| 90 | const page = eventsPage(app.db, repo.id, bob, 5, cursor); | |
| 91 | sizes.push(page.items.length); | |
| 92 | for (const e of page.items) { | |
| 93 | expect(e.kind).not.toBe("webhook.created"); | |
| 94 | if (e.kind === "push") seen.push((e.payload as { n: number }).n); | |
| 95 | } | |
| 96 | if (page.next_cursor === null) break; | |
| 97 | cursor = page.next_cursor; | |
| 98 | } | |
| 99 | expect(sizes.slice(0, 2)).toEqual([5, 5]); | |
| 100 | expect(seen).toEqual(visible.slice().reverse()); | |
| 101 | // The admin sees everything, hidden or not. | |
| 102 | expect(eventsPage(app.db, repo.id, alice, 200).items.length).toBeGreaterThan(36); | |
| 103 | }); | |
| 104 | ||
| 105 | test("1,200 hidden events in front of 1 visible: a short page, a cursor, then the event", async () => { | |
| 106 | await fresh("buried"); | |
| 107 | const first = emit("push", 0); | |
| 108 | for (let i = 0; i < 1200; i++) emit("webhook.created", i); | |
| 109 | const one = eventsPage(app.db, repo.id, bob, 10); | |
| 110 | expect(one.items.length).toBeLessThan(10); | |
| 111 | expect(one.items).toEqual([]); | |
| 112 | expect(one.next_cursor).not.toBeNull(); | |
| 113 | const two = eventsPage(app.db, repo.id, bob, 10, one.next_cursor!); | |
| 114 | expect(two.items.map((e) => e.id)).toContain(first.id); | |
| 115 | expect(two.items.every((e) => e.kind !== "webhook.created")).toBe(true); | |
| 116 | expect(two.next_cursor).toBeNull(); | |
| 117 | }); | |
| 118 | }); |
new above
offline