Migrate schema error body tests to Effect runner (#27172)

This commit is contained in:
Kit Langton
2026-05-12 23:46:19 +00:00
committed by GitHub
parent 81dd46abec
commit 2c334d9242
@@ -3,7 +3,6 @@ import { Effect } from "effect"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import * as Database from "@/storage/db" import * as Database from "@/storage/db"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/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 { MessageID, PartID } from "../../src/session/schema"
import { PartTable } from "@/session/session.sql" import { PartTable } from "@/session/session.sql"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { it } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(Session.defaultLayer)
afterEach(async () => { afterEach(async () => {
await disposeAllInstances() await disposeAllInstances()
await resetDatabase() await resetDatabase()
}) })
const withTmp = <A, E, R>( const seedCorruptStepFinishPart = Effect.gen(function* () {
options: Parameters<typeof tmpdir>[0], const session = yield* Session.Service
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>, const info = yield* session.create({})
) => const message = yield* session.updateMessage({
Effect.acquireRelease( id: MessageID.ascending(),
Effect.promise(() => tmpdir(options)), role: "user",
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), sessionID: info.id,
).pipe(Effect.flatMap(fn)) agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
async function seedCorruptStepFinishPart(directory: string) { time: { created: Date.now() },
return WithInstance.provide({ })
directory, const partID = PartID.ascending()
fn: () => yield* session.updatePart({
Effect.runPromise( id: partID,
Effect.gen(function* () { sessionID: info.id,
const session = yield* Session.Service messageID: message.id,
const info = yield* session.create({}) type: "step-finish",
const message = yield* session.updateMessage({ reason: "stop",
id: MessageID.ascending(), cost: 0,
role: "user", tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
sessionID: info.id, })
agent: "build", // Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, // that broke the user's session in the OMO/Windows bug.
time: { created: Date.now() }, yield* Effect.sync(() =>
}) Database.use((db) =>
const partID = PartID.ascending() db
yield* session.updatePart({ .update(PartTable)
id: partID, .set({
sessionID: info.id, data: {
messageID: message.id,
type: "step-finish", type: "step-finish",
reason: "stop", reason: "stop",
cost: 0, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
}) } as never, // drizzle's .set() can't narrow the discriminated union
// Schema.Finite still rejects NaN at encode — exact mirror of the })
// corrupt row that broke the user's session in the OMO/Windows bug. .where(eq(PartTable.id, partID))
Database.use((db) => .run(),
db ),
.update(PartTable) )
.set({ return info.id
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)),
),
})
}
describe("schema-rejection wire shape", () => { describe("schema-rejection wire shape", () => {
it.live( it.instance(
"Payload schema rejection returns NamedError-shaped JSON, not empty", "Payload schema rejection returns NamedError-shaped JSON, not empty",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance
const res = yield* Effect.promise(async () => const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, { Server.Default().app.request(SyncPaths.history, {
method: "POST", 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 }), 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).toEqual(expect.any(String))
expect(parsed.data.message.length).toBeGreaterThan(0) 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", "Query schema rejection returns NamedError-shaped JSON",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance
// /find/file?limit=999999 violates the limit constraint check. // /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 res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text()) const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400) expect(res.status).toBe(400)
const parsed = JSON.parse(body) const parsed = JSON.parse(body)
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) 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", "rejected request body never echoes back unbounded — message is capped",
// Defense against DoS-amplification + secret-echo: Effect's Issue formatter // Defense against DoS-amplification + secret-echo: Effect's Issue formatter
// dumps the rejected `actual` verbatim. A multi-MB invalid array would // 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. // 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* () { Effect.gen(function* () {
const test = yield* TestInstance
const huge = "X".repeat(50_000) const huge = "X".repeat(50_000)
const res = yield* Effect.promise(async () => const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, { Server.Default().app.request(SyncPaths.history, {
method: "POST", 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 }), body: JSON.stringify({ aggregate: huge }),
}), }),
) )
@@ -139,15 +128,16 @@ describe("schema-rejection wire shape", () => {
const parsed = JSON.parse(body) const parsed = JSON.parse(body)
expect(parsed.data.message).not.toContain(huge) 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", "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path)) const test = yield* TestInstance
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` 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 res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text()) const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400) 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. // Field path in data.message — what made this PR worth shipping.
expect(parsed.data.message).toMatch(/output/) expect(parsed.data.message).toMatch(/output/)
}), }),
), { config: { formatter: false, lsp: false } },
) )
}) })