Files
extension/packages/imanage/test/panel.test.ts
T
Hanzo Dev 8de63fa05f feat(imanage): Hanzo AI for iManage Work — legal DMS panel + OAuth/Work-API proxy
New package @hanzo/imanage: an embedded-app panel over @hanzo/ai for iManage
Work (the dominant legal document management system), mirroring the
packages/procore OAuth + API-proxy + pure-core shape.

- OAuth2 Authorization Code against the iManage Control Center
  (/auth/oauth2/authorize + /token); server-side token exchange + refresh, the
  client secret never reaches the browser.
- Work API v2 wrappers (workspaces, folder children, document profile +
  content, search, gated profile write-back) scoped by customer + library in
  the path, X-Auth-Token auth, envelope/offset-limit pagination. Pure.
- Pure legal document-context assembly with text extraction (binary/OCR out of
  scope, reported honestly) and one truncation-honest windowing algorithm.
- Four AI actions — summarize document, extract key clauses/dates/parties,
  search-and-synthesize a matter, compare a document set — plus freeform ask,
  over a single grounded ask() path.
- Same-origin proxy keeps tokens + secret server-side; NEVER logs document
  content. Legal confidentiality posture documented in the README.
- 121 vitest tests (config/oauth/api/parse/documents/hanzo/actions/panel),
  tsc --noEmit clean, node build.js green.

Registers packages/imanage in pnpm-workspace.yaml (one line).
2026-07-04 02:01:19 -07:00

143 lines
6.7 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { parseLaunchContext, proxyUrl, createProxyClient } from '../src/panel.js';
describe('panel: parseLaunchContext', () => {
it('reads snake_case customer/library/workspace/document ids', () => {
expect(parseLaunchContext('?customer_id=1&library_id=ACTIVE_US&workspace_id=W1&document_id=D1')).toEqual({
customerId: '1',
libraryId: 'ACTIVE_US',
workspaceId: 'W1',
documentId: 'D1',
});
});
it('accepts camelCase fallbacks and defaults to empty', () => {
expect(parseLaunchContext('?customerId=7&libraryId=L')).toEqual({
customerId: '7',
libraryId: 'L',
workspaceId: '',
documentId: '',
});
expect(parseLaunchContext('')).toEqual({ customerId: '', libraryId: '', workspaceId: '', documentId: '' });
});
});
describe('panel: proxyUrl', () => {
it('prefixes /proxy and appends a query, dropping empties', () => {
expect(proxyUrl('', '/customers/1/libraries/ACTIVE_US/workspaces', { limit: '100', offset: '' })).toBe(
'/proxy/customers/1/libraries/ACTIVE_US/workspaces?limit=100',
);
});
it('strips a trailing slash from the base', () => {
expect(proxyUrl('https://imanage.hanzo.ai/', '/x')).toBe('https://imanage.hanzo.ai/proxy/x');
});
});
// jsonResponse builds a fetch Response-like from an iManage envelope body.
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
}
function textResponse(body: string, status = 200): Response {
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } });
}
// fetchMockOf wraps a responder in a fetch-typed mock so calls[i] carries the
// (url, init) arg tuple TypeScript expects.
function fetchMockOf(responder: () => Response) {
return vi.fn((_url: RequestInfo | URL, _init?: RequestInit) => Promise.resolve(responder()));
}
const scope = { customerId: '1', libraryId: 'ACTIVE_US' };
describe('panel: createProxyClient — scoping + shaping', () => {
it('lists workspaces with the customer/library path, credentials, parsed items + paging', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({ data: [{ id: 'ACTIVE_US!12', name: 'Acme / Falcon', client: 'Acme', matter: 'M1' }], total_count: 1 }),
);
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const { items, paging } = await client.listWorkspaces({ limit: 100 });
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Acme / Falcon');
expect(paging.total).toBe(1);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('/proxy/customers/1/libraries/ACTIVE_US/workspaces?limit=100');
expect((init as RequestInit).credentials).toBe('include');
});
it('gets one workspace by id', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ data: { id: 'W1', name: 'Solo' } }));
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const ws = await client.getWorkspace('W1');
expect(ws.name).toBe('Solo');
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/customers/1/libraries/ACTIVE_US/workspaces/W1');
});
it('lists folder children split into folders + documents', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({
data: [
{ id: 'F1', name: 'Contracts', type: 'folder' },
{ id: 'ACTIVE_US!4567.1', name: 'MSA', type: 'document', extension: 'DOCX' },
],
}),
);
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const { items } = await client.listFolderChildren('W1', { limit: 100 });
expect(items.folders.map((f) => f.id)).toEqual(['F1']);
expect(items.documents.map((d) => d.id)).toEqual(['ACTIVE_US!4567.1']);
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/customers/1/libraries/ACTIVE_US/folders/W1/children?limit=100');
});
it('gets a document profile and parses it', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({ data: { id: 'ACTIVE_US!4567.1', name: 'MSA', extension: 'DOCX', author_description: 'Jane Smith' } }),
);
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const doc = await client.getDocument('ACTIVE_US!4567.1');
expect(doc.name).toBe('MSA');
expect(doc.author).toBe('Jane Smith');
expect(String(fetchMock.mock.calls[0][0])).toBe('/proxy/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1');
});
it('fetches document content via /download and extracts text', async () => {
const fetchMock = fetchMockOf(() => textResponse('Governing law: New York.\n\nTerm: 3 years.'));
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const extracted = await client.getDocumentContent('ACTIVE_US!4567.1');
expect(extracted.extracted).toBe(true);
expect(extracted.text).toContain('Governing law: New York.');
expect(String(fetchMock.mock.calls[0][0])).toBe(
'/proxy/customers/1/libraries/ACTIVE_US/documents/ACTIVE_US!4567.1/download',
);
});
it('searches documents with q + pagination', async () => {
const fetchMock = fetchMockOf(() =>
jsonResponse({ data: [{ id: 'ACTIVE_US!4567.1', name: 'MSA', extension: 'DOCX' }], overflow: true }),
);
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
const { items, paging } = await client.search('services agreement', { limit: 50 });
expect(items).toHaveLength(1);
expect(paging.hasMore).toBe(true);
expect(String(fetchMock.mock.calls[0][0])).toBe(
'/proxy/customers/1/libraries/ACTIVE_US/documents/search?q=services+agreement&limit=50',
);
});
it('updateDocumentProfile PATCHes a data-wrapped body (the one write path)', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ data: { id: 'D1' } }));
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
await client.updateDocumentProfile('D1', { comment: 'AI summary' });
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('/proxy/customers/1/libraries/ACTIVE_US/documents/D1');
expect((init as RequestInit).method).toBe('PATCH');
expect((init as RequestInit).credentials).toBe('include');
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ data: { comment: 'AI summary' } });
});
it('surfaces a proxy error with status + message', async () => {
const fetchMock = fetchMockOf(() => jsonResponse({ error: 'not authenticated' }, 401));
const client = createProxyClient({ ...scope, fetch: fetchMock as any });
await expect(client.listWorkspaces()).rejects.toThrow(/401.*not authenticated/);
});
});