Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions packages/testing/stub-http-client.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }) => 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)
}),
))
176 changes: 176 additions & 0 deletions packages/testing/stub-http-client.ts
Original file line number Diff line number Diff line change
@@ -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<response>)`, 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<Record<string, string>>
}

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<void>
/**
* 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<StubResponse>,
) => Effect.Effect<void>
/** Every request the stub has answered, in order. */
readonly captured: Effect.Effect<ReadonlyArray<CapturedHttpRequest>>
}

const routeKey = (method: string, path: string) => Data.struct({ method, path })

type RouteKey = ReturnType<typeof routeKey>

type RouteState = {
readonly sticky: Option.Option<StubResponse>
readonly queue: ReadonlyArray<StubResponse>
}

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<string, string> => {
const base: Record<string, string> =
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<StubHttpClient> = Effect.gen(function* () {
const routes = yield* Ref.make(HashMap.empty<RouteKey, RouteState>())
const captured = yield* Ref.make<ReadonlyArray<CapturedHttpRequest>>([])

const updateRoute = (
method: string,
path: string,
f: (state: RouteState) => RouteState,
): Effect.Effect<void> =>
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<StubResponse> =>
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<void> =>
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),
}
})