From ade18ff23ff366cdc69a27694d4fce7e1ce022d6 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Sun, 21 Jun 2026 11:10:06 +0900 Subject: [PATCH] test: add IssueStore Durable Object tests via runInDurableObject (test-leg slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of the CI test leg (#165): the IssueStore Durable Object (DO-embedded SQLite structured store), tested inside workerd with runInDurableObject. IssueStore self-initializes its schema in the constructor, so no migration step is needed. A minimal entry (src/test-do-entry.ts) re-exports only IssueStore so the pool can bind it as a SQLite DO without loading the full worker graph (mcp/oauth/pipeline + Vectorize/AI, which have no local emulation). The existing workers config gains the DO binding alongside the D1 one; both *.workers.test.ts files share the pool. Covers: - issue upsert/get round-trip (incl label/assignee array (de)serialization) - ON CONFLICT (repo, number) idempotent upsert (update, no duplicate) - listIssuesByRepo state filter / updated_at DESC ordering / limit - getWatermark/setWatermark round-trip + empty-etag -> undefined normalization IssueStore(DO 内蔵 SQLite の構造化ストア)を runInDurableObject で実機寄りに検証。 ポーリング watermark の往復も pin した。Vectorize/AI 依存面は引き続き #165 に残置。 Refs #165 --- src/store.workers.test.ts | 105 ++++++++++++++++++++++++++++++++++++++ src/test-do-entry.ts | 10 ++++ src/test-env.d.ts | 9 +++- vitest.workers.config.ts | 13 +++-- 4 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 src/store.workers.test.ts create mode 100644 src/test-do-entry.ts diff --git a/src/store.workers.test.ts b/src/store.workers.test.ts new file mode 100644 index 0000000..4fa2632 --- /dev/null +++ b/src/store.workers.test.ts @@ -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 & Pick, +): 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, + }); + }); + }); +}); diff --git a/src/test-do-entry.ts b/src/test-do-entry.ts new file mode 100644 index 0000000..0bda625 --- /dev/null +++ b/src/test-do-entry.ts @@ -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 { + return new Response("test-only entry"); + }, +}; diff --git a/src/test-env.d.ts b/src/test-env.d.ts index 3b4f40b..41552de 100644 --- a/src/test-env.d.ts +++ b/src/test-env.d.ts @@ -2,8 +2,8 @@ // scoped to just what the *.workers.test.ts files use. Declared by hand (instead of // `/// `) 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; @@ -14,6 +14,7 @@ declare module "cloudflare:test" { interface ProvidedEnv { DB_FTS: D1Database; TEST_MIGRATIONS: TestD1Migration[]; + ISSUE_STORE: DurableObjectNamespace; } export const env: ProvidedEnv; export function applyD1Migrations( @@ -21,4 +22,8 @@ declare module "cloudflare:test" { migrations: TestD1Migration[], migrationsTableName?: string, ): Promise; + export function runInDurableObject( + stub: DurableObjectStub, + callback: (instance: O, state: DurableObjectState) => R | Promise, + ): Promise; } diff --git a/vitest.workers.config.ts b/vitest.workers.config.ts index 8e24ec7..c7653b3 100644 --- a/vitest.workers.config.ts +++ b/vitest.workers.config.ts @@ -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 @@ -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 }, }, };