From efc0fa8ad55a022064d90a199b449bb92e2c642d Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Sat, 13 Jun 2026 10:09:16 +0100 Subject: [PATCH] testing: owned-fake stub HttpClient seam (comms-e5vm.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build a reusable test HttpClient on @effect/platform HttpClient.make(req => Effect) that fakes the HttpClient PORT the zulip adapter declares in R — no socket, no Bun.serve, no real FetchHttpClient. Responses are keyed by method+path with sticky defaults and one-per- request sequence queues (the event-pump GET /events chain), built as web Responses wrapped via HttpClientResponse.fromWeb so they round-trip .status/.text/.arrayBuffer/.headers. Requests are serialized to a web Request the same way FetchHttpClient serializes them, then captured for url/method/headers/body assertions. Drop-in for Effect.provideService(HttpClient.HttpClient, stub.client). --- packages/testing/stub-http-client.test.ts | 219 ++++++++++++++++++++++ packages/testing/stub-http-client.ts | 176 +++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 packages/testing/stub-http-client.test.ts create mode 100644 packages/testing/stub-http-client.ts diff --git a/packages/testing/stub-http-client.test.ts b/packages/testing/stub-http-client.test.ts new file mode 100644 index 0000000..75935a5 --- /dev/null +++ b/packages/testing/stub-http-client.test.ts @@ -0,0 +1,219 @@ +import { expect, test } from 'bun:test' +import { HttpClient, HttpClientRequest } from '@effect/platform' +import { Effect } from 'effect' +import { makeStubHttpClient } from './stub-http-client.ts' + +const REALM = 'https://zulip.example.com/api/v1' + +const textOf = (response: { readonly text: Effect.Effect }) => response.text + +test('respond maps a GET by method+path to a canned JSON body', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { + body: { result: 'success', user_id: 7, full_name: 'bot' }, + }) + const response = yield* stub.client.execute(HttpClientRequest.get(`${REALM}/users/me`)) + expect(response.status).toBe(200) + const body = yield* textOf(response) + expect(JSON.parse(body)).toEqual({ result: 'success', user_id: 7, full_name: 'bot' }) + }), + )) + +test('a canned response defaults to content-type application/json', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { body: { result: 'success' } }) + const response = yield* stub.client.execute(HttpClientRequest.get(`${REALM}/users/me`)) + expect(response.headers['content-type']).toMatch(/application\/json/) + }), + )) + +test('keys responses by method AND path — GET and POST to one path differ', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/messages', { body: { result: 'success', messages: [] } }) + yield* stub.respond('POST', '/api/v1/messages', { body: { result: 'success', id: 99 } }) + const got = yield* stub.client.execute(HttpClientRequest.get(`${REALM}/messages`)) + const posted = yield* stub.client.execute( + HttpClientRequest.post(`${REALM}/messages`).pipe( + HttpClientRequest.bodyUrlParams({ content: 'hi' }), + ), + ) + expect(JSON.parse(yield* textOf(got))).toEqual({ result: 'success', messages: [] }) + expect(JSON.parse(yield* textOf(posted))).toEqual({ result: 'success', id: 99 }) + }), + )) + +test('respondSequence returns queued responses one-per-request in order', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + // Event-pump shape: register, then chained /events polls that advance + // last_event_id, each poll consuming the next queued batch. + yield* stub.respond('POST', '/api/v1/register', { + body: { result: 'success', queue_id: 'q-1', last_event_id: 0 }, + }) + yield* stub.respondSequence('GET', '/api/v1/events', [ + { body: { result: 'success', events: [{ id: 1, type: 'message' }] } }, + { body: { result: 'success', events: [{ id: 2, type: 'message' }] } }, + ]) + const register = yield* stub.client.execute(HttpClientRequest.post(`${REALM}/register`)) + expect(JSON.parse(yield* textOf(register)).queue_id).toBe('q-1') + const poll1 = yield* stub.client.execute(HttpClientRequest.get(`${REALM}/events`)) + const poll2 = yield* stub.client.execute(HttpClientRequest.get(`${REALM}/events`)) + expect(JSON.parse(yield* textOf(poll1)).events[0].id).toBe(1) + expect(JSON.parse(yield* textOf(poll2)).events[0].id).toBe(2) + }), + )) + +test('a drained sequence falls back to the registered default for that key', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/events', { + body: { result: 'success', events: [] }, + }) + yield* stub.respondSequence('GET', '/api/v1/events', [ + { body: { result: 'success', events: [{ id: 1, type: 'message' }] } }, + ]) + const first = yield* stub.client.execute(HttpClientRequest.get(`${REALM}/events`)) + const second = yield* stub.client.execute(HttpClientRequest.get(`${REALM}/events`)) + expect(JSON.parse(yield* textOf(first)).events).toHaveLength(1) + expect(JSON.parse(yield* textOf(second)).events).toHaveLength(0) + }), + )) + +test('captures request method, url, and auth headers', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { body: { result: 'success' } }) + yield* stub.client.execute( + HttpClientRequest.get(`${REALM}/users/me`).pipe( + HttpClientRequest.setHeader('authorization', 'Basic Ym90OnNla3JldA=='), + ), + ) + const captured = yield* stub.captured + expect(captured).toHaveLength(1) + const req = captured[0] + if (req === undefined) throw new Error('expected a captured request') + expect(req.method).toBe('GET') + expect(req.url.pathname).toBe('/api/v1/users/me') + expect(req.headers.get('authorization')).toBe('Basic Ym90OnNla3JldA==') + }), + )) + +test('captures the form-encoded body and content-type of a POST', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('POST', '/api/v1/messages', { body: { result: 'success', id: 1 } }) + yield* stub.client.execute( + HttpClientRequest.post(`${REALM}/messages`).pipe( + HttpClientRequest.bodyUrlParams({ type: 'stream', content: 'hey there & friends' }), + ), + ) + const req = (yield* stub.captured)[0] + if (req === undefined) throw new Error('expected a captured request') + expect(req.method).toBe('POST') + expect(req.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + const params = new URLSearchParams(req.body) + expect(params.get('type')).toBe('stream') + expect(params.get('content')).toBe('hey there & friends') + }), + )) + +test('captures query-string params on the request url', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/messages', { body: { result: 'success' } }) + yield* stub.client.execute( + HttpClientRequest.get(`${REALM}/messages`).pipe( + HttpClientRequest.setUrlParams({ anchor: 'newest', num_before: 50 }), + ), + ) + const req = (yield* stub.captured)[0] + if (req === undefined) throw new Error('expected a captured request') + expect(req.url.searchParams.get('anchor')).toBe('newest') + expect(req.url.searchParams.get('num_before')).toBe('50') + }), + )) + +test('passes the response status through verbatim (e.g. a 429 error envelope)', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { + body: { result: 'error', code: 'RATE_LIMIT_HIT', msg: 'API rate limit exceeded' }, + status: 429, + }) + const response = yield* stub.client.execute(HttpClientRequest.get(`${REALM}/users/me`)) + expect(response.status).toBe(429) + expect(JSON.parse(yield* textOf(response)).code).toBe('RATE_LIMIT_HIT') + }), + )) + +test('an unregistered route answers 404 with an error envelope, not a crash', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + const response = yield* stub.client.execute(HttpClientRequest.get(`${REALM}/nope`)) + expect(response.status).toBe(404) + const body = JSON.parse(yield* textOf(response)) + expect(body.result).toBe('error') + expect(body.code).toBe('NO_STUB_HANDLER') + }), + )) + +test('serves a Uint8Array body verbatim (download path)', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) + yield* stub.respond('GET', '/user_uploads/1/a/p.png', { + body: bytes, + headers: { 'content-type': 'image/png' }, + }) + const response = yield* stub.client.execute( + HttpClientRequest.get('https://zulip.example.com/user_uploads/1/a/p.png'), + ) + expect(response.headers['content-type']).toBe('image/png') + const buf = yield* response.arrayBuffer + expect(new Uint8Array(buf)).toEqual(bytes) + }), + )) + +test('opens no socket — a request to an unroutable host still resolves', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { body: { result: 'success' } }) + // Were the stub dialling a socket, this host would fail DNS. It resolves + // because the stub answers from its registry without touching the wire. + const response = yield* stub.client.execute( + HttpClientRequest.get('https://this-host-does-not-resolve.invalid/api/v1/users/me'), + ) + expect(response.status).toBe(200) + }), + )) + +test('is a drop-in for the HttpClient.HttpClient service', () => + Effect.runPromise( + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { body: { result: 'success', user_id: 1 } }) + // Exactly how the zulip adapter reaches the port: read HttpClient from + // context and execute. Provided as the service, no FetchHttpClient.layer. + const body = yield* Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + const response = yield* client.execute(HttpClientRequest.get(`${REALM}/users/me`)) + return yield* response.text + }).pipe(Effect.provideService(HttpClient.HttpClient, stub.client)) + expect(JSON.parse(body).user_id).toBe(1) + }), + )) diff --git a/packages/testing/stub-http-client.ts b/packages/testing/stub-http-client.ts new file mode 100644 index 0000000..7a37f20 --- /dev/null +++ b/packages/testing/stub-http-client.ts @@ -0,0 +1,176 @@ +/** + * Owned-fake `HttpClient` for the port the adapters declare in `R` + * (`HttpClient.HttpClient`). Built on `@effect/platform`'s + * `HttpClient.make(req => Effect)`, it answers requests from an + * in-memory registry keyed by method + path — no socket, no `Bun.serve`, + * no real `FetchHttpClient`. This fakes the HTTP PORT we own, not the + * remote Zulip wire a real-socket fixture stands in for. + * + * Responses are constructed as web `Response`s and wrapped with + * `HttpClientResponse.fromWeb`, so they round-trip through `.status`, + * `.text`, `.arrayBuffer`, and `.headers` exactly as a fetched response + * would. Requests are serialized to a web `Request` the same way + * `FetchHttpClient` serializes them, then captured for assertion — so a + * form-urlencoded or multipart body is recorded byte-for-byte as the wire + * would have seen it. + * + * Test-only — never imported by production code. + */ + +import { + type HttpBody, + HttpClient, + type HttpClientRequest, + HttpClientResponse, +} from '@effect/platform' +import { Data, Effect, HashMap, Option, Ref } from 'effect' + +export type StubResponse = { + /** Object → JSON-encoded; string → verbatim; `Uint8Array` → raw bytes. */ + readonly body: unknown + /** HTTP status; defaults to 200. */ + readonly status?: number + /** Extra response headers, merged over the default `content-type`. */ + readonly headers?: Readonly> +} + +export type CapturedHttpRequest = { + readonly method: string + readonly url: URL + readonly headers: Headers + readonly body: string +} + +export type StubHttpClient = { + /** Drop-in for the `HttpClient.HttpClient` service. */ + readonly client: HttpClient.HttpClient + /** Register the sticky response for a (method, path) — used until overridden. */ + readonly respond: (method: string, path: string, response: StubResponse) => Effect.Effect + /** + * Queue responses for a (method, path), consumed one per request in order. + * Once drained, requests fall back to the sticky `respond` default for that + * key (a 404 error envelope if none was registered). This is the seam the + * event-pump needs: a `GET /events` chain where each poll returns the next + * batch. + */ + readonly respondSequence: ( + method: string, + path: string, + responses: ReadonlyArray, + ) => Effect.Effect + /** Every request the stub has answered, in order. */ + readonly captured: Effect.Effect> +} + +const routeKey = (method: string, path: string) => Data.struct({ method, path }) + +type RouteKey = ReturnType + +type RouteState = { + readonly sticky: Option.Option + readonly queue: ReadonlyArray +} + +const emptyRoute: RouteState = { sticky: Option.none(), queue: [] } + +const requestBodyInit = (body: HttpBody.HttpBody): string | Uint8Array | FormData | undefined => { + switch (body._tag) { + case 'Empty': + return undefined + case 'Raw': + case 'Uint8Array': + return body.body as string | Uint8Array + case 'FormData': + return body.formData + case 'Stream': + throw new Error('stub HttpClient does not support streaming request bodies') + } +} + +const responseBodyInit = (response: StubResponse): string | Uint8Array => { + if (response.body instanceof Uint8Array) return response.body + if (typeof response.body === 'string') return response.body + return JSON.stringify(response.body) +} + +const responseHeaders = (response: StubResponse): Record => { + const base: Record = + response.body instanceof Uint8Array ? {} : { 'content-type': 'application/json' } + return { ...base, ...response.headers } +} + +const notFound = (method: string, path: string): StubResponse => ({ + body: { result: 'error', code: 'NO_STUB_HANDLER', msg: `no stub handler for ${method} ${path}` }, + status: 404, +}) + +export const makeStubHttpClient: Effect.Effect = Effect.gen(function* () { + const routes = yield* Ref.make(HashMap.empty()) + const captured = yield* Ref.make>([]) + + const updateRoute = ( + method: string, + path: string, + f: (state: RouteState) => RouteState, + ): Effect.Effect => + Ref.update(routes, (map) => { + const key = routeKey(method, path) + const current = HashMap.get(map, key).pipe(Option.getOrElse(() => emptyRoute)) + return HashMap.set(map, key, f(current)) + }) + + const respond: StubHttpClient['respond'] = (method, path, response) => + updateRoute(method, path, (state) => ({ ...state, sticky: Option.some(response) })) + + const respondSequence: StubHttpClient['respondSequence'] = (method, path, responses) => + updateRoute(method, path, (state) => ({ ...state, queue: [...responses] })) + + const nextResponse = (method: string, path: string): Effect.Effect => + Ref.modify(routes, (map) => { + const key = routeKey(method, path) + const state = HashMap.get(map, key).pipe(Option.getOrElse(() => emptyRoute)) + const [head, ...tail] = state.queue + if (head !== undefined) { + return [head, HashMap.set(map, key, { ...state, queue: tail })] + } + return [Option.getOrElse(state.sticky, () => notFound(method, path)), map] + }) + + const capture = (request: HttpClientRequest.HttpClientRequest, url: URL): Effect.Effect => + Effect.promise(async () => { + const webRequest = new Request(url.href, { + method: request.method, + headers: { ...request.headers }, + body: requestBodyInit(request.body), + }) + const body = await webRequest.text() + return { + method: request.method, + url, + headers: webRequest.headers, + body, + } satisfies CapturedHttpRequest + }).pipe(Effect.flatMap((entry) => Ref.update(captured, (all) => [...all, entry]))) + + const client = HttpClient.make((request, url) => + capture(request, url).pipe( + Effect.zipRight(nextResponse(request.method, url.pathname)), + Effect.map((response) => + HttpClientResponse.fromWeb( + request, + new Response(responseBodyInit(response), { + status: response.status ?? 200, + headers: responseHeaders(response), + }), + ), + ), + ), + ) + + return { + client, + respond, + respondSequence, + captured: Ref.get(captured), + } +})