From 2c334d92420da3e7c5760214edd5aa8299e57022 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 19:46:19 -0400 Subject: [PATCH] Migrate schema error body tests to Effect runner (#27172) --- .../server/httpapi-schema-error-body.test.ts | 136 ++++++++---------- 1 file changed, 63 insertions(+), 73 deletions(-) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index fe6a1caad0..48ed7b6bfb 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -3,7 +3,6 @@ import { Effect } from "effect" import { eq } from "drizzle-orm" import * as Database from "@/storage/db" import { ModelID, ProviderID } from "../../src/provider/schema" -import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session } from "@/session/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" @@ -11,80 +10,68 @@ import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { MessageID, PartID } from "../../src/session/schema" import { PartTable } from "@/session/session.sql" import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { it } from "../lib/effect" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Session.defaultLayer) afterEach(async () => { await disposeAllInstances() await resetDatabase() }) -const withTmp = ( - options: Parameters[0], - fn: (tmp: Awaited>) => Effect.Effect, -) => - Effect.acquireRelease( - Effect.promise(() => tmpdir(options)), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe(Effect.flatMap(fn)) - -async function seedCorruptStepFinishPart(directory: string) { - return WithInstance.provide({ - directory, - fn: () => - Effect.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const info = yield* session.create({}) - const message = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: info.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - const partID = PartID.ascending() - yield* session.updatePart({ - id: partID, - sessionID: info.id, - messageID: message.id, +const seedCorruptStepFinishPart = Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + // Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row + // that broke the user's session in the OMO/Windows bug. + yield* Effect.sync(() => + Database.use((db) => + db + .update(PartTable) + .set({ + data: { type: "step-finish", reason: "stop", cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - }) - // Schema.Finite still rejects NaN at encode — exact mirror of the - // corrupt row that broke the user's session in the OMO/Windows bug. - Database.use((db) => - db - .update(PartTable) - .set({ - data: { - type: "step-finish", - reason: "stop", - cost: 0, - tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, - } as never, // drizzle's .set() can't narrow the discriminated union - }) - .where(eq(PartTable.id, partID)) - .run(), - ) - return info.id - }).pipe(Effect.provide(Session.defaultLayer)), - ), - }) -} + tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, // drizzle's .set() can't narrow the discriminated union + }) + .where(eq(PartTable.id, partID)) + .run(), + ), + ) + return info.id +}) describe("schema-rejection wire shape", () => { - it.live( + it.instance( "Payload schema rejection returns NamedError-shaped JSON, not empty", - withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + () => Effect.gen(function* () { + const test = yield* TestInstance const res = yield* Effect.promise(async () => Server.Default().app.request(SyncPaths.history, { method: "POST", - headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + headers: { "x-opencode-directory": test.directory, "content-type": "application/json" }, body: JSON.stringify({ aggregate: -1 }), }), ) @@ -99,36 +86,38 @@ describe("schema-rejection wire shape", () => { expect(parsed.data.message).toEqual(expect.any(String)) expect(parsed.data.message.length).toBeGreaterThan(0) }), - ), + { git: true, config: { formatter: false, lsp: false } }, ) - it.live( + it.instance( "Query schema rejection returns NamedError-shaped JSON", - withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + () => Effect.gen(function* () { + const test = yield* TestInstance // /find/file?limit=999999 violates the limit constraint check. - const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}` + const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}` const res = yield* Effect.promise(async () => Server.Default().app.request(url)) const body = yield* Effect.promise(async () => res.text()) expect(res.status).toBe(400) const parsed = JSON.parse(body) expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) }), - ), + { git: true, config: { formatter: false, lsp: false } }, ) - it.live( + it.instance( "rejected request body never echoes back unbounded — message is capped", // Defense against DoS-amplification + secret-echo: Effect's Issue formatter // dumps the rejected `actual` verbatim. A multi-MB invalid array would // become a multi-MB 400 response and log line. Cap kicks in around 1KB. - withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + () => Effect.gen(function* () { + const test = yield* TestInstance const huge = "X".repeat(50_000) const res = yield* Effect.promise(async () => Server.Default().app.request(SyncPaths.history, { method: "POST", - headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + headers: { "x-opencode-directory": test.directory, "content-type": "application/json" }, body: JSON.stringify({ aggregate: huge }), }), ) @@ -139,15 +128,16 @@ describe("schema-rejection wire shape", () => { const parsed = JSON.parse(body) expect(parsed.data.message).not.toContain(huge) }), - ), + { git: true, config: { formatter: false, lsp: false } }, ) - it.live( + it.instance( "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path", - withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + () => Effect.gen(function* () { - const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path)) - const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const test = yield* TestInstance + const sessionID = yield* seedCorruptStepFinishPart + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}` const res = yield* Effect.promise(async () => Server.Default().app.request(url)) const body = yield* Effect.promise(async () => res.text()) expect(res.status).toBe(400) @@ -157,6 +147,6 @@ describe("schema-rejection wire shape", () => { // Field path in data.message — what made this PR worth shipping. expect(parsed.data.message).toMatch(/output/) }), - ), + { config: { formatter: false, lsp: false } }, ) })