http.test.ts
Language TypeScript5.9 KB149 lines
import { describe, expect, it, vi } from 'vitest'import { HttpClient } from '../src/http.js'import { NinetyAuthError, NinetyForbiddenError, NinetyRateLimitError, NinetyError } from '../src/errors.js'const json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } })function make(fetchImpl: typeof fetch, opts: Partial<ConstructorParameters<typeof HttpClient>[0]> = {}) {return new HttpClient({token: 'test-token',fetchImpl,sleep: async () => {}, // no real waiting in testsrps: 1000,...opts,})}describe('HttpClient', () => {it('sends the bearer token', async () => {const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer test-token')return json({ ok: true })}) as unknown as typeof fetchawait make(fetchImpl).get('/teams')expect(fetchImpl).toHaveBeenCalledOnce()})it('POSTs a JSON body to the query endpoints', async () => {const fetchImpl = vi.fn(async (_u: string | URL | Request, init?: RequestInit) => {expect(init?.method).toBe('POST')expect(JSON.parse(String(init?.body))).toEqual({ page: 1 })return json([])}) as unknown as typeof fetchawait make(fetchImpl).post('/todos/query', { body: { page: 1 } })})it('retries a 429 and then succeeds', async () => {let calls = 0const fetchImpl = (async () => {calls++return calls < 3 ? json({ msg: 'slow down' }, 429) : json({ ok: true })}) as unknown as typeof fetchconst out = await make(fetchImpl).get('/teams')expect(out).toEqual({ ok: true })expect(calls).toBe(3)})it('throws NinetyRateLimitError once retries are exhausted', async () => {const fetchImpl = (async () => json({}, 429)) as unknown as typeof fetchawait expect(make(fetchImpl, { maxRetries: 1 }).get('/teams')).rejects.toBeInstanceOf(NinetyRateLimitError)})it('retries 5xx but not 400', async () => {let calls = 0const server = (async () => {calls++return json({}, 503)}) as unknown as typeof fetchawait expect(make(server, { maxRetries: 2 }).get('/teams')).rejects.toBeInstanceOf(NinetyError)expect(calls).toBe(3)let badCalls = 0const bad = (async () => {badCalls++return json({}, 400)}) as unknown as typeof fetchawait expect(make(bad).get('/teams')).rejects.toBeInstanceOf(NinetyError)expect(badCalls).toBe(1)})it('maps 401 to an actionable message that does NOT guess a cause', async () => {// A 401 says rejected. The cause is not in the response, and asserting a// diagnosis such as "expired" would write a guess down as a requirement;// regenerating a token on that guess invalidates a working credential.const fetchImpl = (async () => json({}, 401)) as unknown as typeof fetchawait expect(make(fetchImpl).get('/teams')).rejects.toBeInstanceOf(NinetyAuthError)let message = ''try {await make(fetchImpl).get('/teams')} catch (err) {message = err instanceof Error ? err.message : String(err)}// Actionable: names the variable, and orders the checks.expect(message).toContain('NINETY_TOKEN')expect(message).toMatch(/does not say why/i)// And it must not present any single cause as the likely one.expect(message).not.toMatch(/most likely|probably|has expired\b/i)})it('maps 403 to a plan/Observer message', async () => {const fetchImpl = (async () => json({}, 403)) as unknown as typeof fetchawait expect(make(fetchImpl).get('/teams')).rejects.toThrow(/Observer/)})it('names the endpoint and page in the error', async () => {const fetchImpl = (async () => json({}, 400)) as unknown as typeof fetchawait expect(make(fetchImpl).post('/todos/query', { pageIndex: 7 })).rejects.toThrow(/POST \/todos\/query \(page 7\)/,)})it('refuses to construct without a token', () => {expect(() => new HttpClient({ token: '' })).toThrow(/NINETY_TOKEN/)})})/*** A failure must not explain itself with a status it did not get.** A 403's explanation ("/todos/company is restricted to company Owners and* Admins") appended to every Ninety failure would sit beside a 429 and send* somebody to check a role while a rate limit cleared itself (rule 1). The* endpoint-specific note lives in the 403, where the status and the path are* both known, and the callers append nothing.*/describe('errors explain only what happened', () => {const ctx = (endpoint: string) => ({ endpoint })it('does not mention 403 or roles when the failure was a rate limit', () => {const err = new NinetyRateLimitError(ctx('GET /todos/company'), 1000)expect(err.status).toBe(429)expect(err.message).not.toMatch(/403/)expect(err.message).not.toMatch(/owner|admin|role/i)})it('carries the endpoint-specific role note on a 403 that hit that endpoint', () => {const err = new NinetyForbiddenError(ctx('GET /todos/company'))expect(err.status).toBe(403)expect(err.message).toMatch(/owners and admins/i)})it('leaves it off a 403 that hit some other endpoint', () => {// The restriction is a fact about /todos/company, not about Ninety.const err = new NinetyForbiddenError(ctx('GET /teams'))expect(err.message).not.toMatch(/owners and admins/i)// The part that IS true of every 403 stays.expect(err.message).toMatch(/observer/i)})it('an auth failure lists candidate causes rather than picking one', () => {// The message must not assert which cause it is, and must leave retrying on// the table.const err = new NinetyAuthError(ctx('GET /todos/company'))expect(err.message).not.toMatch(/most likely|probably|has expired\b/i)expect(err.message).toMatch(/transient/i)})})