Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/store.workers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import { env, runInDurableObject } from "cloudflare:test";
import type { IssueStore } from "./store.js";
import type { IssueRecord } from "./types.js";

// Each test uses a uniquely-named DO instance for storage isolation. IssueStore
// self-initializes its schema in the constructor (idempotent CREATE TABLE IF NOT
// EXISTS), so no migration step is needed.
function instanceFor(name: string) {
return env.ISSUE_STORE.get(env.ISSUE_STORE.idFromName(name));
}

const issue = (
over: Partial<IssueRecord> & Pick<IssueRecord, "repo" | "number">,
): IssueRecord => ({
type: "issue",
state: "open",
title: "title",
labels: [],
milestone: "",
assignees: [],
bodyHash: "h",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
...over,
});

describe("IssueStore: issue CRUD", () => {
it("round-trips an issue record (including label / assignee arrays)", async () => {
await runInDurableObject(instanceFor("issue-roundtrip"), (s: IssueStore) => {
const rec = issue({
repo: "o/r",
number: 7,
type: "pull_request",
state: "open",
title: "Add retry",
labels: ["bug", "p1"],
milestone: "v1",
assignees: ["alice", "bob"],
bodyHash: "abc",
});
s.upsertIssue(rec);
expect(s.getIssue("o/r", 7)).toEqual(rec);
});
});

it("returns null for a missing issue", async () => {
await runInDurableObject(instanceFor("issue-missing"), (s: IssueStore) => {
expect(s.getIssue("o/r", 999)).toBeNull();
});
});

it("upserts idempotently on (repo, number): second write updates, not duplicates", async () => {
await runInDurableObject(instanceFor("issue-idem"), (s: IssueStore) => {
s.upsertIssue(issue({ repo: "o/r", number: 1, state: "open", title: "first" }));
s.upsertIssue(
issue({ repo: "o/r", number: 1, state: "closed", title: "second", updatedAt: "2026-02-01T00:00:00Z" }),
);
const got = s.getIssue("o/r", 1);
expect(got?.state).toBe("closed");
expect(got?.title).toBe("second");
expect(s.listIssuesByRepo("o/r")).toHaveLength(1);
});
});

it("lists by repo with a state filter, newest first, honoring limit", async () => {
await runInDurableObject(instanceFor("issue-list"), (s: IssueStore) => {
s.upsertIssue(issue({ repo: "o/r", number: 1, state: "open", updatedAt: "2026-01-01T00:00:00Z" }));
s.upsertIssue(issue({ repo: "o/r", number: 2, state: "closed", updatedAt: "2026-01-02T00:00:00Z" }));
s.upsertIssue(issue({ repo: "o/r", number: 3, state: "open", updatedAt: "2026-01-03T00:00:00Z" }));
s.upsertIssue(issue({ repo: "other/repo", number: 9, state: "open" }));

expect(s.listIssuesByRepo("o/r").map((i) => i.number)).toEqual([3, 2, 1]); // updated_at DESC
expect(s.listIssuesByRepo("o/r", { state: "open" }).map((i) => i.number)).toEqual([3, 1]);
expect(s.listIssuesByRepo("o/r", { limit: 1 }).map((i) => i.number)).toEqual([3]);
});
});
});

describe("IssueStore: poll watermark", () => {
it("returns null before any watermark is set", async () => {
await runInDurableObject(instanceFor("wm-null"), (s: IssueStore) => {
expect(s.getWatermark("o/r")).toBeNull();
});
});

it("round-trips a watermark and normalizes an empty etag to undefined", async () => {
await runInDurableObject(instanceFor("wm-roundtrip"), (s: IssueStore) => {
s.setWatermark("o/r", "2026-01-01T00:00:00Z", "etag-123");
expect(s.getWatermark("o/r")).toEqual({
repo: "o/r",
lastPolledAt: "2026-01-01T00:00:00Z",
etag: "etag-123",
});

// No etag -> stored as "" -> read back as undefined.
s.setWatermark("o/r", "2026-02-01T00:00:00Z");
expect(s.getWatermark("o/r")).toEqual({
repo: "o/r",
lastPolledAt: "2026-02-01T00:00:00Z",
etag: undefined,
});
});
});
});
10 changes: 10 additions & 0 deletions src/test-do-entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Minimal worker entry for the Workers-pool Durable Object tests. Re-exports just
// the IssueStore DO so the test pool can bind it without loading the full worker
// graph (mcp/oauth/pipeline + Vectorize/AI bindings that have no local emulation).
export { IssueStore } from "./store.js";

