Files
zeekay b758c19e05 feat(notion): Hanzo AI for Notion — OAuth integration + web app
Notion has no in-app UI-extension surface, so this ships a public OAuth
integration + REST v1 client and a small web app that operates on the
user's pages/databases. Four actions over a page:

- Summarize: recursively read blocks -> text, summarize via @hanzo/ai,
  append as a callout.
- Draft/expand: generate content continuing the page.
- Extract -> database: pull {task, owner, due} action items and create
  one database row per item.
- Ask: freeform Q&A over page context.

Architecture mirrors @hanzo/clio: pure, unit-tested request/blocks/chat
modules with thin DOM + http glue. The Notion client_secret AND bot token
stay server-side (server.ts) — the browser holds only an opaque session id
and proxies every Notion call through an allow-listed /notion/* proxy.
Model calls route through the createAiClient contract to api.hanzo.ai/v1.

104 vitest tests (OAuth shaping, REST wrappers + pagination, blocks<->text
both directions, @hanzo/ai request shaping, context assembly/truncation,
action-item parsing, proxy allow-list). tsc --noEmit clean; esbuild builds.
2026-07-03 19:38:28 -07:00

205 lines
7.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi } from 'vitest';
import {
retrieveBlockChildren,
appendChildren,
retrievePage,
createPage,
retrieveDatabase,
queryDatabase,
search,
readListPage,
notionFetch,
paginate,
NOTION_MAX_PAGE_SIZE,
} from '../src/notion-api';
import { NOTION_VERSION } from '../src/config';
const TOK = 'secret_bot';
const root = 'https://api.notion.com/v1';
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
text: async () => JSON.stringify(body),
json: async () => body,
} as unknown as Response;
}
describe('read wrappers', () => {
it('retrieveBlockChildren builds GET with auth + version, no page params by default', () => {
const req = retrieveBlockChildren({ token: TOK, blockId: 'PAGE' });
expect(req).toMatchObject({ url: `${root}/blocks/PAGE/children`, method: 'GET' });
expect(req.headers.Authorization).toBe(`Bearer ${TOK}`);
expect(req.headers['Notion-Version']).toBe(NOTION_VERSION);
expect(req.headers['Content-Type']).toBeUndefined();
});
it('retrieveBlockChildren adds start_cursor + capped page_size', () => {
const req = retrieveBlockChildren({ token: TOK, blockId: 'B', startCursor: 'cur', pageSize: 500 });
const u = new URL(req.url);
expect(u.searchParams.get('start_cursor')).toBe('cur');
expect(u.searchParams.get('page_size')).toBe(String(NOTION_MAX_PAGE_SIZE));
});
it('retrievePage + retrieveDatabase target the right paths', () => {
expect(retrievePage({ token: TOK, pageId: 'P1' }).url).toBe(`${root}/pages/P1`);
expect(retrieveDatabase({ token: TOK, databaseId: 'D1' }).url).toBe(`${root}/databases/D1`);
});
});
describe('write wrappers', () => {
it('appendChildren PATCHes children under a page and JSON-content-types', () => {
const kids = [{ type: 'paragraph' }];
const req = appendChildren({ token: TOK, blockId: 'PAGE', children: kids });
expect(req).toMatchObject({ url: `${root}/blocks/PAGE/children`, method: 'PATCH' });
expect(req.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(req.body!)).toEqual({ children: kids });
});
it('appendChildren carries an `after` anchor when given', () => {
const req = appendChildren({ token: TOK, blockId: 'PAGE', children: [], after: 'BLK' });
expect(JSON.parse(req.body!).after).toBe('BLK');
});
it('createPage posts parent + properties (database row)', () => {
const req = createPage({
token: TOK,
parent: { database_id: 'DB' },
properties: { Name: { title: [] } },
});
expect(req).toMatchObject({ url: `${root}/pages`, method: 'POST' });
const body = JSON.parse(req.body!);
expect(body.parent).toEqual({ database_id: 'DB' });
expect(body.properties).toEqual({ Name: { title: [] } });
expect(body.children).toBeUndefined();
});
it('createPage includes children only when non-empty', () => {
const req = createPage({
token: TOK,
parent: { page_id: 'P' },
properties: {},
children: [{ type: 'paragraph' }],
});
expect(JSON.parse(req.body!).children).toHaveLength(1);
});
});
describe('body-paginated wrappers', () => {
it('queryDatabase puts filter/sorts/cursor in the JSON body', () => {
const req = queryDatabase({
token: TOK,
databaseId: 'DB',
filter: { property: 'Done', checkbox: { equals: false } },
sorts: [{ property: 'Due', direction: 'ascending' }],
startCursor: 'c1',
pageSize: 25,
});
expect(req).toMatchObject({ url: `${root}/databases/DB/query`, method: 'POST' });
const body = JSON.parse(req.body!);
expect(body.filter).toEqual({ property: 'Done', checkbox: { equals: false } });
expect(body.sorts).toHaveLength(1);
expect(body.start_cursor).toBe('c1');
expect(body.page_size).toBe(25);
});
it('search wraps the object filter in Notion’s { value, property } shape', () => {
const req = search({ token: TOK, query: 'roadmap', filterObject: 'page' });
expect(req).toMatchObject({ url: `${root}/search`, method: 'POST' });
const body = JSON.parse(req.body!);
expect(body.query).toBe('roadmap');
expect(body.filter).toEqual({ value: 'page', property: 'object' });
});
it('search omits query/filter when not given', () => {
const body = JSON.parse(search({ token: TOK }).body!);
expect(body.query).toBeUndefined();
expect(body.filter).toBeUndefined();
});
});
describe('readListPage', () => {
it('extracts results + paging', () => {
const page = readListPage({ results: [1, 2], has_more: true, next_cursor: 'nx' });
expect(page.results).toEqual([1, 2]);
expect(page.hasMore).toBe(true);
expect(page.nextCursor).toBe('nx');
});
it('defaults missing fields safely', () => {
const page = readListPage({});
expect(page.results).toEqual([]);
expect(page.hasMore).toBe(false);
expect(page.nextCursor).toBe('');
});
it('throws on Notion’s error envelope', () => {
expect(() => readListPage({ object: 'error', code: 'unauthorized', message: 'bad token' })).toThrow(
/Notion API error: bad token/,
);
});
});
describe('notionFetch', () => {
it('parses JSON on ok', async () => {
const doFetch = vi.fn().mockResolvedValue(jsonResponse({ ok: 1 }));
const req = retrievePage({ token: TOK, pageId: 'P' });
await expect(notionFetch(req, doFetch as any)).resolves.toEqual({ ok: 1 });
expect(doFetch).toHaveBeenCalledWith(req.url, {
method: 'GET',
headers: req.headers,
body: undefined,
});
});
it('surfaces Notion’s message on non-ok', async () => {
const doFetch = vi.fn().mockResolvedValue(jsonResponse({ message: 'object not found' }, false, 404));
await expect(notionFetch(retrievePage({ token: TOK, pageId: 'X' }), doFetch as any)).rejects.toThrow(
/Notion API 404: object not found/,
);
});
it('reports a non-JSON body with the status', async () => {
const bad = { ok: false, status: 502, text: async () => '<html>bad gateway</html>' } as unknown as Response;
const doFetch = vi.fn().mockResolvedValue(bad);
await expect(notionFetch(retrievePage({ token: TOK, pageId: 'X' }), doFetch as any)).rejects.toThrow(
/Notion API 502/,
);
});
});
describe('paginate', () => {
it('walks every page via has_more/next_cursor and collects all results', async () => {
const pages = [
{ results: ['a', 'b'], has_more: true, next_cursor: 'c1' },
{ results: ['c'], has_more: true, next_cursor: 'c2' },
{ results: ['d'], has_more: false, next_cursor: null },
];
let i = 0;
const doFetch = vi.fn().mockImplementation(async () => jsonResponse(pages[i++]));
const seen: (string | undefined)[] = [];
const all = await paginate<string>(
(cursor) => {
seen.push(cursor);
return retrieveBlockChildren({ token: TOK, blockId: 'P', startCursor: cursor });
},
{ doFetch: doFetch as any },
);
expect(all).toEqual(['a', 'b', 'c', 'd']);
expect(seen).toEqual([undefined, 'c1', 'c2']); // cursor threads forward
});
it('stops at maxPages even if has_more stays true', async () => {
const doFetch = vi
.fn()
.mockResolvedValue(jsonResponse({ results: ['x'], has_more: true, next_cursor: 'loop' }));
const all = await paginate<string>(() => retrieveBlockChildren({ token: TOK, blockId: 'P' }), {
doFetch: doFetch as any,
maxPages: 3,
});
expect(all).toEqual(['x', 'x', 'x']);
expect(doFetch).toHaveBeenCalledTimes(3);
});
});