meetings.test.ts
Language TypeScript7.3 KB178 lines
/*** The Fathom client, pinned against how the API actually behaves.** Two behaviours are worth pinning:** The pagination sentinel is an empty string. Verified live: `next_cursor: ""`* with `limit: 10`, not null and not absent. A loop written as* `while (cursor !== undefined)` would page forever. Vendor docs and the wire* disagree often enough (Plinth's unassigned sentinel is documented as `---`* and sent as `--`) that the live probe came before this file.** The 401 message must not diagnose. Fathom's FAQ lists four causes for a 401* that the response cannot tell apart, and a message asserting one of them* (say, "expired") can send someone to invalidate a working credential. The* test asserts the message offers candidates rather than picking one.*/import { describe, expect, it } from 'vitest'import { FathomClient, FathomError } from '../src/http.js'import { MeetingsResource } from '../src/resources/meetings.js'const meeting = (id: number) => ({title: `Meeting ${id}`,recording_id: id,url: `https://fathom.video/calls/${id}`,created_at: '2026-09-02T14:00:00Z',shared_with: 'no_teams',calendar_invitees: [],recorded_by: { name: 'Sam Whitaker - Meridian Display', email: 'hello@meridiandisplay.example' },action_items: [],})/** Replays scripted responses and records the URLs asked for. */function fake(pages: { body: unknown; status?: number; headers?: Record<string, string> }[]) {const urls: string[] = []const fetchImpl = (async (url: string) => {urls.push(String(url))const p = pages[urls.length - 1] ?? { body: { items: [], next_cursor: '' } }return new Response(JSON.stringify(p.body), {status: p.status ?? 200,headers: { 'content-type': 'application/json', ...(p.headers ?? {}) },})}) as unknown as typeof fetchreturn { urls, fetchImpl }}const resource = (fetchImpl: typeof fetch, sleep = async () => {}) =>new MeetingsResource(new FathomClient({ apiKey: 'k', owner: 'Sam', fetchImpl, sleep }))describe('pagination stops on the empty-string cursor', () => {it('stops after one page when next_cursor is ""', async () => {const { urls, fetchImpl } = fake([{ body: { items: [meeting(1)], next_cursor: '', limit: 10 } }])const w = await resource(fetchImpl).listSince('2026-08-01T00:00:00Z')expect(w.meetings).toHaveLength(1)expect(w.pages).toBe(1)expect(w.truncated).toBe(false)expect(urls).toHaveLength(1)})it.each([[null], [undefined]])('also stops on next_cursor=%s', async (sentinel) => {const { fetchImpl } = fake([{ body: { items: [meeting(1)], next_cursor: sentinel } }])const w = await resource(fetchImpl).listSince('2026-08-01T00:00:00Z')expect(w.truncated).toBe(false)expect(w.pages).toBe(1)})it('follows a non-empty cursor and passes it back', async () => {const { urls, fetchImpl } = fake([{ body: { items: [meeting(1)], next_cursor: 'CURSOR_A' } },{ body: { items: [meeting(2)], next_cursor: '' } },])const w = await resource(fetchImpl).listSince('2026-08-01T00:00:00Z')expect(w.meetings.map((m) => m.recording_id)).toEqual([1, 2])expect(urls[1]).toContain('cursor=CURSOR_A')expect(w.truncated).toBe(false)})})describe('a capped sweep is labelled, not silently short', () => {it('returns what it fetched with truncated:true', async () => {// Never throw away fetched data to guard against a hypothetical.const pages = Array.from({ length: 5 }, (_, i) => ({body: { items: [meeting(i + 1)], next_cursor: `MORE_${i}` },}))const w = await resource(fake(pages).fetchImpl).listSince('2026-08-01T00:00:00Z', 3)expect(w.pages).toBe(3)expect(w.meetings).toHaveLength(3)expect(w.truncated).toBe(true)})})describe('the request stays out of the heavy bucket', () => {it('asks for action items and NEVER for a transcript or summary', async () => {// include_summary / include_transcript move every call into the bucket that// Fathom can squeeze to five calls a minute. The board needs neither.const { urls, fetchImpl } = fake([{ body: { items: [], next_cursor: '' } }])await resource(fetchImpl).listSince('2026-08-01T00:00:00Z')expect(urls[0]).toContain('include_action_items=true')expect(urls[0]).not.toContain('include_transcript')expect(urls[0]).not.toContain('include_summary')})it('sends the window and an explicit page size', async () => {const { urls, fetchImpl } = fake([{ body: { items: [], next_cursor: '' } }])await resource(fetchImpl).listSince('2026-08-20T00:00:00Z')expect(urls[0]).toContain('created_after=2026-08-20')expect(urls[0]).toContain('limit=10')})})describe('rate limiting waits as long as Fathom says', () => {it('honours Retry-After and then succeeds', async () => {const slept: number[] = []const { fetchImpl } = fake([{ body: {}, status: 429, headers: { 'retry-after': '7' } },{ body: { items: [meeting(1)], next_cursor: '' } },])const r = new MeetingsResource(new FathomClient({ apiKey: 'k', fetchImpl, sleep: async (ms) => void slept.push(ms) }),)const w = await r.listSince('2026-08-01T00:00:00Z')expect(slept).toEqual([7000])expect(w.meetings).toHaveLength(1)})it('gives up after the retry budget rather than looping', async () => {const { fetchImpl } = fake(Array.from({ length: 9 }, () => ({ body: {}, status: 429, headers: { 'retry-after': '1' } })),)const r = new MeetingsResource(new FathomClient({ apiKey: 'k', fetchImpl, sleep: async () => {}, maxRetries: 2 }),)await expect(r.listSince('2026-08-01T00:00:00Z')).rejects.toThrow(/rate limit/i)})})describe('auth failures report causes without picking one', () => {it('lists candidates and does not assert a single diagnosis', async () => {const { fetchImpl } = fake([{ body: {}, status: 401 }])const err = await resource(fetchImpl).listSince('2026-08-01T00:00:00Z').catch((e: unknown) => e as FathomError)expect(err).toBeInstanceOf(FathomError)const msg = (err as FathomError).message// Several causes offered, and the one the Plinth handler got wrong is absent.expect(msg).toMatch(/revoked/i)expect(msg).toMatch(/deactivated/i)expect(msg).not.toMatch(/most likely/i)})it('names whose key failed, since there is one key per person', async () => {const { fetchImpl } = fake([{ body: {}, status: 403 }])const err = await resource(fetchImpl).listSince('2026-08-01T00:00:00Z').catch((e: unknown) => e as FathomError)expect((err as FathomError).message).toContain('Sam')})it('refuses to construct without a key, and says where keys come from', () => {expect(() => new FathomClient({ apiKey: '' })).toThrow(/User Settings/)expect(() => new FathomClient({ apiKey: '' })).toThrow(/per USER/)})})describe('the client cannot write', () => {it('issues only GET', async () => {const seen: string[] = []const fetchImpl = (async (_u: string, init?: RequestInit) => {seen.push(init?.method ?? 'GET')return new Response(JSON.stringify({ items: [], next_cursor: '' }), {status: 200,headers: { 'content-type': 'application/json' },})}) as unknown as typeof fetchawait resource(fetchImpl).listSince('2026-08-01T00:00:00Z')expect(seen).toEqual(['GET'])})})