export default {
async fetch(): Promise<Response> {
return new Response("test-only entry");
},
};
9 changes: 7 additions & 2 deletions src/test-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
// scoped to just what the *.workers.test.ts files use. Declared by hand (instead of
// `/// <reference types="@cloudflare/vitest-pool-workers/types" />`) so the worker's
// `tsc --noEmit` does not pull a newer @cloudflare/workers-types whose stricter AI
// binding types conflict with existing code (src/rerank.ts). D1Database is global
// via the worker's @cloudflare/workers-types.
// binding types conflict with existing code (src/rerank.ts). D1Database /
// DurableObject* are global via the worker's @cloudflare/workers-types.

interface TestD1Migration {
name: string;
Expand All @@ -14,11 +14,16 @@ declare module "cloudflare:test" {
interface ProvidedEnv {
DB_FTS: D1Database;
TEST_MIGRATIONS: TestD1Migration[];
ISSUE_STORE: DurableObjectNamespace<import("./store.js").IssueStore>;
}
export const env: ProvidedEnv;
export function applyD1Migrations(
db: D1Database,
migrations: TestD1Migration[],
migrationsTableName?: string,
): Promise<void>;
export function runInDurableObject<O, R>(
stub: DurableObjectStub<O>,
callback: (instance: O, state: DurableObjectState) => R | Promise<R>,
): Promise<R>;
}
13 changes: 9 additions & 4 deletions vitest.workers.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import { defineConfig } from "vitest/config";
import { cloudflarePool, cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers";

// Workers-realistic pool: runs *.workers.test.ts inside workerd with a real local
// D1 (miniflare-backed SQLite). The D1 migrations under ./migrations are read at
// config time and handed to the test via the TEST_MIGRATIONS binding, then applied
// with applyD1Migrations(). Vectorize / Workers AI are intentionally NOT bound here
// — they have no local emulation, so this pool covers the D1 / FTS5 surface only.
// D1 (miniflare-backed SQLite) and the IssueStore Durable Object (SQLite-backed).
// The D1 migrations under ./migrations are read at config time and handed to the
// test via the TEST_MIGRATIONS binding, then applied with applyD1Migrations(). The
// DO is loaded from a minimal entry (src/test-do-entry.ts) that exports only
// IssueStore, so Vectorize / Workers AI (no local emulation) are never bound.
//
// vitest 4 wiring: cloudflareTest() is a Vite plugin (provides the `cloudflare:test`
// module + transforms); cloudflarePool() is the pool runner for `test.pool`.
export default defineConfig(async () => {
const migrations = await readD1Migrations("./migrations");
const workersOptions = {
main: "src/test-do-entry.ts",
singleWorker: true,
// One shared local D1 for the whole file, no per-test snapshotting. The
// isolatedStorage snapshot/restore and bulk DELETE both corrupt FTS5
Expand All @@ -22,6 +24,9 @@ export default defineConfig(async () => {
compatibilityDate: "2025-03-26",
compatibilityFlags: ["nodejs_compat"],
d1Databases: { DB_FTS: "test-fts" },
durableObjects: {
ISSUE_STORE: { className: "IssueStore", useSQLite: true },
},
bindings: { TEST_MIGRATIONS: migrations },
},
};
Expand Down
Loading