diff --git a/test/fixtures/decoder-cases.json b/test/fixtures/decoder-cases.json new file mode 100644 index 0000000..a366649 --- /dev/null +++ b/test/fixtures/decoder-cases.json @@ -0,0 +1,112 @@ +[ + { + "name": "payment-native", + "why": "The most common operation; the exact sentence must state the amount, asset, and destination.", + "operation": { + "type": "payment", + "params": { "destination": "{DEST}", "asset": "native", "amount": "10.5" } + }, + "expectDescription": "Pay 10.5000000 XLM to {DEST}" + }, + { + "name": "create-account", + "why": "Exercises the starting-balance branch, distinct from payment.", + "operation": { + "type": "createAccount", + "params": { "destination": "{NEW_ACCOUNT}", "startingBalance": "25" } + }, + "expectDescription": "Create account {NEW_ACCOUNT} with starting balance 25.0000000 XLM" + }, + { + "name": "manage-data-set", + "why": "Exercises dataValueLabel's printable-text branch (quoted value).", + "operation": { + "type": "manageData", + "params": { "name": "project", "value": "stellar" } + }, + "expectDescription": "Set data entry project = \"stellar\"" + }, + { + "name": "set-options-signer-and-threshold", + "why": "Exercises describeSetOptions' multi-field branch (signer + threshold together), safety-relevant because it changes the account's own multisig setup.", + "operation": { + "type": "setOptions", + "params": { "signer": { "publicKey": "{SIGNER}", "weight": 1 }, "medThreshold": 2 } + }, + "expectDescription": "Set account options: master key weight null; low threshold null; medium threshold 2; high threshold null; set flags null; clear flags null; signer {SIGNER} (weight 1)" + }, + { + "name": "change-trust", + "why": "Exercises assetLabel's non-native code:issuer branch via a trustline limit change.", + "operation": { + "type": "changeTrust", + "params": { "asset": { "code": "USD", "issuer": "{ISSUER}" }, "limit": "1000" } + }, + "expectDescription": "Set trustline for USD:{ISSUER} with limit 1000.0000000" + }, + { + "name": "allow-trust", + "why": "Exercises the authorize-flag formatting branch (parsed as a number, not a boolean).", + "operation": { + "type": "allowTrust", + "params": { "trustor": "{TRUSTOR}", "assetCode": "USD", "authorize": true } + }, + "expectDescription": "Set trust authorization for USD held by {TRUSTOR} (authorize=1)" + }, + { + "name": "account-merge", + "why": "A single-field operation with real safety weight: merging an account is irreversible.", + "operation": { + "type": "accountMerge", + "params": { "destination": "{DEST}" } + }, + "expectDescription": "Merge this account into {DEST}" + }, + { + "name": "path-payment-strict-send", + "why": "Exercises the dual-asset, dual-amount path-payment branch, distinct from a simple payment.", + "operation": { + "type": "pathPaymentStrictSend", + "params": { + "destination": "{DEST}", + "sendAsset": "native", + "sendAmount": "10", + "destAsset": { "code": "USD", "issuer": "{ISSUER}" }, + "destMin": "5" + } + }, + "expectDescription": "Send exactly 10.0000000 XLM to {DEST} delivering at least 5.0000000 USD:{ISSUER}" + }, + { + "name": "bump-sequence", + "why": "A minimal single-field operation, useful as a baseline for the exhaustiveness switch.", + "operation": { + "type": "bumpSequence", + "params": { "bumpTo": "123456789" } + }, + "expectDescription": "Bump sequence number to 123456789" + }, + { + "name": "manage-sell-offer", + "why": "Exercises the offer/price formatting branch shared by the DEX operation family.", + "operation": { + "type": "manageSellOffer", + "params": { + "selling": "native", + "buying": { "code": "USD", "issuer": "{ISSUER}" }, + "amount": "100", + "price": "1.5" + } + }, + "expectDescription": "Place sell offer: sell 100.0000000 XLM for USD:{ISSUER} at price 1.5 (offer 0)" + }, + { + "name": "invoke-host-function-fallback", + "why": "Safety case: Soroban operations are not decoded into plain language in v1, and the fallback sentence must never silently disappear or claim more than it knows.", + "operation": { + "type": "invokeHostFunction", + "params": { "kind": "createStellarAssetContract", "asset": { "code": "USD", "issuer": "{ISSUER}" } } + }, + "expectDescription": "Invoke a Soroban smart contract function" + } +] diff --git a/test/summary.test.ts b/test/summary.test.ts new file mode 100644 index 0000000..0103d05 --- /dev/null +++ b/test/summary.test.ts @@ -0,0 +1,162 @@ +/** + * Decoder-case regression tests, driven by test/fixtures/decoder-cases.json. + * + * That file is shared byte-for-byte with the sibling coordinator-web repo's + * test/fixtures/decoder-cases.json. Running the same human-readable cases and + * expected exact sentences through each repo's own decoder does NOT prove the + * two decoders produce identical output to each other in this test run — this + * suite never touches coordinator-web's code. What it proves is narrower but + * still useful: the same input parameters and the same expected exact + * description continue to hold in THIS repo's decoder. If someone changes + * src/summary.ts in a way that changes one of these sentences, this suite + * fails here; if the same drift happens only in the other repo, only that + * repo's copy of this suite will fail. Catching cross-repo drift still + * requires both suites to be kept green independently. + * + * These are additive to the existing decoder coverage in create.test.ts, + * which exercises the decoder through the full HTTP request/response cycle. + * This suite exercises describeTransaction/describeOperation directly. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { Account, Asset, Keypair, Networks, Operation, Transaction, TransactionBuilder } from '@stellar/stellar-sdk'; +import { describeTransaction } from '../src/summary.js'; + +interface AssetParam { + code: string; + issuer: string; +} + +type OperationParams = Record; + +interface DecoderCase { + name: string; + why: string; + operation: { type: string; params: OperationParams }; + expectDescription: string; +} + +const FIXTURE_PATH = fileURLToPath(new URL('./fixtures/decoder-cases.json', import.meta.url)); +const cases: DecoderCase[] = JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')); + +const PLACEHOLDER_PATTERN = /\{[A-Z_]+\}/g; + +/** Every {TOKEN} appearing anywhere in a case gets one fresh, real keypair. */ +function resolvePlaceholders(caseData: DecoderCase): Map { + const tokens = new Set(JSON.stringify(caseData).match(PLACEHOLDER_PATTERN) ?? []); + const resolved = new Map(); + for (const token of tokens) { + resolved.set(token, Keypair.random().publicKey()); + } + return resolved; +} + +function resolveString(value: string, tokens: Map): string { + if (tokens.has(value)) return tokens.get(value)!; + return value.replace(PLACEHOLDER_PATTERN, (token) => tokens.get(token) ?? token); +} + +function resolveAsset(value: unknown, tokens: Map): Asset { + if (value === 'native') return Asset.native(); + const { code, issuer } = value as AssetParam; + return new Asset(code, resolveString(issuer, tokens)); +} + +/** + * Explicit, per-type builder — deliberately not a generic deserializer, so + * each case's SDK call is plainly visible and each new operation type this + * repo wants to cover requires one new, readable branch here. + */ +function buildOperation(operation: DecoderCase['operation'], tokens: Map) { + const p = operation.params; + switch (operation.type) { + case 'payment': + return Operation.payment({ + destination: resolveString(p.destination as string, tokens), + asset: resolveAsset(p.asset, tokens), + amount: p.amount as string, + }); + case 'createAccount': + return Operation.createAccount({ + destination: resolveString(p.destination as string, tokens), + startingBalance: p.startingBalance as string, + }); + case 'manageData': + return Operation.manageData({ + name: p.name as string, + value: Buffer.from(p.value as string, 'utf8'), + }); + case 'setOptions': { + const signer = p.signer as { publicKey: string; weight: number }; + return Operation.setOptions({ + signer: { ed25519PublicKey: resolveString(signer.publicKey, tokens), weight: signer.weight }, + medThreshold: p.medThreshold as number, + }); + } + case 'changeTrust': + return Operation.changeTrust({ + asset: resolveAsset(p.asset, tokens), + limit: p.limit as string, + }); + case 'allowTrust': + return Operation.allowTrust({ + trustor: resolveString(p.trustor as string, tokens), + assetCode: p.assetCode as string, + authorize: p.authorize as boolean, + }); + case 'accountMerge': + return Operation.accountMerge({ + destination: resolveString(p.destination as string, tokens), + }); + case 'pathPaymentStrictSend': + return Operation.pathPaymentStrictSend({ + destination: resolveString(p.destination as string, tokens), + sendAsset: resolveAsset(p.sendAsset, tokens), + sendAmount: p.sendAmount as string, + destAsset: resolveAsset(p.destAsset, tokens), + destMin: p.destMin as string, + }); + case 'bumpSequence': + return Operation.bumpSequence({ bumpTo: p.bumpTo as string }); + case 'manageSellOffer': + return Operation.manageSellOffer({ + selling: resolveAsset(p.selling, tokens), + buying: resolveAsset(p.buying, tokens), + amount: p.amount as string, + price: p.price as string, + }); + case 'invokeHostFunction': + // Only the createStellarAssetContract shape is covered — it is the + // simplest real invokeHostFunction operation to build without raw XDR. + return Operation.createStellarAssetContract({ + asset: resolveAsset(p.asset, tokens), + }); + default: + throw new Error(`decoder-cases.json: no test builder for operation type "${operation.type}"`); + } +} + +function buildTransactionForCase(caseData: DecoderCase): { transaction: Transaction; tokens: Map } { + const tokens = resolvePlaceholders(caseData); + const source = new Account(Keypair.random().publicKey(), '1234567890'); + const transaction = new TransactionBuilder(source, { fee: '100', networkPassphrase: Networks.TESTNET }) + .addOperation(buildOperation(caseData.operation, tokens)) + .setTimeout(300) + .build(); + return { transaction, tokens }; +} + +describe('decoder cases (shared fixtures)', () => { + for (const caseData of cases) { + it(`${caseData.name}: ${caseData.why}`, () => { + const { transaction, tokens } = buildTransactionForCase(caseData); + const summary = describeTransaction(transaction); + + expect(summary.operations).toHaveLength(1); + const operation = summary.operations[0]!; + expect(operation.type).toBe(caseData.operation.type); + expect(operation.description).toBe(resolveString(caseData.expectDescription, tokens)); + }); + } +});