From 866d2b1e93a9fbb099da493722e831a7cb18c329 Mon Sep 17 00:00:00 2001 From: Bufo Date: Fri, 21 Aug 2026 17:11:14 +0200 Subject: [PATCH] feat: add streaming example and test [AMB-3017] --- AGENTS.md | 19 ++--- packages/payments/examples/.env.example | 5 ++ packages/payments/examples/watch.cts | 39 ++++++++++ packages/payments/examples/watch.ts | 78 +++++++++++++++++++ .../payments/src/resources/streaming.test.ts | 48 ++++++++++++ 5 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 packages/payments/examples/watch.cts create mode 100644 packages/payments/examples/watch.ts diff --git a/AGENTS.md b/AGENTS.md index e51ffde..3dc5adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,15 +137,16 @@ the schema. #### Examples -Runnable scripts in `packages/payments/examples/` (`receive.ts`, `send.ts`) run -against the live API with credentials from `examples/.env` (gitignored; copy -`examples/.env.example`). `verify-webhook.mjs`/`verify-webhook.cjs` need no -credentials (webhook verification is offline) and run in CI as -`pnpm run test:examples`, exercising the built `dist`/`dist-cjs` output of -both packages — the regression check for the dual build. `send.cts`/ -`receive.cts` are CJS counterparts of `send.ts`/`receive.ts` that are only -type-checked in CI (`pnpm run typecheck:examples`), never executed, since -running them would hit the live API the same as their `.ts` counterparts. +Runnable scripts in `packages/payments/examples/` (`receive.ts`, `send.ts`, +`watch.ts`) run against the live API with credentials from `examples/.env` +(gitignored; copy `examples/.env.example`). `verify-webhook.mjs`/ +`verify-webhook.cjs` need no credentials (webhook verification is offline) +and run in CI as `pnpm run test:examples`, exercising the built +`dist`/`dist-cjs` output of both packages — the regression check for the +dual build. `send.cts`/`receive.cts`/`watch.cts` are CJS counterparts of +`send.ts`/`receive.ts`/`watch.ts` that are only type-checked in CI +(`pnpm run typecheck:examples`), never executed, since running them would +hit the live API the same as their `.ts` counterparts. ## Key constraints diff --git a/packages/payments/examples/.env.example b/packages/payments/examples/.env.example index b7e2228..af6f781 100644 --- a/packages/payments/examples/.env.example +++ b/packages/payments/examples/.env.example @@ -39,3 +39,8 @@ AMBOSS_API_KEY= # Amount to invoice, in the wallet asset's base unit (sats for BTC). Default 1000. # RECEIVE_AMOUNT_SATS=1000 # RECEIVE_DESCRIPTION= + +# --- Watch (examples/watch.ts) --------------------------------------------- +# Transaction to stream events for. Leave unset to mint a fresh invoice via +# WALLET_ID + RECEIVE_AMOUNT_SATS (above) and watch that instead. +# TRANSACTION_ID= diff --git a/packages/payments/examples/watch.cts b/packages/payments/examples/watch.cts new file mode 100644 index 0000000..df59a07 --- /dev/null +++ b/packages/payments/examples/watch.cts @@ -0,0 +1,39 @@ +/** + * CJS type-check example for @ambosstech/payments' streaming flow -- the + * compile-only counterpart to watch.ts. Written as `require()`-based + * CommonJS TypeScript (`.cts` forces CJS module/resolution semantics under + * `moduleResolution: NodeNext`, so the `@ambosstech/payments` import below + * resolves via the package's `require` export condition against the built + * `dist-cjs/` declarations). + * + * Like watch.ts, running this for real would mint a live invoice and open a + * live SSE connection -- so this file is only type-checked in CI, never + * executed: + * pnpm exec tsc --noEmit -p tsconfig.examples.cjs.json + */ +import type * as PaymentsModule from '@ambosstech/payments'; + +const { Payments, ApiError } = require('@ambosstech/payments') as typeof PaymentsModule; + +async function watch(): Promise { + const payments = new Payments({ serviceApiKey: 'amb_live_example' }); + + const transaction = await payments.transactions.createReceive({ + wallet_id: 'wallet_1', + amount: '1000', + }); + + try { + for await (const event of payments.transactions.watch(transaction.id)) { + console.log(event.event_type, event.data); + } + } catch (error) { + if (error instanceof ApiError) { + console.error('API error:', error.status, error.message, error.graphqlErrors); + } else { + console.error('unexpected error:', error); + } + } +} + +watch().catch((error: unknown) => console.error(error)); diff --git a/packages/payments/examples/watch.ts b/packages/payments/examples/watch.ts new file mode 100644 index 0000000..26c10de --- /dev/null +++ b/packages/payments/examples/watch.ts @@ -0,0 +1,78 @@ +/** + * Manual smoke test for the @ambosstech/payments SDK streaming flow. + * + * Run from packages/payments (Node 24 runs .ts directly): + * node --env-file=examples/.env examples/watch.ts + * or with tsx: + * pnpm exec tsx --env-file=examples/.env examples/watch.ts + * + * Behaviour: + * 1. If TRANSACTION_ID is unset, mints a Lightning invoice via + * transactions.createReceive (same as receive.ts -- needs WALLET_ID) and + * watches that. + * 2. If TRANSACTION_ID is set, watches it directly. + * 3. Opens an SSE connection via transactions.watch and logs each + * PaymentEvent as it arrives. + * + * This blocks until the server closes the stream -- the transaction reaches a + * terminal status (COMPLETED/FAILED/EXPIRED) or the token's 30-minute max + * stream lifetime elapses. Ctrl-C to exit early. + * + * Copy examples/.env.example -> examples/.env and fill it in. Do NOT commit .env. + */ +import { Payments, ApiError } from '@ambosstech/payments'; + +function required(name: string): string { + const value = process.env[name]; + if (!value) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return value; +} + +async function main(): Promise { + const serviceApiKey = required('AMBOSS_API_KEY'); // the scoped payments service key (amb_live...) + const baseUrl = process.env.AMBOSS_BASE_URL; // optional; defaults to https://rails.amboss.tech/graphql + + const payments = new Payments({ serviceApiKey, ...(baseUrl ? { baseUrl } : {}) }); + + let transactionId = process.env.TRANSACTION_ID; + + if (!transactionId) { + const walletId = required('WALLET_ID'); // needed to mint an invoice to watch + const amount = process.env.RECEIVE_AMOUNT_SATS ?? '1000'; + + console.log('--- no TRANSACTION_ID set, minting an invoice to watch ---'); + const transaction = await payments.transactions.createReceive({ + wallet_id: walletId, + amount, + }); + transactionId = transaction.id; + console.log('payment_request:', transaction.payment_request); + } + + console.log(`\n--- watching transaction ${transactionId} ---`); + console.log( + '(waiting for events; blocks until the transaction settles/expires or the stream closes)\n', + ); + + try { + for await (const event of payments.transactions.watch(transactionId)) { + console.log(`[${new Date().toISOString()}] ${event.event_type}`, JSON.stringify(event.data)); + } + console.log('\n--- stream closed ---'); + } catch (error) { + if (error instanceof ApiError) { + console.error('\n❌ API error:', error.status, error.message, error.graphqlErrors); + } else { + console.error('\n❌ unexpected error:', error); + } + process.exit(1); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/payments/src/resources/streaming.test.ts b/packages/payments/src/resources/streaming.test.ts index 38d5905..871fb64 100644 --- a/packages/payments/src/resources/streaming.test.ts +++ b/packages/payments/src/resources/streaming.test.ts @@ -150,3 +150,51 @@ describe('Wallets.watchEvents', () => { assert.deepEqual(events, [SAMPLE_EVENT]); }); }); + +describe('Transactions.watch — mid-stream error handling', () => { + it('rejects with NetworkError (unwrapped) for a malformed JSON event payload', async () => { + const sse = `data: {not valid json\n\n`; + const payments = new Payments({ + serviceApiKey: 'amb_live_test', + fetch: buildFetch({ streamBody: sse }), + }); + + await assert.rejects( + () => collect(payments.transactions.watch('tx1')), + (err: unknown) => + err instanceof NetworkError && + err.message === 'Received malformed SSE payment event payload', + ); + }); + + it('rejects with NetworkError when the response body stream errors after opening', async () => { + const encoder = new TextEncoder(); + let pulled = false; + const erroringBody = new ReadableStream({ + pull(controller) { + if (!pulled) { + // First read succeeds and is consumed by parseSseStream before the + // second read (below) rejects — proves the error surfaces only + // after some events have already been yielded, not just up front. + pulled = true; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(SAMPLE_EVENT)}\n\n`)); + return; + } + controller.error(new Error('connection reset')); + }, + }); + const payments = new Payments({ + serviceApiKey: 'amb_live_test', + fetch: buildFetch({ streamBody: erroringBody }), + }); + + const events: PaymentEvent[] = []; + await assert.rejects( + async () => { + for await (const event of payments.transactions.watch('tx1')) events.push(event); + }, + (err: unknown) => err instanceof NetworkError, + ); + assert.deepEqual(events, [SAMPLE_EVENT]); + }); +});