diff --git a/src/application/ports/backend-client-port.ts b/src/application/ports/backend-client-port.ts new file mode 100644 index 0000000..bb31894 --- /dev/null +++ b/src/application/ports/backend-client-port.ts @@ -0,0 +1,41 @@ +import { Context, Effect } from 'effect'; +import type { + GetMessageResponse, + TapCreditRequest, + TapCreditResponse, + UnderwriteRequest, + UnderwriteResponse, + VerifyMessageResponse, +} from '../../domain/types/backend.js'; + +/** + * Port for interacting with the Bulla backend API. + * Covers authentication, underwriting, and tap-credit flows. + */ +export interface BackendClientService { + /** GET /auth/{wallet}/getMessage — fetch SIWE challenge message. */ + readonly getMessage: (wallet: string) => Effect.Effect; + + /** POST /auth/{wallet}/verifyMessage — submit signed message and receive JWT. */ + readonly verifyMessage: (wallet: string, signature: string) => Effect.Effect; + + /** POST /underwrite/{wallet}/chain/{chainId}/pool/{poolAddress} — underwrite claims. */ + readonly underwrite: ( + authToken: string, + wallet: string, + chainId: number, + poolAddress: string, + body: UnderwriteRequest, + ) => Effect.Effect; + + /** POST /tapCredit/batch/{wallet}/chain/{chainId}/pool/{poolAddress} — batch tap-credit. */ + readonly tapCredit: ( + authToken: string, + wallet: string, + chainId: number, + poolAddress: string, + body: TapCreditRequest, + ) => Effect.Effect; +} + +export const BackendClientService = Context.GenericTag('@services/BackendClientService'); diff --git a/src/cli/commands/factoring.ts b/src/cli/commands/factoring.ts index bc6dbda..727fdc2 100644 --- a/src/cli/commands/factoring.ts +++ b/src/cli/commands/factoring.ts @@ -1,8 +1,9 @@ import { Command } from '@effect/cli'; +import { backendCommands } from '../factoring/backend-commands.js'; import { factoringCommands } from '../factoring/commands.js'; import { factoringViewCommands, queueCommand } from '../factoring/view-commands.js'; export const factoringCommand = Command.make('factoring', {}).pipe( Command.withDescription('Factoring pool operations'), - Command.withSubcommands([...factoringCommands, ...factoringViewCommands, queueCommand]), + Command.withSubcommands([...factoringCommands, ...factoringViewCommands, ...backendCommands, queueCommand]), ); diff --git a/src/cli/factoring/backend-commands.ts b/src/cli/factoring/backend-commands.ts new file mode 100644 index 0000000..4dc6e55 --- /dev/null +++ b/src/cli/factoring/backend-commands.ts @@ -0,0 +1,160 @@ +import { Command } from '@effect/cli'; +import { readFileSync } from 'node:fs'; +import { Console, Effect } from 'effect'; +import { privateKeyToAccount } from 'viem/accounts'; +import { BackendClientService } from '../../application/ports/backend-client-port.js'; +import type { TapCreditRequestItem } from '../../domain/types/backend.js'; +import type { Hex } from '../../domain/types/eth.js'; +import { BackendClientLive } from '../../infrastructure/http/backend-client.js'; +import type { OutputFormat } from '../formatters/index.js'; +import { formatViewResult } from '../formatters/view.js'; +import { chainOption, formatOption } from '../options/common.js'; +import { getChainId } from '../options/common.js'; +import { authTokenOption, claimIdsOption, poolAddressOption, requestsFileOption } from '../options/factoring-options.js'; +import { privateKeyOption } from '../options/pay-options.js'; + +// ============================================================================ +// HELPERS +// ============================================================================ + +/** Extract wallet address from a JWT token by decoding the base64 payload. */ +const extractWalletFromJwt = (token: string): string => { + const parts = token.split('.'); + const payload = parts[1]; + if (!payload) { + throw new Error('Invalid JWT: missing payload segment'); + } + const decoded = JSON.parse(Buffer.from(payload, 'base64').toString()) as { wallet?: string }; + if (!decoded.wallet) { + throw new Error('Invalid JWT: missing wallet field in payload'); + } + return decoded.wallet; +}; + +// ============================================================================ +// AUTHENTICATE +// ============================================================================ + +const authenticateCommand = Command.make( + 'authenticate', + { + privateKey: privateKeyOption, + }, + ({ privateKey }) => + Effect.gen(function* () { + const account = privateKeyToAccount(privateKey as Hex); + const wallet = account.address; + + const client = yield* BackendClientService; + + // Step 1: Get SIWE challenge message + const { message } = yield* client.getMessage(wallet); + + // Step 2: Sign the message + const signature = yield* Effect.tryPromise({ + try: () => account.signMessage({ message }), + catch: (err) => new Error(`Failed to sign message: ${err instanceof Error ? err.message : String(err)}`), + }); + + // Step 3: Verify and get JWT + const { message: token } = yield* client.verifyMessage(wallet, signature); + + yield* Console.log(token); + }).pipe(Effect.provide(BackendClientLive)), +).pipe(Command.withDescription('Authenticate with the Bulla backend and obtain a JWT token')); + +// ============================================================================ +// UNDERWRITE +// ============================================================================ + +const underwriteCommand = Command.make( + 'underwrite', + { + authToken: authTokenOption, + poolAddress: poolAddressOption, + chain: chainOption, + claimIds: claimIdsOption, + format: formatOption, + }, + ({ authToken, poolAddress, chain, claimIds, format }) => + Effect.gen(function* () { + const wallet = extractWalletFromJwt(authToken); + const chainId = yield* getChainId(chain, undefined); + + const client = yield* BackendClientService; + const claimIdList = claimIds.split(',').map(id => id.trim()); + + const response = yield* client.underwrite(authToken, wallet, chainId, poolAddress, { + claimIds: claimIdList, + }); + + for (const result of response.results) { + yield* Console.log( + formatViewResult( + { + claimId: result.claimId, + status: result.status, + txHash: result.txHash, + errors: result.errors.length > 0 ? result.errors.join(', ') : 'none', + }, + format as OutputFormat, + ), + ); + } + }).pipe(Effect.provide(BackendClientLive)), +).pipe(Command.withDescription('Underwrite claims via the Bulla backend')); + +// ============================================================================ +// TAP-CREDIT +// ============================================================================ + +const tapCreditCommand = Command.make( + 'tap-credit', + { + authToken: authTokenOption, + poolAddress: poolAddressOption, + chain: chainOption, + requestsFile: requestsFileOption, + format: formatOption, + }, + ({ authToken, poolAddress, chain, requestsFile, format }) => + Effect.gen(function* () { + const wallet = extractWalletFromJwt(authToken); + const chainId = yield* getChainId(chain, undefined); + + // Read and parse the requests file + const fileContent = yield* Effect.try({ + try: () => readFileSync(requestsFile, 'utf-8'), + catch: (err) => new Error(`Failed to read requests file: ${err instanceof Error ? err.message : String(err)}`), + }); + + const requests = yield* Effect.try({ + try: () => JSON.parse(fileContent) as readonly TapCreditRequestItem[], + catch: (err) => new Error(`Failed to parse requests file: ${err instanceof Error ? err.message : String(err)}`), + }); + + const client = yield* BackendClientService; + + const response = yield* client.tapCredit(authToken, wallet, chainId, poolAddress, { requests }); + + for (const result of response.results) { + yield* Console.log( + formatViewResult( + { + index: result.index, + status: result.status, + txHash: result.txHash, + errors: result.errors.length > 0 ? result.errors.join(', ') : 'none', + }, + format as OutputFormat, + ), + ); + } + }).pipe(Effect.provide(BackendClientLive)), +).pipe(Command.withDescription('Batch tap-credit requests via the Bulla backend')); + +// ============================================================================ +// EXPORT +// ============================================================================ + +export const backendCommands = [authenticateCommand, underwriteCommand, tapCreditCommand] as const; diff --git a/src/cli/options/factoring-options.ts b/src/cli/options/factoring-options.ts index 0a64bac..d2aa410 100644 --- a/src/cli/options/factoring-options.ts +++ b/src/cli/options/factoring-options.ts @@ -44,3 +44,15 @@ export const poolTermLengthOption = Options.integer('term-length').pipe(Options. export const periodsPerYearOption = Options.integer('periods-per-year').pipe( Options.withDescription('Number of interest periods per year'), ); + +export const authTokenOption = Options.text('auth-token').pipe( + Options.withDescription('JWT authentication token from the authenticate command'), +); + +export const claimIdsOption = Options.text('claim-ids').pipe( + Options.withDescription('Comma-separated list of claim IDs to underwrite'), +); + +export const requestsFileOption = Options.text('requests-file').pipe( + Options.withDescription('Path to JSON file containing tap-credit requests array'), +); diff --git a/src/domain/types/backend.ts b/src/domain/types/backend.ts new file mode 100644 index 0000000..1c31efd --- /dev/null +++ b/src/domain/types/backend.ts @@ -0,0 +1,55 @@ +// ============================================================================ +// Auth types +// ============================================================================ + +export interface GetMessageResponse { + readonly message: string; +} + +export interface VerifyMessageResponse { + readonly message: string; +} + +// ============================================================================ +// Underwrite types +// ============================================================================ + +export interface UnderwriteRequest { + readonly claimIds: readonly string[]; +} + +export interface UnderwriteResultItem { + readonly claimId: string; + readonly status: string; + readonly txHash: string; + readonly errors: readonly string[]; +} + +export interface UnderwriteResponse { + readonly results: readonly UnderwriteResultItem[]; +} + +// ============================================================================ +// Tap-credit types +// ============================================================================ + +export interface TapCreditRequestItem { + readonly description: string; + readonly dueBy: number; + readonly amount: string; +} + +export interface TapCreditRequest { + readonly requests: readonly TapCreditRequestItem[]; +} + +export interface TapCreditResultItem { + readonly index: number; + readonly status: string; + readonly txHash: string; + readonly errors: readonly string[]; +} + +export interface TapCreditResponse { + readonly results: readonly TapCreditResultItem[]; +} diff --git a/src/infrastructure/http/backend-client.ts b/src/infrastructure/http/backend-client.ts new file mode 100644 index 0000000..a26f4dc --- /dev/null +++ b/src/infrastructure/http/backend-client.ts @@ -0,0 +1,61 @@ +import { Effect, Layer } from 'effect'; +import { BackendClientService } from '../../application/ports/backend-client-port.js'; +import type { + GetMessageResponse, + TapCreditRequest, + TapCreditResponse, + UnderwriteRequest, + UnderwriteResponse, + VerifyMessageResponse, +} from '../../domain/types/backend.js'; + +const AUTH_BASE_URL = process.env.BULLA_AUTH_URL ?? 'https://apiauth.bulla.network'; +const UNDERWRITER_BASE_URL = process.env.BULLA_UW_URL ?? 'https://apiuw.bulla.network'; + +const fetchJson = (url: string, init?: RequestInit): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const res = await fetch(url, init); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status} from ${url}: ${body}`); + } + return (await res.json()) as T; + }, + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }); + +export const BackendClientLive = Layer.succeed(BackendClientService, { + getMessage: (wallet: string) => + fetchJson(`${AUTH_BASE_URL}/auth/${wallet}/getMessage`), + + verifyMessage: (wallet: string, signature: string) => + fetchJson(`${AUTH_BASE_URL}/auth/${wallet}/verifyMessage`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: signature, + }), + + underwrite: (authToken: string, wallet: string, chainId: number, poolAddress: string, body: UnderwriteRequest) => + fetchJson(`${UNDERWRITER_BASE_URL}/underwrite/${wallet}/chain/${chainId}/pool/${poolAddress}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }), + + tapCredit: (authToken: string, wallet: string, chainId: number, poolAddress: string, body: TapCreditRequest) => + fetchJson( + `${UNDERWRITER_BASE_URL}/tapCredit/batch/${wallet}/chain/${chainId}/pool/${poolAddress}`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }, + ), +}); diff --git a/test/cli/factoring/backend-commands.e2e.test.ts b/test/cli/factoring/backend-commands.e2e.test.ts new file mode 100644 index 0000000..78e291f --- /dev/null +++ b/test/cli/factoring/backend-commands.e2e.test.ts @@ -0,0 +1,323 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { privateKeyToAccount } from 'viem/accounts'; +import { recoverMessageAddress } from 'viem'; +import { runCli, runCliAsync } from '../../e2e/helpers/cli-runner.js'; + +// ============================================================================ +// Test constants +// ============================================================================ + +const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; // Hardhat #0 +const TEST_ACCOUNT = privateKeyToAccount(TEST_PRIVATE_KEY); +const TEST_WALLET = TEST_ACCOUNT.address; +const TEST_POOL = '0xa5e94f122d421c9579a5cb1e687f55e109ba270b'; +const TEST_CHAIN = '11155111'; + +// Build a JWT with the test wallet so extractWalletFromJwt works +const JWT_PAYLOAD = Buffer.from(JSON.stringify({ wallet: TEST_WALLET, exp: 9999999999 })).toString('base64'); +const FAKE_JWT = `eyJhbGciOiJIUzI1NiJ9.${JWT_PAYLOAD}.fake-signature`; + +// SIWE message the mock server will return +const SIWE_MESSAGE = [ + 'Bulla Banker wants you to sign in with your Ethereum account:', + TEST_WALLET, + '', + 'URI: https://banker.bulla.network/#/onboard', + 'Version: 2', + 'Nonce: 12345678', + 'Chain ID: 1', + `Issued At: 2026-02-23T12:00:00.000Z`, +].join('\n'); + +// ============================================================================ +// Mock HTTP server +// ============================================================================ + +let mockServer: Server; +let mockPort: number; +let tmpDir: string; + +/** Read the full request body as a string. */ +const readBody = (req: IncomingMessage): Promise => + new Promise((resolve) => { + let data = ''; + req.on('data', (chunk: Buffer) => (data += chunk.toString())); + req.on('end', () => resolve(data)); + }); + +/** Route handler for the mock backend. */ +const handleRequest = async (req: IncomingMessage, res: ServerResponse) => { + const url = req.url ?? ''; + const method = req.method ?? 'GET'; + + // GET /auth/{wallet}/getMessage + if (method === 'GET' && url.match(/^\/auth\/0x[0-9a-fA-F]+\/getMessage$/)) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: SIWE_MESSAGE })); + return; + } + + // POST /auth/{wallet}/verifyMessage + if (method === 'POST' && url.match(/^\/auth\/0x[0-9a-fA-F]+\/verifyMessage$/)) { + const signature = await readBody(req); + const walletMatch = url.match(/\/auth\/(0x[0-9a-fA-F]+)\//); + const wallet = walletMatch?.[1] ?? ''; + + // Actually verify the SIWE signature + try { + const recovered = await recoverMessageAddress({ + message: SIWE_MESSAGE, + signature: signature as `0x${string}`, + }); + if (recovered.toLowerCase() !== wallet.toLowerCase()) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Signature mismatch' })); + return; + } + } catch { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid signature' })); + return; + } + + // Return a JWT with the wallet in the payload + const payload = Buffer.from(JSON.stringify({ wallet, exp: 9999999999 })).toString('base64'); + const jwt = `eyJhbGciOiJIUzI1NiJ9.${payload}.mock-signature`; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: jwt })); + return; + } + + // POST /underwrite/{wallet}/chain/{chainId}/pool/{poolAddress} + if (method === 'POST' && url.match(/^\/underwrite\/0x[0-9a-fA-F]+\/chain\/\d+\/pool\/0x[0-9a-fA-F]+$/)) { + const body = JSON.parse(await readBody(req)) as { claimIds: string[] }; + const results = body.claimIds.map((claimId) => ({ + claimId, + status: 'Ok', + txHash: '0x' + 'ab'.repeat(32), + errors: [], + })); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ results })); + return; + } + + // POST /tapCredit/batch/{wallet}/chain/{chainId}/pool/{poolAddress} + if (method === 'POST' && url.match(/^\/tapCredit\/batch\/0x[0-9a-fA-F]+\/chain\/\d+\/pool\/0x[0-9a-fA-F]+$/)) { + const body = JSON.parse(await readBody(req)) as { requests: unknown[] }; + const results = body.requests.map((_, index) => ({ + index, + status: 'Ok', + txHash: '0x' + 'cd'.repeat(32), + errors: [], + })); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ results })); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Unknown route: ${method} ${url}` })); +}; + +/** Env vars that point the CLI at the mock server. */ +const mockEnv = () => ({ + BULLA_AUTH_URL: `http://127.0.0.1:${mockPort}`, + BULLA_UW_URL: `http://127.0.0.1:${mockPort}`, +}); + +// ============================================================================ +// Setup / teardown +// ============================================================================ + +beforeAll(async () => { + tmpDir = join(tmpdir(), `bulla-cli-test-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + + mockServer = createServer((req, res) => { + handleRequest(req, res).catch(() => { + res.writeHead(500); + res.end(); + }); + }); + + await new Promise((resolve) => { + mockServer.listen(0, '127.0.0.1', () => { + const addr = mockServer.address(); + mockPort = typeof addr === 'object' && addr ? addr.port : 0; + resolve(); + }); + }); +}); + +afterAll(async () => { + rmSync(tmpDir, { recursive: true, force: true }); + await new Promise((resolve) => mockServer.close(() => resolve())); +}); + +// ============================================================================ +// authenticate command +// ============================================================================ + +describe('factoring authenticate', () => { + it('fails when --private-key is missing', () => { + const result = runCli(['factoring', 'authenticate']); + expect(result.exitCode).toBeGreaterThanOrEqual(1); + }); + + it('authenticates successfully with a valid private key', async () => { + const result = await runCliAsync( + ['factoring', 'authenticate', '--private-key', TEST_PRIVATE_KEY], + mockEnv(), + ); + expect(result.exitCode).toBe(0); + // The output should be a JWT (three dot-separated base64 segments) + expect(result.stdout).toMatch(/^[A-Za-z0-9_+/=-]+\.[A-Za-z0-9_+/=-]+\.[A-Za-z0-9_+/=-]+$/); + + // Decode the JWT payload and verify the wallet + const payload = JSON.parse(Buffer.from(result.stdout.split('.')[1], 'base64').toString()); + expect(payload.wallet.toLowerCase()).toBe(TEST_WALLET.toLowerCase()); + }); +}); + +// ============================================================================ +// underwrite command +// ============================================================================ + +describe('factoring underwrite', () => { + it('fails when --auth-token is missing', () => { + const result = runCli([ + 'factoring', 'underwrite', '--pool-address', TEST_POOL, '--chain', TEST_CHAIN, '--claim-ids', '1,2,3', + ]); + expect(result.exitCode).toBeGreaterThanOrEqual(1); + }); + + it('fails when --pool-address is missing', () => { + const result = runCli([ + 'factoring', 'underwrite', '--auth-token', FAKE_JWT, '--chain', TEST_CHAIN, '--claim-ids', '1,2,3', + ]); + expect(result.exitCode).toBeGreaterThanOrEqual(1); + }); + + it('fails when --claim-ids is missing', () => { + const result = runCli([ + 'factoring', 'underwrite', '--auth-token', FAKE_JWT, '--pool-address', TEST_POOL, '--chain', TEST_CHAIN, + ]); + expect(result.exitCode).toBeGreaterThanOrEqual(1); + }); + + it('fails with invalid JWT (no payload)', () => { + const result = runCli([ + 'factoring', 'underwrite', '--auth-token', 'not-a-jwt', '--pool-address', TEST_POOL, + '--chain', TEST_CHAIN, '--claim-ids', '1', + ]); + expect(result.exitCode).toBeGreaterThanOrEqual(1); + }); + + it('fails with JWT missing wallet field', () => { + const badPayload = Buffer.from(JSON.stringify({ exp: 999 })).toString('base64'); + const badJwt = `header.${badPayload}.sig`; + const result = runCli([ + 'factoring', 'underwrite', '--auth-token', badJwt, '--pool-address', TEST_POOL, + '--chain', TEST_CHAIN, '--claim-ids', '1', + ]); + expect(result.exitCode).toBeGreaterThanOrEqual(1); + }); + + it('underwrites claims successfully', async () => { + const result = await runCliAsync( + [ + 'factoring', 'underwrite', + '--auth-token', FAKE_JWT, + '--pool-address', TEST_POOL, + '--chain', TEST_CHAIN, + '--claim-ids', '100,200,300', + '--format', 'json', + ], + mockEnv(), + ); + expect(result.exitCode).toBe(0); + + const output = result.stdout; + expect(output).toContain('"claimId": "100"'); + expect(output).toContain('"claimId": "200"'); + expect(output).toContain('"claimId": "300"'); + expect(output).toContain('"status": "Ok"'); + expect(output).toContain('"txHash"'); + }); +}); + +// ============================================================================ +// tap-credit command +// ============================================================================ + +describe('factoring tap-credit', () => { + it('fails when --auth-token is missing', () => { + const requestsFile = join(tmpDir, 'requests-noauth.json'); + writeFileSync(requestsFile, JSON.stringify([{ description: 'test', dueBy: 1700000000, amount: '1000000' }])); + const result = runCli([ + 'factoring', 'tap-credit', '--pool-address', TEST_POOL, '--chain', TEST_CHAIN, + '--requests-file', requestsFile, + ]); + expect(result.exitCode).toBeGreaterThanOrEqual(1); + }); + + it('fails when --requests-file is missing', () => { + const result = runCli([ + 'factoring', 'tap-credit', '--auth-token', FAKE_JWT, '--pool-address', TEST_POOL, '--chain', TEST_CHAIN, + ]); + expect(result.exitCode).toBeGreaterThanOrEqual(1); + }); + + it('fails when requests file does not exist', () => { + const result = runCli([ + 'factoring', 'tap-credit', '--auth-token', FAKE_JWT, '--pool-address', TEST_POOL, + '--chain', TEST_CHAIN, '--requests-file', '/tmp/nonexistent-file-12345.json', + ]); + expect(result.exitCode).toBeGreaterThanOrEqual(1); + }); + + it('fails when requests file contains invalid JSON', () => { + const badFile = join(tmpDir, 'bad-requests.json'); + writeFileSync(badFile, 'not valid json {{{'); + const result = runCli([ + 'factoring', 'tap-credit', '--auth-token', FAKE_JWT, '--pool-address', TEST_POOL, + '--chain', TEST_CHAIN, '--requests-file', badFile, + ]); + expect(result.exitCode).toBeGreaterThanOrEqual(1); + }); + + it('processes tap-credit requests successfully', async () => { + const requestsFile = join(tmpDir, 'requests-ok.json'); + writeFileSync( + requestsFile, + JSON.stringify([ + { description: 'Invoice A', dueBy: 1700000000, amount: '1000000' }, + { description: 'Invoice B', dueBy: 1700100000, amount: '2000000' }, + ]), + ); + + const result = await runCliAsync( + [ + 'factoring', 'tap-credit', + '--auth-token', FAKE_JWT, + '--pool-address', TEST_POOL, + '--chain', TEST_CHAIN, + '--requests-file', requestsFile, + '--format', 'json', + ], + mockEnv(), + ); + expect(result.exitCode).toBe(0); + + const output = result.stdout; + expect(output).toContain('"index": 0'); + expect(output).toContain('"index": 1'); + expect(output).toContain('"status": "Ok"'); + expect(output).toContain('"txHash"'); + }); +}); diff --git a/test/e2e/helpers/cli-runner.ts b/test/e2e/helpers/cli-runner.ts index c054a65..7c3165d 100644 --- a/test/e2e/helpers/cli-runner.ts +++ b/test/e2e/helpers/cli-runner.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -18,10 +18,11 @@ export interface TransactionOutput { } /** Run a bulla CLI command and return raw stdout/stderr/exitCode. */ -export function runCli(args: string[]): CliResult { +export function runCli(args: string[], env?: Record): CliResult { const result = spawnSync('node', [CLI_PATH, ...args], { encoding: 'utf-8', timeout: 60_000, + env: env ? { ...process.env, ...env } : undefined, }); return { stdout: (result.stdout ?? '').trim(), @@ -30,6 +31,24 @@ export function runCli(args: string[]): CliResult { }; } +/** Run a bulla CLI command asynchronously (non-blocking — allows in-process servers to respond). */ +export function runCliAsync(args: string[], env?: Record): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI_PATH, ...args], { + env: env ? { ...process.env, ...env } : undefined, + timeout: 30_000, + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString())); + child.on('close', (code) => { + resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 1 }); + }); + }); +} + /** Run a bulla CLI execute command with --format json and parse the TransactionOutput. */ export function runCliExecute(args: string[]): TransactionOutput { const result = runCli([...args, '--format', 'json']);