|
| 1 | +import { describe, expect } from "bun:test" |
| 2 | +import { Effect, Layer } from "effect" |
| 3 | +import { FetchHttpClient } from "effect/unstable/http" |
| 4 | +import { SettingsHook } from "@/hook/settings" |
| 5 | +import { SessionHooks } from "@/hook/session-hooks" |
| 6 | +import { EventV2Bridge } from "@/event-v2-bridge" |
| 7 | +import { Database } from "@opencode-ai/core/database/database" |
| 8 | +import { SessionID } from "@/session/schema" |
| 9 | +import { testEffect } from "../lib/effect" |
| 10 | + |
| 11 | +// httpHandler runtime contract against a real local server: configured |
| 12 | +// entry.headers MUST reach the wire (auth tokens were silently dropped before |
| 13 | +// this fix), and non-2xx responses MUST surface as the synthetic exitBlock so |
| 14 | +// the trigger aggregator reports a block. |
| 15 | + |
| 16 | +const testLayer = SettingsHook.layer.pipe( |
| 17 | + Layer.provide(EventV2Bridge.defaultLayer), |
| 18 | + Layer.provide(Database.defaultLayer), |
| 19 | + Layer.provideMerge(SessionHooks.defaultLayer), |
| 20 | + Layer.provideMerge(FetchHttpClient.layer), |
| 21 | +) |
| 22 | +const it = testEffect(testLayer) |
| 23 | + |
| 24 | +const withFetch = <A, E, R>( |
| 25 | + fetch: (req: Request) => Response | Promise<Response>, |
| 26 | + fn: (url: string) => Effect.Effect<A, E, R>, |
| 27 | +) => |
| 28 | + Effect.acquireUseRelease( |
| 29 | + Effect.sync(() => Bun.serve({ port: 0, fetch })), |
| 30 | + (server) => fn(server.url.toString()), |
| 31 | + (server) => Effect.sync(() => server.stop(true)), |
| 32 | + ) |
| 33 | + |
| 34 | +describe("SettingsHook http handler", () => { |
| 35 | + it.instance("applies configured entry.headers to the outbound POST", () => |
| 36 | + Effect.gen(function* () { |
| 37 | + const sessionHooks = yield* SessionHooks.Service |
| 38 | + const hook = yield* SettingsHook.Service |
| 39 | + const sessionID = SessionID.descending() |
| 40 | + let seen: Headers | undefined |
| 41 | + yield* withFetch( |
| 42 | + (req) => { |
| 43 | + seen = req.headers |
| 44 | + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }) |
| 45 | + }, |
| 46 | + (url) => |
| 47 | + Effect.gen(function* () { |
| 48 | + yield* sessionHooks.add(sessionID, { |
| 49 | + event: "UserPromptSubmit", |
| 50 | + hooks: [ |
| 51 | + { |
| 52 | + type: "http", |
| 53 | + url, |
| 54 | + headers: { authorization: "Bearer hook-secret", "x-hook-test": "present" }, |
| 55 | + }, |
| 56 | + ], |
| 57 | + }) |
| 58 | + const r = yield* hook.trigger( |
| 59 | + { event: "UserPromptSubmit", prompt: "hi" }, |
| 60 | + { sessionID, transcriptPath: "" }, |
| 61 | + ) |
| 62 | + expect(r.blocked).toBeUndefined() |
| 63 | + expect(seen).toBeDefined() |
| 64 | + expect(seen?.get("authorization")).toBe("Bearer hook-secret") |
| 65 | + expect(seen?.get("x-hook-test")).toBe("present") |
| 66 | + }), |
| 67 | + ) |
| 68 | + }), |
| 69 | + ) |
| 70 | + |
| 71 | + it.instance("non-2xx response surfaces as exitBlock", () => |
| 72 | + Effect.gen(function* () { |
| 73 | + const sessionHooks = yield* SessionHooks.Service |
| 74 | + const hook = yield* SettingsHook.Service |
| 75 | + const sessionID = SessionID.descending() |
| 76 | + yield* withFetch( |
| 77 | + () => new Response("nope", { status: 500 }), |
| 78 | + (url) => |
| 79 | + Effect.gen(function* () { |
| 80 | + yield* sessionHooks.add(sessionID, { |
| 81 | + event: "UserPromptSubmit", |
| 82 | + hooks: [{ type: "http", url }], |
| 83 | + }) |
| 84 | + const r = yield* hook.trigger( |
| 85 | + { event: "UserPromptSubmit", prompt: "hi" }, |
| 86 | + { sessionID, transcriptPath: "" }, |
| 87 | + ) |
| 88 | + expect(r.blocked).toBeDefined() |
| 89 | + expect(r.blocked?.reason).toContain("500") |
| 90 | + }), |
| 91 | + ) |
| 92 | + }), |
| 93 | + ) |
| 94 | +}) |
0 commit comments