sdk/typescript/tests/client.test.ts
Rama main · réplica de lectura del repositorio original.
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<string, string> = {},
): 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<string, string>).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<string, unknown>).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<string>
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('[email protected]', 'Hello!')
const createBody = JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string)
expect(createBody.title).toBe('Send to [email protected]')
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<void>
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> = {}): 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<string, unknown> = {}) {
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<string, string>).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<string, string>).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<string, string>).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')
})
})