diff --git a/package.json b/package.json index 0353310..dc53b16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@floelabs/cli", - "version": "0.2.0", + "version": "0.3.0", "description": "Floe CLI — the full Floe platform from your terminal: agents, keys, budgets, policies, billing, and metered calls", "license": "MIT", "type": "module", diff --git a/src/commands/webhooks.ts b/src/commands/webhooks.ts index 18119a0..ea36638 100644 --- a/src/commands/webhooks.ts +++ b/src/commands/webhooks.ts @@ -24,10 +24,15 @@ import { table } from '../lib/table.js'; * rotate-secret) and must therefore be printed exactly once, never swallowed. */ -/** Mirrors the API's allowed webhook events — typos fail before I/O. */ +/** + * Mirrors the API's webhook event catalog — typos fail before I/O. The server + * catalog (`floe webhooks events`) is the source of truth; extend this list + * when it grows. + */ const ALLOWED_EVENTS = [ 'loan.health_warning', 'loan.expiry_warning', + 'loan.overdue', 'loan.liquidated', 'loan.repaid', 'agent.created', @@ -38,9 +43,41 @@ const ALLOWED_EVENTS = [ 'provider_key.created', 'provider_key.updated', 'provider_key.deleted', + 'credit.warning', + 'credit.at_limit', + 'credit.recovered', + 'call.started', + 'call.ended', + 'call.report.ready', + 'call.recording.ready', + 'call.analyzed', + 'call.rejected', + 'phone.number.grace', + 'phone.number.released', + 'marketplace.job.completed', + 'marketplace.payment.settled', + 'marketplace.spend_cap.hit', + 'marketplace.tripwire.triggered', + 'marketplace.vendor.degraded', + 'marketplace.vendor.recovered', ] as const; -const ALLOWED_SCOPES = ['global', 'wallet', 'loan'] as const; +/** Wrap the catalog into indented help lines, 4 names per line. */ +const EVENT_HELP_LINES = ALLOWED_EVENTS.reduce((lines, name, i) => { + if (i % 4 === 0) lines.push([]); + lines[lines.length - 1]!.push(name); + return lines; +}, []) + .map((group) => ` ${group.join(' ')}`) + .join('\n'); + +const ALLOWED_SCOPES = ['global', 'wallet', 'agent', 'loan'] as const; + +/** Mirrors the API's delivery status enum — the logs --status filter values. */ +const DELIVERY_STATUSES = ['pending', 'retrying', 'success', 'failed'] as const; + +/** wallet + agent scope values are wallet addresses — validated before I/O. */ +const WALLET_ADDRESS = /^0x[a-fA-F0-9]{40}$/; interface WebhookView { id: number; @@ -70,6 +107,29 @@ interface DeliveryView { createdAt: string | null; } +/** One row of GET /v1/developer/webhooks/events — the live catalog. */ +interface CatalogEvent { + name: string; + title: string; + description: string; + category: string; + scope: string; +} + +/** One row of GET /v1/developer/webhook-deliveries — the account-wide log. */ +interface AccountDeliveryView extends DeliveryView { + webhookId: number; + webhookUrl: string | null; + agentWallet: string | null; + correlationId: string | null; +} + +interface AccountDeliveriesResponse { + deliveries: AccountDeliveryView[]; + nextCursor: string | null; + hasMore: boolean; +} + /** POST …/test and …/retry both return this dispatch outcome. */ interface DispatchOutcome { success: boolean; @@ -88,6 +148,15 @@ export interface WebhooksFlags { description?: string; limit?: string; retry?: string; + // logs filters + endpoint?: string; + event?: string; + agent?: string; + status?: string; + from?: string; + to?: string; + id?: string; + cursor?: string; } function requireWebhookId(raw: string | undefined, verb: string): string { @@ -111,6 +180,21 @@ async function withWebhook(id: string, call: Promise): Promise { } } +/** + * Mirrors the API's isSubscribableEvent (routes/developer/shared.ts): an + * exact catalog name, '*' (everything), or a '.*' wildcard that + * covers at least one catalog event. + */ +function isSubscribableEvent(value: string): boolean { + if (value === '*') return true; + if ((ALLOWED_EVENTS as readonly string[]).includes(value)) return true; + if (value.endsWith('.*')) { + const prefix = value.slice(0, -1); // keep the trailing dot: 'call.' + return ALLOWED_EVENTS.some((name) => name.startsWith(prefix)); + } + return false; +} + function parseEvents(raw: string | undefined): string[] { if (!raw) { throw new UsageError( @@ -119,10 +203,11 @@ function parseEvents(raw: string | undefined): string[] { } const events = [...new Set(raw.split(',').map((e) => e.trim()).filter(Boolean))]; if (events.length === 0) throw new UsageError('--events must name at least one event.'); - const unknown = events.filter((e) => !(ALLOWED_EVENTS as readonly string[]).includes(e)); + const unknown = events.filter((e) => !isSubscribableEvent(e)); if (unknown.length > 0) { throw new UsageError( - `Unknown event(s): ${unknown.join(', ')}. Valid events:\n ${ALLOWED_EVENTS.join('\n ')}`, + `Unknown event(s): ${unknown.join(', ')}. Valid events ('*' and '.*' wildcards also accepted):\n ${ALLOWED_EVENTS.join('\n ')}\n` + + 'Live catalog with descriptions: floe webhooks events', ); } return events; @@ -174,6 +259,8 @@ function summarizeEvents(events: string[]): string { const statusLabel = (active: boolean) => (active ? green('active') : yellow('paused')); +const shortWallet = (w: string): string => (w.length > 12 ? `${w.slice(0, 6)}…${w.slice(-4)}` : w); + export async function webhooksListCommand(flags: WebhooksFlags): Promise { const { api } = await devContext(flags); const { webhooks } = await api.dev<{ webhooks: WebhookView[] }>('GET', '/v1/developer/webhooks'); @@ -211,14 +298,17 @@ export async function webhooksCreateCommand(url: string, flags: WebhooksFlags): const scope = flags.scope ?? 'global'; const scopeValue = flags.scopeValue; if (!(ALLOWED_SCOPES as readonly string[]).includes(scope)) { - throw new UsageError(`Unknown --scope "${scope}". Supported: global, wallet, loan.`); + throw new UsageError(`Unknown --scope "${scope}". Supported: global, wallet, agent, loan.`); } if (scope === 'global' && scopeValue) { throw new UsageError('--scope global does not take a --scope-value.'); } - if (scope === 'wallet' && (!scopeValue || !/^0x[a-fA-F0-9]{40}$/.test(scopeValue))) { + if (scope === 'wallet' && (!scopeValue || !WALLET_ADDRESS.test(scopeValue))) { throw new UsageError('--scope wallet requires --scope-value <0x… Ethereum address>.'); } + if (scope === 'agent' && (!scopeValue || !WALLET_ADDRESS.test(scopeValue))) { + throw new UsageError('--scope agent requires --scope-value <0x… agent wallet address>.'); + } if (scope === 'loan' && (!scopeValue || !/^\d+$/.test(scopeValue))) { throw new UsageError('--scope loan requires --scope-value .'); } @@ -259,6 +349,32 @@ export async function webhooksCreateCommand(url: string, flags: WebhooksFlags): printSecretOnce(webhook.secret); } +export async function webhooksEventsCommand(flags: WebhooksFlags): Promise { + const { api } = await devContext(flags); + let catalog: { events: CatalogEvent[] }; + try { + catalog = await api.dev<{ events: CatalogEvent[] }>('GET', '/v1/developer/webhooks/events'); + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + throw new ApiError( + 'This API build predates the webhook event catalog endpoint.', + 404, + err.code, + 'The Events list in `floe help webhooks` is still valid.', + ); + } + throw err; + } + + if (flags.json) return printJson({ events: catalog.events }); + + const rows = [...catalog.events] + .sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)) + .map((e) => [sanitizeText(e.category), sanitizeText(e.name), sanitizeText(e.description)]); + process.stdout.write(`${table(['CATEGORY', 'EVENT', 'DESCRIPTION'], rows)}\n`); + process.stdout.write(`${dim('Subscribe: floe webhooks create --events ')}\n`); +} + export async function webhooksGetCommand(id: string, flags: WebhooksFlags): Promise { const { api } = await devContext(flags); const { webhook, deliveryStats } = await withWebhook( @@ -271,7 +387,12 @@ export async function webhooksGetCommand(id: string, flags: WebhooksFlags): Prom if (flags.json) return printJson({ webhook, deliveryStats }); - const stats = Object.entries(deliveryStats); + // `total` is an aggregate, not a delivery status, and zero-count statuses + // are noise — the API's dense stats shape would otherwise make the + // 'none yet' empty state unreachable. + const stats = Object.entries(deliveryStats).filter( + ([status, count]) => status !== 'total' && count > 0, + ); const rows: Array<[string, string]> = [ ['URL', sanitizeText(webhook.url)], ['Events', sanitizeText(webhook.events.join(', '))], @@ -400,17 +521,129 @@ export async function webhooksDeliveriesCommand(id: string, flags: WebhooksFlags process.stdout.write(`${dim(`Re-send one: floe webhooks deliveries ${id} --retry `)}\n`); } +/** Validate every logs filter and build the query — before any I/O. */ +function buildLogsQuery(flags: WebhooksFlags): URLSearchParams { + const query = new URLSearchParams(); + if (flags.endpoint !== undefined) { + if (!/^\d+$/.test(flags.endpoint)) { + throw new UsageError( + `--endpoint takes the numeric webhook id (got "${flags.endpoint}") — see \`floe webhooks list\`.`, + ); + } + query.set('endpoint', flags.endpoint); + } + if (flags.event !== undefined) { + if (!(ALLOWED_EVENTS as readonly string[]).includes(flags.event)) { + throw new UsageError( + `Unknown --event "${flags.event}". Live catalog: floe webhooks events`, + ); + } + query.set('event', flags.event); + } + if (flags.agent !== undefined) { + if (!WALLET_ADDRESS.test(flags.agent)) { + throw new UsageError(`--agent takes an agent wallet address (0x…, got "${flags.agent}").`); + } + query.set('agent', flags.agent); + } + if (flags.status !== undefined) { + if (!(DELIVERY_STATUSES as readonly string[]).includes(flags.status)) { + throw new UsageError( + `Unknown --status "${flags.status}". Supported: ${DELIVERY_STATUSES.join(', ')}.`, + ); + } + query.set('status', flags.status); + } + for (const [name, raw] of [ + ['from', flags.from], + ['to', flags.to], + ] as const) { + if (raw !== undefined) { + const parsed = Date.parse(raw); + if (Number.isNaN(parsed)) { + throw new UsageError(`--${name} must be an ISO 8601 timestamp (got "${raw}").`); + } + query.set(name, new Date(parsed).toISOString()); + } + } + // Opaque server-side matcher: a session/correlation id or a delivery id. + if (flags.id !== undefined) query.set('id', flags.id); + if (flags.cursor !== undefined) query.set('cursor', flags.cursor); + if (flags.limit !== undefined) query.set('limit', String(parseLimit(flags.limit))); + return query; +} + +export async function webhooksLogsCommand(flags: WebhooksFlags): Promise { + const query = buildLogsQuery(flags); // validation precedes I/O + const { api } = await devContext(flags); + const qs = query.toString(); + const feed = await api.dev( + 'GET', + `/v1/developer/webhook-deliveries${qs ? `?${qs}` : ''}`, + ); + + if (flags.json) { + return printJson({ deliveries: feed.deliveries, nextCursor: feed.nextCursor, hasMore: feed.hasMore }); + } + + if (feed.deliveries.length === 0) { + process.stdout.write( + `No deliveries found. Widen the filters, or send a test event: ${bold('floe webhooks test ')}\n`, + ); + return; + } + const rows = feed.deliveries.map((d) => [ + d.createdAt ? d.createdAt.slice(0, 16).replace('T', ' ') : '—', + d.webhookId !== null && d.webhookId !== undefined + ? `#${d.webhookId}` + : d.webhookUrl + ? sanitizeText(d.webhookUrl) + : '—', + sanitizeText(d.event), + d.correlationId + ? sanitizeText(d.correlationId) + : d.agentWallet + ? shortWallet(sanitizeText(d.agentWallet)) + : '—', + String(d.attempt), + d.statusCode !== null && d.statusCode !== undefined ? String(d.statusCode) : '—', + d.status === 'success' ? green('success') : red(sanitizeText(d.status)), + ]); + process.stdout.write( + `${table(['AT', 'ENDPOINT', 'EVENT', 'AGENT/SESSION', 'ATTEMPT', 'HTTP', 'STATUS'], rows)}\n`, + ); + // No auto-follow: the caller decides whether to page — unbounded loops break scripts. + if (feed.hasMore && feed.nextCursor) { + // Repeat the active filters in the hint — a bare --cursor would silently + // continue into the UNFILTERED account-wide log. + const repeatFlags = [...query.entries()] + .filter(([name]) => name !== 'cursor') + .map(([name, value]) => `--${name} ${sanitizeText(value)} `) + .join(''); + process.stdout.write( + `${dim(`More available — next page: floe webhooks logs ${repeatFlags}--cursor ${sanitizeText(feed.nextCursor)}`)}\n`, + ); + } + process.stdout.write( + `${dim('Re-send a delivery: floe webhooks deliveries --retry (ids via --json)')}\n`, + ); +} + export const webhooksDef: CommandDef = { name: 'webhooks', - summary: 'list | create | get | pause | enable | delete | test | rotate-secret | deliveries', + summary: 'list | create | events | get | pause | enable | delete | test | rotate-secret | deliveries | logs', usage: `Usage: floe webhooks [list] - floe webhooks create --events [--scope global|wallet|loan --scope-value ] [--description ] + floe webhooks create --events [--scope global|wallet|agent|loan --scope-value ] [--description ] + floe webhooks events floe webhooks get floe webhooks pause | enable floe webhooks delete floe webhooks test floe webhooks rotate-secret floe webhooks deliveries [--limit <1-100>] [--retry ] + floe webhooks logs [--endpoint ] [--event ] [--agent <0x…>] + [--status ] [--from ] [--to ] + [--id ] [--limit <1-100>] [--cursor ] Signed event deliveries to your endpoint (HMAC-SHA256 over ".", headers X-Floe-Signature / X-Floe-Timestamp / X-Floe-Delivery-Id). @@ -418,6 +651,7 @@ headers X-Floe-Signature / X-Floe-Timestamp / X-Floe-Delivery-Id). list All webhooks on your developer account (max 10) create Register an endpoint — the whsec_… signing secret is shown exactly once at creation + events The live event catalog (category, name, description) get One webhook + delivery success/failure counts pause | enable Toggle deliveries without losing the endpoint config delete Remove the webhook and stop all deliveries @@ -426,14 +660,16 @@ headers X-Floe-Signature / X-Floe-Timestamp / X-Floe-Delivery-Id). verifying immediately deliveries Recent delivery attempts; --retry re-sends one with a fresh signature (exits 1 on failure) + logs Account-wide delivery log across all webhooks, newest + first; --id matches a session or delivery id; paginate + with --cursor (no auto-follow) -Events (--events, comma-separated): - loan.health_warning loan.expiry_warning loan.liquidated loan.repaid - agent.created agent.suspended key.created key.rotated - x402.first_settlement provider_key.created provider_key.updated - provider_key.deleted +Events (--events, comma-separated; '*' or '.*' wildcards, e.g. call.*, +also accepted; live list: floe webhooks events): +${EVENT_HELP_LINES} -Scopes: global (default) · wallet --scope-value 0x… · loan --scope-value +Scopes: global (default) · wallet --scope-value 0x… · agent --scope-value 0x… + loan --scope-value `, options: { events: { type: 'string' }, @@ -442,6 +678,14 @@ Scopes: global (default) · wallet --scope-value 0x… · loan --scope-value { const [subcommand, arg] = ctx.args; @@ -455,6 +699,14 @@ Scopes: global (default) · wallet --scope-value 0x… · loan --scope-value --events [--scope global|wallet|loan --scope-value ]', + 'Usage: floe webhooks create --events [--scope global|wallet|agent|loan --scope-value ]', ); } expectArgs(ctx, 2); await webhooksCreateCommand(arg, flags); + } else if (subcommand === 'events') { + expectArgs(ctx, 1); + await webhooksEventsCommand(flags); } else if (subcommand === 'get') { expectArgs(ctx, 2); await webhooksGetCommand(requireWebhookId(arg, 'get'), flags); @@ -488,9 +743,12 @@ Scopes: global (default) · wallet --scope-value 0x… · loan --scope-value ; body?: unknown }; /** Stub fetch with a fixed response; capture every call's method/url/headers/parsed body. */ @@ -174,6 +201,66 @@ describe('webhooks create', () => { expect(spy).not.toHaveBeenCalled(); }); + it('accepts the expanded catalog, including loan.overdue', async () => { + const { calls } = stubFetch(201, { webhook: { ...HOOK, secret: 'whsec_x' } }); + await main([ + 'webhooks', 'create', 'https://example.com/hook', + '--events', 'loan.overdue,call.ended,marketplace.job.completed', + ]); + expect(calls).toHaveLength(1); + expect(calls[0]!.body).toEqual({ + url: 'https://example.com/hook', + events: ['loan.overdue', 'call.ended', 'marketplace.job.completed'], + scope: 'global', + }); + expect(process.exitCode ?? 0).toBe(0); + }); + + it('sends scope + wallet scopeValue for agent scope', async () => { + const wallet = '0x1234567890abcdef1234567890abcdef12345678'; + const { calls } = stubFetch(201, { + webhook: { ...HOOK, scope: 'agent', scopeValue: wallet, secret: 'whsec_x' }, + }); + await main([ + 'webhooks', 'create', 'https://example.com/hook', + '--events', 'call.ended', '--scope', 'agent', '--scope-value', wallet, + ]); + expect(calls[0]!.body).toEqual({ + url: 'https://example.com/hook', + events: ['call.ended'], + scope: 'agent', + scopeValue: wallet, + }); + }); + + it('rejects --scope agent without a wallet --scope-value, pre-network', async () => { + const spy = stubNoFetch(); + await main(['webhooks', 'create', 'https://example.com/hook', '--events', 'call.ended', '--scope', 'agent']); + expect(stderr).toContain('--scope agent requires'); + expect(process.exitCode).toBe(2); + expect(spy).not.toHaveBeenCalled(); + }); + + it("accepts '*' and prefix wildcards the API contract allows", async () => { + const { calls } = stubFetch(201, { webhook: { ...HOOK, secret: 'whsec_x' } }); + await main(['webhooks', 'create', 'https://example.com/hook', '--events', 'call.*,loan.repaid']); + expect(calls).toHaveLength(1); + expect(calls[0]!.body).toEqual({ + url: 'https://example.com/hook', + events: ['call.*', 'loan.repaid'], + scope: 'global', + }); + expect(process.exitCode ?? 0).toBe(0); + }); + + it('rejects a wildcard covering no catalog events, pre-network', async () => { + const spy = stubNoFetch(); + await main(['webhooks', 'create', 'https://example.com/hook', '--events', 'bogus.*']); + expect(stderr).toContain('Unknown event(s): bogus.*'); + expect(process.exitCode).toBe(2); + expect(spy).not.toHaveBeenCalled(); + }); + it('remaps the max-webhooks limit error to a friendly message', async () => { stubFetch(400, { error: 'Limit exceeded', message: 'Maximum 10 webhooks allowed' }); await main(['webhooks', 'create', 'https://example.com/hook', '--events', 'loan.repaid']); @@ -193,6 +280,26 @@ describe('webhooks get', () => { expect(stdout).toContain('1 failed'); }); + it('omits total and zero-count statuses from the dense stats shape', async () => { + stubFetch(200, { + webhook: HOOK, + deliveryStats: { pending: 0, success: 3, failed: 0, retrying: 0, total: 3 }, + }); + await main(['webhooks', 'get', '7']); + expect(stdout).toContain('3 success'); + expect(stdout).not.toContain('total'); + expect(stdout).not.toContain('0 pending'); + }); + + it('shows the empty state when the dense stats are all zero', async () => { + stubFetch(200, { + webhook: HOOK, + deliveryStats: { pending: 0, success: 0, failed: 0, retrying: 0, total: 0 }, + }); + await main(['webhooks', 'get', '7']); + expect(stdout).toContain('none yet'); + }); + it('rejects a non-numeric id before any network call', async () => { const spy = stubNoFetch(); await main(['webhooks', 'get', 'abc']); @@ -319,6 +426,195 @@ describe('webhooks deliveries', () => { }); }); +describe('webhooks events', () => { + const CATALOG = [ + { name: 'loan.repaid', title: 'Loan repaid', description: 'A loan was repaid', category: 'loan', scope: 'loan' }, + { name: 'call.ended', title: 'Call ended', description: 'A voice call ended', category: 'call', scope: 'agent' }, + { name: 'call.analyzed', title: 'Call analyzed', description: 'Post-call analysis ready', category: 'call', scope: 'agent' }, + ]; + + it('renders the catalog sorted by category then name', async () => { + const { calls } = stubFetch(200, { events: CATALOG }); + await main(['webhooks', 'events']); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe(`${API}/v1/developer/webhooks/events`); + expect(calls[0]!.method).toBe('GET'); + expect(stdout).toContain('CATEGORY'); + expect(stdout).toContain('A voice call ended'); + // call.analyzed < call.ended < loan.repaid once sorted category-then-name + expect(stdout.indexOf('call.analyzed')).toBeLessThan(stdout.indexOf('call.ended')); + expect(stdout.indexOf('call.ended')).toBeLessThan(stdout.indexOf('loan.repaid')); + expect(process.exitCode ?? 0).toBe(0); + }); + + it('--json round-trips the catalog unsorted', async () => { + stubFetch(200, { events: CATALOG }); + await main(['webhooks', 'events', '--json']); + expect(JSON.parse(stdout)).toEqual({ events: CATALOG }); + }); + + it('explains a 404 from an API build without the catalog endpoint', async () => { + stubFetch(404, { error: 'Not found' }); + await main(['webhooks', 'events']); + expect(stderr).toContain('predates the webhook event catalog'); + expect(stderr).toContain('still valid'); + expect(process.exitCode).toBe(1); + }); +}); + +describe('webhooks logs', () => { + it('GETs the account-wide log without filters and renders the table', async () => { + const { calls } = stubFetch(200, { + deliveries: [LOG_ROW, LOG_ROW_SESSION], + nextCursor: null, + hasMore: false, + }); + await main(['webhooks', 'logs']); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe(`${API}/v1/developer/webhook-deliveries`); + expect(calls[0]!.method).toBe('GET'); + expect(stdout).toContain('2026-08-10 09:15'); // AT + expect(stdout).toContain('#7'); // ENDPOINT + expect(stdout).toContain('call.ended'); + expect(stdout).toContain('0x1234…5678'); // shortened wallet + expect(stdout).toContain('CA9f2f0f5c'); // correlation id wins over wallet + expect(stdout).toContain('500'); + expect(stdout).toContain('failed'); + expect(stdout).toContain('--retry'); + expect(process.exitCode ?? 0).toBe(0); + }); + + it('lands every filter in the query string, with from/to normalized to ISO', async () => { + const { calls } = stubFetch(200, { deliveries: [], nextCursor: null, hasMore: false }); + await main([ + 'webhooks', 'logs', + '--endpoint', '7', + '--event', 'call.ended', + '--agent', '0x1234567890abcdef1234567890abcdef12345678', + '--status', 'failed', + '--from', '2026-08-01', + '--to', '2026-08-05T10:00:00Z', + '--id', 'CA9f2f0f5c', + '--cursor', 'cur_opaque', + '--limit', '25', + ]); + const params = new URL(calls[0]!.url).searchParams; + expect(calls[0]!.url.startsWith(`${API}/v1/developer/webhook-deliveries?`)).toBe(true); + expect(params.get('endpoint')).toBe('7'); + expect(params.get('event')).toBe('call.ended'); + expect(params.get('agent')).toBe('0x1234567890abcdef1234567890abcdef12345678'); + expect(params.get('status')).toBe('failed'); + expect(params.get('from')).toBe('2026-08-01T00:00:00.000Z'); + expect(params.get('to')).toBe('2026-08-05T10:00:00.000Z'); + expect(params.get('id')).toBe('CA9f2f0f5c'); + expect(params.get('cursor')).toBe('cur_opaque'); + expect(params.get('limit')).toBe('25'); + }); + + it('rejects a non-numeric --endpoint before any network call', async () => { + const spy = stubNoFetch(); + await main(['webhooks', 'logs', '--endpoint', 'my-hook']); + expect(stderr).toContain('numeric webhook id'); + expect(process.exitCode).toBe(2); + expect(spy).not.toHaveBeenCalled(); + }); + + it('rejects a malformed --agent before any network call', async () => { + const spy = stubNoFetch(); + await main(['webhooks', 'logs', '--agent', '42']); + expect(stderr).toContain('wallet address'); + expect(process.exitCode).toBe(2); + expect(spy).not.toHaveBeenCalled(); + }); + + it('rejects an unparseable --from before any network call', async () => { + const spy = stubNoFetch(); + await main(['webhooks', 'logs', '--from', 'yesterday-ish']); + expect(stderr).toContain('ISO 8601'); + expect(process.exitCode).toBe(2); + expect(spy).not.toHaveBeenCalled(); + }); + + it('rejects an unknown --event before any network call', async () => { + const spy = stubNoFetch(); + await main(['webhooks', 'logs', '--event', 'loan.exploded']); + expect(stderr).toContain('Unknown --event'); + expect(process.exitCode).toBe(2); + expect(spy).not.toHaveBeenCalled(); + }); + + it('rejects an unknown --status before any network call', async () => { + const spy = stubNoFetch(); + await main(['webhooks', 'logs', '--status', 'bogus']); + expect(stderr).toContain('Unknown --status "bogus"'); + expect(stderr).toContain('pending, retrying, success, failed'); + expect(process.exitCode).toBe(2); + expect(spy).not.toHaveBeenCalled(); + }); + + it('accepts every documented --status value and lands it in the query string', async () => { + for (const status of ['pending', 'retrying', 'success', 'failed']) { + const { calls } = stubFetch(200, { deliveries: [], nextCursor: null, hasMore: false }); + await main(['webhooks', 'logs', '--status', status]); + expect(calls).toHaveLength(1); + expect(new URL(calls[0]!.url).searchParams.get('status')).toBe(status); + expect(process.exitCode ?? 0).toBe(0); + } + }); + + it('prints the real next cursor when hasMore', async () => { + stubFetch(200, { deliveries: [LOG_ROW], nextCursor: 'cur_next123', hasMore: true }); + await main(['webhooks', 'logs']); + expect(stdout).toContain('floe webhooks logs --cursor cur_next123'); + }); + + it('repeats the active filters in the next-page hint', async () => { + stubFetch(200, { deliveries: [LOG_ROW], nextCursor: 'cur_next123', hasMore: true }); + await main(['webhooks', 'logs', '--status', 'failed', '--event', 'call.ended']); + // Filters ride along so page 2 stays the same result set, not the + // unfiltered account-wide log. Query-insertion order: event before status. + expect(stdout).toContain( + 'floe webhooks logs --event call.ended --status failed --cursor cur_next123', + ); + }); + + it('omits the cursor hint on the last page', async () => { + stubFetch(200, { deliveries: [LOG_ROW], nextCursor: null, hasMore: false }); + await main(['webhooks', 'logs']); + expect(stdout).not.toContain('--cursor'); + }); + + it('suggests widening filters or a test event when nothing matches', async () => { + stubFetch(200, { deliveries: [], nextCursor: null, hasMore: false }); + await main(['webhooks', 'logs', '--status', 'failed']); + expect(stdout).toContain('Widen the filters'); + expect(stdout).toContain('floe webhooks test'); + }); + + it('--json emits {deliveries, nextCursor, hasMore} verbatim', async () => { + stubFetch(200, { deliveries: [LOG_ROW], nextCursor: 'cur_next123', hasMore: true }); + await main(['webhooks', 'logs', '--json']); + expect(JSON.parse(stdout)).toEqual({ + deliveries: [LOG_ROW], + nextCursor: 'cur_next123', + hasMore: true, + }); + }); +}); + +describe('webhooks help', () => { + it('lists catalog events in the usage text without a network call', async () => { + const spy = stubNoFetch(); + await main(['help', 'webhooks']); + // Spot-check first and last catalog entries — proves the derived list renders. + expect(stdout).toContain('loan.health_warning'); + expect(stdout).toContain('loan.overdue'); + expect(stdout).toContain('marketplace.vendor.recovered'); + expect(process.exitCode ?? 0).toBe(0); + expect(spy).not.toHaveBeenCalled(); + }); +}); + describe('webhooks dispatch', () => { it('rejects an unknown subcommand', async () => { const spy = stubNoFetch();