← Public packages
@charlesmatte/granola
Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.
test/fake-granola.ts
87 lines · 5.9 KB · TypeScript// In-memory fake of the Granola public API (shapes from https://docs.granola.ai/api-reference/openapi.json).
// Synthetic data only.
export type Call = { method: string; path: string; query: Record<string, string>; body: unknown; auth: string | null }
const id = (prefix: string, n: number) => `${prefix}_${String(n).padStart(14, 'A')}`
export type NoteContent = { title?: string; summary_markdown?: string; private_notes_markdown?: string | null; attendees?: Array<{ name: string | null; email: string }>; transcript?: Array<{ text: string; attribution?: 'me' | 'them' }>; status?: number }
export function makeFake(opts: { notes?: number; transcriptItems?: number; tooLarge?: boolean; status429Once?: boolean; content?: Record<number, NoteContent>; badKey?: boolean } = {}) {
const calls: Array<Call> = []
const notes = Array.from({ length: opts.notes ?? 65 }, (_, i) => ({
id: id('not', i + 1),
object: 'note',
title: opts.content?.[i]?.title ?? (i % 2 ? `Weekly sync ${i}` : `Customer call ${i}`),
owner: { name: 'Sample Owner', email: i % 3 ? 'owner@example.com' : 'other@example.com' },
created_at: `2026-09-${String((i % 28) + 1).padStart(2, '0')}T15:00:00Z`,
updated_at: '2026-09-30T15:00:00Z',
}))
const transcript = Array.from({ length: opts.transcriptItems ?? 250 }, (_, i) => ({
speaker: { source: i % 2 ? 'speaker' : 'microphone', ...(i % 2 ? { attribution: 'them' } : { attribution: 'me' }) },
text: `line ${i}`,
start_time: new Date(Date.parse('2026-09-01T15:00:00Z') + i * 5000).toISOString(),
end_time: new Date(Date.parse('2026-09-01T15:00:00Z') + i * 5000 + 4000).toISOString(),
}))
const folders = [
{ id: id('fol', 1), object: 'folder', name: 'Clients', parent_folder_id: null },
{ id: id('fol', 2), object: 'folder', name: 'Acme', parent_folder_id: id('fol', 1) },
{ id: id('fol', 3), object: 'folder', name: 'Internal', parent_folder_id: null },
]
let throttled = !opts.status429Once
const page = <T>(items: Array<T>, q: Record<string, string>, key: string, max: number) => {
const size = Math.min(Number(q.page_size ?? 10), max)
const start = q.cursor ? Number(atob(q.cursor)) : 0
const slice = items.slice(start, start + size)
const next = start + size
return { [key]: slice, hasMore: next < items.length, cursor: next < items.length ? btoa(String(next)) : null }
}
const json = (status: number, data: unknown, headers: Record<string, string> = {}) =>
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', ...headers } })
const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit) => {
const u = new URL(String(input))
const method = (init?.method ?? 'GET').toUpperCase()
const q = Object.fromEntries(u.searchParams)
const auth = new Headers(init?.headers).get('Authorization')
calls.push({ method, path: u.pathname, query: q, body: init?.body ? JSON.parse(String(init.body)) : undefined, auth })
if (u.host !== 'public-api.granola.ai') throw new Error('fake: wrong host')
if (opts.badKey) return json(401, { error: { code: 'INVALID_API_KEY', message: 'Invalid API key format' } })
if (!throttled) {
throttled = true
return json(429, { error: { code: 'RATE_LIMITED', message: 'slow down' } }, { 'retry-after': '0' })
}
const p = u.pathname
if (method === 'GET' && p === '/v1/notes') return json(200, page(notes, q, 'notes', 30))
const noteMatch = p.match(/^\/v1\/notes\/(not_\w+)(\/transcript)?$/)
if (method === 'GET' && noteMatch) {
const note = notes.find((n) => n.id === noteMatch[1])
if (!note) return json(404, { error: { code: 'NOT_FOUND', message: 'Note not found' } })
const c = opts.content?.[notes.indexOf(note)]
if (c?.status) return json(c.status, { error: { code: 'BOOM', message: 'fake failure' } })
const noteTranscript = c?.transcript
? c.transcript.map((t, k) => ({ speaker: { source: t.attribution === 'them' ? 'speaker' : 'microphone', attribution: t.attribution ?? 'me' }, text: t.text, start_time: new Date(Date.parse('2026-09-01T15:00:00Z') + k * 5000).toISOString(), end_time: new Date(Date.parse('2026-09-01T15:00:00Z') + k * 5000 + 4000).toISOString() }))
: transcript.slice(0, 5)
if (noteMatch[2]) return json(200, page(transcript, q, 'transcript', 100))
if (q.include === 'transcript' && opts.tooLarge) return json(413, { error: { code: 'TRANSCRIPT_TOO_LARGE', message: 'too large' } })
return json(200, {
...note,
web_url: `https://notes.granola.ai/d/${note.id}`,
calendar_event: null,
attendees: c?.attendees ?? [{ name: 'Sample Owner', email: 'owner@example.com' }],
folder_membership: [folders[1]],
summary_text: (c?.summary_markdown ?? '## Summary').replace(/[#*]/g, ''),
summary_markdown: c?.summary_markdown ?? '## Summary',
private_notes_text: c?.private_notes_markdown ?? null,
private_notes_markdown: c?.private_notes_markdown ?? null,
...(q.include === 'transcript' ? { transcript: noteTranscript } : {}),
})
}
if (method === 'GET' && p === '/v1/folders') return json(200, page(folders, q, 'folders', 30))
if (method === 'GET' && p === '/v1/audit') return json(403, { error: { code: 'FORBIDDEN', message: 'admin only' } })
if (method === 'GET' && p === '/v1/webhook-endpoints')
return json(200, { webhook_endpoints: [{ id: id('whe', 1), object: 'webhook_endpoint', url: 'https://example.com/hook', events: ['note.generated'], scopes: ['personal'], folder_ids: [], enabled: true, created_by: null, created_at: '2026-09-01T00:00:00Z' }] })
if (method === 'POST' && p === '/v1/webhook-endpoints') return json(201, { id: id('whe', 2), object: 'webhook_endpoint', ...(JSON.parse(String(init?.body))), enabled: true, signing_secret: 'whsec_FAKEFAKEFAKE' })
if (method === 'GET' && p.startsWith('/v1/legal-holds')) return json(404, { error: { code: 'NOT_FOUND', message: 'Not found' } })
return json(404, { error: { code: 'NOT_FOUND', message: `fake: no route ${method} ${p}` } })
}
return { calls, fetchImpl, notes, folders, transcript }
}