mirror of
https://github.com/hanzoai/extension.git
synced 2026-10-31 21:57:06 +00:00
Post-review corrections verified against Clio's own developer docs and the official clio/example-third-party-application, fixing three ways the first cut would have failed against real Clio: 1. OAuth: removed access_type=offline (a Google-ism Clio ignores — it would have yielded NO refresh_token). Clio returns a refresh_token on the authorization_code grant by default; added the real redirect_on_decline option instead. Wired the refresh path into the SPA (ensureClioToken → /oauth/refresh) so a long matter session survives token expiry — the refresh apparatus is no longer dead code. 2. Custom Action nonce: the nonce is NOT a locally-verifiable HMAC. It is a single-use, 60s code that the app must REPLAY to the Clio API, which validates server-side (403 on bad/stale). Removed the wrong local HMAC verify (and node:crypto from the service); /custom-action now parses the deep-link and redirects into the SPA carrying matter_id + the nonce, and getMatter replays it (custom_action_nonce query param) on the first authenticated call. Corrected the deep-link params to Clio's actual set (custom_action_id/user_id/subject_url/custom_action_nonce; subject_url is URL-encoded) — subject_id was never sent. 3. Documents: gate downloads on content_type (isTextExtractable) — PDF/DOCX (the bulk of legal docs) would have been fed to the model as mojibake, wasting the context budget. Binary docs now contribute name + type only. The MIME check matches the subtype so DOCX (whose vendor type contains the substring "xml") is correctly binary. tsc clean, 89 vitest tests pass, esbuild build succeeds; re-verified the browser bundle has zero secret/crypto refs.
176 lines
6.7 KiB
TypeScript
176 lines
6.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
listMatters,
|
|
getMatter,
|
|
listDocuments,
|
|
downloadDocument,
|
|
listContacts,
|
|
createNote,
|
|
createActivity,
|
|
unwrapData,
|
|
extractNextPageToken,
|
|
} from '../src/clio-api';
|
|
import { clioBase, CLIO_API_VERSION } from '../src/config';
|
|
|
|
const token = 'TOK';
|
|
const base = clioBase('us');
|
|
|
|
function q(url: string): URLSearchParams {
|
|
return new URL(url).searchParams;
|
|
}
|
|
|
|
describe('baseHeaders (via listMatters)', () => {
|
|
it('sends Bearer auth, Accept json, and the pinned X-API-Version', () => {
|
|
const r = listMatters({ token, base });
|
|
expect(r.headers.Authorization).toBe('Bearer TOK');
|
|
expect(r.headers.Accept).toBe('application/json');
|
|
expect(r.headers['X-API-Version']).toBe(CLIO_API_VERSION);
|
|
// GET → no content-type
|
|
expect(r.headers['Content-Type']).toBeUndefined();
|
|
expect(r.method).toBe('GET');
|
|
});
|
|
});
|
|
|
|
describe('listMatters', () => {
|
|
it('hits /matters with default fields joined by commas', () => {
|
|
const r = listMatters({ token, base });
|
|
expect(r.url.startsWith('https://app.clio.com/api/v4/matters?')).toBe(true);
|
|
expect(q(r.url).get('fields')).toContain('display_number');
|
|
expect(q(r.url).get('fields')).toContain('client{id,name}');
|
|
});
|
|
|
|
it('passes status, query, client_id, limit and page_token', () => {
|
|
const r = listMatters({ token, base, status: 'open', query: 'smith', clientId: 42, limit: 25, pageToken: 'PT' });
|
|
const p = q(r.url);
|
|
expect(p.get('status')).toBe('open');
|
|
expect(p.get('query')).toBe('smith');
|
|
expect(p.get('client_id')).toBe('42');
|
|
expect(p.get('limit')).toBe('25');
|
|
expect(p.get('page_token')).toBe('PT');
|
|
});
|
|
|
|
it('drops undefined/empty params', () => {
|
|
const r = listMatters({ token, base, query: '' });
|
|
expect(q(r.url).has('query')).toBe(false);
|
|
expect(q(r.url).has('status')).toBe(false);
|
|
});
|
|
|
|
it('accepts an explicit fields override', () => {
|
|
const r = listMatters({ token, base, fields: ['id', 'description'] });
|
|
expect(q(r.url).get('fields')).toBe('id,description');
|
|
});
|
|
});
|
|
|
|
describe('getMatter', () => {
|
|
it('hits /matters/{id} with the rich default field set', () => {
|
|
const r = getMatter(token, base, 123);
|
|
expect(r.url.startsWith('https://app.clio.com/api/v4/matters/123?')).toBe(true);
|
|
const fields = q(r.url).get('fields')!;
|
|
expect(fields).toContain('responsible_attorney{id,name}');
|
|
expect(fields).toContain('practice_area{id,name}');
|
|
expect(fields).toContain('open_date');
|
|
});
|
|
it('honors a fields override', () => {
|
|
const r = getMatter(token, base, 1, ['id', 'status']);
|
|
expect(q(r.url).get('fields')).toBe('id,status');
|
|
});
|
|
it('replays a Custom Action nonce when given, omits it otherwise', () => {
|
|
expect(q(getMatter(token, base, 1, undefined, 'NONCE').url).get('custom_action_nonce')).toBe('NONCE');
|
|
expect(q(getMatter(token, base, 1).url).has('custom_action_nonce')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('listDocuments', () => {
|
|
it('scopes to a matter and paginates', () => {
|
|
const r = listDocuments({ token, base, matterId: 9, limit: 25, pageToken: 'N' });
|
|
const p = q(r.url);
|
|
expect(r.url.startsWith('https://app.clio.com/api/v4/documents?')).toBe(true);
|
|
expect(p.get('matter_id')).toBe('9');
|
|
expect(p.get('limit')).toBe('25');
|
|
expect(p.get('page_token')).toBe('N');
|
|
expect(p.get('fields')).toContain('content_type');
|
|
});
|
|
});
|
|
|
|
describe('downloadDocument', () => {
|
|
it('hits the download sub-resource with auth + version but no JSON Accept', () => {
|
|
const r = downloadDocument(token, base, 55);
|
|
expect(r.url).toBe('https://app.clio.com/api/v4/documents/55/download');
|
|
expect(r.headers.Authorization).toBe('Bearer TOK');
|
|
expect(r.headers['X-API-Version']).toBe(CLIO_API_VERSION);
|
|
expect(r.headers.Accept).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('listContacts', () => {
|
|
it('hits /contacts with default fields and optional query', () => {
|
|
const r = listContacts(token, base, { query: 'acme', limit: 10 });
|
|
const p = q(r.url);
|
|
expect(r.url.startsWith('https://app.clio.com/api/v4/contacts?')).toBe(true);
|
|
expect(p.get('query')).toBe('acme');
|
|
expect(p.get('limit')).toBe('10');
|
|
expect(p.get('fields')).toContain('primary_email_address');
|
|
});
|
|
});
|
|
|
|
describe('createNote', () => {
|
|
it('POSTs a matter-scoped note wrapped under data with JSON content-type', () => {
|
|
const r = createNote({ token, base, matterId: 7, subject: 'S', detail: 'BODY' });
|
|
expect(r.method).toBe('POST');
|
|
expect(r.url).toBe('https://app.clio.com/api/v4/notes');
|
|
expect(r.headers['Content-Type']).toBe('application/json');
|
|
const body = JSON.parse(r.body!);
|
|
expect(body.data.subject).toBe('S');
|
|
expect(body.data.detail).toBe('BODY');
|
|
expect(body.data.type).toBe('Matter');
|
|
expect(body.data.matter).toEqual({ id: 7 });
|
|
});
|
|
});
|
|
|
|
describe('createActivity', () => {
|
|
it('POSTs a TimeEntry against a matter, quantity default 0', () => {
|
|
const r = createActivity({ token, base, matterId: 3, note: 'logged by Hanzo' });
|
|
expect(r.url).toBe('https://app.clio.com/api/v4/activities');
|
|
const body = JSON.parse(r.body!);
|
|
expect(body.data.type).toBe('TimeEntry');
|
|
expect(body.data.matter).toEqual({ id: 3 });
|
|
expect(body.data.note).toBe('logged by Hanzo');
|
|
expect(body.data.quantity).toBe(0);
|
|
expect(body.data.date).toBeUndefined();
|
|
});
|
|
it('passes an explicit quantity and date', () => {
|
|
const r = createActivity({ token, base, matterId: 3, note: 'n', quantitySeconds: 900, date: '2026-01-02' });
|
|
const body = JSON.parse(r.body!);
|
|
expect(body.data.quantity).toBe(900);
|
|
expect(body.data.date).toBe('2026-01-02');
|
|
});
|
|
});
|
|
|
|
describe('unwrapData', () => {
|
|
it('returns the data field of a Clio envelope', () => {
|
|
expect(unwrapData({ data: { id: 1 } })).toEqual({ id: 1 });
|
|
expect(unwrapData({ data: [{ id: 1 }, { id: 2 }] })).toHaveLength(2);
|
|
});
|
|
it('passes through a payload with no envelope', () => {
|
|
expect(unwrapData({ id: 9 })).toEqual({ id: 9 });
|
|
});
|
|
it('throws on a Clio error / errors payload', () => {
|
|
expect(() => unwrapData({ error: { message: 'nope' } })).toThrow(/nope/);
|
|
expect(() => unwrapData({ errors: [{ type: 'x' }] })).toThrow(/Clio API error/);
|
|
});
|
|
});
|
|
|
|
describe('extractNextPageToken', () => {
|
|
it('pulls page_token from a full next URL', () => {
|
|
const payload = { meta: { paging: { next: 'https://app.clio.com/api/v4/matters?limit=25&page_token=ABC123' } } };
|
|
expect(extractNextPageToken(payload)).toBe('ABC123');
|
|
});
|
|
it('returns empty when there is no next page', () => {
|
|
expect(extractNextPageToken({ meta: { paging: {} } })).toBe('');
|
|
expect(extractNextPageToken({})).toBe('');
|
|
});
|
|
it('tolerates a bare token string', () => {
|
|
expect(extractNextPageToken({ meta: { paging: { next: '?page_token=RAW' } } })).toBe('RAW');
|
|
});
|
|
});
|