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

meetings.test.ts

Language TypeScript7.3 KB178 lines

packages/fathom-client/test/meetings.test.ts

  1. /**
  2. * The Fathom client, pinned against how the API actually behaves.
  3. *
  4. * Two behaviours are worth pinning:
  5. *
  6. * The pagination sentinel is an empty string. Verified live: `next_cursor: ""`
  7. * with `limit: 10`, not null and not absent. A loop written as
  8. * `while (cursor !== undefined)` would page forever. Vendor docs and the wire
  9. * disagree often enough (Plinth's unassigned sentinel is documented as `---`
  10. * and sent as `--`) that the live probe came before this file.
  11. *
  12. * The 401 message must not diagnose. Fathom's FAQ lists four causes for a 401
  13. * that the response cannot tell apart, and a message asserting one of them
  14. * (say, "expired") can send someone to invalidate a working credential. The
  15. * test asserts the message offers candidates rather than picking one.
  16. */
  17. import { describe, expect, it } from 'vitest'
  18. import { FathomClient, FathomError } from '../src/http.js'
  19. import { MeetingsResource } from '../src/resources/meetings.js'
  20. const meeting = (id: number) => ({
  21. title: `Meeting ${id}`,
  22. recording_id: id,
  23. url: `https://fathom.video/calls/${id}`,
  24. created_at: '2026-09-02T14:00:00Z',
  25. shared_with: 'no_teams',
  26. calendar_invitees: [],
  27. recorded_by: { name: 'Sam Whitaker - Meridian Display', email: 'hello@meridiandisplay.example' },
  28. action_items: [],
  29. })
  30. /** Replays scripted responses and records the URLs asked for. */
  31. function fake(pages: { body: unknown; status?: number; headers?: Record<string, string> }[]) {
  32. const urls: string[] = []
  33. const fetchImpl = (async (url: string) => {
  34. urls.push(String(url))
  35. const p = pages[urls.length - 1] ?? { body: { items: [], next_cursor: '' } }
  36. return new Response(JSON.stringify(p.body), {
  37. status: p.status ?? 200,
  38. headers: { 'content-type': 'application/json', ...(p.headers ?? {}) },
  39. })
  40. }) as unknown as typeof fetch
  41. return { urls, fetchImpl }
  42. }
  43. const resource = (fetchImpl: typeof fetch, sleep = async () => {}) =>
  44. new MeetingsResource(new FathomClient({ apiKey: 'k', owner: 'Sam', fetchImpl, sleep }))
  45. describe('pagination stops on the empty-string cursor', () => {
  46. it('stops after one page when next_cursor is ""', async () => {
  47. const { urls, fetchImpl } = fake([{ body: { items: [meeting(1)], next_cursor: '', limit: 10 } }])
  48. const w = await resource(fetchImpl).listSince('2026-08-01T00:00:00Z')
  49. expect(w.meetings).toHaveLength(1)
  50. expect(w.pages).toBe(1)
  51. expect(w.truncated).toBe(false)
  52. expect(urls).toHaveLength(1)
  53. })
  54. it.each([[null], [undefined]])('also stops on next_cursor=%s', async (sentinel) => {
  55. const { fetchImpl } = fake([{ body: { items: [meeting(1)], next_cursor: sentinel } }])
  56. const w = await resource(fetchImpl).listSince('2026-08-01T00:00:00Z')
  57. expect(w.truncated).toBe(false)
  58. expect(w.pages).toBe(1)
  59. })
  60. it('follows a non-empty cursor and passes it back', async () => {
  61. const { urls, fetchImpl } = fake([
  62. { body: { items: [meeting(1)], next_cursor: 'CURSOR_A' } },
  63. { body: { items: [meeting(2)], next_cursor: '' } },
  64. ])
  65. const w = await resource(fetchImpl).listSince('2026-08-01T00:00:00Z')
  66. expect(w.meetings.map((m) => m.recording_id)).toEqual([1, 2])
  67. expect(urls[1]).toContain('cursor=CURSOR_A')
  68. expect(w.truncated).toBe(false)
  69. })
  70. })
  71. describe('a capped sweep is labelled, not silently short', () => {
  72. it('returns what it fetched with truncated:true', async () => {
  73. // Never throw away fetched data to guard against a hypothetical.
  74. const pages = Array.from({ length: 5 }, (_, i) => ({
  75. body: { items: [meeting(i + 1)], next_cursor: `MORE_${i}` },
  76. }))
  77. const w = await resource(fake(pages).fetchImpl).listSince('2026-08-01T00:00:00Z', 3)
  78. expect(w.pages).toBe(3)
  79. expect(w.meetings).toHaveLength(3)
  80. expect(w.truncated).toBe(true)
  81. })
  82. })
  83. describe('the request stays out of the heavy bucket', () => {
  84. it('asks for action items and NEVER for a transcript or summary', async () => {
  85. // include_summary / include_transcript move every call into the bucket that
  86. // Fathom can squeeze to five calls a minute. The board needs neither.
  87. const { urls, fetchImpl } = fake([{ body: { items: [], next_cursor: '' } }])
  88. await resource(fetchImpl).listSince('2026-08-01T00:00:00Z')
  89. expect(urls[0]).toContain('include_action_items=true')
  90. expect(urls[0]).not.toContain('include_transcript')
  91. expect(urls[0]).not.toContain('include_summary')
  92. })
  93. it('sends the window and an explicit page size', async () => {
  94. const { urls, fetchImpl } = fake([{ body: { items: [], next_cursor: '' } }])
  95. await resource(fetchImpl).listSince('2026-08-20T00:00:00Z')
  96. expect(urls[0]).toContain('created_after=2026-08-20')
  97. expect(urls[0]).toContain('limit=10')
  98. })
  99. })
  100. describe('rate limiting waits as long as Fathom says', () => {
  101. it('honours Retry-After and then succeeds', async () => {
  102. const slept: number[] = []
  103. const { fetchImpl } = fake([
  104. { body: {}, status: 429, headers: { 'retry-after': '7' } },
  105. { body: { items: [meeting(1)], next_cursor: '' } },
  106. ])
  107. const r = new MeetingsResource(
  108. new FathomClient({ apiKey: 'k', fetchImpl, sleep: async (ms) => void slept.push(ms) }),
  109. )
  110. const w = await r.listSince('2026-08-01T00:00:00Z')
  111. expect(slept).toEqual([7000])
  112. expect(w.meetings).toHaveLength(1)
  113. })
  114. it('gives up after the retry budget rather than looping', async () => {
  115. const { fetchImpl } = fake(
  116. Array.from({ length: 9 }, () => ({ body: {}, status: 429, headers: { 'retry-after': '1' } })),
  117. )
  118. const r = new MeetingsResource(
  119. new FathomClient({ apiKey: 'k', fetchImpl, sleep: async () => {}, maxRetries: 2 }),
  120. )
  121. await expect(r.listSince('2026-08-01T00:00:00Z')).rejects.toThrow(/rate limit/i)
  122. })
  123. })
  124. describe('auth failures report causes without picking one', () => {
  125. it('lists candidates and does not assert a single diagnosis', async () => {
  126. const { fetchImpl } = fake([{ body: {}, status: 401 }])
  127. const err = await resource(fetchImpl)
  128. .listSince('2026-08-01T00:00:00Z')
  129. .catch((e: unknown) => e as FathomError)
  130. expect(err).toBeInstanceOf(FathomError)
  131. const msg = (err as FathomError).message
  132. // Several causes offered, and the one the Plinth handler got wrong is absent.
  133. expect(msg).toMatch(/revoked/i)
  134. expect(msg).toMatch(/deactivated/i)
  135. expect(msg).not.toMatch(/most likely/i)
  136. })
  137. it('names whose key failed, since there is one key per person', async () => {
  138. const { fetchImpl } = fake([{ body: {}, status: 403 }])
  139. const err = await resource(fetchImpl)
  140. .listSince('2026-08-01T00:00:00Z')
  141. .catch((e: unknown) => e as FathomError)
  142. expect((err as FathomError).message).toContain('Sam')
  143. })
  144. it('refuses to construct without a key, and says where keys come from', () => {
  145. expect(() => new FathomClient({ apiKey: '' })).toThrow(/User Settings/)
  146. expect(() => new FathomClient({ apiKey: '' })).toThrow(/per USER/)
  147. })
  148. })
  149. describe('the client cannot write', () => {
  150. it('issues only GET', async () => {
  151. const seen: string[] = []
  152. const fetchImpl = (async (_u: string, init?: RequestInit) => {
  153. seen.push(init?.method ?? 'GET')
  154. return new Response(JSON.stringify({ items: [], next_cursor: '' }), {
  155. status: 200,
  156. headers: { 'content-type': 'application/json' },
  157. })
  158. }) as unknown as typeof fetch
  159. await resource(fetchImpl).listSince('2026-08-01T00:00:00Z')
  160. expect(seen).toEqual(['GET'])
  161. })
  162. })