import { describe, it, expect, vi, afterEach } from 'vitest' import { ImpriClient, ImpriApiError, ImpriConfigError, ImpriConflict, ImpriExpired, ImpriNotFound, ImpriQuotaExceeded, ImpriRateLimited, ImpriRejected, ImpriTimeout, ImpriUnauthorized, ImpriValidationError, type BulkDecisionResponse, type WatcherPreset, } from '../src/index.js' // ─── Test helpers ───────────────────────────────────────────────────────────── const TEST_KEY = 'im_test_abc123' const TEST_BASE = 'http://localhost:19999' const ACT_ID = 'act_test001' function client() { return new ImpriClient({ apiKey: TEST_KEY, baseUrl: TEST_BASE }) } /** Build a mock Response-like object. */ function resp( status: number, body: unknown, headers: Record = {}, ): Response { return { ok: status >= 200 && status < 300, status, statusText: status === 200 ? 'OK' : String(status), headers: { get: (name: string) => headers[name.toLowerCase()] ?? null, }, json: () => Promise.resolve(body), } as unknown as Response } function noContentResp(): Response { return resp(204, null) } /** Minimal pending action fixture. */ function pendingAction(id = ACT_ID) { return { id, kind: 'test.action', title: 'Test action', status: 'pending', preview: { format: 'plain', body: 'hello' }, payload: null, editable: [], created_at: 1720000000, updated_at: 1720000000, expires_at: 1720086400, } } function approvedAction(id = ACT_ID) { return { ...pendingAction(id), status: 'approved', decision: { verdict: 'approve', decided_at: 1720003600 }, } } function rejectedAction(id = ACT_ID) { return { ...pendingAction(id), status: 'rejected', decision: { verdict: 'reject', decided_at: 1720003600 }, } } function expiredAction(id = ACT_ID) { return { ...pendingAction(id), status: 'expired' } } // ─── Teardown ───────────────────────────────────────────────────────────────── afterEach(() => { vi.unstubAllGlobals() }) // ─── Constructor ────────────────────────────────────────────────────────────── describe('ImpriClient constructor', () => { it('throws ImpriConfigError when no API key is provided', () => { const saved = process.env.IMPRI_API_KEY delete process.env.IMPRI_API_KEY try { expect(() => new ImpriClient({ baseUrl: TEST_BASE })).toThrow(ImpriConfigError) } finally { if (saved !== undefined) process.env.IMPRI_API_KEY = saved } }) it('reads API key from IMPRI_API_KEY env var', () => { const saved = process.env.IMPRI_API_KEY process.env.IMPRI_API_KEY = 'im_from_env' try { expect(() => new ImpriClient({ baseUrl: TEST_BASE })).not.toThrow() } finally { if (saved !== undefined) process.env.IMPRI_API_KEY = saved else delete process.env.IMPRI_API_KEY } }) it('throws ImpriConfigError for a non-URL baseUrl', () => { expect(() => new ImpriClient({ apiKey: TEST_KEY, baseUrl: 'not-a-url' })).toThrow( ImpriConfigError, ) }) it('strips trailing slash from baseUrl', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(200, { items: [], has_more: false })) vi.stubGlobal('fetch', mockFetch) const c = new ImpriClient({ apiKey: TEST_KEY, baseUrl: 'http://localhost:19999/' }) await c.listActions() const url = mockFetch.mock.calls[0][0] as string expect(url).toMatch(/^http:\/\/localhost:19999\/v1\//) expect(url).not.toMatch(/\/\/v1/) }) it('includes Bearer token in Authorization header', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(200, { items: [], has_more: false })) vi.stubGlobal('fetch', mockFetch) await client().listActions() const init = mockFetch.mock.calls[0][1] as RequestInit expect((init.headers as Record).Authorization).toBe(`Bearer ${TEST_KEY}`) }) }) // ─── createAction ───────────────────────────────────────────────────────────── describe('createAction', () => { it('POSTs to /v1/actions with the correct body', async () => { const created = { id: ACT_ID, status: 'pending', inbox_url: 'http://localhost:8080/inbox/act_test001', expires_at: 1720086400, created_at: 1720000000, } const mockFetch = vi.fn().mockResolvedValue(resp(201, created)) vi.stubGlobal('fetch', mockFetch) const result = await client().createAction({ kind: 'email.send', title: 'Send newsletter', preview: { format: 'plain', body: 'Hello!' }, idempotency_key: 'idem-1', }) expect(result.id).toBe(ACT_ID) expect(result.status).toBe('pending') const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] expect(url).toBe(`${TEST_BASE}/v1/actions`) expect(init.method).toBe('POST') const body = JSON.parse(init.body as string) expect(body.kind).toBe('email.send') expect(body.idempotency_key).toBe('idem-1') }) it('auto-generates idempotency_key when omitted', async () => { const mockFetch = vi .fn() .mockResolvedValue( resp(201, { id: 'act_1', status: 'pending', inbox_url: 'u', expires_at: 0, created_at: 0 }), ) vi.stubGlobal('fetch', mockFetch) await client().createAction({ kind: 'test', title: 'T', preview: { format: 'plain', body: 'B' }, }) const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(body.idempotency_key).toMatch(/^sdk-\d+-\d+-[0-9a-f]+$/) }) it('returns duplicate_of on a 200 idempotent response', async () => { const dup = { id: ACT_ID, status: 'pending', inbox_url: 'u', expires_at: 0, created_at: 0, duplicate_of: 'act_original', } vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, dup))) const result = await client().createAction({ kind: 'k', title: 't', preview: { format: 'plain', body: 'b' }, idempotency_key: 'dup', }) expect(result.duplicate_of).toBe('act_original') }) }) // ─── getAction ──────────────────────────────────────────────────────────────── describe('getAction', () => { it('GETs /v1/actions/:id and normalizes is_untrusted=false', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, pendingAction()))) const action = await client().getAction(ACT_ID) expect(action.id).toBe(ACT_ID) expect(action.is_untrusted).toBe(false) expect((vi.mocked(fetch).mock.calls[0][0] as string)).toBe( `${TEST_BASE}/v1/actions/${ACT_ID}`, ) }) it('sets is_untrusted=true when payload.untrusted is true', async () => { const raw = { ...pendingAction(), payload: { untrusted: true, url: 'https://example.com' } } vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, raw))) const action = await client().getAction(ACT_ID) expect(action.is_untrusted).toBe(true) }) it('defaults editable to [] when missing from response', async () => { const raw = { ...pendingAction() } ;(raw as Record).editable = undefined vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, raw))) const action = await client().getAction(ACT_ID) expect(action.editable).toEqual([]) }) }) // ─── listActions ────────────────────────────────────────────────────────────── describe('listActions', () => { it('GETs /v1/actions with query params', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(200, { items: [], has_more: false })) vi.stubGlobal('fetch', mockFetch) await client().listActions({ status: 'pending', kind: 'email.send', limit: 10 }) const url = mockFetch.mock.calls[0][0] as string expect(url).toContain('status=pending') expect(url).toContain('kind=email.send') expect(url).toContain('limit=10') }) it('does not include undefined params in the query string', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(200, { items: [], has_more: false })) vi.stubGlobal('fetch', mockFetch) await client().listActions({}) const url = mockFetch.mock.calls[0][0] as string expect(url).not.toContain('?') }) it('auto-paginates across two pages when autoPaginate=true', async () => { const page1 = { items: [pendingAction('act_1')], has_more: true, next_cursor: 'cur1' } const page2 = { items: [pendingAction('act_2')], has_more: false } const mockFetch = vi .fn() .mockResolvedValueOnce(resp(200, page1)) .mockResolvedValueOnce(resp(200, page2)) vi.stubGlobal('fetch', mockFetch) const result = await client().listActions({ autoPaginate: true }) expect(result.items).toHaveLength(2) expect(result.has_more).toBe(false) expect(result.items[0].id).toBe('act_1') expect(result.items[1].id).toBe('act_2') const secondUrl = mockFetch.mock.calls[1][0] as string expect(secondUrl).toContain('cursor=cur1') }) it('returns paged result with next_cursor on single page', async () => { const mockFetch = vi .fn() .mockResolvedValue(resp(200, { items: [pendingAction()], has_more: true, next_cursor: 'nc1' })) vi.stubGlobal('fetch', mockFetch) const result = await client().listActions({ limit: 1 }) expect(result.has_more).toBe(true) expect(result.next_cursor).toBe('nc1') }) }) // ─── decide ─────────────────────────────────────────────────────────────────── describe('decide', () => { it('POSTs `decision` (not `verdict`) to /v1/actions/:id/decision', async () => { // Regression test (QA 2026-10-30): the request body field is `decision` // per server/src/schemas.ts DecisionBody — the bulk-decision endpoint uses // `verdict` instead, and this test used to assert that wrong field name, // which is exactly how the bug shipped: the mock accepted any field name, // so nothing here ever hit the server's real zod validation. Confirmed // live against a running server that the pre-fix body `{ verdict: 'approve' }` // gets rejected with 400 "decision: Required". const decisionResult = { id: ACT_ID, status: 'approved', verdict: 'approve', decided_at: 1720003600, final_preview: { format: 'plain', body: 'hello' }, } const mockFetch = vi.fn().mockResolvedValue(resp(200, decisionResult)) vi.stubGlobal('fetch', mockFetch) const result = await client().decide(ACT_ID, 'approve') expect(result.verdict).toBe('approve') const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] expect(url).toBe(`${TEST_BASE}/v1/actions/${ACT_ID}/decision`) expect(init.method).toBe('POST') const sentBody = JSON.parse(init.body as string) expect(sentBody.decision).toBe('approve') expect(sentBody.verdict).toBeUndefined() }) it('includes edited fields and channel in the request body', async () => { const mockFetch = vi.fn().mockResolvedValue( resp(200, { id: ACT_ID, status: 'approved', verdict: 'approve', decided_at: 0, final_preview: { format: 'plain', body: 'edited' }, }), ) vi.stubGlobal('fetch', mockFetch) await client().decide(ACT_ID, 'approve', { edited: { 'preview.body': 'edited' }, channel: 'web', }) const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(body.edited).toEqual({ 'preview.body': 'edited' }) expect(body.channel).toBe('web') }) }) // ─── reportResult ───────────────────────────────────────────────────────────── describe('reportResult', () => { it('POSTs status to /v1/actions/:id/result', async () => { const mockFetch = vi .fn() .mockResolvedValue(resp(200, { id: ACT_ID, status: 'executed', updated_at: 1720003600 })) vi.stubGlobal('fetch', mockFetch) const ack = await client().reportResult(ACT_ID, 'executed') expect(ack.status).toBe('executed') const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] expect(url).toBe(`${TEST_BASE}/v1/actions/${ACT_ID}/result`) expect(JSON.parse(init.body as string).status).toBe('executed') }) it('sends detail when provided', async () => { const mockFetch = vi .fn() .mockResolvedValue(resp(200, { id: ACT_ID, status: 'execute_failed', updated_at: 0 })) vi.stubGlobal('fetch', mockFetch) await client().reportResult(ACT_ID, 'execute_failed', { detail: 'HTTP 500 from mailer' }) const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(body.detail).toBe('HTTP 500 from mailer') }) it('omits detail key when not provided', async () => { const mockFetch = vi .fn() .mockResolvedValue(resp(200, { id: ACT_ID, status: 'executed', updated_at: 0 })) vi.stubGlobal('fetch', mockFetch) await client().reportResult(ACT_ID, 'executed') const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect('detail' in body).toBe(false) }) }) // ─── awaitDecision ──────────────────────────────────────────────────────────── describe('awaitDecision', () => { it('returns the action immediately when already approved', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, approvedAction()))) const action = await client().awaitDecision(ACT_ID) expect(action.status).toBe('approved') expect(action.decision?.verdict).toBe('approve') }) it('polls until approved on the second attempt', async () => { const mockFetch = vi .fn() .mockResolvedValueOnce(resp(200, pendingAction())) .mockResolvedValueOnce(resp(200, approvedAction())) vi.stubGlobal('fetch', mockFetch) const action = await client().awaitDecision(ACT_ID, { pollIntervalS: 0 }) expect(action.status).toBe('approved') expect(mockFetch).toHaveBeenCalledTimes(2) }) it('throws ImpriRejected when action is rejected', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, rejectedAction()))) await expect(client().awaitDecision(ACT_ID)).rejects.toBeInstanceOf(ImpriRejected) }) it('ImpriRejected carries actionId and decision', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, rejectedAction()))) try { await client().awaitDecision(ACT_ID) expect.fail('should have thrown') } catch (err) { expect(err).toBeInstanceOf(ImpriRejected) const e = err as ImpriRejected expect(e.actionId).toBe(ACT_ID) expect(e.decision.verdict).toBe('reject') } }) it('throws ImpriExpired when action is expired', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, expiredAction()))) await expect(client().awaitDecision(ACT_ID)).rejects.toBeInstanceOf(ImpriExpired) }) it('throws ImpriTimeout when deadline elapses while action is still pending', async () => { // Always return pending — timeout=0 means deadline is immediately past. vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, pendingAction()))) await expect( client().awaitDecision(ACT_ID, { timeoutS: 0, pollIntervalS: 0 }), ).rejects.toBeInstanceOf(ImpriTimeout) }) it('ImpriTimeout carries the actionId', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, pendingAction()))) try { await client().awaitDecision(ACT_ID, { timeoutS: 0, pollIntervalS: 0 }) expect.fail('should have thrown') } catch (err) { expect(err).toBeInstanceOf(ImpriTimeout) expect((err as ImpriTimeout).actionId).toBe(ACT_ID) } }) }) // ─── Error mapping ──────────────────────────────────────────────────────────── describe('HTTP error mapping', () => { const cases = [ [401, ImpriUnauthorized], [403, ImpriUnauthorized], [404, ImpriNotFound], [409, ImpriConflict], [410, ImpriExpired], [429, ImpriRateLimited], [402, ImpriQuotaExceeded], [422, ImpriValidationError], [400, ImpriValidationError], [500, ImpriApiError], [503, ImpriApiError], ] as const for (const [status, ErrorClass] of cases) { it(`maps HTTP ${status} to ${ErrorClass.name}`, async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(status, { message: 'test error' }))) await expect(client().getAction(ACT_ID)).rejects.toBeInstanceOf(ErrorClass) }) } it('ImpriUnauthorized carries statusCode', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(401, { message: 'Unauthorized' }))) try { await client().getAction(ACT_ID) } catch (err) { expect((err as ImpriUnauthorized).statusCode).toBe(401) } }) it('ImpriRateLimited carries retryAfter from Retry-After header', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(resp(429, { message: 'Too many requests' }, { 'retry-after': '42' })), ) try { await client().getAction(ACT_ID) } catch (err) { expect(err).toBeInstanceOf(ImpriRateLimited) expect((err as ImpriRateLimited).retryAfter).toBe(42) } }) it('ImpriQuotaExceeded carries limit and tier', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(resp(402, { message: 'Quota exceeded', limit: 100, tier: 'free' })), ) try { await client().getAction(ACT_ID) } catch (err) { expect(err).toBeInstanceOf(ImpriQuotaExceeded) const e = err as ImpriQuotaExceeded expect(e.limit).toBe(100) expect(e.tier).toBe('free') } }) it('ImpriValidationError carries issues array', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( resp(422, { message: 'Invalid', issues: [{ path: ['kind'], message: 'Required' }] }), ), ) try { await client().getAction(ACT_ID) } catch (err) { expect(err).toBeInstanceOf(ImpriValidationError) expect((err as ImpriValidationError).issues).toHaveLength(1) } }) }) // ─── requiresApproval ───────────────────────────────────────────────────────── describe('requiresApproval', () => { it('calls the wrapped function after approval', async () => { const created = { id: ACT_ID, status: 'pending', inbox_url: 'u', expires_at: 0, created_at: 0 } const mockFetch = vi .fn() .mockResolvedValueOnce(resp(201, created)) // createAction .mockResolvedValueOnce(resp(200, approvedAction())) // awaitDecision vi.stubGlobal('fetch', mockFetch) const fn = vi.fn().mockResolvedValue('result') const wrapped = client().requiresApproval(fn, { kind: 'test', title: 'Test', preview: { format: 'plain', body: 'body' }, }) const result = await wrapped() expect(result).toBe('result') expect(fn).toHaveBeenCalledOnce() }) it('throws ImpriRejected and does NOT call fn when rejected', async () => { const created = { id: ACT_ID, status: 'pending', inbox_url: 'u', expires_at: 0, created_at: 0 } const mockFetch = vi .fn() .mockResolvedValueOnce(resp(201, created)) .mockResolvedValueOnce(resp(200, rejectedAction())) vi.stubGlobal('fetch', mockFetch) const fn = vi.fn().mockResolvedValue('result') const wrapped = client().requiresApproval(fn, { kind: 'test', title: 'Test' }) await expect(wrapped()).rejects.toBeInstanceOf(ImpriRejected) expect(fn).not.toHaveBeenCalled() }) it('resolves title and preview from function arguments', async () => { const created = { id: ACT_ID, status: 'pending', inbox_url: 'u', expires_at: 0, created_at: 0 } const mockFetch = vi .fn() .mockResolvedValueOnce(resp(201, created)) .mockResolvedValueOnce(resp(200, approvedAction())) vi.stubGlobal('fetch', mockFetch) type Fn = (to: string, body: string) => Promise const fn: Fn = vi.fn().mockResolvedValue('sent') const wrapped = client().requiresApproval(fn, { kind: 'email.send', title: (to: string) => `Send to ${to}`, preview: (_to: string, body: string) => ({ format: 'plain' as const, body }), }) await wrapped('alice@example.com', 'Hello!') const createBody = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(createBody.title).toBe('Send to alice@example.com') expect(createBody.preview.body).toBe('Hello!') }) it('passes payload factory result to createAction', async () => { const created = { id: ACT_ID, status: 'pending', inbox_url: 'u', expires_at: 0, created_at: 0 } const mockFetch = vi .fn() .mockResolvedValueOnce(resp(201, created)) .mockResolvedValueOnce(resp(200, approvedAction())) vi.stubGlobal('fetch', mockFetch) type Fn = (id: number) => Promise const fn: Fn = vi.fn().mockResolvedValue(undefined) const wrapped = client().requiresApproval(fn, { kind: 'record.delete', title: (id: number) => `Delete record ${id}`, payload: (id: number) => ({ record_id: id }), }) await wrapped(42) const createBody = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(createBody.payload).toEqual({ record_id: 42 }) }) }) // ─── approvalGate ───────────────────────────────────────────────────────────── describe('approvalGate', () => { it('returns ApprovedAction with finalPreview from decision', async () => { const editedApproved = { ...approvedAction(), decision: { verdict: 'approve', decided_at: 1720003600, final_preview: { format: 'plain', body: 'edited body' }, diff: '...', }, } const created = { id: ACT_ID, status: 'pending', inbox_url: 'u', expires_at: 0, created_at: 0 } const mockFetch = vi .fn() .mockResolvedValueOnce(resp(201, created)) .mockResolvedValueOnce(resp(200, editedApproved)) vi.stubGlobal('fetch', mockFetch) const gate = await client().approvalGate({ kind: 'db.exec', title: 'DROP TABLE users', preview: { format: 'plain', body: 'original sql' }, editable: ['preview.body'], }) expect(gate.actionId).toBe(ACT_ID) expect(gate.finalPreview.body).toBe('edited body') expect(gate.decision.verdict).toBe('approve') }) it('falls back to action preview when decision has no final_preview', async () => { const created = { id: ACT_ID, status: 'pending', inbox_url: 'u', expires_at: 0, created_at: 0 } const mockFetch = vi .fn() .mockResolvedValueOnce(resp(201, created)) .mockResolvedValueOnce(resp(200, approvedAction())) vi.stubGlobal('fetch', mockFetch) const gate = await client().approvalGate({ kind: 'test', title: 'T', preview: { format: 'plain', body: 'original' }, }) expect(gate.finalPreview.body).toBe('hello') // from pendingAction fixture }) it('throws ImpriRejected on rejection', async () => { const created = { id: ACT_ID, status: 'pending', inbox_url: 'u', expires_at: 0, created_at: 0 } const mockFetch = vi .fn() .mockResolvedValueOnce(resp(201, created)) .mockResolvedValueOnce(resp(200, rejectedAction())) vi.stubGlobal('fetch', mockFetch) await expect( client().approvalGate({ kind: 'k', title: 't', preview: { format: 'plain', body: 'b' } }), ).rejects.toBeInstanceOf(ImpriRejected) }) }) // ─── Watchers ───────────────────────────────────────────────────────────────── describe('watchers', () => { const watcher = { id: 'w_1', name: 'AI News', kind: 'rss', config: { url: 'https://example.com/feed.rss' }, keywords: [{ pattern: 'gpt', points: 5 }], keywords_none: [], min_score: 1, schedule: { every: '8h' }, status: 'active', fail_count: 0, first_run_done: false, next_run_at: 1720010000, created_at: 1720000000, updated_at: 1720000000, } it('createWatcher POSTs to /v1/watchers', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(201, watcher)) vi.stubGlobal('fetch', mockFetch) const w = await client().createWatcher({ name: 'AI News', kind: 'rss', config: { url: 'https://example.com/feed.rss' }, schedule: { every: '8h' }, }) expect(w.id).toBe('w_1') expect(w.kind).toBe('rss') const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] expect(url).toBe(`${TEST_BASE}/v1/watchers`) expect(init.method).toBe('POST') }) it('listWatchers GETs /v1/watchers with status filter', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(200, { items: [watcher], has_more: false })) vi.stubGlobal('fetch', mockFetch) await client().listWatchers({ status: 'active' }) const url = mockFetch.mock.calls[0][0] as string expect(url).toContain('status=active') }) it('getWatcher GETs /v1/watchers/:id and returns item_count', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(resp(200, { ...watcher, item_count: 42 })), ) const w = await client().getWatcher('w_1') expect(w.item_count).toBe(42) expect((vi.mocked(fetch).mock.calls[0][0] as string)).toBe( `${TEST_BASE}/v1/watchers/w_1`, ) }) it('updateWatcher PATCHes /v1/watchers/:id', async () => { const mockFetch = vi .fn() .mockResolvedValue(resp(200, { ...watcher, status: 'paused' })) vi.stubGlobal('fetch', mockFetch) const w = await client().updateWatcher('w_1', { status: 'paused' }) expect(w.status).toBe('paused') expect((mockFetch.mock.calls[0][1] as RequestInit).method).toBe('PATCH') }) it('deleteWatcher DELETEs /v1/watchers/:id (204)', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(noContentResp())) await expect(client().deleteWatcher('w_1')).resolves.toBeUndefined() expect((vi.mocked(fetch).mock.calls[0][1] as RequestInit).method).toBe('DELETE') }) }) // ─── API Keys ───────────────────────────────────────────────────────────────── describe('api keys', () => { it('createKey POSTs to /v1/keys and returns raw key', async () => { const keyResp = { id: 'key_1', name: 'CI Key', key: 'im_rawvalue', prefix: 'im_rawvalue000', scopes: ['actions'], project_id: 'proj_1', created_at: 1720000000, note: 'Store immediately.', } vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(201, keyResp))) const k = await client().createKey('CI Key', ['actions']) expect(k.key).toBe('im_rawvalue') expect(k.scopes).toContain('actions') }) it('listKeys GETs /v1/keys', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, []))) const keys = await client().listKeys() expect(Array.isArray(keys)).toBe(true) }) it('revokeKey DELETEs /v1/keys/:id', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(noContentResp())) await expect(client().revokeKey('key_1')).resolves.toBeUndefined() }) }) // ─── Project ────────────────────────────────────────────────────────────────── describe('project', () => { const project = { id: 'proj_1', name: 'Test Project', timezone: 'UTC', created_at: 1720000000 } it('getProject GETs /v1/project', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, project))) const p = await client().getProject() expect(p.id).toBe('proj_1') expect(p.timezone).toBe('UTC') }) it('updateProject PATCHes /v1/project', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(200, { ...project, name: 'New Name' })) vi.stubGlobal('fetch', mockFetch) const p = await client().updateProject({ name: 'New Name' }) expect(p.name).toBe('New Name') expect((mockFetch.mock.calls[0][1] as RequestInit).method).toBe('PATCH') }) it('rotateWebhookSecret POSTs to /v1/project/rotate-webhook-secret', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(resp(200, { webhook_secret: 'whsec_new', note: 'Update handler.' })), ) const r = await client().rotateWebhookSecret() expect(r.webhook_secret).toBe('whsec_new') expect((vi.mocked(fetch).mock.calls[0][0] as string)).toContain( 'rotate-webhook-secret', ) }) it('exportProject GETs /v1/project/export', async () => { const exportData = { exported_at: 1720000000, project: {}, actions: [], decisions: [], watchers: [], audit_log: [], } vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, exportData))) const e = await client().exportProject() expect(e.exported_at).toBe(1720000000) }) it('eraseProjectData DELETEs /v1/project/data and returns counts', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(resp(200, { erased: true, actions: 5, watchers: 2 })), ) const r = await client().eraseProjectData() expect(r.erased).toBe(true) expect(r.actions).toBe(5) expect(r.watchers).toBe(2) expect((vi.mocked(fetch).mock.calls[0][1] as RequestInit).method).toBe('DELETE') }) }) // ─── Watcher Presets ────────────────────────────────────────────────────────── /** Minimal preset fixture matching the catalog shape. */ function makePreset(overrides: Partial = {}): WatcherPreset { return { id: 'hn-front-page', title: 'Hacker News Front Page', description: 'New posts as they appear on the HN front page', category: 'Community', kind: 'rss', params: [], defaultScheduleEvery: '30m', buildNotes: 'config.url = "https://news.ycombinator.com/rss".', ...overrides, } } /** Minimal watcher fixture used by createWatcherFromPreset responses. */ function makeWatcher(overrides: Record = {}) { return { id: 'w_preset_1', name: 'Hacker News Front Page', kind: 'rss', config: { url: 'https://news.ycombinator.com/rss' }, keywords: [], keywords_none: [], min_score: 0, schedule: { every: '30m' }, status: 'active', fail_count: 0, first_run_done: false, next_run_at: 1720010000, created_at: 1720000000, updated_at: 1720000000, ...overrides, } } describe('listWatcherPresets', () => { it('GETs /v1/watcher-presets and returns the presets array', async () => { const presets = [makePreset(), makePreset({ id: 'github-releases', title: 'GitHub Releases' })] const mockFetch = vi.fn().mockResolvedValue(resp(200, { presets })) vi.stubGlobal('fetch', mockFetch) const result = await client().listWatcherPresets() expect(result).toHaveLength(2) expect(result[0].id).toBe('hn-front-page') expect(result[1].id).toBe('github-releases') const url = mockFetch.mock.calls[0][0] as string expect(url).toBe(`${TEST_BASE}/v1/watcher-presets`) expect((mockFetch.mock.calls[0][1] as RequestInit).method).toBe('GET') }) it('sends Bearer token in Authorization header', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(200, { presets: [makePreset()] })) vi.stubGlobal('fetch', mockFetch) await client().listWatcherPresets() const init = mockFetch.mock.calls[0][1] as RequestInit expect((init.headers as Record).Authorization).toBe(`Bearer ${TEST_KEY}`) }) it('returns an empty array when the catalog has no presets', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, { presets: [] }))) const result = await client().listWatcherPresets() expect(result).toEqual([]) }) it('returns presets with correct shape including params array', async () => { const preset = makePreset({ id: 'reddit-keyword', kind: 'reddit_search', params: [ { name: 'query', required: true, description: 'Search query', example: 'self-hosting AI' }, { name: 'subreddit', required: false, description: 'Subreddit', example: 'selfhosted' }, ], }) vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, { presets: [preset] }))) const [p] = await client().listWatcherPresets() expect(p.params).toHaveLength(2) expect(p.params[0].required).toBe(true) expect(p.params[1].required).toBe(false) }) it('propagates 401 as ImpriUnauthorized', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(401, { message: 'Unauthorized' }))) await expect(client().listWatcherPresets()).rejects.toBeInstanceOf(ImpriUnauthorized) }) }) describe('createWatcherFromPreset', () => { it('POSTs to /v1/watchers/from-preset with preset_id in body', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(201, makeWatcher())) vi.stubGlobal('fetch', mockFetch) const w = await client().createWatcherFromPreset('hn-front-page') expect(w.id).toBe('w_preset_1') expect(w.kind).toBe('rss') const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] expect(url).toBe(`${TEST_BASE}/v1/watchers/from-preset`) expect(init.method).toBe('POST') const body = JSON.parse(init.body as string) expect(body.preset_id).toBe('hn-front-page') }) it('sends params when provided', async () => { const mockFetch = vi .fn() .mockResolvedValue(resp(201, makeWatcher({ id: 'w_2', kind: 'reddit_search' }))) vi.stubGlobal('fetch', mockFetch) await client().createWatcherFromPreset('reddit-keyword', { query: 'self-hosting AI', subreddit: 'selfhosted', }) const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(body.params).toEqual({ query: 'self-hosting AI', subreddit: 'selfhosted' }) }) it('omits params key when not provided', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(201, makeWatcher())) vi.stubGlobal('fetch', mockFetch) await client().createWatcherFromPreset('hn-front-page') const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect('params' in body).toBe(false) }) it('sends name override when provided via opts', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(201, makeWatcher({ name: 'My HN Feed' }))) vi.stubGlobal('fetch', mockFetch) await client().createWatcherFromPreset('hn-front-page', undefined, { name: 'My HN Feed' }) const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(body.name).toBe('My HN Feed') }) it('sends schedule override when provided via opts', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(201, makeWatcher())) vi.stubGlobal('fetch', mockFetch) await client().createWatcherFromPreset('hn-front-page', undefined, { schedule: { every: '2h', window: '06:00-22:00' }, }) const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(body.schedule).toEqual({ every: '2h', window: '06:00-22:00' }) }) it('omits name and schedule keys when opts is not provided', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(201, makeWatcher())) vi.stubGlobal('fetch', mockFetch) await client().createWatcherFromPreset('hn-front-page') const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect('name' in body).toBe(false) expect('schedule' in body).toBe(false) }) it('sends params, name, and schedule together', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(201, makeWatcher())) vi.stubGlobal('fetch', mockFetch) await client().createWatcherFromPreset( 'github-releases', { owner: 'fastify', repo: 'fastify' }, { name: 'Fastify Releases', schedule: { every: '6h' } }, ) const body = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(body.preset_id).toBe('github-releases') expect(body.params).toEqual({ owner: 'fastify', repo: 'fastify' }) expect(body.name).toBe('Fastify Releases') expect(body.schedule).toEqual({ every: '6h' }) }) it('throws ImpriNotFound (404) when preset_id is unknown', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(resp(404, { error: 'preset_not_found' })), ) await expect(client().createWatcherFromPreset('no-such-preset')).rejects.toBeInstanceOf( ImpriNotFound, ) }) it('throws ImpriValidationError (400) when a required param is missing', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( resp(400, { error: 'Bad Request', issues: [{ path: ['params', 'query'], message: 'Required' }], }), ), ) // Calling reddit-keyword without the required 'query' param await expect( client().createWatcherFromPreset('reddit-keyword', {}), ).rejects.toBeInstanceOf(ImpriValidationError) }) it('throws ImpriValidationError when a param value fails format validation', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( resp(400, { error: 'Bad Request', issues: [{ path: ['params', 'channel_id'], message: 'Invalid YouTube channel ID format' }], }), ), ) await expect( client().createWatcherFromPreset('youtube-channel', { channel_id: 'INVALID' }), ).rejects.toBeInstanceOf(ImpriValidationError) }) it('throws ImpriQuotaExceeded (402) when watcher limit is reached', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( resp(402, { message: 'Watcher limit reached', limit: 5, tier: 'free' }), ), ) await expect(client().createWatcherFromPreset('hn-front-page')).rejects.toBeInstanceOf( ImpriQuotaExceeded, ) }) it('throws ImpriRateLimited (429) when rate limit bucket is exhausted', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( resp(429, { message: 'Too many requests' }, { 'retry-after': '10' }), ), ) await expect(client().createWatcherFromPreset('hn-front-page')).rejects.toBeInstanceOf( ImpriRateLimited, ) }) it('includes Bearer token in Authorization header', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(201, makeWatcher())) vi.stubGlobal('fetch', mockFetch) await client().createWatcherFromPreset('hn-front-page') const init = mockFetch.mock.calls[0][1] as RequestInit expect((init.headers as Record).Authorization).toBe(`Bearer ${TEST_KEY}`) }) }) // ─── bulkDecide ─────────────────────────────────────────────────────────────── function bulkResp( results: Array<{ id: string; ok: boolean; status?: string; error?: string; current_status?: string }>, ): BulkDecisionResponse { return { results, succeeded: results.filter(r => r.ok).length, failed: results.filter(r => !r.ok).length, } as BulkDecisionResponse } describe('bulkDecide', () => { it('POSTs to /v1/actions/bulk-decision with ids and verdict', async () => { const ids = ['act_aaa', 'act_bbb'] const body = bulkResp([ { id: 'act_aaa', ok: true, status: 'approved' }, { id: 'act_bbb', ok: true, status: 'approved' }, ]) const mockFetch = vi.fn().mockResolvedValue(resp(200, body)) vi.stubGlobal('fetch', mockFetch) const result = await client().bulkDecide(ids, 'approve') const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] expect(url).toBe(`${TEST_BASE}/v1/actions/bulk-decision`) expect(init.method).toBe('POST') const reqBody = JSON.parse(init.body as string) expect(reqBody.ids).toEqual(['act_aaa', 'act_bbb']) expect(reqBody.verdict).toBe('approve') expect(result.succeeded).toBe(2) expect(result.failed).toBe(0) expect(result.results).toHaveLength(2) expect(result.results[0].ok).toBe(true) }) it('sends reject verdict correctly', async () => { const mockFetch = vi.fn().mockResolvedValue( resp(200, bulkResp([{ id: 'act_zzz', ok: true, status: 'rejected' }])), ) vi.stubGlobal('fetch', mockFetch) await client().bulkDecide(['act_zzz'], 'reject') const reqBody = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(reqBody.verdict).toBe('reject') }) it('includes comment in request body when provided', async () => { const mockFetch = vi.fn().mockResolvedValue( resp(200, bulkResp([{ id: 'act_1', ok: true, status: 'approved' }])), ) vi.stubGlobal('fetch', mockFetch) await client().bulkDecide(['act_1'], 'approve', { comment: 'Looks good' }) const reqBody = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect(reqBody.comment).toBe('Looks good') }) it('omits comment key when not provided', async () => { const mockFetch = vi.fn().mockResolvedValue( resp(200, bulkResp([{ id: 'act_1', ok: true, status: 'approved' }])), ) vi.stubGlobal('fetch', mockFetch) await client().bulkDecide(['act_1'], 'approve') const reqBody = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string) expect('comment' in reqBody).toBe(false) }) it('returns partial failure results with error and current_status', async () => { const body = bulkResp([ { id: 'act_aaa', ok: true, status: 'approved' }, { id: 'act_bbb', ok: false, error: 'already_decided', current_status: 'rejected' }, { id: 'act_ccc', ok: false, error: 'not_found' }, ]) const mockFetch = vi.fn().mockResolvedValue(resp(200, body)) vi.stubGlobal('fetch', mockFetch) const result = await client().bulkDecide(['act_aaa', 'act_bbb', 'act_ccc'], 'approve') expect(result.succeeded).toBe(1) expect(result.failed).toBe(2) const already = result.results.find(r => r.id === 'act_bbb') expect(already?.ok).toBe(false) expect(already?.error).toBe('already_decided') expect(already?.current_status).toBe('rejected') const notFound = result.results.find(r => r.id === 'act_ccc') expect(notFound?.ok).toBe(false) expect(notFound?.error).toBe('not_found') }) it('includes Bearer token in Authorization header', async () => { const mockFetch = vi.fn().mockResolvedValue( resp(200, bulkResp([{ id: 'act_1', ok: true, status: 'approved' }])), ) vi.stubGlobal('fetch', mockFetch) await client().bulkDecide(['act_1'], 'approve') const init = mockFetch.mock.calls[0][1] as RequestInit expect((init.headers as Record).Authorization).toBe(`Bearer ${TEST_KEY}`) }) it('throws ImpriRateLimited on 429', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue(resp(429, { message: 'Too many requests' }, { 'retry-after': '6' })), ) await expect(client().bulkDecide(['act_1'], 'approve')).rejects.toBeInstanceOf(ImpriRateLimited) }) it('throws ImpriUnauthorized on 403', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(403, { message: 'Forbidden' }))) await expect(client().bulkDecide(['act_1'], 'approve')).rejects.toBeInstanceOf(ImpriUnauthorized) }) it('throws ImpriValidationError on 400 (e.g. ids array too large)', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue( resp(400, { message: 'Validation error', issues: [{ path: ['ids'], message: 'Array must contain at most 50 element(s)' }], }), ), ) const oversizedIds = Array.from({ length: 51 }, (_, i) => `act_${i}`) await expect(client().bulkDecide(oversizedIds, 'approve')).rejects.toBeInstanceOf( ImpriValidationError, ) }) it('all-failed response has succeeded=0 and failed=N', async () => { const body = bulkResp([ { id: 'act_x', ok: false, error: 'not_found' }, { id: 'act_y', ok: false, error: 'internal' }, ]) vi.stubGlobal('fetch', vi.fn().mockResolvedValue(resp(200, body))) const result = await client().bulkDecide(['act_x', 'act_y'], 'reject') expect(result.succeeded).toBe(0) expect(result.failed).toBe(2) }) }) // ─── listActions q filter ───────────────────────────────────────────────────── describe('listActions q filter', () => { it('includes q param in query string when provided', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(200, { items: [], has_more: false })) vi.stubGlobal('fetch', mockFetch) await client().listActions({ q: 'send newsletter' }) const url = mockFetch.mock.calls[0][0] as string expect(url).toContain('q=send+newsletter') }) it('combines q with kind and since in query string', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(200, { items: [], has_more: false })) vi.stubGlobal('fetch', mockFetch) await client().listActions({ q: 'drop table', kind: 'db.exec', since: 1720000000 }) const url = mockFetch.mock.calls[0][0] as string expect(url).toContain('q=drop+table') expect(url).toContain('kind=db.exec') expect(url).toContain('since=1720000000') }) it('does not include q param when undefined', async () => { const mockFetch = vi.fn().mockResolvedValue(resp(200, { items: [], has_more: false })) vi.stubGlobal('fetch', mockFetch) await client().listActions({ status: 'pending' }) const url = mockFetch.mock.calls[0][0] as string expect(url).not.toContain('q=') }) it('passes q through autoPaginate correctly on the first page request', async () => { const page1 = { items: [pendingAction('act_1')], has_more: true, next_cursor: 'cur1' } const page2 = { items: [pendingAction('act_2')], has_more: false } const mockFetch = vi .fn() .mockResolvedValueOnce(resp(200, page1)) .mockResolvedValueOnce(resp(200, page2)) vi.stubGlobal('fetch', mockFetch) const result = await client().listActions({ q: 'hello', autoPaginate: true }) expect(result.items).toHaveLength(2) const firstUrl = mockFetch.mock.calls[0][0] as string expect(firstUrl).toContain('q=hello') const secondUrl = mockFetch.mock.calls[1][0] as string expect(secondUrl).toContain('q=hello') expect(secondUrl).toContain('cursor=cur1') }) })