Skip to content
← 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
Raw
// 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 }
}