diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd515de..704a4a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +env: + BUN_VERSION: 1.3.14 + jobs: quality: name: Lint, Typecheck and Tests @@ -21,10 +24,10 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: ${{ env.BUN_VERSION }} - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Lint run: cd package && bun run lint @@ -34,3 +37,127 @@ jobs: - name: Tests run: cd package && bun run test:ci + + - name: Check generated Nitro code is up to date + run: | + cd package + bun run specs + if [ -n "$(git status --porcelain -- nitrogen)" ]; then + echo "::error::nitrogen/generated is out of date. Run 'cd package && bun run specs' and commit the result." + git status --short -- nitrogen + git diff -- nitrogen + exit 1 + fi + + package: + name: Package contents + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check npm pack contents + run: cd package && bun run check:pack + + # Installing the package must not run anything in the app (#9). npm 11 + # fails on install scripts with --strict-allow-scripts; older npm runs + # them, and a `tsc` install script would then emit index.js here. + - name: Install the tarball into an app + run: | + tarball="$RUNNER_TEMP/$(cd package && npm pack --silent --pack-destination "$RUNNER_TEMP")" + app="$RUNNER_TEMP/app" + mkdir "$app" && cd "$app" + npm init -y > /dev/null + npm install --no-audit --no-fund typescript + echo '{ "compilerOptions": { "skipLibCheck": true } }' > tsconfig.json + echo 'export const answer: number = 42' > index.ts + list() { find . -mindepth 1 -maxdepth 1 -not -name node_modules | sort; } + list > "$RUNNER_TEMP/before.txt" + npm install --no-audit --no-fund --legacy-peer-deps --foreground-scripts --strict-allow-scripts "$tarball" + list > "$RUNNER_TEMP/after.txt" + if ! diff "$RUNNER_TEMP/before.txt" "$RUNNER_TEMP/after.txt"; then + echo "::error::Installing the package changed files outside node_modules." + exit 1 + fi + + android: + name: Android build and unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Generate the example Android project + run: cd example && bunx expo prebuild --platform android --no-install + + - name: Build and test the library + working-directory: example/android + run: >- + ./gradlew + :gmisoftware_react-native-pay:assembleDebug + :gmisoftware_react-native-pay:testDebugUnitTest + -PreactNativeArchitectures=x86_64 + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: android-test-reports + path: package/android/build/reports/tests + if-no-files-found: ignore + + ios: + name: iOS build and unit tests + runs-on: macos-26 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Also runs `pod install`. The example links the NitroPay test spec + # through `expo-build-properties` (see example/app.json). + - name: Generate the example iOS project + run: cd example && bunx expo prebuild --platform ios + + - name: Build and test the library + working-directory: example/ios + run: | + udid=$(xcrun simctl list devices available --json \ + | jq -r '[.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | startswith("iPhone"))][0].udid') + xcodebuild test \ + -workspace example.xcworkspace \ + -scheme NitroPay-Unit-Tests \ + -destination "id=$udid" \ + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3c28c98..1351713 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,7 +29,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.14 - name: Install dependencies run: bun install diff --git a/AGENTS.md b/AGENTS.md index bcd3a3d..a7d0522 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,9 +21,12 @@ cd example && bun install - Generate Nitro bindings: `cd package && bun run specs` - Typecheck: `cd package && bun run typecheck` -- Lint: `cd package && bun run lint` +- Lint: `cd package && bun run lint` (fails on warnings). Fix: `cd package && bun run lint:fix` - Test (all): `cd package && bun run test` - Test (single file): `cd package && bun run test -- src/hooks/__tests__/usePaymentCheckout.integration.test.ts` +- Package contents: `cd package && bun run check:pack` (builds `lib/`, then checks what `npm pack` would publish) +- Android unit tests (JDK 17): `cd example && npx expo prebuild --platform android`, then `cd android && ./gradlew :gmisoftware_react-native-pay:testDebugUnitTest` +- iOS unit tests: `cd example && npx expo prebuild --platform ios`, then `cd ios && xcodebuild test -workspace example.xcworkspace -scheme NitroPay-Unit-Tests -destination 'platform=iOS Simulator,name='`, with a name from `xcrun simctl list devices available`. Tests live in `package/ios/Tests`; the example links them through `expo-build-properties` in `app.json`. - Run example: `cd example && npx expo prebuild && npx expo run:ios` or `npx expo run:android` - Docs (dev): `bun run docs:start`. Docs (build): `bun run docs:build` @@ -60,6 +63,8 @@ Example template: - [ ] `cd package && bun run typecheck` (not run) - [ ] `cd package && bun run lint` (not run) - [ ] `cd package && bun run test:ci` (not run) +- [ ] `./gradlew :gmisoftware_react-native-pay:testDebugUnitTest` in `example/android` (if you changed Kotlin; not run) +- [ ] `xcodebuild test ... -scheme NitroPay-Unit-Tests` in `example/ios` (if you changed Swift; not run) - [ ] `cd example && bunx expo run:ios` (not run) - [ ] `cd example && bunx expo run:android` (not run) - [ ] `bun run docs:build` (if you changed `docs/` or public API/setup; not run) diff --git a/CHANGELOG.md b/CHANGELOG.md index 828282e..935404c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ All notable changes to this project are documented in this file. config that emits, it also wrote `.js` files next to the app's sources. The published package now has no install scripts. The contributor build of `lib/` moved to the private monorepo root, so `bun install` in this repository still builds it. +- **The published package no longer includes the library's tests.** 0.0.17 shipped + `src/**/__tests__` and their compiled copies under `lib/**/__tests__` (21 files). ## 0.0.17 — 2026-09-16 diff --git a/bun.lock b/bun.lock index 3175aa2..7fe8481 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "@gmisoftware/react-native-pay": "*", "@react-navigation/native": "^7.1.8", "expo": "~57.0.0", + "expo-build-properties": "~57.0.22", "expo-constants": "~57.0.3", "expo-dev-client": "~57.0.5", "expo-font": "~57.0.0", @@ -65,7 +66,7 @@ }, "package": { "name": "@gmisoftware/react-native-pay", - "version": "0.0.16", + "version": "0.0.17", "devDependencies": { "@react-native/eslint-config": "0.86.0", "@react-native/jest-preset": "0.86.0", @@ -621,7 +622,7 @@ "@expo/router-server": ["@expo/router-server@57.0.1", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@expo/metro-runtime": "^57.0.2", "expo": "*", "expo-constants": "^57.0.2", "expo-font": "^57.0.0", "expo-router": "*", "expo-server": "^57.0.0", "react": "*", "react-dom": "*", "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" }, "optionalPeers": ["@expo/metro-runtime", "expo-router", "react-dom", "react-server-dom-webpack"] }, "sha512-jZ+jHG34rRa8HyurTQq5r558dMcb77F3Mt0HMKotAJ8Wm5bJLULJsL3KVLkEVcYP254xI9zzeJVcN79Fck/nLQ=="], - "@expo/schema-utils": ["@expo/schema-utils@57.0.1", "", {}, "sha512-IVdaqtP3+iHFRE/y22mKijQtZanLcB18q1zi2kfN6RINTCryyzM7qHGsWHc5LBJbOuj1G4avCOECs97hOJm0GQ=="], + "@expo/schema-utils": ["@expo/schema-utils@57.0.2", "", {}, "sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw=="], "@expo/sdk-runtime-versions": ["@expo/sdk-runtime-versions@1.0.0", "", {}, "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ=="], @@ -1821,6 +1822,8 @@ "expo-asset": ["expo-asset@57.0.3", "", { "dependencies": { "@expo/image-utils": "^0.11.1", "expo-constants": "~57.0.3" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-6E08JDuYrktY5pKNn2WiFBPBfj6bvFb++ccuBTijK8BUk/afSoy6n35h583LmC2YHgHTepWHHWNfr61nYcMd8Q=="], + "expo-build-properties": ["expo-build-properties@57.0.22", "", { "dependencies": { "@expo/schema-utils": "^57.0.2", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, "peerDependencies": { "expo": "*" } }, "sha512-A5oRf2I02XDRu2NnqKo7Au5kUu244unvyoRMUsB+xvg/BOlsR+G/oyDFGkjbda1cJbeKm9eqposIXbA2ri5UrQ=="], + "expo-constants": ["expo-constants@57.0.3", "", { "dependencies": { "@expo/env": "~2.4.1" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-BghbZlzwFnA22BG0CWv6W29zx8w19FozYPfSeZ3HjMitoy4aAmF1FnFbbjYSmZz6HHXXTWOfqwobOEIaglfzxg=="], "expo-dev-client": ["expo-dev-client@57.0.5", "", { "dependencies": { "expo-dev-launcher": "~57.0.5", "expo-dev-menu": "~57.0.5", "expo-dev-menu-interface": "~57.0.0", "expo-manifests": "~57.0.0", "expo-updates-interface": "~57.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-OOQJHb4Cf403AhZAbcZg0OOVJKLc1HArVck92LHd9PO2HuTadj/6YAlWIyXXYfeYYdjoVgLd/cAXnaEAwp6HKg=="], @@ -3961,6 +3964,8 @@ "@easyops-cn/docusaurus-search-local/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + "@expo/cli/@expo/schema-utils": ["@expo/schema-utils@57.0.1", "", {}, "sha512-IVdaqtP3+iHFRE/y22mKijQtZanLcB18q1zi2kfN6RINTCryyzM7qHGsWHc5LBJbOuj1G4avCOECs97hOJm0GQ=="], + "@expo/cli/ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], "@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="], @@ -4195,8 +4200,14 @@ "expect/jest-util": ["jest-util@30.2.0", "", { "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.2" } }, "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA=="], + "expo-build-properties/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "expo-dev-launcher/@expo/schema-utils": ["@expo/schema-utils@57.0.1", "", {}, "sha512-IVdaqtP3+iHFRE/y22mKijQtZanLcB18q1zi2kfN6RINTCryyzM7qHGsWHc5LBJbOuj1G4avCOECs97hOJm0GQ=="], + "expo-modules-autolinking/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "expo-router/@expo/schema-utils": ["@expo/schema-utils@57.0.1", "", {}, "sha512-IVdaqtP3+iHFRE/y22mKijQtZanLcB18q1zi2kfN6RINTCryyzM7qHGsWHc5LBJbOuj1G4avCOECs97hOJm0GQ=="], + "express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], diff --git a/example/app.json b/example/app.json index f7e4606..bd1e800 100644 --- a/example/app.json +++ b/example/app.json @@ -39,6 +39,20 @@ "merchantIdentifier": "merchant.pl.sfd.sklep", "enableGooglePay": true } + ], + [ + "expo-build-properties", + { + "ios": { + "extraPods": [ + { + "name": "NitroPay", + "path": "../../package", + "testspecs": ["Tests"] + } + ] + } + } ] ], "experiments": { diff --git a/example/package.json b/example/package.json index a4bd27c..64d814d 100644 --- a/example/package.json +++ b/example/package.json @@ -13,6 +13,7 @@ "@gmisoftware/react-native-pay": "*", "@react-navigation/native": "^7.1.8", "expo": "~57.0.0", + "expo-build-properties": "~57.0.22", "expo-constants": "~57.0.3", "expo-dev-client": "~57.0.5", "expo-font": "~57.0.0", diff --git a/package.json b/package.json index 6fbe467..85fb24a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ ], "version": "1.0.0", "scripts": { - "postinstall": "cd package && tsc || exit 0", + "postinstall": "cd package && tsc -p tsconfig.build.json || exit 0", "android": "expo run:android", "ios": "expo run:ios", "docs:start": "cd docs && bun run start", diff --git a/package/NitroPay.podspec b/package/NitroPay.podspec index a828736..f360d31 100644 --- a/package/NitroPay.podspec +++ b/package/NitroPay.podspec @@ -22,6 +22,12 @@ Pod::Spec.new do |s| # Implementation (C++ objects) "cpp/**/*.{hpp,cpp}", ] + s.exclude_files = "ios/Tests/**" + + # Unit tests. Add `:testspecs => ["Tests"]` to the NitroPay pod to build them. + s.test_spec "Tests" do |test_spec| + test_spec.source_files = "ios/Tests/**/*.swift" + end # FOLLY_NO_CONFIG conflicts with RN >= 0.80's generated folly-config.h. # Fail hard if React Native cannot be resolved — a silent fallback would apply diff --git a/package/android/src/test/java/com/margelo/nitro/pay/PaymentMapperTest.kt b/package/android/src/test/java/com/margelo/nitro/pay/PaymentMapperTest.kt new file mode 100644 index 0000000..ddceba1 --- /dev/null +++ b/package/android/src/test/java/com/margelo/nitro/pay/PaymentMapperTest.kt @@ -0,0 +1,123 @@ +package com.margelo.nitro.pay + +import com.google.android.gms.wallet.PaymentData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class PaymentMapperTest { + + private fun paymentData(paymentMethodData: String): PaymentData = + PaymentData.fromJson("""{"apiVersion":2,"apiVersionMinor":0,"paymentMethodData":$paymentMethodData}""") + + private fun authorizedResult() = PaymentResult( + success = true, + transactionId = "transaction", + token = null, + error = null + ) + + @Test + fun mapPaymentDataToResult_readsTokenAndCard() { + val result = PaymentMapper.mapPaymentDataToResult( + paymentData( + """ + { + "type": "CARD", + "info": {"cardNetwork": "MASTERCARD", "cardDetails": "4242"}, + "tokenizationData": {"type": "PAYMENT_GATEWAY", "token": "gateway-token"} + } + """ + ) + ) + + assertTrue(result.success) + assertNull(result.error) + assertNotNull(result.transactionId) + val token = result.token!! + assertEquals("gateway-token", token.paymentData) + assertEquals("MASTERCARD 4242", token.paymentMethod.displayName) + assertEquals(PaymentNetwork.MASTERCARD, token.paymentMethod.network) + assertEquals(PaymentMethodType.UNKNOWN, token.paymentMethod.type) + assertNull(token.paymentMethod.billingAddress) + } + + @Test + fun mapPaymentDataToResult_fillsMissingCardInfo() { + val result = PaymentMapper.mapPaymentDataToResult( + paymentData("""{"info": {}, "tokenizationData": {"token": "gateway-token"}}""") + ) + + assertTrue(result.success) + assertEquals("unknown ****", result.token!!.paymentMethod.displayName) + } + + @Test + fun mapPaymentDataToResult_reportsMissingTokenAsError() { + val result = PaymentMapper.mapPaymentDataToResult( + paymentData("""{"info": {"cardNetwork": "VISA", "cardDetails": "4242"}}""") + ) + + assertFalse(result.success) + assertNull(result.token) + assertTrue(result.error!!.startsWith("Failed to parse payment data:")) + } + + @Test + fun mapCardNetwork_mapsGooglePayNetworksIgnoringCase() { + assertEquals(PaymentNetwork.VISA, PaymentMapper.mapCardNetwork("VISA")) + assertEquals(PaymentNetwork.MASTERCARD, PaymentMapper.mapCardNetwork("MASTERCARD")) + assertEquals(PaymentNetwork.AMEX, PaymentMapper.mapCardNetwork("AMEX")) + assertEquals(PaymentNetwork.DISCOVER, PaymentMapper.mapCardNetwork("DISCOVER")) + assertEquals(PaymentNetwork.JCB, PaymentMapper.mapCardNetwork("JCB")) + assertEquals(PaymentNetwork.INTERAC, PaymentMapper.mapCardNetwork("INTERAC")) + assertEquals(PaymentNetwork.MAESTRO, PaymentMapper.mapCardNetwork("maestro")) + assertEquals(PaymentNetwork.ELECTRON, PaymentMapper.mapCardNetwork("Electron")) + assertEquals(PaymentNetwork.ELO, PaymentMapper.mapCardNetwork("elo")) + } + + @Test + fun mapCardNetwork_reportsUnknownNetworksAsVisa() { + // Current behavior, tracked in #13. + assertEquals(PaymentNetwork.VISA, PaymentMapper.mapCardNetwork("unknown")) + } + + @Test + fun applyAuthorization_keepsTheAuthorizedResultOnSuccess() { + val authorized = authorizedResult() + + val result = PaymentMapper.applyAuthorization( + PaymentAuthorizationResult(PaymentAuthorizationStatus.SUCCESS, null), + authorized + ) + + assertSame(authorized, result) + } + + @Test + fun applyAuthorization_reportsTheMerchantsFailure() { + val result = PaymentMapper.applyAuthorization( + PaymentAuthorizationResult(PaymentAuthorizationStatus.FAILURE, "Card declined"), + authorizedResult() + ) + + assertEquals(PaymentMapper.createErrorResult("Card declined"), result) + } + + @Test + fun applyAuthorization_defaultsTheFailureMessage() { + val result = PaymentMapper.applyAuthorization( + PaymentAuthorizationResult(PaymentAuthorizationStatus.FAILURE, null), + authorizedResult() + ) + + assertFalse(result.success) + assertNull(result.transactionId) + assertNull(result.token) + assertEquals(PaymentConstants.ERROR_AUTHORIZATION_FAILED, result.error) + } +} diff --git a/package/ios/HybridPaymentHandler.swift b/package/ios/HybridPaymentHandler.swift index 02a13dc..2b74389 100644 --- a/package/ios/HybridPaymentHandler.swift +++ b/package/ios/HybridPaymentHandler.swift @@ -38,7 +38,8 @@ private enum BundleKey { // MARK: - Payment Request Builder -private struct PaymentRequestBuilder { +// Internal, not private, so the NitroPay unit tests can build requests. +struct PaymentRequestBuilder { static func build(from request: PaymentRequest) throws -> PKPaymentRequest { guard let merchantIdentifier = resolveMerchantIdentifier(from: request) else { diff --git a/package/ios/Tests/PassKitTypeMapperTests.swift b/package/ios/Tests/PassKitTypeMapperTests.swift new file mode 100644 index 0000000..4f69d13 --- /dev/null +++ b/package/ios/Tests/PassKitTypeMapperTests.swift @@ -0,0 +1,98 @@ +import Contacts +import PassKit +import XCTest +@testable import NitroPay + +final class PassKitTypeMapperTests: XCTestCase { + + private let networkNames = [ + "visa", "mastercard", "amex", "discover", "jcb", "maestro", + "electron", "elo", "idcredit", "interac", "privateLabel", + ] + + // Swift drops this `convert` overload when it is looked up before the + // C++ `PaymentNetwork` enum is imported. Naming the type in a signature + // imports it first. + private func convert(_ network: PKPaymentNetwork) -> PaymentNetwork { + return PassKitTypeMapper.convert(network) + } + + func testConvertsEveryNetworkNameToPassKitAndBack() throws { + for name in networkNames { + let pkNetwork = try XCTUnwrap(PassKitTypeMapper.convertToPKPaymentNetwork(name), name) + XCTAssertEqual(convert(pkNetwork), PaymentNetwork(fromString: name), name) + } + XCTAssertEqual(PassKitTypeMapper.convertToPKPaymentNetwork("mastercard"), .masterCard) + XCTAssertEqual(PassKitTypeMapper.convertToPKPaymentNetwork("idcredit"), .idCredit) + } + + func testIgnoresUnknownNetworkNames() { + XCTAssertNil(PassKitTypeMapper.convertToPKPaymentNetwork("unknown")) + XCTAssertNil(PassKitTypeMapper.convertToPKPaymentNetwork("Visa")) + } + + func testReportsUnsupportedPassKitNetworksAsVisa() { + // Current behavior: PaymentNetwork has no "other" case. + XCTAssertEqual(convert(.girocard), .visa) + } + + func testConvertsPaymentMethodTypes() { + XCTAssertEqual(PassKitTypeMapper.convert(PKPaymentMethodType.unknown), .unknown) + XCTAssertEqual(PassKitTypeMapper.convert(PKPaymentMethodType.debit), .debit) + XCTAssertEqual(PassKitTypeMapper.convert(PKPaymentMethodType.credit), .credit) + XCTAssertEqual(PassKitTypeMapper.convert(PKPaymentMethodType.prepaid), .prepaid) + XCTAssertEqual(PassKitTypeMapper.convert(PKPaymentMethodType.store), .store) + } + + func testConvertsContacts() { + let address = CNMutablePostalAddress() + address.street = "1 Infinite Loop" + address.city = "Cupertino" + address.state = "CA" + address.postalCode = "95014" + address.country = "United States" + address.isoCountryCode = "us" + + let contact = CNMutableContact() + contact.contactType = .organization + contact.givenName = "Jane" + contact.familyName = "Appleseed" + contact.organizationName = "Example Inc." + contact.phoneNumbers = [ + CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: "+1 555 0100")), + ] + contact.emailAddresses = [CNLabeledValue(label: CNLabelWork, value: "jane@example.com" as NSString)] + contact.postalAddresses = [CNLabeledValue(label: CNLabelHome, value: address)] + + let converted = PassKitTypeMapper.convert(contact) + + XCTAssertEqual(converted.identifier, contact.identifier) + XCTAssertEqual(converted.contactType, .organization) + XCTAssertEqual(converted.givenName, "Jane") + XCTAssertEqual(converted.familyName, "Appleseed") + XCTAssertEqual(converted.organizationName, "Example Inc.") + + XCTAssertEqual(converted.phoneNumbers.count, 1) + XCTAssertEqual(converted.phoneNumbers.first?.label, CNLabelPhoneNumberMobile) + XCTAssertEqual(converted.phoneNumbers.first?.value.stringValue, "+1 555 0100") + + XCTAssertEqual(converted.emailAddresses.count, 1) + XCTAssertEqual(converted.emailAddresses.first?.label, CNLabelWork) + XCTAssertEqual(converted.emailAddresses.first?.value, "jane@example.com") + + XCTAssertEqual(converted.postalAddresses.count, 1) + let postal = converted.postalAddresses.first + XCTAssertEqual(postal?.label, CNLabelHome) + XCTAssertEqual(postal?.value.street, "1 Infinite Loop") + XCTAssertEqual(postal?.value.city, "Cupertino") + XCTAssertEqual(postal?.value.state, "CA") + XCTAssertEqual(postal?.value.postalCode, "95014") + XCTAssertEqual(postal?.value.country, "United States") + XCTAssertEqual(postal?.value.isoCountryCode, "us") + } + + func testConvertsContactTypes() { + XCTAssertEqual(PassKitTypeMapper.convertContactType(.person), .person) + XCTAssertEqual(PassKitTypeMapper.convertContactType(.organization), .organization) + } +} diff --git a/package/ios/Tests/PaymentAmountDecimalTests.swift b/package/ios/Tests/PaymentAmountDecimalTests.swift new file mode 100644 index 0000000..4c1f4f3 --- /dev/null +++ b/package/ios/Tests/PaymentAmountDecimalTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import NitroPay + +final class PaymentAmountDecimalTests: XCTestCase { + + func testReadsDecimalStringsExactly() { + XCTAssertEqual(PaymentAmount.second("19.99").decimalNumber, NSDecimalNumber(string: "19.99")) + XCTAssertEqual(PaymentAmount.second("-5.00").decimalNumber, NSDecimalNumber(string: "-5")) + XCTAssertEqual(PaymentAmount.second("1500").decimalNumber, NSDecimalNumber(string: "1500")) + } + + func testReadsNumbersThroughTheirShortestDecimalForm() { + // Decimal(19.99) would be 19.98999999999999488. + XCTAssertEqual(PaymentAmount.first(19.99).decimalNumber, NSDecimalNumber(string: "19.99")) + XCTAssertEqual(PaymentAmount.first(1.0e7).decimalNumber, NSDecimalNumber(string: "10000000")) + } + + func testRejectsAnythingButPlainDecimals() { + let invalid: [PaymentAmount] = [ + .first(.nan), + .first(.infinity), + .second(""), + .second("12abc"), + .second(" 1.00"), + .second("1,00"), + .second("1e3"), + .second("1."), + ] + + for amount in invalid { + XCTAssertNil(amount.decimalNumber, "\(amount)") + } + } +} diff --git a/package/ios/Tests/PaymentRequestBuilderTests.swift b/package/ios/Tests/PaymentRequestBuilderTests.swift new file mode 100644 index 0000000..855201c --- /dev/null +++ b/package/ios/Tests/PaymentRequestBuilderTests.swift @@ -0,0 +1,149 @@ +import PassKit +import XCTest +@testable import NitroPay + +final class PaymentRequestBuilderTests: XCTestCase { + + private func item(_ label: String, _ amount: String, _ type: PaymentItemType = .final) -> PaymentItem { + return PaymentItem(label: label, amount: .second(amount), type: type) + } + + private func paymentRequest( + items: [PaymentItem], + merchantIdentifier: String? = "merchant.com.example", + merchantName: String? = nil, + merchantCapabilities: [String] = ["3DS"], + supportedNetworks: [String] = ["visa"], + shippingType: String? = nil, + billingContactRequired: Bool? = nil, + shippingContactRequired: Bool? = nil + ) -> PaymentRequest { + return PaymentRequest( + applePayMerchantIdentifier: merchantIdentifier, + merchantName: merchantName, + countryCode: "US", + currencyCode: "USD", + paymentItems: items, + merchantCapabilities: merchantCapabilities, + supportedNetworks: supportedNetworks, + shippingType: shippingType, + shippingMethods: nil, + billingContactRequired: billingContactRequired, + shippingContactRequired: shippingContactRequired, + googlePayMerchantId: nil, + googlePayEnvironment: nil, + googlePayGateway: nil, + googlePayGatewayMerchantId: nil + ) + } + + private func assertBuildFails( + _ request: PaymentRequest, + with message: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertThrowsError(try PaymentRequestBuilder.build(from: request), file: file, line: line) { error in + XCTAssertEqual(error.localizedDescription, message, file: file, line: line) + } + } + + func testCopiesRequestFields() throws { + let request = try PaymentRequestBuilder.build(from: paymentRequest( + items: [item("Coffee", "4.99")], + merchantIdentifier: " merchant.com.example\n", + merchantCapabilities: ["3DS", "Debit", "Unknown"], + supportedNetworks: ["visa", "amex", "unknown"], + shippingType: "delivery" + )) + + XCTAssertEqual(request.merchantIdentifier, "merchant.com.example") + XCTAssertEqual(request.countryCode, "US") + XCTAssertEqual(request.currencyCode, "USD") + XCTAssertEqual(request.merchantCapabilities, [.capability3DS, .capabilityDebit]) + XCTAssertEqual(request.supportedNetworks, [.visa, .amex]) + XCTAssertEqual(request.shippingType, .delivery) + } + + func testRequestsContactFieldsOnlyWhenRequired() throws { + let optional = try PaymentRequestBuilder.build(from: paymentRequest(items: [item("Coffee", "4.99")])) + XCTAssertEqual(optional.requiredBillingContactFields, []) + XCTAssertEqual(optional.requiredShippingContactFields, []) + + let required = try PaymentRequestBuilder.build(from: paymentRequest( + items: [item("Coffee", "4.99")], + billingContactRequired: true, + shippingContactRequired: true + )) + XCTAssertEqual(required.requiredBillingContactFields, [.postalAddress, .name]) + XCTAssertEqual(required.requiredShippingContactFields, [.postalAddress, .name]) + } + + func testSingleItemIsTheTotal() throws { + let request = try PaymentRequestBuilder.build(from: paymentRequest( + items: [item("Coffee", "4.99")], + merchantName: "Coffee Shop" + )) + + XCTAssertEqual(request.paymentSummaryItems.map(\.label), ["Coffee"]) + XCTAssertEqual(request.paymentSummaryItems[0].amount, NSDecimalNumber(string: "4.99")) + XCTAssertEqual(request.paymentSummaryItems[0].type, .final) + } + + func testAppendsAnExactTotalLabelledWithTheMerchant() throws { + let request = try PaymentRequestBuilder.build(from: paymentRequest( + items: [ + PaymentItem(label: "Coffee", amount: .first(4.99), type: .final), + item("Tip", "0.50"), + item("Discount", "-1.00"), + ], + merchantName: " Coffee Shop " + )) + + let items = request.paymentSummaryItems + XCTAssertEqual(items.map(\.label), ["Coffee", "Tip", "Discount", "Coffee Shop"]) + XCTAssertEqual(items[3].amount, NSDecimalNumber(string: "4.49")) + XCTAssertEqual(items[3].type, .final) + } + + func testTotalIsLabelledTotalWithoutMerchantName() throws { + for merchantName in [nil, " "] { + let request = try PaymentRequestBuilder.build(from: paymentRequest( + items: [item("Coffee", "4.99"), item("Tip", "0.50")], + merchantName: merchantName + )) + + XCTAssertEqual(request.paymentSummaryItems.last?.label, "Total") + } + } + + func testTotalIsPendingWhenAnyItemIsPending() throws { + let request = try PaymentRequestBuilder.build(from: paymentRequest( + items: [item("Coffee", "4.99"), item("Delivery", "2.00", .pending)] + )) + + XCTAssertEqual(request.paymentSummaryItems.map(\.type), [.final, .pending, .pending]) + } + + func testRejectsInvalidAmounts() { + assertBuildFails( + paymentRequest(items: [item("Coffee", "4.99"), item("Tip", "1,00")]), + with: "Amount for \"Tip\" must be a finite number or a decimal string like \"19.99\"" + ) + } + + func testRejectsNegativeTotal() { + assertBuildFails( + paymentRequest(items: [item("Coffee", "4.99"), item("Discount", "-5.00")]), + with: "Payment total must not be negative" + ) + } + + func testRequiresMerchantIdentifier() { + // The test bundle has no ReactNativePayApplePayMerchantIdentifiers entry to fall back to. + assertBuildFails( + paymentRequest(items: [item("Coffee", "4.99")], merchantIdentifier: " "), + with: "No Apple Pay merchant identifier configured" + ) + } +} diff --git a/package/package.json b/package/package.json index ee508eb..e42a44b 100644 --- a/package/package.json +++ b/package/package.json @@ -70,7 +70,8 @@ }, "eslintIgnore": [ "node_modules/", - "lib/" + "lib/", + "android/build/" ], "files": [ "src", @@ -83,11 +84,13 @@ "android/CMakeLists.txt", "android/src", "!android/src/test", + "!**/__tests__", "ios/**/*.h", "ios/**/*.m", "ios/**/*.mm", "ios/**/*.cpp", "ios/**/*.swift", + "!ios/Tests", "app.plugin.js", "nitro.json", "*.podspec", @@ -118,17 +121,18 @@ }, "react-native": "src/index", "scripts": { - "prepack": "rm -rf lib tsconfig.tsbuildinfo && tsc && cp ../README.md ./README.md", + "prepack": "rm -rf lib && tsc -p tsconfig.build.json && cp ../README.md ./README.md", "postpack": "rm ./README.md", "typecheck": "tsc --noEmit", "test": "jest", "test:watch": "jest --watch", "test:ci": "jest --runInBand --ci", "clean": "rm -rf android/build node_modules/**/android/build lib tsconfig.tsbuildinfo", - "lint": "eslint \"**/*.{js,ts,tsx}\" --fix", - "lint-ci": "eslint \"**/*.{js,ts,tsx}\" -f @jamesacarr/github-actions", - "typescript": "tsc", - "specs": "tsc && nitrogen --logLevel=\"debug\"", + "lint": "eslint \"**/*.{js,ts,tsx}\" --max-warnings 0", + "lint:fix": "eslint \"**/*.{js,ts,tsx}\" --fix", + "typescript": "tsc -p tsconfig.build.json", + "check:pack": "node scripts/check-pack.js", + "specs": "tsc -p tsconfig.build.json && nitrogen --logLevel=\"debug\"", "release": "release-it" }, "source": "src/index", diff --git a/package/scripts/check-pack.js b/package/scripts/check-pack.js new file mode 100644 index 0000000..27db73b --- /dev/null +++ b/package/scripts/check-pack.js @@ -0,0 +1,60 @@ +/** + * Checks what `npm pack` would publish. Runs `prepack`, so `lib/` is rebuilt + * from scratch first. + * + * - The entry points must be in the tarball. A stale incremental build once + * made `tsc` a no-op and shipped a package without `lib/`. + * - Tests must not ship. + * - The package must not run scripts in the consumer's project on install. + */ +const { execFileSync } = require('child_process') +const pkg = require('../package.json') + +const REQUIRED_FILES = [ + 'lib/index.js', + 'lib/index.d.ts', + 'lib/plugin/index.js', + 'app.plugin.js', + 'nitro.json', + 'NitroPay.podspec', + 'README.md', +] +const FORBIDDEN_PATHS = [ + /(^|\/)__tests__\//, + /^android\/src\/test\//, + /^ios\/Tests\//, +] +const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall'] + +const output = execFileSync('npm', ['pack', '--dry-run', '--json'], { + cwd: `${__dirname}/..`, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], +}) +const files = JSON.parse(output)[0].files.map((file) => file.path) +const problems = [] + +for (const required of REQUIRED_FILES) { + if (!files.includes(required)) { + problems.push(`missing: ${required}`) + } +} +for (const file of files) { + if (FORBIDDEN_PATHS.some((pattern) => pattern.test(file))) { + problems.push(`test file: ${file}`) + } +} +for (const script of INSTALL_SCRIPTS) { + if (pkg.scripts?.[script]) { + problems.push(`install script: "${script}"`) + } +} + +if (problems.length > 0) { + console.error(`npm pack: ${problems.length} problem(s)`) + for (const problem of problems) { + console.error(` ${problem}`) + } + process.exit(1) +} +console.log(`npm pack: ${files.length} files, contents OK`) diff --git a/package/tsconfig.build.json b/package/tsconfig.build.json new file mode 100644 index 0000000..7a5ed5d --- /dev/null +++ b/package/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/__tests__"] +} diff --git a/package/tsconfig.json b/package/tsconfig.json index d55fb0d..9ca9d48 100644 --- a/package/tsconfig.json +++ b/package/tsconfig.json @@ -1,7 +1,7 @@ { "include": ["src"], "compilerOptions": { - "composite": true, + "declaration": true, "outDir": "lib", "rootDir": "src", "allowUnreachableCode": false,