mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

http.test.ts

Language TypeScript5.9 KB149 lines

packages/ninety-client/test/http.test.ts

  1. import { describe, expect, it, vi } from 'vitest'
  2. import { HttpClient } from '../src/http.js'
  3. import { NinetyAuthError, NinetyForbiddenError, NinetyRateLimitError, NinetyError } from '../src/errors.js'
  4. const json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>
  5. new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } })
  6. function make(fetchImpl: typeof fetch, opts: Partial<ConstructorParameters<typeof HttpClient>[0]> = {}) {
  7. return new HttpClient({
  8. token: 'test-token',
  9. fetchImpl,
  10. sleep: async () => {}, // no real waiting in tests
  11. rps: 1000,
  12. ...opts,
  13. })
  14. }
  15. describe('HttpClient', () => {
  16. it('sends the bearer token', async () => {
  17. const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
  18. expect((init?.headers as Record<string, string>).Authorization).toBe('Bearer test-token')
  19. return json({ ok: true })
  20. }) as unknown as typeof fetch
  21. await make(fetchImpl).get('/teams')
  22. expect(fetchImpl).toHaveBeenCalledOnce()
  23. })
  24. it('POSTs a JSON body to the query endpoints', async () => {
  25. const fetchImpl = vi.fn(async (_u: string | URL | Request, init?: RequestInit) => {
  26. expect(init?.method).toBe('POST')
  27. expect(JSON.parse(String(init?.body))).toEqual({ page: 1 })
  28. return json([])
  29. }) as unknown as typeof fetch
  30. await make(fetchImpl).post('/todos/query', { body: { page: 1 } })
  31. })
  32. it('retries a 429 and then succeeds', async () => {
  33. let calls = 0
  34. const fetchImpl = (async () => {
  35. calls++
  36. return calls < 3 ? json({ msg: 'slow down' }, 429) : json({ ok: true })
  37. }) as unknown as typeof fetch
  38. const out = await make(fetchImpl).get('/teams')
  39. expect(out).toEqual({ ok: true })
  40. expect(calls).toBe(3)
  41. })
  42. it('throws NinetyRateLimitError once retries are exhausted', async () => {
  43. const fetchImpl = (async () => json({}, 429)) as unknown as typeof fetch
  44. await expect(make(fetchImpl, { maxRetries: 1 }).get('/teams')).rejects.toBeInstanceOf(NinetyRateLimitError)
  45. })
  46. it('retries 5xx but not 400', async () => {
  47. let calls = 0
  48. const server = (async () => {
  49. calls++
  50. return json({}, 503)
  51. }) as unknown as typeof fetch
  52. await expect(make(server, { maxRetries: 2 }).get('/teams')).rejects.toBeInstanceOf(NinetyError)
  53. expect(calls).toBe(3)
  54. let badCalls = 0
  55. const bad = (async () => {
  56. badCalls++
  57. return json({}, 400)
  58. }) as unknown as typeof fetch
  59. await expect(make(bad).get('/teams')).rejects.toBeInstanceOf(NinetyError)
  60. expect(badCalls).toBe(1)
  61. })
  62. it('maps 401 to an actionable message that does NOT guess a cause', async () => {
  63. // A 401 says rejected. The cause is not in the response, and asserting a
  64. // diagnosis such as "expired" would write a guess down as a requirement;
  65. // regenerating a token on that guess invalidates a working credential.
  66. const fetchImpl = (async () => json({}, 401)) as unknown as typeof fetch
  67. await expect(make(fetchImpl).get('/teams')).rejects.toBeInstanceOf(NinetyAuthError)
  68. let message = ''
  69. try {
  70. await make(fetchImpl).get('/teams')
  71. } catch (err) {
  72. message = err instanceof Error ? err.message : String(err)
  73. }
  74. // Actionable: names the variable, and orders the checks.
  75. expect(message).toContain('NINETY_TOKEN')
  76. expect(message).toMatch(/does not say why/i)
  77. // And it must not present any single cause as the likely one.
  78. expect(message).not.toMatch(/most likely|probably|has expired\b/i)
  79. })
  80. it('maps 403 to a plan/Observer message', async () => {
  81. const fetchImpl = (async () => json({}, 403)) as unknown as typeof fetch
  82. await expect(make(fetchImpl).get('/teams')).rejects.toThrow(/Observer/)
  83. })
  84. it('names the endpoint and page in the error', async () => {
  85. const fetchImpl = (async () => json({}, 400)) as unknown as typeof fetch
  86. await expect(make(fetchImpl).post('/todos/query', { pageIndex: 7 })).rejects.toThrow(
  87. /POST \/todos\/query \(page 7\)/,
  88. )
  89. })
  90. it('refuses to construct without a token', () => {
  91. expect(() => new HttpClient({ token: '' })).toThrow(/NINETY_TOKEN/)
  92. })
  93. })
  94. /**
  95. * A failure must not explain itself with a status it did not get.
  96. *
  97. * A 403's explanation ("/todos/company is restricted to company Owners and
  98. * Admins") appended to every Ninety failure would sit beside a 429 and send
  99. * somebody to check a role while a rate limit cleared itself (rule 1). The
  100. * endpoint-specific note lives in the 403, where the status and the path are
  101. * both known, and the callers append nothing.
  102. */
  103. describe('errors explain only what happened', () => {
  104. const ctx = (endpoint: string) => ({ endpoint })
  105. it('does not mention 403 or roles when the failure was a rate limit', () => {
  106. const err = new NinetyRateLimitError(ctx('GET /todos/company'), 1000)
  107. expect(err.status).toBe(429)
  108. expect(err.message).not.toMatch(/403/)
  109. expect(err.message).not.toMatch(/owner|admin|role/i)
  110. })
  111. it('carries the endpoint-specific role note on a 403 that hit that endpoint', () => {
  112. const err = new NinetyForbiddenError(ctx('GET /todos/company'))
  113. expect(err.status).toBe(403)
  114. expect(err.message).toMatch(/owners and admins/i)
  115. })
  116. it('leaves it off a 403 that hit some other endpoint', () => {
  117. // The restriction is a fact about /todos/company, not about Ninety.
  118. const err = new NinetyForbiddenError(ctx('GET /teams'))
  119. expect(err.message).not.toMatch(/owners and admins/i)
  120. // The part that IS true of every 403 stays.
  121. expect(err.message).toMatch(/observer/i)
  122. })
  123. it('an auth failure lists candidate causes rather than picking one', () => {
  124. // The message must not assert which cause it is, and must leave retrying on
  125. // the table.
  126. const err = new NinetyAuthError(ctx('GET /todos/company'))
  127. expect(err.message).not.toMatch(/most likely|probably|has expired\b/i)
  128. expect(err.message).toMatch(/transient/i)
  129. })
  130. })