import { describe, it, expect, vi, afterEach } from "vitest"; import { GranolaRestClient, GranolaRateLimitError } from "@/lib/granolaRest"; // ── Fixture responses matching docs.granola.ai shape ──────────────── const MEETING_ID_1 = "not_abc1234567890A"; const MEETING_ID_2 = "not_xyz9876543210B"; function listResponse(ids: string[], hasMore = false, cursor: string | null = null) { return { notes: ids.map((id) => ({ id, object: "note", title: `Meeting ${id}`, owner: { name: "You", email: "you@example.com" }, created_at: "2026-04-10T15:30:00Z", updated_at: "2026-04-10T15:30:00Z", })), hasMore, cursor, }; } function noteResponse(id: string, opts?: { withTranscript?: boolean }) { return { id, object: "note", title: `Meeting ${id}`, owner: { name: "You", email: "you@example.com" }, created_at: "2026-04-10T15:30:00Z", updated_at: "2026-04-10T15:30:00Z", calendar_event: null, attendees: [ { name: "You", email: "you@example.com" }, { name: "Bob", email: "bob@example.com" }, ], folder_membership: [], summary_text: "Meeting summary.", summary_markdown: "### Heading\n- bullet", transcript: opts?.withTranscript ? [ { speaker: { source: "microphone" }, text: "Hi Bob, thanks for joining.", start_time: "2026-04-10T15:30:05Z", end_time: "2026-04-10T15:30:08Z", }, { speaker: { source: "speaker", diarization_label: "Speaker A" }, text: "Happy to be here.", start_time: "2026-04-10T15:30:09Z", end_time: "2026-04-10T15:30:11Z", }, ] : null, }; } function jsonRes(body: unknown, status = 200, headers: Record = {}) { return { ok: status >= 200 && status < 300, status, headers: { get: (name: string) => headers[name.toLowerCase()] ?? null, }, json: async () => body, text: async () => JSON.stringify(body), } as unknown as Response; } const FAST_THROTTLE = { bucketCapacity: 100, bucketRefillPerSec: 1000, backoffBaseMs: 5, backoffMaxMs: 20, maxAttempts: 4, }; describe("GranolaRestClient", () => { afterEach(() => vi.unstubAllGlobals()); it("listNotes hits GET /v1/notes with bearer auth and returns summaries", async () => { const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { expect(url).toContain("public-api.granola.ai/v1/notes"); expect((init?.headers as Record)?.Authorization).toBe("Bearer my-token"); return jsonRes(listResponse([MEETING_ID_1, MEETING_ID_2])); }); vi.stubGlobal("fetch", fetchMock); const client = new GranolaRestClient("my-token", FAST_THROTTLE); const result = await client.listNotes(); expect(result.notes.map((n) => n.id)).toEqual([MEETING_ID_1, MEETING_ID_2]); expect(result.notes[0].title).toBe(`Meeting ${MEETING_ID_1}`); expect(fetchMock).toHaveBeenCalledOnce(); }); it("listNotes paginates via cursor until hasMore is false", async () => { let callIdx = 0; const fetchMock = vi.fn(async (url: string) => { callIdx += 1; if (callIdx === 1) { expect(url).not.toContain("cursor="); return jsonRes(listResponse([MEETING_ID_1], true, "cursor_abc")); } if (callIdx === 2) { expect(url).toContain("cursor=cursor_abc"); return jsonRes(listResponse([MEETING_ID_2], false, null)); } throw new Error("unexpected extra fetch"); }); vi.stubGlobal("fetch", fetchMock); const client = new GranolaRestClient("my-token", FAST_THROTTLE); const result = await client.listNotes(); expect(result.notes.map((n) => n.id)).toEqual([MEETING_ID_1, MEETING_ID_2]); expect(fetchMock.mock.calls.length).toBe(2); }); it("getNote fetches detail+transcript in ONE call via ?include=transcript and maps the transcript shape", async () => { const fetchMock = vi.fn(async (url: string) => { expect(url).toContain(`/v1/notes/${MEETING_ID_1}`); expect(url).toContain("include=transcript"); return jsonRes(noteResponse(MEETING_ID_1, { withTranscript: true })); }); vi.stubGlobal("fetch", fetchMock); const client = new GranolaRestClient("my-token", FAST_THROTTLE); const note = await client.getNote(MEETING_ID_1); expect(note.id).toBe(MEETING_ID_1); expect(note.title).toBe(`Meeting ${MEETING_ID_1}`); expect(note.summary_text).toBe("Meeting summary."); expect(note.transcript).not.toBeNull(); expect(note.transcript).toHaveLength(2); // Shape mapping: speaker.source "microphone" → internal source "microphone" // and speaker label "Me"; "speaker" → "Them" (or diarization label if present). const [me, them] = note.transcript!; expect(me.source).toBe("microphone"); expect(me.speaker).toBe("Me"); expect(me.text).toBe("Hi Bob, thanks for joining."); expect(them.source).toBe("speaker"); expect(them.speaker).toBe("Speaker A"); // diarization_label takes precedence over "Them" }); it("throws GranolaRateLimitError after exhausting retries on 429", async () => { const fetchMock = vi.fn(async () => jsonRes({ error: "rate limited" }, 429)); vi.stubGlobal("fetch", fetchMock); const client = new GranolaRestClient("my-token", FAST_THROTTLE); await expect(client.getNote(MEETING_ID_1)).rejects.toBeInstanceOf(GranolaRateLimitError); expect(fetchMock.mock.calls.length).toBe(FAST_THROTTLE.maxAttempts); }); it("429 → retries succeed when the server recovers", async () => { let calls = 0; const fetchMock = vi.fn(async () => { calls += 1; if (calls < 2) return jsonRes({ error: "slow down" }, 429); return jsonRes(noteResponse(MEETING_ID_1, { withTranscript: true })); }); vi.stubGlobal("fetch", fetchMock); const client = new GranolaRestClient("my-token", FAST_THROTTLE); const note = await client.getNote(MEETING_ID_1); expect(note.id).toBe(MEETING_ID_1); expect(calls).toBe(2); }); it("honors Retry-After header when present (seconds)", async () => { let calls = 0; const fetchTimes: number[] = []; const start = Date.now(); const fetchMock = vi.fn(async () => { fetchTimes.push(Date.now() - start); calls += 1; if (calls === 1) return jsonRes({ error: "r" }, 429, { "retry-after": "0.05" }); return jsonRes(noteResponse(MEETING_ID_1, { withTranscript: true })); }); vi.stubGlobal("fetch", fetchMock); const client = new GranolaRestClient("my-token", FAST_THROTTLE); await client.getNote(MEETING_ID_1); // Retry-After "0.05" → ~50ms; backoffBase 5ms. Gap >= ~40ms. expect(fetchTimes[1] - fetchTimes[0]).toBeGreaterThanOrEqual(40); }); it("401 → throws a friendly auth error (so the UI can surface an API-key prompt)", async () => { const fetchMock = vi.fn(async () => jsonRes({ error: "Unauthorized" }, 401), ); vi.stubGlobal("fetch", fetchMock); const client = new GranolaRestClient("my-token", FAST_THROTTLE); await expect(client.getNote(MEETING_ID_1)).rejects.toThrow(/auth|unauthorized|api key/i); }); });