Files
zeekay 94476b43e2 feat(hubspot): Hanzo AI for HubSpot — CRM card + AI over records (@hanzo/hubspot)
A HubSpot public app on the developer-projects platform: a CRM card UI
extension (crm.record.tab, on contacts/companies/deals) backed by two
serverless functions. Summarize a record, draft an email, suggest next
steps, or ask — grounded in the record + its associated records — and
write the result back as a Note or Task.

Built on the published @hanzo/ai@^0.2.0 headless client (createAiClient,
/v1 chat.completions + models.list) + @hanzo/iam. The Hanzo API key
(HANZO_API_KEY secret) and the per-portal OAuth token live only in the
serverless backend; the browser never sees them.

Pure, unit-tested core (config, hanzo call + context windowing + the four
actions, HubSpot CRM v3 request shaping/parsing, OAuth token exchange,
record→context assembly, Note/Task write-back payloads). 75 vitest tests
green, tsc --noEmit clean, dual ESM+CJS build so the CJS serverless
runtime can require it.
2026-07-03 20:53:24 -07:00

116 lines
3.9 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
authorizeUrl,
tokenExchange,
refreshExchange,
parseTokenResponse,
} from '../src/oauth.js';
describe('authorizeUrl', () => {
it('builds the authorize URL with space-joined scopes and state', () => {
const url = authorizeUrl({
clientId: 'cid',
redirectUri: 'https://hubspot.hanzo.ai/oauth/callback',
scopes: ['crm.objects.contacts.read', 'crm.objects.contacts.write'],
state: 'csrf123',
});
const u = new URL(url);
expect(u.origin + u.pathname).toBe('https://app.hubspot.com/oauth/authorize');
expect(u.searchParams.get('client_id')).toBe('cid');
expect(u.searchParams.get('redirect_uri')).toBe('https://hubspot.hanzo.ai/oauth/callback');
expect(u.searchParams.get('scope')).toBe(
'crm.objects.contacts.read crm.objects.contacts.write',
);
expect(u.searchParams.get('state')).toBe('csrf123');
expect(u.searchParams.get('optional_scope')).toBeNull();
});
it('adds optional_scope only when optional scopes are given', () => {
const url = authorizeUrl({
clientId: 'cid',
redirectUri: 'https://x/cb',
scopes: ['a'],
state: 's',
optionalScopes: ['b', 'c'],
});
expect(new URL(url).searchParams.get('optional_scope')).toBe('b c');
});
});
describe('tokenExchange', () => {
it('shapes the code→token request as a form body on /oauth/v1/token', () => {
const req = tokenExchange({
clientId: 'cid',
clientSecret: 'secret',
redirectUri: 'https://x/cb',
code: 'abc',
});
expect(req.url).toBe('https://api.hubapi.com/oauth/v1/token');
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('client_id')).toBe('cid');
expect(body.get('client_secret')).toBe('secret');
expect(body.get('redirect_uri')).toBe('https://x/cb');
expect(body.get('code')).toBe('abc');
});
it('keeps the secret out of the URL', () => {
const req = tokenExchange({ clientId: 'c', clientSecret: 'topsecret', redirectUri: 'r', code: 'x' });
expect(req.url).not.toContain('topsecret');
});
});
describe('refreshExchange', () => {
it('shapes a refresh_token grant without a redirect_uri', () => {
const req = refreshExchange({ clientId: 'cid', clientSecret: 'secret', refreshToken: 'rt' });
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('rt');
expect(body.get('redirect_uri')).toBeNull();
});
});
describe('parseTokenResponse', () => {
it('parses a valid token set', () => {
const t = parseTokenResponse({
access_token: 'at',
refresh_token: 'rt',
expires_in: 1800,
token_type: 'bearer',
});
expect(t).toEqual({
access_token: 'at',
refresh_token: 'rt',
expires_in: 1800,
token_type: 'bearer',
});
});
it('throws on a HubSpot { status: "error" } payload with its message', () => {
expect(() =>
parseTokenResponse({ status: 'error', message: 'invalid code' }),
).toThrow(/invalid code/);
});
it('throws on an OAuth2 error payload', () => {
expect(() =>
parseTokenResponse({ error: 'invalid_grant', error_description: 'expired' }),
).toThrow(/expired/);
});
it('throws when access_token is missing', () => {
expect(() => parseTokenResponse({ token_type: 'bearer' })).toThrow(/missing access_token/);
});
it('throws on an empty response', () => {
expect(() => parseTokenResponse(null)).toThrow(/empty token response/);
});
it('drops non-string optional fields rather than trusting them', () => {
const t = parseTokenResponse({ access_token: 'at', refresh_token: 123, expires_in: 'x' });
expect(t.refresh_token).toBeUndefined();
expect(t.expires_in).toBeUndefined();
});
});