diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d069ead..5ca18c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,16 @@ jobs: - name: Lint run: pnpm lint - - name: Test - run: pnpm test + - name: Test with coverage + run: pnpm test:coverage + + - name: Upload SDK coverage report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sdk-coverage + path: coverage/ + if-no-files-found: error # Last: the slowest step, and the one the earlier checks usually predict. - name: Build diff --git a/package.json b/package.json index 9d5a71b..53d6521 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "start": "next start", "lint": "eslint", "test": "vitest run", + "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", "export:emails": "tsx scripts/export-emails.ts" }, @@ -36,6 +37,7 @@ "@types/node": "^26", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^3.2.7", "eslint": "^9", "eslint-config-next": "16.4.0", "tailwindcss": "^4", diff --git a/src/lib/sdk/sdk.test.ts b/src/lib/sdk/sdk.test.ts index ddd2be8..2846035 100644 --- a/src/lib/sdk/sdk.test.ts +++ b/src/lib/sdk/sdk.test.ts @@ -105,12 +105,70 @@ describe("transaction building + signing", () => { }); const tx = decodeTx(xdr, Networks.TESTNET); expect(tx.source).toBe(VECTOR_ACCOUNT); + expect(tx.networkPassphrase).toBe(info.network_passphrase); expect(tx.signatures.length).toBe(1); // The signature must verify against the wallet's public key. const kp = Keypair.fromPublicKey(VECTOR_ACCOUNT); expect(kp.verify(tx.hash(), tx.signatures[0]!.signature())).toBe(true); }); + it("builds a signed payment to a muxed destination", () => { + const w = fromMnemonic(VECTOR_MNEMONIC); + const destination = new MuxedAccount( + new Account("GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX", "0"), + "42", + ).accountId(); + const xdr = buildSignedPayment(w.keypair, info, { destination, amount: "1" }); + const tx = decodeTx(xdr, info.network_passphrase); + const operation = tx.operations[0]!; + + expect(tx.signatures).toHaveLength(1); + expect(tx.networkPassphrase).toBe(info.network_passphrase); + expect(operation.type).toBe("payment"); + if (operation.type !== "payment") return; + expect(operation.destination).toBe(destination); + }); + + it.each([ + ["invalid destination", "not-a-stellar-address", "1"], + ["amount with more than seven decimals", "GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX", "1.00000001"], + ["zero amount", "GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX", "0"], + ["negative amount", "GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX", "-1"], + ])("buildSignedPayment rejects %s", (_reason, destination, amount) => { + const w = fromMnemonic(VECTOR_MNEMONIC); + expect(() => buildSignedPayment(w.keypair, info, { destination, amount })).toThrow(); + }); + + it("buildSignedPayment rejects an invalid asset issuer", () => { + const w = fromMnemonic(VECTOR_MNEMONIC); + expect(() => + buildSignedPayment(w.keypair, info, { + destination: "GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX", + amount: "1", + asset: { code: "USDC", issuer: "not-a-stellar-address" }, + }), + ).toThrow(); + }); + + it.each([ + ["invalid destination", "not-a-stellar-address", "1"], + ["amount with more than seven decimals", "GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX", "1.00000001"], + ["zero amount", "GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX", "0"], + ["negative amount", "GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX", "-1"], + ])("buildUnsignedPayment rejects %s", (_reason, destination, amount) => { + expect(() => buildUnsignedPayment(VECTOR_ACCOUNT, info, { destination, amount })).toThrow(); + }); + + it("buildUnsignedPayment rejects an invalid asset issuer", () => { + expect(() => + buildUnsignedPayment(VECTOR_ACCOUNT, info, { + destination: "GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX", + amount: "1", + asset: { code: "USDC", issuer: "not-a-stellar-address" }, + }), + ).toThrow(); + }); + it("builds a signed change-trust (USDC) envelope", () => { const w = fromMnemonic(VECTOR_MNEMONIC); const xdr = buildSignedChangeTrust(w.keypair, info, { @@ -121,9 +179,19 @@ describe("transaction building + signing", () => { }); const tx = decodeTx(xdr, Networks.TESTNET); expect(tx.source).toBe(VECTOR_ACCOUNT); + expect(tx.networkPassphrase).toBe(info.network_passphrase); expect(tx.signatures.length).toBe(1); }); + it("buildSignedChangeTrust rejects an invalid asset issuer", () => { + const w = fromMnemonic(VECTOR_MNEMONIC); + expect(() => + buildSignedChangeTrust(w.keypair, info, { + asset: { code: "USDC", issuer: "not-a-stellar-address" }, + }), + ).toThrow(); + }); + it("builds a signed create-account for an unfunded XLM destination", () => { const w = fromMnemonic(VECTOR_MNEMONIC); const destination = "GBAW5XGWORWVFE2XTJYDTLDHXTY2Q2MO73HYCGB3XMFMQ562Q2W2GJQX"; diff --git a/vitest.config.ts b/vitest.config.ts index 248f499..3f993ab 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,4 +6,18 @@ export default defineConfig({ resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, }, + test: { + coverage: { + provider: "v8", + include: ["src/lib/sdk/**/*.ts"], + all: true, + reporter: ["text", "text-summary", "html"], + thresholds: { + branches: 85, + functions: 90, + lines: 90, + statements: 90, + }, + }, + }, });