From 42b132e43b6671075dccf1cd59f903a47daa0fbc Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:40:23 +0100 Subject: [PATCH] =?UTF-8?q?testing:=20migrate=20http.test.ts=20response-ha?= =?UTF-8?q?ndling=20=E2=86=92=20stub=20HttpClient=20(comms-e5vm.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move ZulipHttp request-shape and response-handling off the shared Bun.serve fixture onto the owned-fake stub HttpClient + effectTest (the comms-e5vm.2 pattern), so status/error/parsing/response-shape and the 429 retry policy run deterministically with no socket. - request-shape (URL build, Basic auth, query string, form-encode, host override, trailing-slash, bare DELETE) reads the stub's captured request - response-handling (error envelopes, non-JSON non-2xx, schema-mismatch ParseError, download/upload shape+errors) drives canned responses - 429-retry end-to-end runs on stub + TestClock via a deterministic settle loop (no real socket off the test clock, unlike the old runUnderTestClock) - delete two happy-path cases that merely duplicated the live contract (GET-parses-success-envelope, POST-returns-success-body) Two tests deliberately stay off the stub: the pure-unit cases (rate-limit policy replay, brand validators, decodeUserUploadPath, the ZulipApiError tag) that never touch HTTP, and the one irreducible real-socket case — a transport failure from a refused connection producing a genuine platform RequestError, which the in-memory stub cannot fabricate without lowering fidelity. The brief's reference to an AbortSignal/long-poll teardown test in this file was a conflation: that residue is the event-pump's (comms-4lz5), not here. --- packages/zulip/http.test.ts | 1249 +++++++++++++++++------------------ 1 file changed, 616 insertions(+), 633 deletions(-) diff --git a/packages/zulip/http.test.ts b/packages/zulip/http.test.ts index 7a0d80a..d27e0a4 100644 --- a/packages/zulip/http.test.ts +++ b/packages/zulip/http.test.ts @@ -1,5 +1,41 @@ +/** + * `ZulipHttp` request-shape and response-handling, exercised on the **owned-fake + * stub HttpClient** — no `Bun.serve`, no real socket (the Tier-2 migration, + * comms-e5vm.8; follows the event-pump proof comms-e5vm.2). + * + * The stub answers each request from a canned `(method, path)` registry and + * captures the outgoing `HttpClientRequest` (serialized exactly as the wire + * would see it), so request-shape assertions read the captured request and + * response-handling assertions drive the canned response. Status codes, error + * envelopes, non-JSON bodies, schema mismatches, the 429 retry policy and the + * download/upload paths all run deterministically, off any socket. + * + * TWO kinds of test deliberately stay OFF the stub: + * + * - **Pure-unit tests** (the `rateLimitSchedule` replay, the `RealmUrl` / + * `BotEmail` / `ApiKey` brand validators, `decodeUserUploadPath`, the + * `ZulipApiError` tag) never touch HTTP at all — they keep their plain + * `Effect.runPromise` shape. + * - **One irreducible real-socket case** — `a transport failure surfaces as a + * ZulipApiError` — needs a genuine platform `RequestError` from a refused + * connection, which the in-memory stub cannot fabricate without lowering + * fidelity. It keeps a real `FetchHttpClient` against a claimed-then-released + * port (comms-e5vm.8 orchestrator ruling). + * + * Happy-path cases that merely re-asserted a success body round-trip + * (GET-parses-success-envelope, POST-returns-success-body) were deleted: the + * contract-against-real run (`contract.live.test.ts`) exercises those success + * round-trips end-to-end against a live realm. + */ + import { expect, test } from 'bun:test' -import { registerRealmHooks } from '@commy/testing/realm-hooks' +import { effectTest } from '@commy/testing/effect-test' +import { + type CapturedHttpRequest, + makeStubHttpClient, + type StubHttpClient, + type StubResponse, +} from '@commy/testing/stub-http-client' import { FetchHttpClient, HttpClient } from '@effect/platform' import { Cause, @@ -27,98 +63,7 @@ import { ZulipApiError, } from './http.ts' -type Captured = { - readonly url: string - readonly method: string - readonly headers: Headers - readonly body: string -} - -type FixtureResponseInit = { - readonly status?: number - readonly statusText?: string - readonly headers?: Readonly> -} - -type FixtureResponse = { - readonly body: unknown - readonly init?: FixtureResponseInit -} - -type Fixture = { - readonly port: number - readonly captured: ReadonlyArray - readonly respond: (body: unknown, init?: FixtureResponseInit) => void - /** - * Queue responses consumed one-per-request before falling back to the - * `respond` default. Lets a test drive a 429-then-success sequence to - * exercise the send layer's rate-limit retry. - */ - readonly respondSequence: (responses: ReadonlyArray) => void - readonly stop: () => Promise -} - -const startFixture = (): Fixture => { - const captured: Captured[] = [] - let respondWith: FixtureResponse = { body: { result: 'success', msg: '' } } - const queue: FixtureResponse[] = [] - const server = Bun.serve({ - port: 0, - fetch: async (req) => { - captured.push({ - url: req.url, - method: req.method, - headers: new Headers(req.headers), - body: await req.text(), - }) - const responder = queue.length > 0 ? (queue.shift() as FixtureResponse) : respondWith - const init = responder.init - const headers: Record = {} - if (!(responder.body instanceof Uint8Array)) { - headers['content-type'] = 'application/json' - } - if (init?.headers !== undefined) { - for (const [k, v] of Object.entries(init.headers)) { - headers[k] = v - } - } - const responseBody = - responder.body instanceof Uint8Array - ? responder.body - : typeof responder.body === 'string' - ? responder.body - : JSON.stringify(responder.body) - const responseInit: ResponseInit = { status: init?.status ?? 200, headers } - if (init?.statusText !== undefined) { - Object.assign(responseInit, { statusText: init.statusText }) - } - return new Response(responseBody, responseInit) - }, - }) - if (typeof server.port !== 'number') { - throw new Error('fixture server failed to bind a TCP port') - } - return { - port: server.port, - captured, - respond: (body, init) => { - respondWith = init === undefined ? { body } : { body, init } - }, - respondSequence: (responses) => { - queue.length = 0 - queue.push(...responses) - }, - stop: async () => { - await server.stop(true) - }, - } -} - -let fixture: Fixture - -registerRealmHooks(startFixture, (next) => { - fixture = next -}) +const REALM_URL = 'https://zulip.example.com' const successSchema = Schema.Struct({ result: Schema.Literal('success') }) @@ -133,80 +78,219 @@ const sentMessageSchema = Schema.Struct({ id: Schema.Int, }) -const httpClient = Effect.runSync(HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))) - -const makeHttp = (overrides: Partial = {}): Effect.Effect => +// Build a ZulipHttp wired to the stub: the port reads `HttpClient` from +// context, so we provide `stub.client` exactly where the application edge would +// provide the real `FetchHttpClient`. +const makeHttp = ( + stub: StubHttpClient, + overrides: Partial = {}, +): Effect.Effect => Effect.gen(function* () { const base: ZulipHttpConfig = { - realmUrl: yield* RealmUrl(`http://localhost:${fixture.port}`), + realmUrl: yield* RealmUrl(REALM_URL), email: yield* BotEmail('bot@example.com'), apiKey: yield* ApiKey('sekret'), } return yield* makeZulipHttp({ ...base, ...overrides }) - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.orDie) + }).pipe(Effect.provideService(HttpClient.HttpClient, stub.client), Effect.orDie) -test('GET prepends /api/v1 to the supplied resource path', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success', user_id: 1, full_name: 'b' }) - const http = yield* makeHttp() - yield* http.get('/users/me', userMeSchema) - expect(fixture.captured[0]?.url).toMatch(/\/api\/v1\/users\/me$/) +const firstRequest = (stub: StubHttpClient): Effect.Effect => + stub.captured.pipe( + Effect.flatMap((reqs) => { + const head = reqs[0] + return head === undefined + ? Effect.die(new Error('expected a captured request')) + : Effect.succeed(head) }), - )) + ) -test('GET sends HTTP Basic auth with email:apiKey base64-encoded', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success', user_id: 1, full_name: 'b' }) - const http = yield* makeHttp() - yield* http.get('/users/me', userMeSchema) - expect(fixture.captured[0]?.headers.get('authorization')).toBe( - `Basic ${Encoding.encodeBase64('bot@example.com:sekret')}`, - ) - }), - )) +// --- request shape: URL, auth, query, body encoding, host --- -test('the Basic auth header base64-decodes back to email:apiKey (Encoding round-trip)', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success', user_id: 1, full_name: 'b' }) - const http = yield* makeHttp() - yield* http.get('/users/me', userMeSchema) - const auth = fixture.captured[0]?.headers.get('authorization') - if (auth === undefined || auth === null) throw new Error('expected an authorization header') - const decoded = yield* Encoding.decodeBase64String(auth.slice('Basic '.length)) - expect(decoded).toBe('bot@example.com:sekret') - }), - )) - -test('GET parses the JSON envelope through the supplied schema', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success', user_id: 7, full_name: 'bot' }) - const http = yield* makeHttp() - const body = yield* http.get('/users/me', userMeSchema) - expect(body.user_id).toBe(7) - expect(body.full_name).toBe('bot') +effectTest('GET prepends /api/v1 to the supplied resource path', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { + body: { result: 'success', user_id: 1, full_name: 'b' }, + }) + const http = yield* makeHttp(stub) + yield* http.get('/users/me', userMeSchema) + const req = yield* firstRequest(stub) + expect(req.url.pathname).toBe('/api/v1/users/me') + }), +) + +effectTest('GET sends HTTP Basic auth with email:apiKey base64-encoded', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { + body: { result: 'success', user_id: 1, full_name: 'b' }, + }) + const http = yield* makeHttp(stub) + yield* http.get('/users/me', userMeSchema) + const req = yield* firstRequest(stub) + expect(req.headers.get('authorization')).toBe( + `Basic ${Encoding.encodeBase64('bot@example.com:sekret')}`, + ) + }), +) + +effectTest('the Basic auth header base64-decodes back to email:apiKey (Encoding round-trip)', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { + body: { result: 'success', user_id: 1, full_name: 'b' }, + }) + const http = yield* makeHttp(stub) + yield* http.get('/users/me', userMeSchema) + const req = yield* firstRequest(stub) + const auth = req.headers.get('authorization') + if (auth === null) throw new Error('expected an authorization header') + const decoded = yield* Encoding.decodeBase64String(auth.slice('Basic '.length)) + expect(decoded).toBe('bot@example.com:sekret') + }), +) + +effectTest('GET appends params as URL-encoded query string', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/messages', { body: { result: 'success' } }) + const http = yield* makeHttp(stub) + yield* http.get('/messages', successSchema, { + anchor: 'newest', + num_before: 50, + apply_markdown: true, + narrow: '[["stream","x"]]', + }) + const req = yield* firstRequest(stub) + expect(req.url.searchParams.get('anchor')).toBe('newest') + expect(req.url.searchParams.get('num_before')).toBe('50') + expect(req.url.searchParams.get('apply_markdown')).toBe('true') + expect(req.url.searchParams.get('narrow')).toBe('[["stream","x"]]') + }), +) + +effectTest('POST form-encodes the body and sets the content-type', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('POST', '/api/v1/messages', { body: { result: 'success', id: 42 } }) + const http = yield* makeHttp(stub) + yield* http.post('/messages', sentMessageSchema, { + type: 'stream', + to: 'general', + topic: 'hello', + content: 'hey there & friends', + }) + const req = yield* firstRequest(stub) + 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('to')).toBe('general') + expect(params.get('topic')).toBe('hello') + expect(params.get('content')).toBe('hey there & friends') + }), +) + +effectTest('PATCH form-encodes the body and sets the content-type', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('PATCH', '/api/v1/messages/42', { body: { result: 'success' } }) + const http = yield* makeHttp(stub) + yield* http.patch('/messages/42', successSchema, { content: 'edited body & more' }) + const req = yield* firstRequest(stub) + expect(req.method).toBe('PATCH') + expect(req.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + const params = new URLSearchParams(req.body) + expect(params.get('content')).toBe('edited body & more') + }), +) + +effectTest('DELETE with a body form-encodes it and sets content-type', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('DELETE', '/api/v1/messages/42/reactions', { body: { result: 'success' } }) + const http = yield* makeHttp(stub) + yield* http.delete('/messages/42/reactions', successSchema, { emoji_name: 'thumbs_up' }) + const req = yield* firstRequest(stub) + expect(req.method).toBe('DELETE') + expect(req.headers.get('content-type')).toBe('application/x-www-form-urlencoded') + const params = new URLSearchParams(req.body) + expect(params.get('emoji_name')).toBe('thumbs_up') + }), +) + +effectTest('DELETE without a body issues a bare DELETE with no content-type or body', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('DELETE', '/api/v1/users/me/queues/q-1', { body: { result: 'success' } }) + const http = yield* makeHttp(stub) + yield* http.delete('/users/me/queues/q-1', successSchema) + const req = yield* firstRequest(stub) + expect(req.method).toBe('DELETE') + expect(req.headers.get('content-type')).toBeNull() + expect(req.body).toBe('') + }), +) + +effectTest('hostHeader overrides the Host header on outgoing requests', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { body: { result: 'success' } }) + const http = yield* makeHttp(stub, { hostHeader: 'zulip.example.com' }) + yield* http.get('/users/me', successSchema) + const req = yield* firstRequest(stub) + expect(req.headers.get('host')).toBe('zulip.example.com') + }), +) + +effectTest( + 'without hostHeader, no Host override is sent (the transport fills it at the wire)', + () => + Effect.gen(function* () { + // Our seam only injects a Host header when `hostHeader` is configured; + // deriving Host from the URL is the transport's job at the socket, which + // the contract-against-real run covers. The stub sends no socket, so the + // observable contract here is the negative: we add no override. + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { body: { result: 'success' } }) + const http = yield* makeHttp(stub) + yield* http.get('/users/me', successSchema) + const req = yield* firstRequest(stub) + expect(req.headers.get('host')).toBeNull() }), - )) +) -test('GET throws ZulipApiError with msg + code + status when result=error', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond( - { result: 'error', msg: 'Invalid API key', code: 'BAD_API_KEY' }, - { status: 401 }, - ) - const http = yield* makeHttp() - const err = yield* Effect.flip(http.get('/users/me', userMeSchema)) - expect(err).toBeInstanceOf(ZulipApiError) - const apiErr = err as ZulipApiError - expect(apiErr.message).toContain('Invalid API key') - expect(apiErr.code).toBe('BAD_API_KEY') - expect(apiErr.status).toBe(401) - }), - )) +effectTest('trailing slashes on the realm URL are normalised before /api/v1 is appended', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { body: { result: 'success' } }) + const realmUrl = yield* RealmUrl(`${REALM_URL}///`) + const http = yield* makeHttp(stub, { realmUrl }) + yield* http.get('/users/me', successSchema) + const req = yield* firstRequest(stub) + expect(req.url.pathname).toBe('/api/v1/users/me') + expect(req.url.href).not.toContain('//api') + }), +) + +// --- response handling: error envelopes, non-JSON, schema mismatch --- + +effectTest('GET throws ZulipApiError with msg + code + status when result=error', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { + body: { result: 'error', msg: 'Invalid API key', code: 'BAD_API_KEY' }, + status: 401, + }) + const http = yield* makeHttp(stub) + const err = yield* Effect.flip(http.get('/users/me', userMeSchema)) + expect(err).toBeInstanceOf(ZulipApiError) + const apiErr = err as ZulipApiError + expect(apiErr.message).toContain('Invalid API key') + expect(apiErr.code).toBe('BAD_API_KEY') + expect(apiErr.status).toBe(401) + }), +) test('ZulipApiError carries Data.TaggedError discriminator for Effect.catchTag', () => { const err = new ZulipApiError({ @@ -218,226 +302,108 @@ test('ZulipApiError carries Data.TaggedError discriminator for Effect.catchTag', expect(err._tag).toBe('ZulipApiError') }) -test('GET throws ZulipApiError on non-JSON, non-2xx upstream', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond('upstream blew up', { - status: 502, - headers: { 'content-type': 'text/plain' }, - }) - const http = yield* makeHttp() - const err = yield* Effect.flip(http.get('/users/me', userMeSchema)) - expect(err).toBeInstanceOf(ZulipApiError) - expect((err as ZulipApiError).status).toBe(502) - }), - )) - -test('GET appends params as URL-encoded query string', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success' }) - const http = yield* makeHttp() - yield* http.get('/messages', successSchema, { - anchor: 'newest', - num_before: 50, - apply_markdown: true, - narrow: '[["stream","x"]]', - }) - const req = fixture.captured[0] - if (req === undefined) throw new Error('expected captured request') - const url = new URL(req.url) - expect(url.searchParams.get('anchor')).toBe('newest') - expect(url.searchParams.get('num_before')).toBe('50') - expect(url.searchParams.get('apply_markdown')).toBe('true') - expect(url.searchParams.get('narrow')).toBe('[["stream","x"]]') - }), - )) - -test('POST form-encodes the body and sets the content-type', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success', id: 42 }) - const http = yield* makeHttp() - yield* http.post('/messages', sentMessageSchema, { - type: 'stream', - to: 'general', - topic: 'hello', - content: 'hey there & friends', - }) - const req = fixture.captured[0] - if (req === undefined) throw new Error('expected 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('to')).toBe('general') - expect(params.get('topic')).toBe('hello') - expect(params.get('content')).toBe('hey there & friends') - }), - )) - -test('POST returns the parsed JSON body when result=success', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success', id: 99 }) - const http = yield* makeHttp() - const body = yield* http.post('/messages', sentMessageSchema, { content: 'x' }) - expect(body.id).toBe(99) - }), - )) - -test('POST surfaces Zulip API errors the same way GET does', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond( - { result: 'error', msg: 'Topic too long', code: 'BAD_REQUEST' }, - { status: 400 }, - ) - const http = yield* makeHttp() - const err = yield* Effect.flip(http.post('/messages', sentMessageSchema, { content: 'x' })) - expect(err).toBeInstanceOf(ZulipApiError) - expect((err as ZulipApiError).code).toBe('BAD_REQUEST') - expect((err as ZulipApiError).status).toBe(400) - }), - )) - -test('PATCH form-encodes the body and sets the content-type', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success' }) - const http = yield* makeHttp() - yield* http.patch('/messages/42', successSchema, { - content: 'edited body & more', - }) - const req = fixture.captured[0] - if (req === undefined) throw new Error('expected captured request') - expect(req.method).toBe('PATCH') - expect(req.headers.get('content-type')).toBe('application/x-www-form-urlencoded') - const params = new URLSearchParams(req.body) - expect(params.get('content')).toBe('edited body & more') - }), - )) - -test('PATCH surfaces Zulip API errors the same way POST does', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond( - { - result: 'error', - msg: "You don't have permission to edit this message", - code: 'BAD_REQUEST', - }, - { status: 400 }, - ) - const http = yield* makeHttp() - const err = yield* Effect.flip(http.patch('/messages/42', successSchema, { content: 'x' })) - expect(err).toBeInstanceOf(ZulipApiError) - expect((err as ZulipApiError).status).toBe(400) - }), - )) - -test('DELETE with a body form-encodes it and sets content-type', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success' }) - const http = yield* makeHttp() - yield* http.delete('/messages/42/reactions', successSchema, { - emoji_name: 'thumbs_up', +effectTest('GET throws ZulipApiError on non-JSON, non-2xx upstream', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { + body: 'upstream blew up', + status: 502, + headers: { 'content-type': 'text/plain' }, + }) + const http = yield* makeHttp(stub) + const err = yield* Effect.flip(http.get('/users/me', userMeSchema)) + expect(err).toBeInstanceOf(ZulipApiError) + expect((err as ZulipApiError).status).toBe(502) + }), +) + +effectTest('POST surfaces Zulip API errors the same way GET does', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('POST', '/api/v1/messages', { + body: { result: 'error', msg: 'Topic too long', code: 'BAD_REQUEST' }, + status: 400, + }) + const http = yield* makeHttp(stub) + const err = yield* Effect.flip(http.post('/messages', sentMessageSchema, { content: 'x' })) + expect(err).toBeInstanceOf(ZulipApiError) + expect((err as ZulipApiError).code).toBe('BAD_REQUEST') + expect((err as ZulipApiError).status).toBe(400) + }), +) + +effectTest('PATCH surfaces Zulip API errors the same way POST does', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('PATCH', '/api/v1/messages/42', { + body: { + result: 'error', + msg: "You don't have permission to edit this message", + code: 'BAD_REQUEST', + }, + status: 400, + }) + const http = yield* makeHttp(stub) + const err = yield* Effect.flip(http.patch('/messages/42', successSchema, { content: 'x' })) + expect(err).toBeInstanceOf(ZulipApiError) + expect((err as ZulipApiError).status).toBe(400) + }), +) + +effectTest( + 'schema mismatch on a 200/result=success response surfaces a ParseError in the E channel', + () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { + body: { result: 'success', user_id: 'not a number', full_name: 'b' }, }) - const req = fixture.captured[0] - if (req === undefined) throw new Error('expected captured request') - expect(req.method).toBe('DELETE') - expect(req.headers.get('content-type')).toBe('application/x-www-form-urlencoded') - const params = new URLSearchParams(req.body) - expect(params.get('emoji_name')).toBe('thumbs_up') - }), - )) - -test('DELETE without a body issues a bare DELETE with no content-type or body', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success' }) - const http = yield* makeHttp() - yield* http.delete('/users/me/queues/q-1', successSchema) - const req = fixture.captured[0] - if (req === undefined) throw new Error('expected captured request') - expect(req.method).toBe('DELETE') - expect(req.headers.get('content-type')).toBeNull() - expect(req.body).toBe('') - }), - )) - -test('hostHeader overrides the Host header on outgoing requests', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success' }) - const http = yield* makeHttp({ hostHeader: 'zulip.example.com' }) - yield* http.get('/users/me', successSchema) - expect(fixture.captured[0]?.headers.get('host')).toBe('zulip.example.com') - }), - )) - -test('without hostHeader, Host header matches the URL the wrapper was built from', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success' }) - const http = yield* makeHttp() - yield* http.get('/users/me', successSchema) - expect(fixture.captured[0]?.headers.get('host')).toBe(`localhost:${fixture.port}`) - }), - )) - -test('schema mismatch on a 200/result=success response surfaces a ParseError in the E channel', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success', user_id: 'not a number', full_name: 'b' }) - const http = yield* makeHttp() + const http = yield* makeHttp(stub) const err = yield* Effect.flip(http.get('/users/me', userMeSchema)) expect(ParseResult.isParseError(err)).toBe(true) }), - )) +) test('a transport failure surfaces as a ZulipApiError that preserves the underlying cause', () => + // IRREDUCIBLE real socket (comms-e5vm.8 ruling): a genuine platform + // `RequestError` only arises from a real refused connection. Claim a port + // then release it so the connection is refused — no mock, no in-memory stub + // (which cannot fabricate a real RequestError without dropping fidelity). + // This is the one case in this file that keeps `FetchHttpClient`. Effect.runPromise( Effect.gen(function* () { - // Claim a port then immediately release it so the connection is - // refused — a real RequestError from the platform HttpClient, no mock. const deadServer = Bun.serve({ port: 0, fetch: () => new Response('') }) const deadPort = deadServer.port yield* Effect.promise(() => deadServer.stop(true)) - const realmUrl = yield* RealmUrl(`http://localhost:${deadPort}`) - const email = yield* BotEmail('bot@example.com') - const apiKey = yield* ApiKey('sekret') - const http = yield* makeZulipHttp({ realmUrl, email, apiKey }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - ) - + const http = yield* makeZulipHttp({ + realmUrl: yield* RealmUrl(`http://localhost:${deadPort}`), + email: yield* BotEmail('bot@example.com'), + apiKey: yield* ApiKey('sekret'), + }) const err = yield* Effect.flip(http.get('/users/me', userMeSchema)) expect(err).toBeInstanceOf(ZulipApiError) const apiErr = err as ZulipApiError expect(apiErr.status).toBe(0) expect(apiErr.cause).toBeDefined() expect((apiErr.cause as { _tag?: unknown })._tag).toBe('RequestError') - }), + }).pipe(Effect.provide(FetchHttpClient.layer)), )) // --- 429 rate-limit retry (comms-nbz) --- // -// A 429 carries `retry-after` — backpressure with instructions, not a -// fatal error. The send path absorbs it: wait the realm's retry-after and -// retry within a bounded total wait budget, so callers never see a -// transient rate limit. retry-after stays adapter-internal; it never -// reaches the port. +// A 429 carries `retry-after` — backpressure with instructions, not a fatal +// error. The send path absorbs it: wait the realm's retry-after and retry +// within a bounded total wait budget, so callers never see a transient rate +// limit. retry-after stays adapter-internal; it never reaches the port. // -// The wait now rides on `Effect.sleep` (Clock default service) inside the -// retry Schedule, so two test seams replace the old injected `sleep` thunk: -// - policy: `Schedule.delays(rateLimitSchedule())` replayed over a list -// of ZulipApiErrors observes the exact wait sequence, purely, no timers -// (same approach as the event-pump's `defaultRetrySchedule` test). -// - end-to-end: fork the request and drive `TestClock` so the real -// fixture round-trip retries without sleeping real time. - -const rateLimited = (retryAfter?: number): FixtureResponse => ({ +// Two seams cover this without sleeping real time: +// - policy: `Schedule.delays(rateLimitSchedule())` replayed over a list of +// ZulipApiErrors observes the exact wait sequence, purely, no timers. +// - end-to-end: drive the stub round-trip under `TestClock`. On the stub the +// response is in-memory (no real socket off the test clock, unlike the old +// Bun.serve fixture), so the retry sleeps run entirely on the virtual clock. + +const rateLimited = (retryAfter?: number): StubResponse => ({ body: retryAfter === undefined ? { result: 'error', code: 'RATE_LIMIT_HIT', msg: 'API rate limit exceeded' } @@ -447,18 +413,18 @@ const rateLimited = (retryAfter?: number): FixtureResponse => ({ msg: 'API rate limit exceeded', 'retry-after': retryAfter, }, - init: { status: 429 }, + status: 429, }) const apiError = (status: number, retryAfter: number | undefined): ZulipApiError => new ZulipApiError({ message: 'rate limited', status, code: 'RATE_LIMIT_HIT', retryAfter }) -// Replay the retry policy over a list of errors and collect the wait it -// would pick before each retry. `Schedule.delays` reads the delay off each -// decision's interval, so no real (or virtual) time passes. A terminal -// (done) decision carries a zero-length interval, so the sequence ends in a -// trailing 0 once the policy stops retrying — dropped here so the result is -// just the non-zero waits the policy actually schedules. +// Replay the retry policy over a list of errors and collect the wait it would +// pick before each retry. `Schedule.delays` reads the delay off each decision's +// interval, so no real (or virtual) time passes. A terminal (done) decision +// carries a zero-length interval, so the sequence ends in a trailing 0 once the +// policy stops retrying — dropped here so the result is just the non-zero waits +// the policy actually schedules. const waitsFor = (errors: ReadonlyArray): Effect.Effect> => Schedule.run(Schedule.delays(rateLimitSchedule()), 0, errors).pipe( Effect.map((chunk) => @@ -495,8 +461,8 @@ test('the retry policy clamps a single oversized retry-after to the remaining bu test('the retry policy spends the budget in equal waits then stops', () => Effect.runPromise( Effect.gen(function* () { - // Each 5s 429 is honoured until the 15s budget is exhausted: three - // 5s waits, then the schedule is done (no fourth wait). + // Each 5s 429 is honoured until the 15s budget is exhausted: three 5s + // waits, then the schedule is done (no fourth wait). const waits = yield* waitsFor([ apiError(429, 5), apiError(429, 5), @@ -515,105 +481,104 @@ test('the retry policy emits no wait for a non-429 error', () => }), )) -// Fork the request, then repeatedly advance the virtual clock until the -// fiber settles. The fixture round-trip is real socket I/O (off the test -// clock), so a single adjust can race the scheduled retry sleep: the sleep -// is only registered once the real 429 response lands. Looping — adjust, -// yield, poll — releases each retry sleep as it appears without sleeping -// real time, the same shape as the event-pump's drainOneUnderTestClock. -const runUnderTestClock = ( - build: Effect.Effect, +// Advance the virtual clock until the forked request settles. Each retry sleep +// is only registered once its 429 response lands, so a single adjust can race +// the not-yet-scheduled next sleep; advance-poll-yield in a loop releases each +// retry sleep as it appears. Unlike the old `runUnderTestClock`, the stub +// round-trip is fully in-memory — there is no real socket I/O off the test +// clock, so this is a deterministic settle loop, not a race against the wire. +const settleUnderTestClock = ( + build: Effect.Effect, step: Duration.DurationInput, maxAdvances: number, -): Promise => +): Effect.Effect => Effect.gen(function* () { const fiber = yield* Effect.fork(build) for (let i = 0; i < maxAdvances; i++) { const settled = yield* Fiber.poll(fiber) - if (settled._tag === 'Some') break + if (Option.isSome(settled)) break yield* TestClock.adjust(step) yield* Effect.yieldNow() } return yield* Fiber.join(fiber) - }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provide(TestContext.TestContext), - Effect.runPromise, - ) - -const withCreds = ( - use: (http: ZulipHttp) => Effect.Effect, -): Effect.Effect => - Effect.gen(function* () { - const http = yield* makeZulipHttp({ - realmUrl: yield* RealmUrl(`http://localhost:${fixture.port}`), - email: yield* BotEmail('bot@example.com'), - apiKey: yield* ApiKey('sekret'), - }) - return yield* use(http) }) -test('GET retries after a 429 (under TestClock) and returns the success body', () => { - fixture.respond({ result: 'success', user_id: 5, full_name: 'bot' }) - fixture.respondSequence([rateLimited(0.25)]) - return runUnderTestClock( - withCreds((http) => - Effect.gen(function* () { - const body = yield* http.get('/users/me', userMeSchema) - expect(body.user_id).toBe(5) - expect(fixture.captured.length).toBe(2) - return body - }), - ), - Duration.millis(250), - 4, - ) -}) - -test('GET gives up and throws the 429 once the retry budget is spent', () => { - fixture.respond(rateLimited(5).body, { status: 429 }) - return runUnderTestClock( - withCreds((http) => - Effect.gen(function* () { - const err = yield* Effect.flip(http.get('/users/me', userMeSchema)) - expect(err).toBeInstanceOf(ZulipApiError) - expect((err as ZulipApiError).status).toBe(429) - // Initial attempt + three retries (3 × 5s = 15s budget). - expect(fixture.captured.length).toBe(4) - return err - }), - ), - Duration.seconds(5), - 6, - ) -}) - -test('POST retries on 429 too — the retry lives in the shared send path', () => { - fixture.respond({ result: 'success', id: 7 }) - fixture.respondSequence([rateLimited(0.01)]) - return runUnderTestClock( - withCreds((http) => - Effect.gen(function* () { - const body = yield* http.post('/messages', sentMessageSchema, { content: 'x' }) - expect(body.id).toBe(7) - expect(fixture.captured.length).toBe(2) - return body - }), - ), - Duration.millis(100), - 4, - ) -}) +effectTest( + 'GET retries after a 429 (under TestClock) and returns the success body', + () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respondSequence('GET', '/api/v1/users/me', [ + rateLimited(0.25), + { body: { result: 'success', user_id: 5, full_name: 'bot' } }, + ]) + const http = yield* makeHttp(stub) + const body = yield* settleUnderTestClock( + http.get('/users/me', userMeSchema), + Duration.millis(250), + 4, + ) + expect(body.user_id).toBe(5) + expect((yield* stub.captured).length).toBe(2) + }), + { layer: TestContext.TestContext }, +) + +effectTest( + 'GET gives up and throws the 429 once the retry budget is spent', + () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', rateLimited(5)) + const http = yield* makeHttp(stub) + const err = yield* settleUnderTestClock( + Effect.flip(http.get('/users/me', userMeSchema)), + Duration.seconds(5), + 6, + ) + expect(err).toBeInstanceOf(ZulipApiError) + expect((err as ZulipApiError).status).toBe(429) + // Initial attempt + three retries (3 × 5s = 15s budget). + expect((yield* stub.captured).length).toBe(4) + }), + { layer: TestContext.TestContext }, +) -test('non-429 errors are surfaced immediately without retry', () => - Effect.runPromise( +effectTest( + 'POST retries on 429 too — the retry lives in the shared send path', + () => Effect.gen(function* () { - fixture.respond({ result: 'error', code: 'BAD_REQUEST', msg: 'nope' }, { status: 400 }) - const http = yield* makeHttp() - yield* Effect.ignore(http.get('/users/me', userMeSchema)) - expect(fixture.captured.length).toBe(1) + const stub = yield* makeStubHttpClient + yield* stub.respondSequence('POST', '/api/v1/messages', [ + rateLimited(0.01), + { body: { result: 'success', id: 7 } }, + ]) + const http = yield* makeHttp(stub) + const body = yield* settleUnderTestClock( + http.post('/messages', sentMessageSchema, { content: 'x' }), + Duration.millis(100), + 4, + ) + expect(body.id).toBe(7) + expect((yield* stub.captured).length).toBe(2) }), - )) + { layer: TestContext.TestContext }, +) + +effectTest('non-429 errors are surfaced immediately without retry', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/api/v1/users/me', { + body: { result: 'error', code: 'BAD_REQUEST', msg: 'nope' }, + status: 400, + }) + const http = yield* makeHttp(stub) + yield* Effect.ignore(http.get('/users/me', userMeSchema)) + expect((yield* stub.captured).length).toBe(1) + }), +) + +// --- brand validation (no HTTP) --- test('RealmUrl rejects non-URL strings', () => Effect.runPromise( @@ -658,7 +623,8 @@ test('ApiKey rejects empty strings', () => // A path not starting with '/' is a programmer error, so it surfaces as a // TypeError defect in the Effect channel (comms-0m8) — no longer a synchronous -// throw from an Effect-returning verb. +// throw from an Effect-returning verb. No request is sent, so these run on the +// stub with no canned response registered. const expectPathDefect = (eff: Effect.Effect): Effect.Effect => Effect.gen(function* () { const exit = yield* Effect.exit(eff) @@ -670,125 +636,128 @@ const expectPathDefect = (eff: Effect.Effect): Effect.Effect = } }) -test('GET fails with a TypeError defect when path does not start with /', () => - Effect.runPromise( - Effect.gen(function* () { - const http = yield* makeHttp() - yield* expectPathDefect(http.get('users/me', successSchema)) - }), - )) - -test('POST fails with a TypeError defect when path does not start with /', () => - Effect.runPromise( - Effect.gen(function* () { - const http = yield* makeHttp() - yield* expectPathDefect(http.post('messages', successSchema, { content: 'x' })) - }), - )) +effectTest('GET fails with a TypeError defect when path does not start with /', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + const http = yield* makeHttp(stub) + yield* expectPathDefect(http.get('users/me', successSchema)) + }), +) -test('DELETE fails with a TypeError defect when path does not start with /', () => - Effect.runPromise( - Effect.gen(function* () { - const http = yield* makeHttp() - yield* expectPathDefect(http.delete('messages/42/reactions', successSchema)) - }), - )) +effectTest('POST fails with a TypeError defect when path does not start with /', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + const http = yield* makeHttp(stub) + yield* expectPathDefect(http.post('messages', successSchema, { content: 'x' })) + }), +) -test('trailing slashes on the realm URL are normalised before /api/v1 is appended', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond({ result: 'success' }) - const realmUrl = yield* RealmUrl(`http://localhost:${fixture.port}///`) - const email = yield* BotEmail('bot@example.com') - const apiKey = yield* ApiKey('sekret') - const httpWithTrailing = yield* makeZulipHttp({ realmUrl, email, apiKey }).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - ) - yield* httpWithTrailing.get('/users/me', successSchema) - expect(fixture.captured[0]?.url).toMatch(/\/api\/v1\/users\/me$/) - expect(fixture.captured[0]?.url).not.toContain('//api') - }).pipe(Effect.orDie), - )) +effectTest('DELETE fails with a TypeError defect when path does not start with /', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + const http = yield* makeHttp(stub) + yield* expectPathDefect(http.delete('messages/42/reactions', successSchema)) + }), +) // --- downloadRaw (comms-xos) --- -test('downloadRaw resolves path against realm root, not /api/v1', () => - Effect.runPromise( - Effect.gen(function* () { - const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]) - fixture.respond(bytes, { headers: { 'content-type': 'image/jpeg' } }) - const http = yield* makeHttp() - yield* http.downloadRaw('/user_uploads/2/56/image.jpeg') - expect(fixture.captured[0]?.url).toMatch(/\/user_uploads\/2\/56\/image\.jpeg$/) - expect(fixture.captured[0]?.url).not.toContain('/api/v1') - }), - )) - -test('downloadRaw sends HTTP Basic auth header', () => - Effect.runPromise( - Effect.gen(function* () { - const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) - fixture.respond(bytes, { headers: { 'content-type': 'image/png' } }) - const http = yield* makeHttp() - yield* http.downloadRaw('/user_uploads/1/abc/photo.png') - expect(fixture.captured[0]?.headers.get('authorization')).toBe( - `Basic ${Encoding.encodeBase64('bot@example.com:sekret')}`, - ) - }), - )) - -test('downloadRaw returns raw bytes and content-type from the response', () => - Effect.runPromise( - Effect.gen(function* () { - const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]) - fixture.respond(bytes, { headers: { 'content-type': 'image/png' } }) - const http = yield* makeHttp() - const result = yield* http.downloadRaw('/user_uploads/1/abc/photo.png') - expect(new Uint8Array(result.data)).toEqual(bytes) - expect(result.contentType).toBe('image/png') - }), - )) - -test('downloadRaw throws ZulipApiError on non-2xx response', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond('Not found', { status: 404, headers: { 'content-type': 'text/plain' } }) - const http = yield* makeHttp() - const err = yield* Effect.flip(http.downloadRaw('/user_uploads/1/abc/missing.png')) - expect(err).toBeInstanceOf(ZulipApiError) - expect((err as ZulipApiError).status).toBe(404) - }), - )) - -test('downloadRaw fails with a TypeError defect when path does not start with /', () => - Effect.runPromise( - Effect.gen(function* () { - const http = yield* makeHttp() - yield* expectPathDefect(http.downloadRaw('user_uploads/1/abc/photo.png')) - }), - )) - -test('downloadRaw sends host header when configured', () => - Effect.runPromise( - Effect.gen(function* () { - const bytes = new Uint8Array([0x00]) - fixture.respond(bytes, { headers: { 'content-type': 'application/octet-stream' } }) - const http = yield* makeHttp({ hostHeader: 'zulip.example.com' }) - yield* http.downloadRaw('/user_uploads/1/a/f.bin') - expect(fixture.captured[0]?.headers.get('host')).toBe('zulip.example.com') - }), - )) +effectTest('downloadRaw resolves path against realm root, not /api/v1', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/user_uploads/2/56/image.jpeg', { + body: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), + headers: { 'content-type': 'image/jpeg' }, + }) + const http = yield* makeHttp(stub) + yield* http.downloadRaw('/user_uploads/2/56/image.jpeg') + const req = yield* firstRequest(stub) + expect(req.url.pathname).toBe('/user_uploads/2/56/image.jpeg') + expect(req.url.href).not.toContain('/api/v1') + }), +) + +effectTest('downloadRaw sends HTTP Basic auth header', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/user_uploads/1/abc/photo.png', { + body: new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + headers: { 'content-type': 'image/png' }, + }) + const http = yield* makeHttp(stub) + yield* http.downloadRaw('/user_uploads/1/abc/photo.png') + const req = yield* firstRequest(stub) + expect(req.headers.get('authorization')).toBe( + `Basic ${Encoding.encodeBase64('bot@example.com:sekret')}`, + ) + }), +) + +effectTest('downloadRaw returns raw bytes and content-type from the response', () => + Effect.gen(function* () { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]) + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/user_uploads/1/abc/photo.png', { + body: bytes, + headers: { 'content-type': 'image/png' }, + }) + const http = yield* makeHttp(stub) + const result = yield* http.downloadRaw('/user_uploads/1/abc/photo.png') + expect(new Uint8Array(result.data)).toEqual(bytes) + expect(result.contentType).toBe('image/png') + }), +) + +effectTest('downloadRaw throws ZulipApiError on non-2xx response', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/user_uploads/1/abc/missing.png', { + body: 'Not found', + status: 404, + headers: { 'content-type': 'text/plain' }, + }) + const http = yield* makeHttp(stub) + const err = yield* Effect.flip(http.downloadRaw('/user_uploads/1/abc/missing.png')) + expect(err).toBeInstanceOf(ZulipApiError) + expect((err as ZulipApiError).status).toBe(404) + }), +) + +effectTest('downloadRaw fails with a TypeError defect when path does not start with /', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + const http = yield* makeHttp(stub) + yield* expectPathDefect(http.downloadRaw('user_uploads/1/abc/photo.png')) + }), +) -test('downloadRaw uses GET method', () => - Effect.runPromise( - Effect.gen(function* () { - const bytes = new Uint8Array([0x00]) - fixture.respond(bytes, { headers: { 'content-type': 'application/octet-stream' } }) - const http = yield* makeHttp() - yield* http.downloadRaw('/user_uploads/1/a/f.bin') - expect(fixture.captured[0]?.method).toBe('GET') - }), - )) +effectTest('downloadRaw sends host header when configured', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/user_uploads/1/a/f.bin', { + body: new Uint8Array([0x00]), + headers: { 'content-type': 'application/octet-stream' }, + }) + const http = yield* makeHttp(stub, { hostHeader: 'zulip.example.com' }) + yield* http.downloadRaw('/user_uploads/1/a/f.bin') + const req = yield* firstRequest(stub) + expect(req.headers.get('host')).toBe('zulip.example.com') + }), +) + +effectTest('downloadRaw uses GET method', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('GET', '/user_uploads/1/a/f.bin', { + body: new Uint8Array([0x00]), + headers: { 'content-type': 'application/octet-stream' }, + }) + const http = yield* makeHttp(stub) + yield* http.downloadRaw('/user_uploads/1/a/f.bin') + const req = yield* firstRequest(stub) + expect(req.method).toBe('GET') + }), +) // --- uploadRaw (comms-nsa) --- @@ -800,79 +769,93 @@ const uploadSuccess = (urlPath: string, filename: string) => ({ filename, }) -test('uploadRaw POSTs multipart form-data to /api/v1/user_uploads', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond(uploadSuccess('/user_uploads/1/ab/chart.png', 'chart.png')) - const http = yield* makeHttp() - yield* http.uploadRaw('chart.png', new Uint8Array([1, 2, 3])) - expect(fixture.captured[0]?.url).toMatch(/\/api\/v1\/user_uploads$/) - expect(fixture.captured[0]?.method).toBe('POST') - expect(fixture.captured[0]?.headers.get('content-type')).toMatch(/^multipart\/form-data/) - }), - )) - -test('uploadRaw includes the file bytes and filename in the multipart body', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond(uploadSuccess('/user_uploads/1/ab/notes.txt', 'notes.txt')) - const http = yield* makeHttp() - yield* http.uploadRaw('notes.txt', new TextEncoder().encode('hello upload')) - expect(fixture.captured[0]?.body).toContain('filename="notes.txt"') - expect(fixture.captured[0]?.body).toContain('hello upload') - }), - )) - -test('uploadRaw returns the canonical url and filename from the response', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond(uploadSuccess('/user_uploads/1/ab/chart.png', 'chart.png')) - const http = yield* makeHttp() - const result = yield* http.uploadRaw('chart.png', new Uint8Array([0x89, 0x50])) - expect(result.url).toBe(decodeUserUploadPathSync('/user_uploads/1/ab/chart.png')) - expect(result.filename).toBe('chart.png') - }), - )) - -test('uploadRaw sends HTTP Basic auth header', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond(uploadSuccess('/user_uploads/1/a/f.bin', 'f.bin')) - const http = yield* makeHttp() - yield* http.uploadRaw('f.bin', new Uint8Array([0])) - expect(fixture.captured[0]?.headers.get('authorization')).toBe( - `Basic ${Encoding.encodeBase64('bot@example.com:sekret')}`, - ) - }), - )) - -test('uploadRaw surfaces Zulip API errors the same way other verbs do', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond( - { - result: 'error', - msg: 'File is larger than the maximum upload size', - code: 'BAD_REQUEST', - }, - { status: 400 }, - ) - const http = yield* makeHttp() - const err = yield* Effect.flip(http.uploadRaw('big.bin', new Uint8Array([0]))) - expect(err).toBeInstanceOf(ZulipApiError) - expect((err as ZulipApiError).status).toBe(400) - }), - )) - -test('uploadRaw sends host header when configured', () => - Effect.runPromise( - Effect.gen(function* () { - fixture.respond(uploadSuccess('/user_uploads/1/a/f.bin', 'f.bin')) - const http = yield* makeHttp({ hostHeader: 'zulip.example.com' }) - yield* http.uploadRaw('f.bin', new Uint8Array([0])) - expect(fixture.captured[0]?.headers.get('host')).toBe('zulip.example.com') - }), - )) +effectTest('uploadRaw POSTs multipart form-data to /api/v1/user_uploads', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('POST', '/api/v1/user_uploads', { + body: uploadSuccess('/user_uploads/1/ab/chart.png', 'chart.png'), + }) + const http = yield* makeHttp(stub) + yield* http.uploadRaw('chart.png', new Uint8Array([1, 2, 3])) + const req = yield* firstRequest(stub) + expect(req.url.pathname).toBe('/api/v1/user_uploads') + expect(req.method).toBe('POST') + expect(req.headers.get('content-type')).toMatch(/^multipart\/form-data/) + }), +) + +effectTest('uploadRaw includes the file bytes and filename in the multipart body', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('POST', '/api/v1/user_uploads', { + body: uploadSuccess('/user_uploads/1/ab/notes.txt', 'notes.txt'), + }) + const http = yield* makeHttp(stub) + yield* http.uploadRaw('notes.txt', new TextEncoder().encode('hello upload')) + const req = yield* firstRequest(stub) + expect(req.body).toContain('filename="notes.txt"') + expect(req.body).toContain('hello upload') + }), +) + +effectTest('uploadRaw returns the canonical url and filename from the response', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('POST', '/api/v1/user_uploads', { + body: uploadSuccess('/user_uploads/1/ab/chart.png', 'chart.png'), + }) + const http = yield* makeHttp(stub) + const result = yield* http.uploadRaw('chart.png', new Uint8Array([0x89, 0x50])) + expect(result.url).toBe(decodeUserUploadPathSync('/user_uploads/1/ab/chart.png')) + expect(result.filename).toBe('chart.png') + }), +) + +effectTest('uploadRaw sends HTTP Basic auth header', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('POST', '/api/v1/user_uploads', { + body: uploadSuccess('/user_uploads/1/a/f.bin', 'f.bin'), + }) + const http = yield* makeHttp(stub) + yield* http.uploadRaw('f.bin', new Uint8Array([0])) + const req = yield* firstRequest(stub) + expect(req.headers.get('authorization')).toBe( + `Basic ${Encoding.encodeBase64('bot@example.com:sekret')}`, + ) + }), +) + +effectTest('uploadRaw surfaces Zulip API errors the same way other verbs do', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('POST', '/api/v1/user_uploads', { + body: { + result: 'error', + msg: 'File is larger than the maximum upload size', + code: 'BAD_REQUEST', + }, + status: 400, + }) + const http = yield* makeHttp(stub) + const err = yield* Effect.flip(http.uploadRaw('big.bin', new Uint8Array([0]))) + expect(err).toBeInstanceOf(ZulipApiError) + expect((err as ZulipApiError).status).toBe(400) + }), +) + +effectTest('uploadRaw sends host header when configured', () => + Effect.gen(function* () { + const stub = yield* makeStubHttpClient + yield* stub.respond('POST', '/api/v1/user_uploads', { + body: uploadSuccess('/user_uploads/1/a/f.bin', 'f.bin'), + }) + const http = yield* makeHttp(stub, { hostHeader: 'zulip.example.com' }) + yield* http.uploadRaw('f.bin', new Uint8Array([0])) + const req = yield* firstRequest(stub) + expect(req.headers.get('host')).toBe('zulip.example.com') + }), +) // --- decodeUserUploadPath (comms-spj3.13) ---