diff --git a/.github/workflows/ci-expo-iap.yml b/.github/workflows/ci-expo-iap.yml index 017692288..115be63aa 100644 --- a/.github/workflows/ci-expo-iap.yml +++ b/.github/workflows/ci-expo-iap.yml @@ -88,6 +88,9 @@ jobs: - name: Test run: bun run test:coverage + - name: Enforce line coverage + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 + - name: Upload coverage uses: codecov/codecov-action@v7 with: diff --git a/.github/workflows/ci-flutter-inapp-purchase.yml b/.github/workflows/ci-flutter-inapp-purchase.yml index 9070507aa..b33857a5f 100644 --- a/.github/workflows/ci-flutter-inapp-purchase.yml +++ b/.github/workflows/ci-flutter-inapp-purchase.yml @@ -69,6 +69,9 @@ jobs: - name: Filter generated coverage run: dart run tool/filter_coverage.dart + - name: Enforce line coverage + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 + - name: Upload coverage uses: codecov/codecov-action@v7 with: diff --git a/.github/workflows/ci-react-native-iap.yml b/.github/workflows/ci-react-native-iap.yml index 02dfe6dcf..72f8a3f46 100644 --- a/.github/workflows/ci-react-native-iap.yml +++ b/.github/workflows/ci-react-native-iap.yml @@ -93,6 +93,9 @@ jobs: - name: Test run: yarn test:ci + - name: Enforce line coverage + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 + - name: Upload coverage uses: codecov/codecov-action@v7 with: diff --git a/.github/workflows/deploy-kit.yml b/.github/workflows/deploy-kit.yml index c788e1a97..54113131c 100644 --- a/.github/workflows/deploy-kit.yml +++ b/.github/workflows/deploy-kit.yml @@ -20,6 +20,7 @@ on: paths: - "packages/kit/**" - "packages/mcp-server/**" + - "codecov.yml" - ".github/workflows/deploy-kit.yml" - "bun.lock" - "package.json" @@ -29,11 +30,17 @@ jobs: verify: name: Typecheck + Build (kit) runs-on: ubuntu-latest + permissions: + contents: read + id-token: write defaults: run: working-directory: packages/kit steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -59,7 +66,22 @@ jobs: run: bunx prettier --check "src/**/*.{ts,tsx,css,json}" "server/**/*.ts" "convex/**/*.ts" - name: Run tests (convex + server unit tests) - run: bun run test + run: bun run test:coverage + + - name: Enforce server line coverage + run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90 + + - name: Upload server coverage + uses: codecov/codecov-action@v7 + with: + use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + disable_search: true + files: coverage/lcov.info + flags: iapkit-server + name: iapkit-server + network_prefix: packages/kit/ + working-directory: packages/kit - name: Lint + test MCP server (served by kit's /mcp route) # kit's Fly binary imports @hyodotdev/openiap-mcp-server/web from diff --git a/bun.lock b/bun.lock index 7a8e2e6e2..dcb410076 100644 --- a/bun.lock +++ b/bun.lock @@ -127,6 +127,7 @@ "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^5.2.0", + "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "^4", "autoprefixer": "~10", "dotenv": "^16.4.7", @@ -243,6 +244,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], "@convex-dev/auth": ["@convex-dev/auth@0.0.94", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "cookie": "^1.0.1", "is-network-error": "^1.1.0", "jose": "^5.2.2", "jwt-decode": "^4.0.0", "lucia": "^3.2.0", "oauth4webapi": "^3.1.2", "path-to-regexp": "^6.3.0", "server-only": "^0.0.1" }, "peerDependencies": { "@auth/core": "^0.41.1", "convex": "^1.17.0", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["react"], "bin": { "auth": "dist/bin.cjs" } }, "sha512-A/ily6mXQFhSOUAYoiVZdzAZj3RBhiU4d2MOqpEByL7Uzzub6Y+MYQCzSTi0hQzE1dv7dVHprFku0lQuiq6A5w=="], @@ -1031,6 +1034,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.5", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.5", "vitest": "4.1.5" }, "optionalPeers": ["@vitest/browser"] }, "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A=="], + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], @@ -1091,6 +1096,8 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="], + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], @@ -1525,6 +1532,8 @@ "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -1661,11 +1670,17 @@ "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1787,6 +1802,10 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "map-cache": ["map-cache@0.2.2", "", {}, "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg=="], "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], @@ -2465,6 +2484,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -2601,6 +2622,12 @@ "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "magicast/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "magicast/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -2753,6 +2780,10 @@ "log-update/slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "magicast/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "magicast/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/codecov.yml b/codecov.yml index 1e6f74ddd..039e5c870 100644 --- a/codecov.yml +++ b/codecov.yml @@ -8,11 +8,59 @@ coverage: target: auto threshold: 1% informational: true + react-native-iap: + target: 90% + threshold: 0% + informational: false + flags: + - react-native-iap + expo-iap: + target: 90% + threshold: 0% + informational: false + flags: + - expo-iap + flutter-inapp-purchase: + target: 90% + threshold: 0% + informational: false + flags: + - flutter-inapp-purchase + iapkit-server: + target: 90% + threshold: 0% + informational: false + flags: + - iapkit-server patch: default: target: auto threshold: 1% informational: true + react-native-iap: + target: 90% + threshold: 0% + informational: false + flags: + - react-native-iap + expo-iap: + target: 90% + threshold: 0% + informational: false + flags: + - expo-iap + flutter-inapp-purchase: + target: 90% + threshold: 0% + informational: false + flags: + - flutter-inapp-purchase + iapkit-server: + target: 90% + threshold: 0% + informational: false + flags: + - iapkit-server flags: react-native-iap: @@ -27,6 +75,10 @@ flags: paths: - "libraries/flutter_inapp_purchase/lib/**" carryforward: true + iapkit-server: + paths: + - "packages/kit/server/**" + carryforward: true component_management: individual_components: @@ -48,6 +100,12 @@ component_management: - "libraries/flutter_inapp_purchase/lib/**" flag_regexes: - "^flutter-inapp-purchase$" + - component_id: iapkit-server + name: IAPKit Server + paths: + - "packages/kit/server/**" + flag_regexes: + - "^iapkit-server$" comment: layout: "reach,diff,flags,components,files" diff --git a/libraries/expo-iap/src/__tests__/index.kepler.test.ts b/libraries/expo-iap/src/__tests__/index.kepler.test.ts index 899a5ab51..f87b81bce 100644 --- a/libraries/expo-iap/src/__tests__/index.kepler.test.ts +++ b/libraries/expo-iap/src/__tests__/index.kepler.test.ts @@ -4,6 +4,7 @@ import { openRedeemOfferCodeAndroid, requestPurchase, } from '../index.kepler'; +import * as Kepler from '../index.kepler'; import {getVegaIapModule} from '../vega'; jest.mock('../vega', () => ({ @@ -89,4 +90,141 @@ describe('Amazon Vega public API', () => { code: 'billing-response-json-parse-error', }); }); + + it('reports a missing Vega native module', async () => { + (getVegaIapModule as jest.Mock).mockReturnValue(null); + + await expect(Kepler.initConnection()).rejects.toThrow( + 'Amazon Vega IAP module is unavailable', + ); + }); + + it('delegates listeners and connection lifecycle calls', async () => { + const subscription = {remove: jest.fn()}; + const module = { + addListener: jest.fn().mockReturnValue(subscription), + removeListener: jest.fn(), + initConnection: jest.fn().mockResolvedValue(true), + endConnection: jest.fn().mockResolvedValue(true), + }; + (getVegaIapModule as jest.Mock).mockReturnValue(module); + const listener = jest.fn(); + + expect( + Kepler.emitter.addListener(Kepler.OpenIapEvent.PurchaseUpdated, listener), + ).toBe(subscription); + Kepler.emitter.removeListener( + Kepler.OpenIapEvent.PurchaseUpdated, + listener, + ); + expect(module.removeListener).toHaveBeenCalledWith( + Kepler.OpenIapEvent.PurchaseUpdated, + listener, + ); + const purchaseSub = Kepler.purchaseUpdatedListener(listener); + const purchaseHandler = module.addListener.mock.lastCall?.[1]; + const errorSub = Kepler.purchaseErrorListener(listener); + const errorHandler = module.addListener.mock.lastCall?.[1]; + expect(purchaseSub).toBe(subscription); + expect(errorSub).toBe(subscription); + purchaseHandler?.({productId: 'premium'}); + errorHandler?.({code: 'network-error'}); + expect(listener).toHaveBeenCalledTimes(2); + + await expect(Kepler.initConnection()).resolves.toBe(true); + await expect(Kepler.endConnection()).resolves.toBe(true); + expect(module.initConnection).toHaveBeenCalledWith(null); + }); + + it('delegates purchase completion and catalog helpers', async () => { + const module = { + requestPurchase: jest.fn().mockResolvedValue([]), + getAvailableItems: jest.fn().mockResolvedValue([]), + consumePurchaseAndroid: jest.fn().mockResolvedValue(undefined), + acknowledgePurchaseAndroid: jest.fn().mockResolvedValue(undefined), + getActiveSubscriptions: jest.fn().mockResolvedValue([]), + hasActiveSubscriptions: jest.fn().mockResolvedValue(false), + getStorefront: jest.fn().mockResolvedValue('USA'), + verifyPurchaseWithProvider: jest.fn().mockResolvedValue({isValid: true}), + }; + (getVegaIapModule as jest.Mock).mockReturnValue(module); + const purchase = { + id: 'transaction', + productId: 'premium', + purchaseToken: 'opaque-token', + } as any; + + await expect( + Kepler.requestPurchase({ + request: {google: {skus: ['premium']}}, + type: 'subs', + } as any), + ).resolves.toEqual([]); + await Kepler.finishTransaction({purchase, isConsumable: true}); + await Kepler.finishTransaction({purchase, isConsumable: false}); + await expect(Kepler.acknowledgePurchaseAndroid('opaque')).resolves.toBe( + true, + ); + await expect(Kepler.consumePurchaseAndroid('opaque')).resolves.toBe(true); + await Kepler.restorePurchases(); + await expect(Kepler.getActiveSubscriptions()).resolves.toEqual([]); + await expect(Kepler.hasActiveSubscriptions()).resolves.toBe(false); + await expect(Kepler.getStorefront()).resolves.toBe('USA'); + await expect( + Kepler.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + apiKey: 'public-test-key', + amazon: {receiptId: 'redacted', sandbox: true}, + }, + }), + ).resolves.toEqual({isValid: true}); + + expect(module.consumePurchaseAndroid).toHaveBeenCalledWith('opaque-token'); + expect(module.acknowledgePurchaseAndroid).toHaveBeenCalledWith( + 'opaque-token', + ); + }); + + it('rejects transaction completion without a purchase token', async () => { + await expect( + Kepler.finishTransaction({ + purchase: {productId: 'premium'} as any, + isConsumable: false, + }), + ).rejects.toMatchObject({ + code: 'developer-error', + productId: 'premium', + }); + }); + + it.each([ + 'verifyPurchase', + 'syncIOS', + 'presentExternalPurchaseLinkIOS', + 'deepLinkToSubscriptions', + 'isBillingProgramAvailableAndroid', + 'getBillingChoiceInfoAndroid', + 'launchExternalLinkAndroid', + 'createBillingProgramReportingDetailsAndroid', + 'showBillingProgramInformationDialogAndroid', + 'showInAppMessagesAndroid', + ])('rejects unsupported %s calls', async (api) => { + await expect((Kepler as any)[api]()).rejects.toThrow( + 'not supported on Amazon Vega', + ); + }); + + it('returns inert subscriptions for unavailable listener APIs', () => { + expect(typeof Kepler.promotedProductListenerIOS().remove).toBe('function'); + expect(typeof Kepler.userChoiceBillingListenerAndroid().remove).toBe( + 'function', + ); + expect(typeof Kepler.developerProvidedBillingListenerAndroid().remove).toBe( + 'function', + ); + expect(typeof Kepler.subscriptionBillingIssueListener().remove).toBe( + 'function', + ); + }); }); diff --git a/libraries/expo-iap/src/__tests__/kit-api.test.ts b/libraries/expo-iap/src/__tests__/kit-api.test.ts index efe827971..5b20cc83d 100644 --- a/libraries/expo-iap/src/__tests__/kit-api.test.ts +++ b/libraries/expo-iap/src/__tests__/kit-api.test.ts @@ -92,3 +92,303 @@ describe('kitApi client payload caching', () => { expect(cache.removeItem).toHaveBeenCalledTimes(1); }); }); + +describe('kitApi requests', () => { + const success = (value: unknown) => Response.json(value); + + it('normalizes the base URL and encodes read paths', async () => { + const fetchImpl = jest + .fn() + .mockResolvedValueOnce(success({active: false, subscription: null})) + .mockResolvedValueOnce( + success({userId: 'user / one', productIds: [], subscriptions: []}), + ); + const api = kitApi({ + apiKey: 'key / one', + baseUrl: 'https://kit.test/', + fetchImpl, + }); + + await api.status('user / one'); + await api.entitlements('user / one'); + + expect(api.apiKey).toBe('key / one'); + expect(api.baseUrl).toBe('https://kit.test'); + expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([ + 'https://kit.test/v1/subscriptions/status/key%20%2F%20one?userId=user%20%2F%20one', + 'https://kit.test/v1/subscriptions/entitlements/key%20%2F%20one?userId=user%20%2F%20one', + ]); + }); + + it('serializes catalog options and rejects an unscoped payload read', async () => { + const fetchImpl = jest + .fn() + .mockImplementation(() => + Promise.resolve(success({products: [], hasMore: false})), + ); + const api = kitApi({apiKey: 'key', fetchImpl}); + + await api.products(); + await api.products({ + platform: 'Android', + includeClientPayload: true, + limit: 12, + cursor: 'next / page', + }); + + expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([ + 'https://kit.openiap.dev/v1/products/key', + 'https://kit.openiap.dev/v1/products/key?platform=Android&includeClientPayload=true&limit=12&cursor=next+%2F+page', + ]); + expect(() => api.products({includeClientPayload: true})).toThrow( + 'requires platform', + ); + }); + + it('posts a binding with the internal JSON headers', async () => { + const fetchImpl = jest + .fn() + .mockResolvedValue(success({ok: true, bound: true})); + const api = kitApi({apiKey: 'key', fetchImpl}); + + await expect(api.bindUser('token', 'user')).resolves.toEqual({ + ok: true, + bound: true, + }); + + const init = fetchImpl.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe('POST'); + expect(init.body).toBe('{"purchaseToken":"token","userId":"user"}'); + const headers = new Headers(init.headers); + expect(headers.get('accept')).toBe('application/json'); + expect(headers.get('content-type')).toBe('application/json'); + }); + + it('uses global fetch when no override is supplied', async () => { + const originalFetch = globalThis.fetch; + const globalFetch = jest + .fn() + .mockResolvedValue(success({active: false, subscription: null})); + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: globalFetch, + }); + try { + await kitApi({apiKey: 'key'}).status('user'); + expect(globalFetch).toHaveBeenCalledTimes(1); + } finally { + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: originalFetch, + }); + } + }); + + it('requires an injected fetch when the runtime has no global fetch', () => { + const originalFetch = globalThis.fetch; + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: undefined, + }); + try { + expect(() => kitApi({apiKey: 'key'})).toThrow( + 'requires a fetch implementation', + ); + } finally { + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: originalFetch, + }); + } + }); + + it('merges an ETag without a global Headers constructor', async () => { + const originalHeaders = globalThis.Headers; + const cache = memoryCache(); + const fetchImpl = jest + .fn() + .mockResolvedValueOnce( + Response.json(payload, {headers: {etag: '"fallback-v3"'}}), + ) + .mockResolvedValueOnce(new Response(null, {status: 304})); + Object.defineProperty(globalThis, 'Headers', { + configurable: true, + writable: true, + value: undefined, + }); + try { + const api = kitApi({ + apiKey: 'key', + fetchImpl, + clientPayloadCache: cache, + }); + await api.clientPayload('premium', 'IOS'); + await api.clientPayload('premium', 'IOS', {refresh: true}); + expect(fetchImpl.mock.calls[1]?.[1]?.headers).toEqual({ + 'If-None-Match': '"fallback-v3"', + accept: 'application/json', + }); + } finally { + Object.defineProperty(globalThis, 'Headers', { + configurable: true, + writable: true, + value: originalHeaders, + }); + } + }); + + it('adds default plain headers when no internal headers are provided', async () => { + const originalHeaders = globalThis.Headers; + const fetchImpl = jest + .fn() + .mockResolvedValue(success({ok: true, bound: true})); + Object.defineProperty(globalThis, 'Headers', { + configurable: true, + writable: true, + value: undefined, + }); + try { + await kitApi({apiKey: 'key', fetchImpl}).bindUser('token', 'user'); + expect(fetchImpl.mock.calls[0]?.[1]?.headers).toEqual({ + accept: 'application/json', + 'content-type': 'application/json', + }); + } finally { + Object.defineProperty(globalThis, 'Headers', { + configurable: true, + writable: true, + value: originalHeaders, + }); + } + }); + + it('returns null for an empty successful response', async () => { + const fetchImpl = jest.fn().mockResolvedValue(new Response(null)); + await expect( + kitApi({apiKey: 'key', fetchImpl}).status('user'), + ).resolves.toBeNull(); + }); + + it.each([ + { + response: Response.json({error: 'denied'}, {status: 403}), + expectedBody: {error: 'denied'}, + }, + { + response: new Response('upstream unavailable', {status: 502}), + expectedBody: 'upstream unavailable', + }, + ])( + 'throws a structured API error for a failed response', + async ({response, expectedBody}) => { + const api = kitApi({ + apiKey: 'key', + fetchImpl: jest.fn().mockResolvedValue(response), + }); + + await expect(api.status('user')).rejects.toMatchObject({ + name: 'KitApiError', + status: response.status, + body: expectedBody, + }); + }, + ); + + it('throws a structured API error for a non-JSON success body', async () => { + const api = kitApi({ + apiKey: 'key', + fetchImpl: jest.fn().mockResolvedValue(new Response('oops')), + }); + + await expect(api.status('user')).rejects.toMatchObject({ + name: 'KitApiError', + status: 200, + body: 'oops', + message: expect.stringContaining('non-JSON 200 body'), + }); + }); +}); + +describe('kitApi cache resilience', () => { + it.each([ + '{not-json', + JSON.stringify({clientPayload: {format: 'binary'}}), + JSON.stringify({ + clientPayload: { + format: 'text', + body: 'rules', + version: 0, + updatedAt: 1, + }, + }), + JSON.stringify({...payload, etag: 42}), + ])('ignores an invalid cache entry', async (stored) => { + const cache = { + getItem: jest.fn().mockResolvedValue(stored), + setItem: jest.fn(), + removeItem: jest.fn(), + }; + const fetchImpl = jest.fn().mockResolvedValue(Response.json(payload)); + + await expect( + kitApi({ + apiKey: 'key', + fetchImpl, + clientPayloadCache: cache, + }).clientPayload('premium', 'IOS'), + ).resolves.toEqual(payload); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('keeps successful reads when cache operations fail', async () => { + const cache = { + getItem: jest.fn().mockRejectedValue(new Error('read failed')), + setItem: jest.fn().mockRejectedValue(new Error('write failed')), + removeItem: jest.fn().mockRejectedValue(new Error('remove failed')), + }; + const fetchImpl = jest + .fn() + .mockResolvedValueOnce(Response.json(payload)) + .mockResolvedValueOnce(Response.json({error: 'missing'}, {status: 404})); + const api = kitApi({ + apiKey: 'key', + fetchImpl, + clientPayloadCache: cache, + }); + + await expect(api.clientPayload('premium', 'IOS')).resolves.toEqual(payload); + await expect(api.clientPayload('premium', 'IOS')).rejects.toMatchObject({ + status: 404, + }); + }); + + it('returns a valid cached payload without an ETag and skips the network', async () => { + const cache = memoryCache(); + cache.getItem.mockReturnValue(JSON.stringify(payload)); + const fetchImpl = jest.fn(); + + await expect( + kitApi({ + apiKey: 'key', + fetchImpl, + clientPayloadCache: cache, + }).clientPayload('premium', 'Android'), + ).resolves.toEqual(payload); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('treats a 304 without a cached payload as an API error', async () => { + const api = kitApi({ + apiKey: 'key', + fetchImpl: jest.fn().mockResolvedValue(new Response(null, {status: 304})), + }); + + await expect(api.clientPayload('premium', 'IOS')).rejects.toMatchObject({ + status: 304, + }); + }); +}); diff --git a/libraries/expo-iap/src/kit-api.ts b/libraries/expo-iap/src/kit-api.ts index fe47dd9fd..5d60a87ad 100644 --- a/libraries/expo-iap/src/kit-api.ts +++ b/libraries/expo-iap/src/kit-api.ts @@ -121,19 +121,19 @@ type CachedClientPayload = KitClientPayloadResponse & { etag?: string; }; +type InternalRequestInit = Omit & { + headers?: Record; +}; + const DEFAULT_BASE_URL = "https://kit.openiap.dev"; -// Merge caller-supplied headers with kit defaults (`accept`, -// optionally `content-type`). When the runtime exposes a global -// `Headers` constructor we use it directly so callers passing a -// `Headers` instance (a `HeadersInit`) keep that exact instance's -// values. When `Headers` is missing — older React Native builds where -// the operator wires up `fetchImpl` without a `Headers` polyfill — -// we fall back to a case-insensitive merge into a plain record so -// the request still goes through. Either way, caller-set values take -// precedence over kit defaults. +// Merge the request's internal headers with kit defaults (`accept`, +// optionally `content-type`). When `Headers` is missing — older React +// Native builds where the operator wires up `fetchImpl` without a +// `Headers` polyfill — the internal request sites use plain records, +// so a small case-insensitive merge is sufficient. function mergeHeaders( - callerHeaders: HeadersInit | undefined, + callerHeaders: Record | undefined, hasBody: boolean, ): HeadersInit { if (typeof Headers === "function") { @@ -144,41 +144,16 @@ function mergeHeaders( } return merged; } - // Plain-object fallback path. Build a case-insensitive name map - // from whatever the caller passed (Headers-shaped, array-of-pairs, - // or plain record) and re-emit as a record `fetchImpl` accepts. + // Plain-object fallback path. Build a case-insensitive name map and + // re-emit it as a record `fetchImpl` accepts. const lower = new Map(); const setIfAbsent = (name: string, value: string) => { const key = name.toLowerCase(); if (!lower.has(key)) lower.set(key, { name, value }); }; - const setForce = (name: string, value: string) => { - const key = name.toLowerCase(); - lower.set(key, { name, value }); - }; if (callerHeaders) { - if (Array.isArray(callerHeaders)) { - for (const [name, value] of callerHeaders) setForce(name, value); - } else if ( - typeof (callerHeaders as { forEach?: unknown }).forEach === "function" - ) { - // `Headers`-like (without being our `typeof Headers === "function"` - // global). RN polyfills sometimes attach `Headers` only to - // request/response instances rather than the global scope. - // Standard signature is `forEach((value, key, parent))`; we - // bind the first two positionally so a polyfill that omits - // the third argument still works. `key` is the header name. - ( - callerHeaders as { - forEach: (cb: (value: string, key: string) => void) => void; - } - ).forEach((value, key) => setForce(key, value)); - } else { - for (const [name, value] of Object.entries( - callerHeaders as Record, - )) { - setForce(name, value); - } + for (const [name, value] of Object.entries(callerHeaders)) { + lower.set(name.toLowerCase(), { name, value }); } } setIfAbsent("accept", "application/json"); @@ -212,16 +187,18 @@ export function kitApi(options: KitApiOptions) { ); })(); - async function request(path: string, init?: RequestInit): Promise { + async function request( + path: string, + init?: InternalRequestInit, + ): Promise { // Normalize headers without depending on a global `Headers` // constructor: older React Native runtimes ship `fetch` (or a // polyfill via `fetchImpl`) without exposing `Headers` globally. // The prior implementation crashed before the first request on - // those runtimes. We use `new Headers()` when available (preserves - // caller-supplied `Headers` instances exactly), and otherwise fall - // back to a small case-insensitive merge into a plain record. - // Either way, kit defaults only apply when the caller hasn't set - // the same name. + // those runtimes. We use `new Headers()` when available and + // otherwise fall back to a small case-insensitive merge into a + // plain record. Either way, kit defaults only apply when the + // internal request hasn't set the same name. const headers = mergeHeaders(init?.headers, init?.body != null); // Prepend a leading slash if `path` is missing one. Today's // call sites all hard-code the leading "/", but normalizing here @@ -278,7 +255,7 @@ export function kitApi(options: KitApiOptions) { return parsed as T; } - async function call(path: string, init?: RequestInit): Promise { + async function call(path: string, init?: InternalRequestInit): Promise { return parseResponse(await request(path, init), path); } diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 5db595a55..aeb47dcda 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -3057,8 +3057,20 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); return value ?? ''; }, - purchaseError: () async => - await purchaseErrorListener.first as gentype.PurchaseError, + purchaseError: () async { + final error = await purchaseErrorListener.first; + return gentype.PurchaseError( + code: error.code ?? gentype.ErrorCode.Unknown, + debugMessage: error.debugMessage, + isEmptyProductList: error.isEmptyProductList, + message: error.message, + productId: error.productId, + productIds: error.productIds, + productType: error.productType, + responseCode: error.responseCode, + subResponseCodeAndroid: error.subResponseCodeAndroid, + ); + }, purchaseUpdated: ({bool? dedupeTransactionIOS}) async { final options = gentype.PurchaseUpdatedListenerOptions( dedupeTransactionIOS: dedupeTransactionIOS, diff --git a/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart b/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart new file mode 100644 index 000000000..7ebf0c364 --- /dev/null +++ b/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart @@ -0,0 +1,667 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; +import 'package:flutter_inapp_purchase/types.dart' as types; +import 'package:flutter_test/flutter_test.dart'; +import 'package:platform/platform.dart'; + +typedef _IapCall = Future Function(FlutterInappPurchase iap); + +class _FailureCase { + const _FailureCase(this.name, this.call, {this.invalidResponse = 7}); + + final String name; + final _IapCall call; + final dynamic invalidResponse; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('flutter_inapp'); + late DebugPrintCallback previousDebugPrint; + + setUp(() { + previousDebugPrint = debugPrint; + debugPrint = (String? _, {int? wrapWidth}) {}; + }); + + tearDown(() { + debugPrint = previousDebugPrint; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final appleFailureCases = <_FailureCase>[ + _FailureCase('storefront', (iap) => iap.getStorefront()), + _FailureCase('sync', (iap) => iap.syncIOS()), + _FailureCase( + 'intro offer eligibility', + (iap) => iap.isEligibleForIntroOfferIOS('group'), + ), + _FailureCase( + 'subscription status', + (iap) => iap.subscriptionStatusIOS('premium'), + invalidResponse: '{', + ), + _FailureCase('clear transactions', (iap) => iap.clearTransactionIOS()), + _FailureCase( + 'promoted product', + (iap) => iap.getPromotedProductIOS(), + invalidResponse: const {'id': true}, + ), + _FailureCase('app transaction', (iap) => iap.getAppTransactionIOS()), + _FailureCase( + 'code redemption', + (iap) => iap.presentCodeRedemptionSheetIOS(), + ), + _FailureCase( + 'refund request', + (iap) => iap.beginRefundRequestIOS('premium'), + ), + _FailureCase( + 'manage subscriptions', + (iap) => iap.showManageSubscriptionsIOS(), + invalidResponse: 'not-json', + ), + _FailureCase( + 'pending transactions', + (iap) => iap.getPendingTransactionsIOS(), + invalidResponse: 'not-json', + ), + _FailureCase( + 'all transactions', + (iap) => iap.getAllTransactionsIOS(), + invalidResponse: 'not-json', + ), + _FailureCase( + 'current entitlement', + (iap) => iap.currentEntitlementIOS('premium'), + ), + _FailureCase( + 'latest transaction', + (iap) => iap.latestTransactionIOS('premium'), + ), + _FailureCase( + 'transaction verification', + (iap) => iap.isTransactionVerifiedIOS('premium'), + ), + _FailureCase( + 'transaction JWS', + (iap) => iap.getTransactionJwsIOS('premium'), + ), + _FailureCase('receipt data', (iap) => iap.getReceiptDataIOS()), + _FailureCase( + 'external notice eligibility', + (iap) => iap.canPresentExternalPurchaseNoticeIOS(), + ), + _FailureCase( + 'external notice sheet', + (iap) => iap.presentExternalPurchaseNoticeSheetIOS(), + ), + _FailureCase( + 'external purchase link', + (iap) => iap.presentExternalPurchaseLinkIOS('https://example.test'), + ), + _FailureCase( + 'custom-link eligibility', + (iap) => iap.isEligibleForExternalPurchaseCustomLinkIOS(), + ), + _FailureCase( + 'custom-link token', + (iap) => iap.getExternalPurchaseCustomLinkTokenIOS( + types.ExternalPurchaseCustomLinkTokenTypeIOS.Acquisition, + ), + ), + _FailureCase( + 'custom-link notice', + (iap) => iap.showExternalPurchaseCustomLinkNoticeIOS( + types.ExternalPurchaseCustomLinkNoticeTypeIOS.Browser, + ), + ), + ]; + + group('Apple wrapper failure coverage', () { + for (final testCase in appleFailureCases) { + test('${testCase.name} converts PlatformException', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw PlatformException( + code: 'service-error', + message: '${testCase.name} failed', + ); + }); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + + await expectLater(testCase.call(iap), throwsA(isA())); + }); + + test('${testCase.name} converts unexpected exceptions', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + return testCase.invalidResponse; + }); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + + await expectLater(testCase.call(iap), throwsA(isA())); + }); + } + }); + + group('platform fallback coverage', () { + test('Apple-only helpers return documented Android fallbacks', () async { + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + expect(await iap.syncIOS(), isFalse); + expect(await iap.isEligibleForIntroOfferIOS('group'), isFalse); + expect(await iap.subscriptionStatusIOS('premium'), isEmpty); + expect(await iap.clearTransactionIOS(), isFalse); + expect(await iap.getPromotedProductIOS(), isNull); + expect(await iap.getAppTransactionIOS(), isNull); + expect(await iap.getPendingTransactionsIOS(), isEmpty); + expect(await iap.getAllTransactionsIOS(), isEmpty); + expect(await iap.currentEntitlementIOS('premium'), isNull); + expect(await iap.latestTransactionIOS('premium'), isNull); + expect(await iap.isTransactionVerifiedIOS('premium'), isFalse); + expect(await iap.getTransactionJwsIOS('premium'), isNull); + expect(await iap.getReceiptDataIOS(), isNull); + expect(await iap.canPresentExternalPurchaseNoticeIOS(), isFalse); + expect( + (await iap.presentExternalPurchaseNoticeSheetIOS()).result, + types.ExternalPurchaseNoticeAction.Dismissed, + ); + expect( + (await iap.presentExternalPurchaseLinkIOS('https://example.test')) + .success, + isFalse, + ); + expect(await iap.isEligibleForExternalPurchaseCustomLinkIOS(), isFalse); + expect( + (await iap.getExternalPurchaseCustomLinkTokenIOS( + types.ExternalPurchaseCustomLinkTokenTypeIOS.Acquisition, + )) + .token, + isNull, + ); + expect( + (await iap.showExternalPurchaseCustomLinkNoticeIOS( + types.ExternalPurchaseCustomLinkNoticeTypeIOS.Browser, + )) + .continued, + isFalse, + ); + }); + + test('Android-only helpers reject Apple callers', () async { + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + + await expectLater( + iap.acknowledgePurchaseAndroid('opaque'), + throwsA(isA()), + ); + await expectLater( + iap.consumePurchaseAndroid('opaque'), + throwsA(isA()), + ); + await expectLater( + iap.createBillingProgramReportingDetailsAndroid( + types.BillingProgramAndroid.ExternalOffer, + ), + throwsA(isA()), + ); + await expectLater( + iap.getBillingChoiceInfoAndroid( + const types.GetBillingChoiceInfoParamsAndroid(), + ), + throwsA(isA()), + ); + await expectLater( + iap.showBillingProgramInformationDialogAndroid( + const types.BillingProgramInformationDialogParamsAndroid( + externalTransactionToken: 'opaque', + ), + ), + throwsA(isA()), + ); + await expectLater( + iap.showInAppMessagesAndroid(), + throwsA(isA()), + ); + await expectLater( + iap.openRedeemOfferCodeAndroid(), + throwsA(isA()), + ); + }); + }); + + group('Android wrapper fallback coverage', () { + test('native errors return safe acknowledge and consume results', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + throw StateError('native failure'); + }); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + expect(await iap.acknowledgePurchaseAndroid('opaque'), isFalse); + expect(await iap.consumePurchaseAndroid('opaque'), isFalse); + expect( + (await iap.isBillingProgramAvailableAndroid( + types.BillingProgramAndroid.ExternalOffer, + )) + .isAvailable, + isFalse, + ); + await expectLater( + iap.createBillingProgramReportingDetailsAndroid( + types.BillingProgramAndroid.ExternalOffer, + ), + throwsA(isA()), + ); + await expectLater( + iap.getBillingChoiceInfoAndroid( + const types.GetBillingChoiceInfoParamsAndroid(), + ), + throwsA(isA()), + ); + await expectLater( + iap.showBillingProgramInformationDialogAndroid( + const types.BillingProgramInformationDialogParamsAndroid( + externalTransactionToken: 'opaque', + ), + ), + throwsA(isA()), + ); + await expectLater( + iap.showInAppMessagesAndroid(), + throwsA(isA()), + ); + await expectLater( + iap.openRedeemOfferCodeAndroid(), + throwsA(isA()), + ); + }); + + test('null native results take documented defaults', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + expect(await iap.acknowledgePurchaseAndroid('opaque'), isTrue); + expect(await iap.consumePurchaseAndroid('opaque'), isTrue); + expect( + (await iap.isBillingProgramAvailableAndroid( + types.BillingProgramAndroid.ExternalOffer, + )) + .isAvailable, + isFalse, + ); + expect( + (await iap.showInAppMessagesAndroid()).responseCode, + types.InAppMessageResponseCodeAndroid.NoActionNeeded, + ); + expect( + await iap.launchExternalLinkAndroid(_externalLinkParams()), isFalse); + expect(await iap.openRedeemOfferCodeAndroid(), isFalse); + }); + }); + + group('transaction completion coverage', () { + test('handles Android consume, acknowledge, cache, and response variants', + () async { + final responses = { + 'consume': {'responseCode': 0}, + 'bool': true, + 'json': '{"responseCode":"0"}', + 'map': {'responseCode': 0}, + 'failure': null, + }; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final token = (call.arguments as Map)['purchaseToken'] as String; + return responses[token]; + }); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + await expectLater( + iap.finishTransaction(purchase: _androidPurchase(null)), + throwsA(isA()), + ); + await iap.finishTransaction( + purchase: _androidPurchase('consume'), + isConsumable: true, + ); + await iap.finishTransaction(purchase: _androidPurchase('bool')); + await iap.finishTransaction(purchase: _androidPurchase('bool')); + await iap.finishTransaction(purchase: _androidPurchase('json')); + await iap.finishTransaction(purchase: _androidPurchase('map')); + await iap.finishTransaction(purchase: _androidPurchase('failure')); + }); + + test('finishes Apple transactions and rejects unsupported platforms', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + final ios = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + final linux = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'linux'), + ); + + await ios.finishTransaction( + purchase: _iosPurchase(), + isConsumable: false, + ); + expect(calls.single.method, 'finishTransaction'); + await expectLater( + linux.finishTransaction(purchase: _iosPurchase()), + throwsA(isA()), + ); + }); + }); + + group('native event coverage', () { + const codec = StandardMethodCodec(); + + Future send(String method, Object? arguments) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + 'flutter_inapp', + codec.encodeMethodCall(MethodCall(method, arguments)), + (_) {}, + ); + } + + test('emits user-choice and subscription billing-issue events', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => true); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + await iap.initConnection(); + final userChoice = iap.userChoiceBillingAndroid.first; + final billingIssue = iap.subscriptionBillingIssueListener.first; + + await send( + 'user-choice-billing-android', + jsonEncode({ + 'externalTransactionToken': 'opaque', + 'products': ['premium'], + }), + ); + await send( + 'subscription-billing-issue', + jsonEncode(_androidPurchaseJson('billing-issue')), + ); + + expect((await userChoice).products, ['premium']); + expect((await billingIssue).productId, 'premium'); + }); + + test('isolates malformed native events and rejects unknown methods', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => true); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + await iap.initConnection(); + + await send('user-choice-billing-android', '{'); + await send('developer-provided-billing-android', '{'); + await send('subscription-billing-issue', '{'); + await send('purchase-updated', '{'); + await send('unknown-event', null); + }); + }); + + test('generated handler bundles expose every implemented bridge', () { + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + final query = iap.queryHandlers; + final mutation = iap.mutationHandlers; + final subscription = iap.subscriptionHandlers; + + expect( + iap.purchaseUpdatedListenerWithOptions(null), + isA>(), + ); + + expect(query.fetchProducts, isNotNull); + expect(query.getBillingChoiceInfoAndroid, isNotNull); + expect(mutation.acknowledgePurchaseAndroid, isNotNull); + expect(mutation.beginRefundRequestIOS, isNotNull); + expect(mutation.consumePurchaseAndroid, isNotNull); + expect(mutation.createBillingProgramReportingDetailsAndroid, isNotNull); + expect(mutation.deepLinkToSubscriptions, isNotNull); + expect(mutation.endConnection, isNotNull); + expect(mutation.finishTransaction, isNotNull); + expect(mutation.initConnection, isNotNull); + expect(mutation.isBillingProgramAvailableAndroid, isNotNull); + expect(mutation.launchExternalLinkAndroid, isNotNull); + expect(mutation.openRedeemOfferCodeAndroid, isNotNull); + expect(mutation.presentCodeRedemptionSheetIOS, isNotNull); + expect(mutation.requestPurchase, isNotNull); + expect(mutation.restorePurchases, isNotNull); + expect(mutation.showBillingProgramInformationDialogAndroid, isNotNull); + expect(mutation.showInAppMessagesAndroid, isNotNull); + expect(mutation.showManageSubscriptionsIOS, isNotNull); + expect(mutation.syncIOS, isNotNull); + expect(mutation.verifyPurchase, isNotNull); + expect(mutation.clearTransactionIOS, isNotNull); + expect(mutation.presentExternalPurchaseNoticeSheetIOS, isNotNull); + expect(mutation.presentExternalPurchaseLinkIOS, isNotNull); + expect(mutation.showExternalPurchaseCustomLinkNoticeIOS, isNotNull); + expect(mutation.verifyPurchaseWithProvider, isNotNull); + expect(subscription.promotedProductIOS, isNotNull); + expect(subscription.purchaseError, isNotNull); + expect(subscription.purchaseUpdated, isNotNull); + expect(subscription.subscriptionBillingIssue, isNotNull); + expect(subscription.userChoiceBillingAndroid, isNotNull); + expect(subscription.developerProvidedBillingAndroid, isNotNull); + }); + + test('generated Android handlers forward named arguments', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'launchExternalLinkAndroid': + return true; + case 'getBillingChoiceInfoAndroid': + return jsonEncode({ + 'playBillingChoiceImageUrl': 'https://example.test/choice.png', + }); + case 'createBillingProgramReportingDetailsAndroid': + return jsonEncode({ + 'billingProgram': 'external-offer', + 'externalTransactionToken': 'opaque', + }); + case 'showBillingProgramInformationDialogAndroid': + return jsonEncode({'responseCode': 0}); + case 'showInAppMessagesAndroid': + return jsonEncode({ + 'responseCode': 'no-action-needed', + }); + } + return null; + }); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + final query = iap.queryHandlers; + final mutation = iap.mutationHandlers; + + await query.getBillingChoiceInfoAndroid!( + billingProgram: types.BillingProgramAndroid.BillingChoice, + playBillingChoiceImageLayout: + types.BillingChoiceImageLayoutAndroid.RectangularFourByOne, + userLocale: 'en-US', + ); + await mutation.launchExternalLinkAndroid!( + billingProgram: types.BillingProgramAndroid.ExternalOffer, + launchMode: + types.ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, + linkType: types.ExternalLinkTypeAndroid.LinkToDigitalContentOffer, + linkUri: 'https://example.test', + externalTransactionToken: 'opaque', + ); + await mutation.createBillingProgramReportingDetailsAndroid!( + program: types.BillingProgramAndroid.ExternalOffer, + developerBillingType: types.DeveloperBillingTypeAndroid.ExternalLink, + ); + await mutation.showBillingProgramInformationDialogAndroid!( + billingProgram: types.BillingProgramAndroid.BillingChoice, + externalTransactionToken: 'opaque', + ); + await mutation.showInAppMessagesAndroid!( + categories: const [ + types.InAppMessageCategoryAndroid.Transactional, + ], + ); + }); + + test('generated subscription handlers deliver native events', () async { + const codec = StandardMethodCodec(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => true); + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + await iap.initConnection(); + final handlers = iap.subscriptionHandlers; + + Future send(String method, Object? arguments) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + codec.encodeMethodCall(MethodCall(method, arguments)), + (_) {}, + ); + } + + final promoted = handlers.promotedProductIOS!(); + await send('iap-promoted-product', 'premium'); + expect(await promoted, 'premium'); + + final purchaseError = handlers.purchaseError!(); + await send( + 'purchase-error', + jsonEncode({ + 'code': 'service-error', + 'debugMessage': 'native debug', + 'message': 'store unavailable', + 'productId': 'premium', + 'responseCode': 7, + }), + ); + final mappedError = await purchaseError; + expect(mappedError.code, types.ErrorCode.ServiceError); + expect(mappedError.debugMessage, 'native debug'); + expect(mappedError.message, 'store unavailable'); + expect(mappedError.productId, 'premium'); + expect(mappedError.responseCode, 7); + + final purchaseUpdated = handlers.purchaseUpdated!(); + await send('purchase-updated', jsonEncode(_androidPurchaseJson('updated'))); + expect((await purchaseUpdated).purchaseToken, 'updated'); + + final billingIssue = handlers.subscriptionBillingIssue!(); + await send( + 'subscription-billing-issue', + jsonEncode(_androidPurchaseJson('billing-issue')), + ); + expect((await billingIssue).purchaseToken, 'billing-issue'); + + final userChoice = handlers.userChoiceBillingAndroid!(); + await send( + 'user-choice-billing-android', + jsonEncode({ + 'externalTransactionToken': 'opaque', + 'products': ['premium'], + }), + ); + expect((await userChoice).products, ['premium']); + + final developerProvided = handlers.developerProvidedBillingAndroid!(); + await send( + 'developer-provided-billing-android', + jsonEncode({ + 'linkUri': 'https://example.test/checkout', + 'products': >[ + {'id': 'premium', 'type': 'subs'}, + ], + }), + ); + expect((await developerProvided).products.single.id, 'premium'); + }); +} + +types.LaunchExternalLinkParamsAndroid _externalLinkParams() { + return const types.LaunchExternalLinkParamsAndroid( + billingProgram: types.BillingProgramAndroid.ExternalOffer, + launchMode: + types.ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, + linkType: types.ExternalLinkTypeAndroid.LinkToDigitalContentOffer, + linkUri: 'https://example.test', + ); +} + +types.PurchaseAndroid _androidPurchase(String? token) { + return types.PurchaseAndroid( + id: token ?? 'missing-token', + isAutoRenewing: false, + productId: 'premium', + purchaseState: types.PurchaseState.Purchased, + purchaseToken: token, + quantity: 1, + store: types.IapStore.Google, + transactionDate: 1, + ); +} + +types.PurchaseIOS _iosPurchase() { + return const types.PurchaseIOS( + id: 'apple-transaction', + isAutoRenewing: false, + productId: 'premium', + purchaseState: types.PurchaseState.Purchased, + quantity: 1, + store: types.IapStore.Apple, + transactionDate: 1, + transactionId: 'apple-transaction', + ); +} + +Map _androidPurchaseJson(String token) { + return { + 'id': token, + 'isAutoRenewing': false, + 'productId': 'premium', + 'purchaseState': 'purchased', + 'purchaseToken': token, + 'quantity': 1, + 'store': 'google', + 'transactionDate': 1, + }; +} diff --git a/libraries/flutter_inapp_purchase/test/fetch_products_all_test.dart b/libraries/flutter_inapp_purchase/test/fetch_products_all_test.dart index c53aafcdd..01a893278 100644 --- a/libraries/flutter_inapp_purchase/test/fetch_products_all_test.dart +++ b/libraries/flutter_inapp_purchase/test/fetch_products_all_test.dart @@ -139,4 +139,22 @@ void main() { contains(isA()), ); }); + + test('queryHandlers fetchProducts preserves typed result branches', () async { + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + await iap.initConnection(); + + final subscriptions = await iap.queryHandlers.fetchProducts!( + skus: const ['premium_monthly'], + type: types.ProductQueryType.Subs, + ); + final products = await iap.queryHandlers.fetchProducts!( + skus: const ['coin_pack'], + ); + + expect(subscriptions, isA()); + expect(products, isA()); + }); } diff --git a/libraries/flutter_inapp_purchase/test/helpers_coverage_test.dart b/libraries/flutter_inapp_purchase/test/helpers_coverage_test.dart new file mode 100644 index 000000000..00d6ac77f --- /dev/null +++ b/libraries/flutter_inapp_purchase/test/helpers_coverage_test.dart @@ -0,0 +1,275 @@ +import 'dart:convert'; + +import 'package:flutter_inapp_purchase/helpers.dart'; +import 'package:flutter_inapp_purchase/types.dart' as types; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('product bridge fallback coverage', () { + test('recovers StoreKit 1 identifiers and infers Android metadata', () { + final product = parseProductFromNative( + { + 'productIdentifier': 'legacy.product', + 'nameAndroid': 'Legacy Product', + 'localizedPrice': '\$1.99', + 'price': '1.99', + 'title': 'Legacy Product', + 'description': 'Description', + 'currency': 'USD', + 'discountOffers': >[], + }, + 'in-app', + fallbackIsIOS: true, + ); + + expect(product, isA()); + expect(product.id, 'legacy.product'); + expect(product.displayPrice, '\$1.99'); + expect(product.price, 1.99); + }); + + test('accepts enum platforms and both platform heuristics', () { + final enumPlatform = parseProductFromNative( + { + 'id': 'enum', + 'platform': types.IapPlatform.IOS, + 'title': 'Enum', + 'description': '', + 'displayPrice': 'Free', + 'price': 0, + 'typeIOS': 'CONSUMABLE', + }, + 'in-app', + fallbackIsIOS: false, + ); + final iosHeuristic = parseProductFromNative( + { + 'id': 'ios', + 'jsonRepresentationIOS': '{}', + 'title': 'iOS', + 'description': '', + 'displayPrice': 'Free', + 'price': 0, + }, + 'in-app', + fallbackIsIOS: false, + ); + final ambiguous = parseProductFromNative( + { + 'id': 'ambiguous', + 'nameAndroid': 'Android', + 'jsonRepresentationIOS': '{}', + 'title': 'Ambiguous', + 'description': '', + 'displayPrice': 'Free', + 'price': 0, + }, + 'in-app', + fallbackIsIOS: true, + ); + + expect(enumPlatform, isA()); + expect(iosHeuristic, isA()); + expect(ambiguous, isA()); + }); + + test('parses Android subscription offers and optional status', () { + final product = parseProductFromNative( + { + 'id': 'premium', + 'platform': 'android', + 'nameAndroid': 'Premium', + 'title': 'Premium', + 'description': 'Monthly access', + 'displayPrice': '\$4.99', + 'price': 4.99, + 'currency': 'USD', + 'productStatusAndroid': 'PURCHASABLE', + 'subscriptionOffers': jsonEncode(>[ + { + 'id': 'intro', + 'displayPrice': 'Free', + 'price': 0, + 'type': 'introductory', + 'paymentMode': 'free-trial', + 'period': {'unit': 'month', 'value': '1'}, + 'periodCount': '1', + 'offerTokenAndroid': 'opaque', + }, + ]), + }, + 'subs', + fallbackIsIOS: false, + ) as types.ProductSubscriptionAndroid; + + expect(product.subscriptionOffers, hasLength(1)); + expect(product.subscriptionOffers.first.id, 'intro'); + }); + + test('parses iOS offer aliases and ignores malformed list members', () { + final product = parseProductFromNative( + { + 'id': 'premium', + 'platform': 'ios', + 'title': 'Premium', + 'description': 'Monthly access', + 'displayPrice': '\$4.99', + 'price': 4.99, + 'currency': 'USD', + 'typeIOS': 'SUBSCRIPTION_SUITE', + 'introductoryPricePaymentModeIOS': 'unknown', + 'introductoryPriceSubscriptionPeriodIOS': 'unknown', + 'subscriptionPeriodUnitIOS': 'YEAR', + 'pricingTermsIOS': jsonEncode([ + { + 'billingDisplayPrice': '\$4.99', + 'billingPlanType': 'monthly', + }, + 7, + ]), + 'bundledSubscriptionsIOS': '{', + 'subscriptionOffers': [ + { + 'id': 'legacy-offer', + 'displayPrice': 'Free', + 'price': '0', + 'type': 'WINBACK', + 'paymentMode': 'FREETRIAL', + 'period': {'unit': 'MONTH', 'value': '1'}, + 'periodCount': '1', + }, + 7, + ], + }, + 'subs', + fallbackIsIOS: true, + ) as types.ProductSubscriptionIOS; + + expect(product.typeIOS, types.ProductTypeIOS.SubscriptionSuite); + expect(product.subscriptionOffers, hasLength(1)); + expect(product.bundledSubscriptionsIOS, isNull); + }); + }); + + group('purchase bridge fallback coverage', () { + test('normalizes Android state aliases, timestamps, lists, and quantities', + () { + final acknowledged = {}; + final purchased = convertToPurchase( + { + 'id': 'android', + 'productId': 'premium', + 'store': 'google', + 'purchaseState': 'purchase_state_purchased', + 'purchaseToken': 'opaque', + 'transactionDate': '2026-01-02T03:04:05Z', + 'quantity': '2', + 'ids': [1, 'premium'], + 'isAcknowledgedAndroid': true, + }, + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: acknowledged, + ) as types.PurchaseAndroid; + final pending = convertToPurchase( + { + 'id': 'pending', + 'productId': 'premium', + 'store': 'google', + 'purchaseState': 2.0, + 'transactionDate': '1', + }, + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}, + ); + final defaultState = convertToPurchase( + { + 'id': 'default', + 'productId': 'premium', + 'store': 'google', + 'transactionDate': 1, + }, + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}, + ); + + expect(purchased.quantity, 2); + expect(purchased.ids, ['1', 'premium']); + expect(acknowledged['opaque'], isTrue); + expect(pending.purchaseState, types.PurchaseState.Pending); + expect(defaultState.purchaseState, types.PurchaseState.Purchased); + }); + + test('normalizes Apple restored, pending, numeric, and unknown states', () { + for (final entry in <(dynamic, types.PurchaseState)>[ + ('restored', types.PurchaseState.Purchased), + ('pending', types.PurchaseState.Pending), + (0, types.PurchaseState.Pending), + (1, types.PurchaseState.Purchased), + (99, types.PurchaseState.Unknown), + ]) { + final purchase = convertToPurchase( + { + 'id': 'apple-${entry.$1}', + 'transactionId': 'apple-${entry.$1}', + 'productId': 'premium', + 'store': 'apple', + 'purchaseState': entry.$1, + 'transactionDate': 1, + 'originalTransactionDateIOS': '2026-01-02T03:04:05Z', + }, + platformIsAndroid: false, + platformIsIOS: true, + acknowledgedAndroidPurchaseTokens: {}, + ); + expect(purchase.purchaseState, entry.$2); + } + }); + + test('handles malformed purchase lists in strict and lenient modes', () { + expect( + extractPurchases( + '{', + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}, + ), + isEmpty, + ); + expect( + () => extractPurchases( + '{', + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}, + rejectMalformed: true, + ), + throwsFormatException, + ); + expect( + extractPurchases( + [ + 7, + {null: 'ignored'} + ], + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}, + ), + isEmpty, + ); + expect( + () => extractPurchases( + [7], + platformIsAndroid: true, + platformIsIOS: false, + acknowledgedAndroidPurchaseTokens: {}, + rejectMalformed: true, + ), + throwsFormatException, + ); + }); + }); +} diff --git a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts index 32b500400..9106ed12e 100644 --- a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts @@ -1,9 +1,4 @@ -import { - fetchProducts, - getAvailablePurchases, - openRedeemOfferCodeAndroid, - requestPurchase, -} from '../index.kepler'; +import * as IAP from '../index.kepler'; import type {RequestPurchaseProps} from '../types'; import {getVegaIapModule} from '../vega'; @@ -16,29 +11,44 @@ describe('Amazon Vega public API', () => { const fetchProductsNative = jest.fn().mockResolvedValue([]); const requestPurchaseNative = jest.fn().mockResolvedValue([]); const getAvailablePurchasesNative = jest.fn().mockResolvedValue([]); + const vegaModule = { + initConnection: jest.fn().mockResolvedValue(true), + endConnection: jest.fn().mockResolvedValue(true), + fetchProducts: fetchProductsNative, + requestPurchase: requestPurchaseNative, + getAvailablePurchases: getAvailablePurchasesNative, + finishTransaction: jest.fn().mockResolvedValue(undefined), + restorePurchases: jest.fn().mockResolvedValue(undefined), + getActiveSubscriptions: jest.fn().mockResolvedValue([]), + hasActiveSubscriptions: jest.fn().mockResolvedValue(false), + addPurchaseUpdatedListener: jest.fn().mockReturnValue(7), + removePurchaseUpdatedListener: jest.fn(), + addPurchaseErrorListener: jest.fn(), + removePurchaseErrorListener: jest.fn(), + getStorefront: jest.fn().mockResolvedValue('US'), + verifyPurchaseWithProvider: jest.fn().mockResolvedValue({ok: true}), + acknowledgePurchaseAndroid: jest.fn().mockResolvedValue(true), + consumePurchaseAndroid: jest.fn().mockResolvedValue(true), + }; beforeEach(() => { jest.clearAllMocks(); - (getVegaIapModule as jest.Mock).mockReturnValue({ - fetchProducts: fetchProductsNative, - requestPurchase: requestPurchaseNative, - getAvailablePurchases: getAvailablePurchasesNative, - }); + (getVegaIapModule as jest.Mock).mockReturnValue(vegaModule); }); it('returns false when offer-code redemption is unsupported', async () => { - await expect(openRedeemOfferCodeAndroid()).resolves.toBe(false); + await expect(IAP.openRedeemOfferCodeAndroid()).resolves.toBe(false); }); it("uses the canonical 'in-app' product type", async () => { - await fetchProducts({skus: ['coins'], type: 'in-app'}); + await IAP.fetchProducts({skus: ['coins'], type: 'in-app'}); expect(fetchProductsNative).toHaveBeenCalledWith(['coins'], 'in-app'); }); it('rejects removed product type aliases', async () => { await expect( - fetchProducts({skus: ['coins'], type: 'inapp' as any}), + IAP.fetchProducts({skus: ['coins'], type: 'inapp' as any}), ).rejects.toThrow(/Unsupported product type/); expect(fetchProductsNative).not.toHaveBeenCalled(); }); @@ -49,7 +59,7 @@ describe('Amazon Vega public API', () => { type: 'in-app', }; - await requestPurchase(request); + await IAP.requestPurchase(request); expect(requestPurchaseNative).toHaveBeenLastCalledWith({ google: {skus: ['coins']}, @@ -74,7 +84,7 @@ describe('Amazon Vega public API', () => { type: 'in-app', }; - await expect(requestPurchase(request)).rejects.toMatchObject({ + await expect(IAP.requestPurchase(request)).rejects.toMatchObject({ code: 'billing-response-json-parse-error', }); }); @@ -93,7 +103,7 @@ describe('Amazon Vega public API', () => { {id: 'malformed'}, ]); - await expect(getAvailablePurchases()).rejects.toMatchObject({ + await expect(IAP.getAvailablePurchases()).rejects.toMatchObject({ code: 'billing-response-json-parse-error', }); }); @@ -112,8 +122,184 @@ describe('Amazon Vega public API', () => { }, ]); - await expect(getAvailablePurchases()).rejects.toMatchObject({ + await expect(IAP.getAvailablePurchases()).rejects.toMatchObject({ code: 'billing-response-json-parse-error', }); }); + + it('reports Vega capabilities and rejects a missing native module', async () => { + expect(IAP.isNitroReady()).toBe(false); + expect(IAP.isTVOS()).toBe(false); + expect(IAP.isMacOS()).toBe(false); + expect(IAP.isStandardIOS()).toBe(false); + + (getVegaIapModule as jest.Mock).mockReturnValueOnce(undefined); + await expect(IAP.initConnection()).rejects.toThrow( + /Amazon Vega IAP module is unavailable/, + ); + }); + + it('forwards connection, subscription, storefront, and provider APIs', async () => { + await expect(IAP.initConnection()).resolves.toBe(true); + await expect(IAP.endConnection()).resolves.toBe(true); + await expect(IAP.getActiveSubscriptions()).resolves.toEqual([]); + await expect(IAP.hasActiveSubscriptions(['premium'])).resolves.toBe(false); + await expect(IAP.getStorefront()).resolves.toBe('US'); + await expect( + IAP.verifyPurchaseWithProvider({ + provider: 'iapkit', + iapkit: { + apiKey: 'public-test-key', + amazon: {receiptId: 'redacted', sandbox: true}, + }, + }), + ).resolves.toEqual({ok: true}); + + expect(vegaModule.initConnection).toHaveBeenCalledWith(null); + expect(vegaModule.getActiveSubscriptions).toHaveBeenCalledWith(undefined); + expect(vegaModule.hasActiveSubscriptions).toHaveBeenCalledWith(['premium']); + }); + + it('normalizes product queries and maps subscriptions', async () => { + fetchProductsNative.mockResolvedValueOnce([ + { + id: 'premium', + title: 'Premium', + description: 'Premium plan', + type: 'subs', + displayPrice: '$4.99', + price: 4.99, + currency: 'USD', + platform: 'android', + subscriptionOfferDetailsAndroid: [], + }, + {title: 'invalid'}, + ]); + + await expect( + IAP.fetchProducts({skus: ['premium'], type: 'subs'}), + ).resolves.toHaveLength(1); + await IAP.fetchProducts({skus: ['premium'], type: 'all'}); + await IAP.fetchProducts({skus: ['premium']}); + + expect(fetchProductsNative.mock.calls.map((call) => call[1])).toEqual([ + 'subs', + 'all', + 'in-app', + ]); + }); + + it('validates purchase and finish requests', async () => { + await expect( + IAP.requestPurchase({request: {google: {skus: []}}, type: 'in-app'}), + ).rejects.toThrow(/must be a non-empty array/); + + requestPurchaseNative.mockResolvedValueOnce(null); + await expect( + IAP.requestPurchase({ + request: {google: {skus: ['coins']}}, + type: 'in-app', + }), + ).resolves.toBeNull(); + + await expect( + IAP.finishTransaction({purchase: {productId: 'coins'} as any}), + ).rejects.toThrow(/purchaseToken required/); + await IAP.finishTransaction({ + purchase: {productId: 'coins', purchaseToken: 'receipt'} as any, + isConsumable: true, + }); + expect(vegaModule.finishTransaction).toHaveBeenCalledWith({ + android: {purchaseToken: 'receipt', isConsumable: true}, + }); + }); + + it('forwards restore and Android completion helpers', async () => { + await IAP.restorePurchases(); + await expect(IAP.acknowledgePurchaseAndroid('receipt')).resolves.toBe(true); + await expect(IAP.consumePurchaseAndroid('receipt')).resolves.toBe(true); + + expect(vegaModule.restorePurchases).toHaveBeenCalledTimes(1); + expect(vegaModule.acknowledgePurchaseAndroid).toHaveBeenCalledWith( + 'receipt', + ); + expect(vegaModule.consumePurchaseAndroid).toHaveBeenCalledWith('receipt'); + }); + + it('adapts purchase listeners and removes native subscriptions', () => { + const purchaseListener = jest.fn(); + const subscription = IAP.purchaseUpdatedListener(purchaseListener); + const nativeListener = + vegaModule.addPurchaseUpdatedListener.mock.calls[0][0]; + + nativeListener({ + id: 'purchase', + productId: 'coins', + transactionDate: Date.now(), + store: 'amazon', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + purchaseToken: 'receipt', + }); + nativeListener({id: 'invalid'}); + subscription.remove(); + + expect(purchaseListener).toHaveBeenCalledTimes(1); + expect(vegaModule.removePurchaseUpdatedListener).toHaveBeenCalledWith(7); + }); + + it('normalizes purchase errors and removes the same callback', () => { + const listener = jest.fn(); + const subscription = IAP.purchaseErrorListener(listener); + const nativeListener = vegaModule.addPurchaseErrorListener.mock.calls[0][0]; + + nativeListener({}); + subscription.remove(); + + expect(listener).toHaveBeenCalledWith({ + code: 'service-error', + message: 'Amazon Vega purchase failed', + }); + expect(vegaModule.removePurchaseErrorListener).toHaveBeenCalledWith( + nativeListener, + ); + }); + + it('returns documented iOS fallbacks and rejects unsupported APIs', async () => { + await expect(IAP.getAppTransactionIOS()).resolves.toBeNull(); + await expect(IAP.getPromotedProductIOS()).resolves.toBeNull(); + await expect(IAP.showManageSubscriptionsIOS()).resolves.toEqual([]); + await expect(IAP.presentCodeRedemptionSheetIOS()).resolves.toBeNull(); + + for (const call of [ + () => IAP.verifyPurchase({} as any), + () => IAP.syncIOS(), + () => IAP.presentExternalPurchaseLinkIOS('https://example.com'), + () => IAP.deepLinkToSubscriptions({} as any), + () => IAP.isBillingProgramAvailableAndroid('external-offer' as any), + () => IAP.getBillingChoiceInfoAndroid({}), + () => + IAP.launchExternalLinkAndroid({ + billingProgram: 'external-offer', + launchMode: 'launch-in-external-browser-or-app', + linkType: 'link-to-digital-content-offer', + linkUri: 'https://example.com', + }), + () => + IAP.createBillingProgramReportingDetailsAndroid( + 'external-offer' as any, + ), + () => + IAP.showBillingProgramInformationDialogAndroid('external-offer' as any), + () => IAP.showInAppMessagesAndroid({} as any), + ]) { + await expect(call()).rejects.toThrow(/not supported on Amazon Vega/); + } + + IAP.promotedProductListenerIOS().remove(); + IAP.userChoiceBillingListenerAndroid().remove(); + IAP.developerProvidedBillingListenerAndroid().remove(); + IAP.subscriptionBillingIssueListener().remove(); + }); }); diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 576c65bb5..ee852ff3b 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -29,6 +29,8 @@ const mockIap: any = { removePromotedProductListenerIOS: jest.fn(), addSubscriptionBillingIssueListener: jest.fn(), removeSubscriptionBillingIssueListener: jest.fn(), + addUserChoiceBillingListenerAndroid: jest.fn(), + removeUserChoiceBillingListenerAndroid: jest.fn(), // iOS-only getAppTransactionIOS: jest.fn(async () => null), @@ -1354,6 +1356,39 @@ describe('Public API (src/index.ts)', () => { ); }); + it('getAppTransactionIOS accepts JSON and object bridge results', async () => { + const value = { + appId: 1, + appVersion: '1.0.0', + appVersionId: 2, + bundleId: 'dev.hyo.app', + deviceVerification: 'verification', + deviceVerificationNonce: 'nonce', + environment: 'Sandbox', + originalAppVersion: '1.0.0', + originalPurchaseDate: 3, + signedDate: 4, + }; + + mockIap.getAppTransactionIOS + .mockResolvedValueOnce(JSON.stringify(value)) + .mockResolvedValueOnce(value); + + await expect(IAP.getAppTransactionIOS()).resolves.toMatchObject(value); + await expect(IAP.getAppTransactionIOS()).resolves.toBe(value); + }); + + it('getAppTransactionIOS rejects malformed and native failures', async () => { + mockIap.getAppTransactionIOS.mockResolvedValueOnce('{'); + await expect(IAP.getAppTransactionIOS()).rejects.toThrow( + /Unable to parse app transaction payload/, + ); + + const nativeError = new Error('app transaction failed'); + mockIap.getAppTransactionIOS.mockRejectedValueOnce(nativeError); + await expect(IAP.getAppTransactionIOS()).rejects.toBe(nativeError); + }); + it('presentCodeRedemptionSheetIOS returns the verified purchase', async () => { (Platform as any).OS = 'ios'; mockIap.presentCodeRedemptionSheetIOS.mockResolvedValueOnce({ @@ -1608,6 +1643,71 @@ describe('Public API (src/index.ts)', () => { }); }); + describe('Android user choice billing listener', () => { + it('fans out native events, isolates callbacks, and removes listeners', () => { + (Platform as any).OS = 'android'; + const first = IAP.userChoiceBillingListenerAndroid(() => { + throw new Error('consumer failed'); + }); + const listener = jest.fn(); + const second = IAP.userChoiceBillingListenerAndroid(listener); + const nativeListener = + mockIap.addUserChoiceBillingListenerAndroid.mock.calls[0][0]; + const details = { + products: ['premium'], + externalTransactionToken: 'opaque', + }; + + nativeListener(details); + first.remove(); + second.remove(); + + expect(listener).toHaveBeenCalledWith(details); + expect(console.error).toHaveBeenCalled(); + expect(mockIap.addUserChoiceBillingListenerAndroid).toHaveBeenCalledTimes( + 1, + ); + expect( + mockIap.removeUserChoiceBillingListenerAndroid, + ).toHaveBeenCalledTimes(1); + expect( + mockIap.removeUserChoiceBillingListenerAndroid, + ).toHaveBeenCalledWith(nativeListener); + }); + + it('returns an inert subscription outside Android', () => { + (Platform as any).OS = 'ios'; + const subscription = IAP.userChoiceBillingListenerAndroid(jest.fn()); + expect(() => subscription.remove()).not.toThrow(); + expect( + mockIap.addUserChoiceBillingListenerAndroid, + ).not.toHaveBeenCalled(); + }); + + it('keeps the listener inert while Nitro initializes', () => { + (Platform as any).OS = 'android'; + mockIap.addUserChoiceBillingListenerAndroid.mockImplementationOnce(() => { + throw new Error('Nitro runtime not installed'); + }); + + expect(() => + IAP.userChoiceBillingListenerAndroid(jest.fn()), + ).not.toThrow(); + expect(console.warn).toHaveBeenCalled(); + }); + + it('surfaces unexpected native listener failures', () => { + (Platform as any).OS = 'android'; + mockIap.addUserChoiceBillingListenerAndroid.mockImplementationOnce(() => { + throw new Error('native listener failed'); + }); + + expect(() => IAP.userChoiceBillingListenerAndroid(jest.fn())).toThrow( + 'native listener failed', + ); + }); + }); + describe('Android-only wrappers', () => { it('acknowledgePurchaseAndroid calls unified finishTransaction', async () => { (Platform as any).OS = 'android'; @@ -3014,4 +3114,351 @@ describe('Public API (src/index.ts)', () => { ); }); }); + + describe('native failure normalization', () => { + const replacedNativeMethods = new Map(); + const replaceNativeMethod = (name: string, value: unknown) => { + if (!replacedNativeMethods.has(name)) { + replacedNativeMethods.set(name, mockIap[name]); + } + mockIap[name] = value; + }; + + afterEach(() => { + for (const [name, value] of replacedNativeMethods) { + if (value === undefined) delete mockIap[name]; + else mockIap[name] = value; + } + replacedNativeMethods.clear(); + }); + + const iosFailureCases: { + name: string; + nativeMethod: string; + invoke: () => Promise; + }[] = [ + { + name: 'get promoted product', + nativeMethod: 'getPromotedProductIOS', + invoke: () => IAP.getPromotedProductIOS(), + }, + { + name: 'subscription status', + nativeMethod: 'subscriptionStatusIOS', + invoke: () => IAP.subscriptionStatusIOS('premium'), + }, + { + name: 'current entitlement', + nativeMethod: 'currentEntitlementIOS', + invoke: () => IAP.currentEntitlementIOS('premium'), + }, + { + name: 'latest transaction', + nativeMethod: 'latestTransactionIOS', + invoke: () => IAP.latestTransactionIOS('premium'), + }, + { + name: 'pending transactions', + nativeMethod: 'getPendingTransactionsIOS', + invoke: () => IAP.getPendingTransactionsIOS(), + }, + { + name: 'all transactions', + nativeMethod: 'getAllTransactionsIOS', + invoke: () => IAP.getAllTransactionsIOS(), + }, + { + name: 'manage subscriptions', + nativeMethod: 'showManageSubscriptionsIOS', + invoke: () => IAP.showManageSubscriptionsIOS(), + }, + { + name: 'intro offer eligibility', + nativeMethod: 'isEligibleForIntroOfferIOS', + invoke: () => IAP.isEligibleForIntroOfferIOS('group'), + }, + { + name: 'receipt data', + nativeMethod: 'getReceiptDataIOS', + invoke: () => IAP.getReceiptDataIOS(), + }, + { + name: 'receipt refresh', + nativeMethod: 'requestReceiptRefreshIOS', + invoke: () => IAP.requestReceiptRefreshIOS(), + }, + { + name: 'transaction verification', + nativeMethod: 'isTransactionVerifiedIOS', + invoke: () => IAP.isTransactionVerifiedIOS('premium'), + }, + { + name: 'transaction JWS', + nativeMethod: 'getTransactionJwsIOS', + invoke: () => IAP.getTransactionJwsIOS('premium'), + }, + { + name: 'StoreKit sync', + nativeMethod: 'syncIOS', + invoke: () => IAP.syncIOS(), + }, + { + name: 'code redemption sheet', + nativeMethod: 'presentCodeRedemptionSheetIOS', + invoke: () => IAP.presentCodeRedemptionSheetIOS(), + }, + { + name: 'refund request', + nativeMethod: 'beginRefundRequestIOS', + invoke: () => IAP.beginRefundRequestIOS('premium'), + }, + { + name: 'iOS subscription deep link', + nativeMethod: 'deepLinkToSubscriptionsIOS', + invoke: () => IAP.deepLinkToSubscriptionsIOS(), + }, + ]; + + it.each(iosFailureCases)('normalizes $name failures', async (testCase) => { + const nativeError = new Error('native failure'); + replaceNativeMethod( + testCase.nativeMethod, + jest.fn().mockRejectedValue(nativeError), + ); + + await expect(testCase.invoke()).rejects.toMatchObject({ + message: 'native failure', + }); + }); + + it('normalizes connection lifecycle failures', async () => { + replaceNativeMethod( + 'initConnection', + jest.fn().mockRejectedValueOnce(new Error('init failed')), + ); + await expect(IAP.initConnection()).rejects.toMatchObject({ + message: 'init failed', + }); + + replaceNativeMethod('initConnection', jest.fn().mockResolvedValue(true)); + expect(IAP.isNitroReady()).toBe(true); + replaceNativeMethod( + 'endConnection', + jest.fn().mockRejectedValueOnce(new Error('end failed')), + ); + await expect(IAP.endConnection()).rejects.toMatchObject({ + message: 'end failed', + }); + }); + + it.each([ + { + name: 'Billing Choice info', + nativeMethod: 'getBillingChoiceInfoAndroid', + invoke: () => IAP.getBillingChoiceInfoAndroid(), + }, + { + name: 'Billing Choice dialog', + nativeMethod: 'showBillingProgramInformationDialogAndroid', + invoke: () => + IAP.showBillingProgramInformationDialogAndroid({ + externalTransactionToken: 'opaque', + }), + }, + { + name: 'in-app messages', + nativeMethod: 'showInAppMessagesAndroid', + invoke: () => IAP.showInAppMessagesAndroid(), + }, + { + name: 'external link', + nativeMethod: 'launchExternalLinkAndroid', + invoke: () => + IAP.launchExternalLinkAndroid({ + billingProgram: 'external-offer', + launchMode: 'launch-in-external-browser-or-app', + linkType: 'link-to-digital-content-offer', + linkUri: 'https://example.test', + }), + }, + ])('surfaces Android $name failures', async (testCase) => { + (Platform as any).OS = 'android'; + const nativeError = new Error('android failure'); + replaceNativeMethod( + testCase.nativeMethod, + jest.fn().mockRejectedValue(nativeError), + ); + + await expect(testCase.invoke()).rejects.toBe(nativeError); + }); + + it.each([ + { + name: 'external notice sheet', + nativeMethod: 'presentExternalPurchaseNoticeSheetIOS', + invoke: () => IAP.presentExternalPurchaseNoticeSheetIOS(), + }, + { + name: 'external purchase link', + nativeMethod: 'presentExternalPurchaseLinkIOS', + invoke: () => + IAP.presentExternalPurchaseLinkIOS('https://example.test/purchase'), + }, + ])('surfaces iOS $name failures', async (testCase) => { + const nativeError = new Error('external purchase failure'); + replaceNativeMethod( + testCase.nativeMethod, + jest.fn().mockRejectedValue(nativeError), + ); + + await expect(testCase.invoke()).rejects.toBe(nativeError); + }); + }); + + describe('listener failure isolation', () => { + const validPurchase = { + id: 'transaction', + transactionId: 'transaction', + productId: 'premium', + transactionDate: Date.now(), + store: 'apple', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }; + + it('isolates throwing purchase update and error listeners', () => { + IAP.purchaseUpdatedListener(() => { + throw new Error('consumer update failed'); + }); + mockIap.addPurchaseUpdatedListener.mock.calls[0][0](validPurchase); + + IAP.purchaseErrorListener(() => { + throw new Error('consumer error failed'); + }); + mockIap.addPurchaseErrorListener.mock.calls[0][0]({ + code: 'network-error', + message: 'offline', + }); + + expect(console.error).toHaveBeenCalled(); + }); + + it('drops invalid native promoted products and isolates callback errors', () => { + const listener = jest.fn(() => { + throw new Error('consumer promoted product failed'); + }); + IAP.promotedProductListenerIOS(listener); + const nativeHandler = + mockIap.addPromotedProductListenerIOS.mock.calls[0][0]; + + nativeHandler({id: null}); + nativeHandler({ + id: 'premium', + title: 'Premium', + description: 'Premium access', + displayName: 'Premium', + displayPrice: '$1.00', + currency: 'USD', + price: 1, + platform: 'ios', + type: 'in-app', + }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('drops malformed billing issue events and isolates callback errors', () => { + const listener = jest.fn(() => { + throw new Error('consumer billing issue failed'); + }); + IAP.subscriptionBillingIssueListener(listener); + const nativeHandler = + mockIap.addSubscriptionBillingIssueListener.mock.calls[0][0]; + + nativeHandler({productId: null}); + nativeHandler(validPurchase); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + api: 'purchaseUpdatedListener', + nativeMethod: 'addPurchaseUpdatedListener', + }, + {api: 'purchaseErrorListener', nativeMethod: 'addPurchaseErrorListener'}, + { + api: 'promotedProductListenerIOS', + nativeMethod: 'addPromotedProductListenerIOS', + }, + ])('keeps $api inert while Nitro initializes', ({api, nativeMethod}) => { + mockIap[nativeMethod] = jest.fn(() => { + throw new Error('Nitro runtime not installed'); + }); + + expect(() => IAP[api](jest.fn())).not.toThrow(); + expect(console.warn).toHaveBeenCalled(); + }); + }); + + describe('request payload coverage', () => { + it('forwards optional Apple purchase fields', async () => { + await IAP.requestPurchase({ + request: { + apple: { + sku: 'premium', + andDangerouslyFinishTransactionAutomatically: false, + appAccountToken: '00000000-0000-0000-0000-000000000000', + quantity: 2, + }, + }, + type: 'in-app', + }); + + expect(mockIap.requestPurchase).toHaveBeenCalledWith({ + apple: { + sku: 'premium', + andDangerouslyFinishTransactionAutomatically: false, + appAccountToken: '00000000-0000-0000-0000-000000000000', + quantity: 2, + }, + }); + }); + + it('forwards optional Android purchase fields', async () => { + (Platform as any).OS = 'android'; + await IAP.requestPurchase({ + request: { + google: { + skus: ['coins'], + obfuscatedAccountId: 'account-alias', + obfuscatedProfileId: 'profile-alias', + isOfferPersonalized: false, + offerToken: 'opaque-offer', + }, + }, + type: 'in-app', + }); + + expect(mockIap.requestPurchase).toHaveBeenCalledWith({ + google: { + skus: ['coins'], + obfuscatedAccountId: 'account-alias', + obfuscatedProfileId: 'profile-alias', + isOfferPersonalized: false, + offerToken: 'opaque-offer', + }, + }); + }); + + it('rejects a missing request and unsupported product type', async () => { + await expect( + IAP.requestPurchase({request: undefined, type: 'in-app'}), + ).rejects.toThrow(/Missing purchase request configuration/); + await expect( + IAP.fetchProducts({skus: ['premium'], type: 'legacy'}), + ).rejects.toThrow(/Unsupported product type/); + }); + }); }); diff --git a/libraries/react-native-iap/src/__tests__/kit-api.test.ts b/libraries/react-native-iap/src/__tests__/kit-api.test.ts index efe827971..9472f2237 100644 --- a/libraries/react-native-iap/src/__tests__/kit-api.test.ts +++ b/libraries/react-native-iap/src/__tests__/kit-api.test.ts @@ -92,3 +92,307 @@ describe('kitApi client payload caching', () => { expect(cache.removeItem).toHaveBeenCalledTimes(1); }); }); + +describe('kitApi requests', () => { + const success = (value: unknown) => Response.json(value); + + it('normalizes the base URL and encodes read paths', async () => { + const fetchImpl = jest + .fn() + .mockResolvedValueOnce(success({active: false, subscription: null})) + .mockResolvedValueOnce( + success({userId: 'user / one', productIds: [], subscriptions: []}), + ); + const api = kitApi({ + apiKey: 'key / one', + baseUrl: 'https://kit.test/', + fetchImpl, + }); + + await api.status('user / one'); + await api.entitlements('user / one'); + + expect(api.apiKey).toBe('key / one'); + expect(api.baseUrl).toBe('https://kit.test'); + expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([ + 'https://kit.test/v1/subscriptions/status/key%20%2F%20one?userId=user%20%2F%20one', + 'https://kit.test/v1/subscriptions/entitlements/key%20%2F%20one?userId=user%20%2F%20one', + ]); + }); + + it('serializes catalog options and rejects an unscoped payload read', async () => { + const fetchImpl = jest + .fn() + .mockImplementation(() => + Promise.resolve(success({products: [], hasMore: false})), + ); + const api = kitApi({apiKey: 'key', fetchImpl}); + + await api.products(); + await api.products({ + platform: 'Android', + includeClientPayload: true, + limit: 12, + cursor: 'next / page', + }); + + expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([ + 'https://kit.openiap.dev/v1/products/key', + 'https://kit.openiap.dev/v1/products/key?platform=Android&includeClientPayload=true&limit=12&cursor=next+%2F+page', + ]); + expect(() => api.products({includeClientPayload: true})).toThrow( + 'requires platform', + ); + }); + + it('posts a binding with the internal JSON headers', async () => { + const fetchImpl = jest + .fn() + .mockResolvedValue(success({ok: true, bound: true})); + const api = kitApi({apiKey: 'key', fetchImpl}); + + await expect(api.bindUser('token', 'user')).resolves.toEqual({ + ok: true, + bound: true, + }); + + const init = fetchImpl.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe('POST'); + expect(init.body).toBe('{"purchaseToken":"token","userId":"user"}'); + const headers = new Headers(init.headers); + expect(headers.get('accept')).toBe('application/json'); + expect(headers.get('content-type')).toBe('application/json'); + }); + + it('uses global fetch when no override is supplied', async () => { + const originalFetch = globalThis.fetch; + const globalFetch = jest + .fn() + .mockResolvedValue(success({active: false, subscription: null})); + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: globalFetch, + }); + try { + await kitApi({apiKey: 'key'}).status('user'); + expect(globalFetch).toHaveBeenCalledTimes(1); + } finally { + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: originalFetch, + }); + } + }); + + it('requires an injected fetch when the runtime has no global fetch', () => { + const originalFetch = globalThis.fetch; + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: undefined, + }); + try { + expect(() => kitApi({apiKey: 'key'})).toThrow( + 'requires a fetch implementation', + ); + } finally { + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + writable: true, + value: originalFetch, + }); + } + }); + + it('merges an ETag without a global Headers constructor', async () => { + const originalHeaders = globalThis.Headers; + const cache = memoryCache(); + const fetchImpl = jest + .fn() + .mockResolvedValueOnce( + Response.json(payload, {headers: {etag: '"fallback-v3"'}}), + ) + .mockResolvedValueOnce(new Response(null, {status: 304})); + Object.defineProperty(globalThis, 'Headers', { + configurable: true, + writable: true, + value: undefined, + }); + try { + const api = kitApi({ + apiKey: 'key', + fetchImpl, + clientPayloadCache: cache, + }); + await api.clientPayload('premium', 'IOS'); + await api.clientPayload('premium', 'IOS', {refresh: true}); + const merged = fetchImpl.mock.calls[1]?.[1]?.headers as Record< + string, + string + >; + expect(merged).toEqual({ + 'If-None-Match': '"fallback-v3"', + accept: 'application/json', + }); + } finally { + Object.defineProperty(globalThis, 'Headers', { + configurable: true, + writable: true, + value: originalHeaders, + }); + } + }); + + it('adds default plain headers when no internal headers are provided', async () => { + const originalHeaders = globalThis.Headers; + const fetchImpl = jest + .fn() + .mockResolvedValue(success({ok: true, bound: true})); + Object.defineProperty(globalThis, 'Headers', { + configurable: true, + writable: true, + value: undefined, + }); + try { + await kitApi({apiKey: 'key', fetchImpl}).bindUser('token', 'user'); + expect(fetchImpl.mock.calls[0]?.[1]?.headers).toEqual({ + accept: 'application/json', + 'content-type': 'application/json', + }); + } finally { + Object.defineProperty(globalThis, 'Headers', { + configurable: true, + writable: true, + value: originalHeaders, + }); + } + }); + + it('returns null for an empty successful response', async () => { + const fetchImpl = jest.fn().mockResolvedValue(new Response(null)); + await expect( + kitApi({apiKey: 'key', fetchImpl}).status('user'), + ).resolves.toBeNull(); + }); + + it.each([ + { + response: Response.json({error: 'denied'}, {status: 403}), + expectedBody: {error: 'denied'}, + }, + { + response: new Response('upstream unavailable', {status: 502}), + expectedBody: 'upstream unavailable', + }, + ])( + 'throws a structured API error for a failed response', + async ({response, expectedBody}) => { + const api = kitApi({ + apiKey: 'key', + fetchImpl: jest.fn().mockResolvedValue(response), + }); + + await expect(api.status('user')).rejects.toMatchObject({ + name: 'KitApiError', + status: response.status, + body: expectedBody, + }); + }, + ); + + it('throws a structured API error for a non-JSON success body', async () => { + const api = kitApi({ + apiKey: 'key', + fetchImpl: jest.fn().mockResolvedValue(new Response('oops')), + }); + + await expect(api.status('user')).rejects.toMatchObject({ + name: 'KitApiError', + status: 200, + body: 'oops', + message: expect.stringContaining('non-JSON 200 body'), + }); + }); +}); + +describe('kitApi cache resilience', () => { + it.each([ + '{not-json', + JSON.stringify({clientPayload: {format: 'binary'}}), + JSON.stringify({ + clientPayload: { + format: 'text', + body: 'rules', + version: 0, + updatedAt: 1, + }, + }), + JSON.stringify({...payload, etag: 42}), + ])('ignores an invalid cache entry', async (stored) => { + const cache = { + getItem: jest.fn().mockResolvedValue(stored), + setItem: jest.fn(), + removeItem: jest.fn(), + }; + const fetchImpl = jest.fn().mockResolvedValue(Response.json(payload)); + + await expect( + kitApi({ + apiKey: 'key', + fetchImpl, + clientPayloadCache: cache, + }).clientPayload('premium', 'IOS'), + ).resolves.toEqual(payload); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('keeps successful reads when cache operations fail', async () => { + const cache = { + getItem: jest.fn().mockRejectedValue(new Error('read failed')), + setItem: jest.fn().mockRejectedValue(new Error('write failed')), + removeItem: jest.fn().mockRejectedValue(new Error('remove failed')), + }; + const fetchImpl = jest + .fn() + .mockResolvedValueOnce(Response.json(payload)) + .mockResolvedValueOnce(Response.json({error: 'missing'}, {status: 404})); + const api = kitApi({ + apiKey: 'key', + fetchImpl, + clientPayloadCache: cache, + }); + + await expect(api.clientPayload('premium', 'IOS')).resolves.toEqual(payload); + await expect(api.clientPayload('premium', 'IOS')).rejects.toMatchObject({ + status: 404, + }); + }); + + it('returns a valid cached payload without an ETag and skips the network', async () => { + const cache = memoryCache(); + cache.getItem.mockReturnValue(JSON.stringify(payload)); + const fetchImpl = jest.fn(); + + await expect( + kitApi({ + apiKey: 'key', + fetchImpl, + clientPayloadCache: cache, + }).clientPayload('premium', 'Android'), + ).resolves.toEqual(payload); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('treats a 304 without a cached payload as an API error', async () => { + const api = kitApi({ + apiKey: 'key', + fetchImpl: jest.fn().mockResolvedValue(new Response(null, {status: 304})), + }); + + await expect(api.clientPayload('premium', 'IOS')).rejects.toMatchObject({ + status: 304, + }); + }); +}); diff --git a/libraries/react-native-iap/src/__tests__/utils/coverage-regression.test.ts b/libraries/react-native-iap/src/__tests__/utils/coverage-regression.test.ts new file mode 100644 index 000000000..ceb11f132 --- /dev/null +++ b/libraries/react-native-iap/src/__tests__/utils/coverage-regression.test.ts @@ -0,0 +1,59 @@ +import {parseAppTransactionPayload} from '../../utils'; +import {RnIapConsole} from '../../utils/debug'; +import {getSuccessFromPurchaseVariant} from '../../utils/purchase'; + +describe('utility fallback coverage', () => { + afterEach(() => { + delete process.env.RN_IAP_DEV_MODE; + delete (global as any).RN_IAP_DEV_MODE; + jest.restoreAllMocks(); + }); + + it('rejects non-object, incomplete, and malformed app transactions', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(parseAppTransactionPayload('null')).toBeNull(); + expect(parseAppTransactionPayload('{"appId":"invalid"}')).toBeNull(); + expect(parseAppTransactionPayload('{')).toBeNull(); + expect(warn).toHaveBeenCalled(); + }); + + it('returns native success variants and throws normalized failures', () => { + expect(getSuccessFromPurchaseVariant(true, 'finishTransaction')).toBe(true); + expect( + getSuccessFromPurchaseVariant( + {responseCode: 0, code: 'ok'} as never, + 'finishTransaction', + ), + ).toBe(true); + + expect(() => + getSuccessFromPurchaseVariant( + { + responseCode: 5, + code: 'E_NETWORK_ERROR', + message: '', + debugMessage: 'offline', + purchaseToken: null, + } as never, + 'consumePurchaseAndroid', + ), + ).toThrow( + /"code":"network-error".*"message":"Failed to consumePurchaseAndroid"/, + ); + }); + + it('emits opt-in development logs through both supported switches', () => { + const log = jest.spyOn(console, 'log').mockImplementation(() => {}); + const info = jest.spyOn(console, 'info').mockImplementation(() => {}); + + process.env.RN_IAP_DEV_MODE = 'true'; + RnIapConsole.log('environment'); + delete process.env.RN_IAP_DEV_MODE; + (global as any).RN_IAP_DEV_MODE = true; + RnIapConsole.info('global'); + + expect(log).toHaveBeenCalledWith('[RN-IAP]', 'environment'); + expect(info).toHaveBeenCalledWith('[RN-IAP]', 'global'); + }); +}); diff --git a/libraries/react-native-iap/src/__tests__/utils/errorMapping.test.ts b/libraries/react-native-iap/src/__tests__/utils/errorMapping.test.ts index 437711ead..ef7e82375 100644 --- a/libraries/react-native-iap/src/__tests__/utils/errorMapping.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/errorMapping.test.ts @@ -5,6 +5,9 @@ import { isDuplicatePurchaseError, DUPLICATE_PURCHASE_CODE, ErrorCodeUtils, + createPurchaseErrorFromPlatform, + isNetworkError, + normalizeErrorCodeFromNative, } from '../../utils/errorMapping'; import {ErrorCode} from '../../types'; @@ -131,4 +134,96 @@ describe('utils/errorMapping', () => { getUserFriendlyErrorMessage({code: input, message: 'ignored'}), ).not.toBe('ignored'); }); + + test.each([ + [ErrorCode.ServiceDisconnected, 'Billing service disconnected'], + [ErrorCode.BillingUnavailable, 'Billing is unavailable'], + [ErrorCode.ItemUnavailable, 'not available for purchase'], + [ErrorCode.ItemNotOwned, "don't own"], + [ErrorCode.AlreadyOwned, 'already own'], + [ErrorCode.SkuNotFound, 'could not be found'], + [ErrorCode.SkuOfferMismatch, 'does not match'], + [ErrorCode.DeferredPayment, 'pending approval'], + [ErrorCode.NotPrepared, 'not ready'], + [ErrorCode.ServiceError, 'Store service error'], + [ErrorCode.FeatureNotSupported, 'not supported'], + [ErrorCode.TransactionValidationFailed, 'could not be verified'], + [ErrorCode.PurchaseVerificationFailed, 'verification failed'], + [ErrorCode.PurchaseVerificationFinished, 'verification completed'], + [ErrorCode.PurchaseVerificationFinishFailed, 'complete purchase'], + [ErrorCode.EmptySkuList, 'No product IDs'], + [ErrorCode.InitConnection, 'initialize billing'], + [ErrorCode.QueryProduct, 'query products'], + ])('provides a useful message for %s', (code, expectedText) => { + expect(getUserFriendlyErrorMessage({code, message: 'ignored'})).toContain( + expectedText, + ); + }); + + test('falls back safely when an unknown error has no message', () => { + expect(getUserFriendlyErrorMessage('unknown')).toBe( + 'An unexpected error occurred', + ); + expect( + getUserFriendlyErrorMessage({code: 'unknown', message: undefined}), + ).toBe('An unexpected error occurred'); + }); + + test.each([ + [ErrorCode.NetworkError, true], + [ErrorCode.RemoteError, true], + [ErrorCode.ServiceError, true], + [ErrorCode.ServiceDisconnected, true], + [ErrorCode.BillingUnavailable, true], + [ErrorCode.UserCancelled, false], + ])('classifies network error %s', (code, expected) => { + expect(isNetworkError({code})).toBe(expected); + }); + + test('treats missing error codes and objects as non-network errors', () => { + expect(isNetworkError({code: undefined})).toBe(false); + expect(isNetworkError(null)).toBe(false); + }); + + test.each([ + ['USER_CANCEL', ErrorCode.UserCancelled], + ['E_NETWORK_ERROR', ErrorCode.NetworkError], + ['service_disconnected', ErrorCode.ServiceDisconnected], + ['already-owned', ErrorCode.AlreadyOwned], + ['NETWORK-ERROR', ErrorCode.NetworkError], + ['not-a-real-code', ErrorCode.Unknown], + [42, ErrorCode.Unknown], + ])('normalizes native error code %s', (input, expected) => { + expect(normalizeErrorCodeFromNative(input)).toBe(expected); + }); + + test('converts platform error data and preserves diagnostics', () => { + const error = createPurchaseErrorFromPlatform( + { + code: 'E_NETWORK_ERROR', + message: 'offline', + responseCode: 7, + debugMessage: 'retry', + productId: 'premium', + }, + 'android', + ); + + expect(error).toMatchObject({ + code: ErrorCode.NetworkError, + message: 'offline', + responseCode: 7, + debugMessage: 'retry', + productId: 'premium', + }); + expect(ErrorCodeUtils.getNativeErrorCode(ErrorCode.NetworkError)).toBe( + ErrorCode.NetworkError, + ); + expect(ErrorCodeUtils.toPlatformCode(ErrorCode.NetworkError, 'ios')).toBe( + ErrorCode.NetworkError, + ); + expect( + ErrorCodeUtils.isValidForPlatform(ErrorCode.NetworkError, 'ios'), + ).toBe(true); + }); }); diff --git a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts index 8badcd3f4..0e8df700e 100644 --- a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts @@ -42,6 +42,15 @@ const purchase = (overrides: Partial = {}): NitroPurchase => ({ }); describe('type-bridge utilities', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('convertNitroProductToProduct', () => { it('converts iOS product metadata and normalizes enums', () => { const result = convertNitroProductToProduct( @@ -177,6 +186,66 @@ describe('type-bridge utilities', () => { expect(iosResult).not.toHaveProperty('discountOffers'); expect(androidResult.subscriptionOffers).toEqual([]); }); + + it('normalizes native aliases and malformed optional metadata', () => { + const fallbackPlatform = convertNitroProductToProduct( + product({platform: 'unknown' as never}), + ); + const result = convertNitroProductToProduct( + product({ + typeIOS: 'mystery' as never, + introductoryPricePaymentModeIOS: 'payAsYouGo' as never, + introductoryPriceSubscriptionPeriodIOS: 'DAY' as never, + subscriptionPeriodUnitIOS: 'WEEK' as never, + pricingTermsIOS: '{', + bundledSubscriptionsIOS: '{', + }), + ) as any; + + expect(fallbackPlatform.platform).toBe('android'); + expect(result.typeIOS).toBe('non-consumable'); + expect(result.introductoryPricePaymentModeIOS).toBe('pay-as-you-go'); + expect(result.introductoryPriceSubscriptionPeriodIOS).toBe('day'); + expect(result.subscriptionPeriodUnitIOS).toBe('week'); + expect(result.pricingTermsIOS).toBeNull(); + expect(result.bundledSubscriptionsIOS).toBeNull(); + expect(console.warn).toHaveBeenCalled(); + }); + + it.each([ + ['consumable', 'consumable'], + ['non_consumable', 'non-consumable'], + ['nonRenewingSubscription', 'non-renewing-subscription'], + ['subscriptionSuite', 'subscription-suite'], + ] as const)('normalizes iOS type %s', (nativeType, expected) => { + expect( + ( + convertNitroProductToProduct( + product({typeIOS: nativeType as never}), + ) as any + ).typeIOS, + ).toBe(expected); + }); + + it('handles non-array iOS metadata and invalid Android discounts', () => { + const ios = convertNitroProductToProduct( + product({ + pricingTermsIOS: '{}', + bundledSubscriptionsIOS: '{}', + introductoryPricePaymentModeIOS: 'payUpFront' as never, + introductoryPriceSubscriptionPeriodIOS: 'invalid' as never, + }), + ) as any; + const android = convertNitroProductToProduct( + product({platform: 'android', discountOffers: '{'}), + ) as any; + + expect(ios.pricingTermsIOS).toBeNull(); + expect(ios.bundledSubscriptionsIOS).toBeNull(); + expect(ios.introductoryPricePaymentModeIOS).toBe('pay-up-front'); + expect(ios.introductoryPriceSubscriptionPeriodIOS).toBe('empty'); + expect(android.discountOffers).toBeNull(); + }); }); it('preserves subscription type when casting a product', () => { @@ -187,6 +256,14 @@ describe('type-bridge utilities', () => { expect(subscription.type).toBe('subs'); }); + it('warns when casting an in-app product as a subscription', () => { + const result = convertProductToProductSubscription( + convertNitroProductToProduct(product()), + ); + expect(result.id).toBe('com.example.product'); + expect(console.warn).toHaveBeenCalled(); + }); + describe('convertNitroPurchaseToPurchase', () => { it('uses store as the purchase discriminator for Apple', () => { const result = convertNitroPurchaseToPurchase( @@ -332,6 +409,48 @@ describe('type-bridge utilities', () => { purchaseToken: 'pending-token', }); }); + + it('normalizes numeric states and nullable native primitives', () => { + const purchased = convertNitroPurchaseToPurchase( + purchase({ + store: 'google', + purchaseState: 1 as never, + transactionId: null, + isAutoRenewing: false, + autoRenewingAndroid: 1 as never, + isAcknowledgedAndroid: 'true' as never, + }), + ) as PurchaseAndroid; + const pending = convertNitroPurchaseToPurchase( + purchase({store: 'google', purchaseState: 2 as never}), + ); + const unknown = convertNitroPurchaseToPurchase( + purchase({store: 'other' as never, purchaseState: 99 as never}), + ); + + expect(purchased.purchaseState).toBe('purchased'); + expect(purchased.autoRenewingAndroid).toBe(true); + expect(purchased.isAcknowledgedAndroid).toBe(true); + expect(pending.purchaseState).toBe('pending'); + expect(unknown.purchaseState).toBe('unknown'); + expect(unknown.store).toBe('unknown'); + }); + + it('drops malformed optional Apple offer JSON', () => { + const result = convertNitroPurchaseToPurchase( + purchase({ + offerIOS: '{', + quantityIOS: '2' as never, + expirationDateIOS: 'not-a-number' as never, + isUpgradedIOS: 'true' as never, + }), + ) as PurchaseIOS; + + expect(result.offerIOS).toBeNull(); + expect(result.quantityIOS).toBe(2); + expect(result.expirationDateIOS).toBeNull(); + expect(result.isUpgradedIOS).toBe(true); + }); }); it('converts iOS subscription status renewal metadata', () => { @@ -360,48 +479,47 @@ describe('type-bridge utilities', () => { ); }); + it('converts an iOS subscription status without renewal metadata', () => { + expect( + convertNitroSubscriptionStatusToSubscriptionStatusIOS({ + state: null, + platform: 'ios', + renewalInfo: null, + } as never), + ).toEqual({state: '', renewalInfo: undefined}); + }); + describe('validation helpers', () => { it('validates the required NitroProduct fields', () => { - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => undefined); - - try { - expect(validateNitroProduct(product())).toBe(true); - expect( - validateNitroProduct({title: 'missing fields'} as NitroProduct), - ).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith( - '[RN-IAP]', - 'NitroProduct missing required field: id', - {title: 'missing fields'}, - ); - } finally { - consoleErrorSpy.mockRestore(); - } + expect(validateNitroProduct(product())).toBe(true); + expect( + validateNitroProduct({title: 'missing fields'} as NitroProduct), + ).toBe(false); + expect(console.error).toHaveBeenCalledWith( + '[RN-IAP]', + 'NitroProduct missing required field: id', + {title: 'missing fields'}, + ); + }); + + it('rejects non-object products and purchases', () => { + expect(validateNitroProduct(null as never)).toBe(false); + expect(validateNitroPurchase(null as never)).toBe(false); }); it('requires store when validating NitroPurchase', () => { - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => undefined); - - try { - expect(validateNitroPurchase(purchase())).toBe(true); - expect( - validateNitroPurchase({ - id: 'id', - productId: 'sku', - transactionDate: 1, - } as NitroPurchase), - ).toBe(false); - expect(consoleErrorSpy).toHaveBeenCalledWith( - '[RN-IAP]', - 'NitroPurchase has invalid required field: store', - ); - } finally { - consoleErrorSpy.mockRestore(); - } + expect(validateNitroPurchase(purchase())).toBe(true); + expect( + validateNitroPurchase({ + id: 'id', + productId: 'sku', + transactionDate: 1, + } as NitroPurchase), + ).toBe(false); + expect(console.error).toHaveBeenCalledWith( + '[RN-IAP]', + 'NitroPurchase has invalid required field: store', + ); }); it('accepts only decodable object JSON for the Nitro iOS offer', () => { @@ -421,6 +539,22 @@ describe('type-bridge utilities', () => { ); expect(validateNitroPurchase(purchase({offerIOS: '[]'}))).toBe(false); }); + + it.each([ + ['id', {id: ''}], + ['productId', {productId: ''}], + ['transactionDate', {transactionDate: Number.NaN}], + ['purchaseState', {purchaseState: ''}], + ['quantity', {quantity: 1.5}], + ['isAutoRenewing', {isAutoRenewing: null}], + ['transactionId', {transactionId: null}], + ['ids', {ids: [1]}], + ['pendingPurchaseUpdateAndroid', {pendingPurchaseUpdateAndroid: []}], + ['renewalInfoIOS', {renewalInfoIOS: 'invalid'}], + ['offerIOS', {offerIOS: 1}], + ] as const)('rejects invalid %s metadata', (_field, overrides) => { + expect(validateNitroPurchase(purchase(overrides as never))).toBe(false); + }); }); it('keeps type synchronization healthy', () => { diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 8d992c99e..c46a93b67 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -648,9 +648,29 @@ export const userChoiceBillingListenerAndroid = ( } } + let removed = false; return { remove: () => { + if (removed) return; + removed = true; userChoiceBillingJsListeners.delete(listener); + if ( + userChoiceBillingJsListeners.size > 0 || + !userChoiceBillingNativeAttached + ) { + return; + } + try { + IAP.instance.removeUserChoiceBillingListenerAndroid( + userChoiceBillingNativeHandler, + ); + userChoiceBillingNativeAttached = false; + } catch (e) { + RnIapConsole.warn( + '[userChoiceBillingListenerAndroid] native remove failed:', + e, + ); + } }, }; }; diff --git a/libraries/react-native-iap/src/kit-api.ts b/libraries/react-native-iap/src/kit-api.ts index fe47dd9fd..5d60a87ad 100644 --- a/libraries/react-native-iap/src/kit-api.ts +++ b/libraries/react-native-iap/src/kit-api.ts @@ -121,19 +121,19 @@ type CachedClientPayload = KitClientPayloadResponse & { etag?: string; }; +type InternalRequestInit = Omit & { + headers?: Record; +}; + const DEFAULT_BASE_URL = "https://kit.openiap.dev"; -// Merge caller-supplied headers with kit defaults (`accept`, -// optionally `content-type`). When the runtime exposes a global -// `Headers` constructor we use it directly so callers passing a -// `Headers` instance (a `HeadersInit`) keep that exact instance's -// values. When `Headers` is missing — older React Native builds where -// the operator wires up `fetchImpl` without a `Headers` polyfill — -// we fall back to a case-insensitive merge into a plain record so -// the request still goes through. Either way, caller-set values take -// precedence over kit defaults. +// Merge the request's internal headers with kit defaults (`accept`, +// optionally `content-type`). When `Headers` is missing — older React +// Native builds where the operator wires up `fetchImpl` without a +// `Headers` polyfill — the internal request sites use plain records, +// so a small case-insensitive merge is sufficient. function mergeHeaders( - callerHeaders: HeadersInit | undefined, + callerHeaders: Record | undefined, hasBody: boolean, ): HeadersInit { if (typeof Headers === "function") { @@ -144,41 +144,16 @@ function mergeHeaders( } return merged; } - // Plain-object fallback path. Build a case-insensitive name map - // from whatever the caller passed (Headers-shaped, array-of-pairs, - // or plain record) and re-emit as a record `fetchImpl` accepts. + // Plain-object fallback path. Build a case-insensitive name map and + // re-emit it as a record `fetchImpl` accepts. const lower = new Map(); const setIfAbsent = (name: string, value: string) => { const key = name.toLowerCase(); if (!lower.has(key)) lower.set(key, { name, value }); }; - const setForce = (name: string, value: string) => { - const key = name.toLowerCase(); - lower.set(key, { name, value }); - }; if (callerHeaders) { - if (Array.isArray(callerHeaders)) { - for (const [name, value] of callerHeaders) setForce(name, value); - } else if ( - typeof (callerHeaders as { forEach?: unknown }).forEach === "function" - ) { - // `Headers`-like (without being our `typeof Headers === "function"` - // global). RN polyfills sometimes attach `Headers` only to - // request/response instances rather than the global scope. - // Standard signature is `forEach((value, key, parent))`; we - // bind the first two positionally so a polyfill that omits - // the third argument still works. `key` is the header name. - ( - callerHeaders as { - forEach: (cb: (value: string, key: string) => void) => void; - } - ).forEach((value, key) => setForce(key, value)); - } else { - for (const [name, value] of Object.entries( - callerHeaders as Record, - )) { - setForce(name, value); - } + for (const [name, value] of Object.entries(callerHeaders)) { + lower.set(name.toLowerCase(), { name, value }); } } setIfAbsent("accept", "application/json"); @@ -212,16 +187,18 @@ export function kitApi(options: KitApiOptions) { ); })(); - async function request(path: string, init?: RequestInit): Promise { + async function request( + path: string, + init?: InternalRequestInit, + ): Promise { // Normalize headers without depending on a global `Headers` // constructor: older React Native runtimes ship `fetch` (or a // polyfill via `fetchImpl`) without exposing `Headers` globally. // The prior implementation crashed before the first request on - // those runtimes. We use `new Headers()` when available (preserves - // caller-supplied `Headers` instances exactly), and otherwise fall - // back to a small case-insensitive merge into a plain record. - // Either way, kit defaults only apply when the caller hasn't set - // the same name. + // those runtimes. We use `new Headers()` when available and + // otherwise fall back to a small case-insensitive merge into a + // plain record. Either way, kit defaults only apply when the + // internal request hasn't set the same name. const headers = mergeHeaders(init?.headers, init?.body != null); // Prepend a leading slash if `path` is missing one. Today's // call sites all hard-code the leading "/", but normalizing here @@ -278,7 +255,7 @@ export function kitApi(options: KitApiOptions) { return parsed as T; } - async function call(path: string, init?: RequestInit): Promise { + async function call(path: string, init?: InternalRequestInit): Promise { return parseResponse(await request(path, init), path); } diff --git a/packages/gql/src/kit-api.ts b/packages/gql/src/kit-api.ts index fe47dd9fd..5d60a87ad 100644 --- a/packages/gql/src/kit-api.ts +++ b/packages/gql/src/kit-api.ts @@ -121,19 +121,19 @@ type CachedClientPayload = KitClientPayloadResponse & { etag?: string; }; +type InternalRequestInit = Omit & { + headers?: Record; +}; + const DEFAULT_BASE_URL = "https://kit.openiap.dev"; -// Merge caller-supplied headers with kit defaults (`accept`, -// optionally `content-type`). When the runtime exposes a global -// `Headers` constructor we use it directly so callers passing a -// `Headers` instance (a `HeadersInit`) keep that exact instance's -// values. When `Headers` is missing — older React Native builds where -// the operator wires up `fetchImpl` without a `Headers` polyfill — -// we fall back to a case-insensitive merge into a plain record so -// the request still goes through. Either way, caller-set values take -// precedence over kit defaults. +// Merge the request's internal headers with kit defaults (`accept`, +// optionally `content-type`). When `Headers` is missing — older React +// Native builds where the operator wires up `fetchImpl` without a +// `Headers` polyfill — the internal request sites use plain records, +// so a small case-insensitive merge is sufficient. function mergeHeaders( - callerHeaders: HeadersInit | undefined, + callerHeaders: Record | undefined, hasBody: boolean, ): HeadersInit { if (typeof Headers === "function") { @@ -144,41 +144,16 @@ function mergeHeaders( } return merged; } - // Plain-object fallback path. Build a case-insensitive name map - // from whatever the caller passed (Headers-shaped, array-of-pairs, - // or plain record) and re-emit as a record `fetchImpl` accepts. + // Plain-object fallback path. Build a case-insensitive name map and + // re-emit it as a record `fetchImpl` accepts. const lower = new Map(); const setIfAbsent = (name: string, value: string) => { const key = name.toLowerCase(); if (!lower.has(key)) lower.set(key, { name, value }); }; - const setForce = (name: string, value: string) => { - const key = name.toLowerCase(); - lower.set(key, { name, value }); - }; if (callerHeaders) { - if (Array.isArray(callerHeaders)) { - for (const [name, value] of callerHeaders) setForce(name, value); - } else if ( - typeof (callerHeaders as { forEach?: unknown }).forEach === "function" - ) { - // `Headers`-like (without being our `typeof Headers === "function"` - // global). RN polyfills sometimes attach `Headers` only to - // request/response instances rather than the global scope. - // Standard signature is `forEach((value, key, parent))`; we - // bind the first two positionally so a polyfill that omits - // the third argument still works. `key` is the header name. - ( - callerHeaders as { - forEach: (cb: (value: string, key: string) => void) => void; - } - ).forEach((value, key) => setForce(key, value)); - } else { - for (const [name, value] of Object.entries( - callerHeaders as Record, - )) { - setForce(name, value); - } + for (const [name, value] of Object.entries(callerHeaders)) { + lower.set(name.toLowerCase(), { name, value }); } } setIfAbsent("accept", "application/json"); @@ -212,16 +187,18 @@ export function kitApi(options: KitApiOptions) { ); })(); - async function request(path: string, init?: RequestInit): Promise { + async function request( + path: string, + init?: InternalRequestInit, + ): Promise { // Normalize headers without depending on a global `Headers` // constructor: older React Native runtimes ship `fetch` (or a // polyfill via `fetchImpl`) without exposing `Headers` globally. // The prior implementation crashed before the first request on - // those runtimes. We use `new Headers()` when available (preserves - // caller-supplied `Headers` instances exactly), and otherwise fall - // back to a small case-insensitive merge into a plain record. - // Either way, kit defaults only apply when the caller hasn't set - // the same name. + // those runtimes. We use `new Headers()` when available and + // otherwise fall back to a small case-insensitive merge into a + // plain record. Either way, kit defaults only apply when the + // internal request hasn't set the same name. const headers = mergeHeaders(init?.headers, init?.body != null); // Prepend a leading slash if `path` is missing one. Today's // call sites all hard-code the leading "/", but normalizing here @@ -278,7 +255,7 @@ export function kitApi(options: KitApiOptions) { return parsed as T; } - async function call(path: string, init?: RequestInit): Promise { + async function call(path: string, init?: InternalRequestInit): Promise { return parseResponse(await request(path, init), path); } diff --git a/packages/kit/README.md b/packages/kit/README.md index bd2961f7b..6ef4f6709 100644 --- a/packages/kit/README.md +++ b/packages/kit/README.md @@ -4,6 +4,11 @@ IAPKit

+

+ CI + IAPKit server coverage +

+ Hosted receipt-validation SaaS and dashboard managed by [OpenIAP](https://openiap.dev). Validates App Store and Google Play receipts on your backend so one-time in-app purchases cannot be faked, replayed, or tampered with. The **official hosted instance** is deployed only at diff --git a/packages/kit/package.json b/packages/kit/package.json index 17121e631..b31236997 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -19,6 +19,7 @@ "lint:convex": "convex typecheck", "lint:eslint": "env NODE_OPTIONS=--max-old-space-size=4096 eslint ./src ./server ./convex --ext ts,tsx --report-unused-disable-directives --no-warn-ignored", "test": "vitest run", + "test:coverage": "vitest run --coverage --coverage.include='server/**/*.ts' --coverage.exclude='server/**/*.test.ts' --coverage.reporter=text --coverage.reporter=lcov", "test:watch": "vitest", "clean": "rm -rf node_modules bun.lock bun.lockb dist openiap-kit-server", "clean:cache": "rm -rf node_modules/.vite", @@ -80,6 +81,7 @@ "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^5.2.0", + "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "^4", "autoprefixer": "~10", "dotenv": "^16.4.7", diff --git a/packages/kit/server/api/v1/webhooks.test.ts b/packages/kit/server/api/v1/webhooks.test.ts index 49bdfc427..e6882bea4 100644 --- a/packages/kit/server/api/v1/webhooks.test.ts +++ b/packages/kit/server/api/v1/webhooks.test.ts @@ -1,13 +1,51 @@ -import { beforeAll, describe, expect, it } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; import { Hono } from "hono"; +const originalConvexUrl = process.env.VITE_KIT_CONVEX_URL; +const originalGooglePubsubPushAudience = + process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE; +const originalAllowUnauthenticatedPubsub = + process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB; +let client: typeof import("../../convex").client; let helpers: typeof import("./webhooks"); beforeAll(async () => { process.env.VITE_KIT_CONVEX_URL = "https://placeholder.convex.cloud"; + ({ client } = await import("../../convex")); helpers = await import("./webhooks"); }); +afterAll(() => { + if (originalConvexUrl === undefined) { + delete process.env.VITE_KIT_CONVEX_URL; + } else { + process.env.VITE_KIT_CONVEX_URL = originalConvexUrl; + } +}); + +afterEach(() => { + if (originalGooglePubsubPushAudience === undefined) { + delete process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE; + } else { + process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE = originalGooglePubsubPushAudience; + } + if (originalAllowUnauthenticatedPubsub === undefined) { + delete process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB; + } else { + process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB = + originalAllowUnauthenticatedPubsub; + } + vi.restoreAllMocks(); +}); + describe("pubSubOidcAudiences", () => { it("accepts concrete push endpoint audience when configured for the origin", () => { const audiences = helpers.pubSubOidcAudiences( @@ -33,6 +71,12 @@ describe("pubSubOidcAudiences", () => { }); describe("webhooksRoutes", () => { + function createApp() { + const app = new Hono(); + app.route("/webhooks", helpers.webhooksRoutes); + return app; + } + it("does not expose outbound webhook stream routes", async () => { const app = new Hono(); app.route("/webhooks", helpers.webhooksRoutes); @@ -160,6 +204,176 @@ describe("webhooksRoutes", () => { }); } }); + + it("rejects malformed JSON before store detection", async () => { + const response = await createApp().request( + "/webhooks/openiap-kit_pk_mobile", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{", + }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [{ code: "INVALID_INPUT", message: "Body is not JSON" }], + }); + }); + + it("accepts Apple lifecycle events and preserves the public response", async () => { + vi.spyOn(client, "action").mockResolvedValueOnce({ + type: "DID_RENEW", + deduped: false, + }); + + const response = await createApp().request( + "/webhooks/openiap-kit_pk_mobile", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ signedPayload: "opaque-signed-payload" }), + }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + eventType: "DID_RENEW", + deduped: false, + }); + }); + + it("rejects empty Apple signed payloads", async () => { + const response = await createApp().request( + "/webhooks/openiap-kit_pk_mobile", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ signedPayload: "" }), + }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { code: "INVALID_INPUT", message: "Missing or invalid signedPayload" }, + ], + }); + }); + + it("returns a sanitized 500 for unexpected Apple ingest failures", async () => { + vi.spyOn(client, "action").mockRejectedValueOnce(new Error("internal")); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + const response = await createApp().request( + "/webhooks/openiap-kit_pk_mobile", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ signedPayload: "opaque-signed-payload" }), + }, + ); + + expect(response.status).toBe(500); + expect(error).toHaveBeenCalledWith( + "[webhooks/apple] unexpected error", + "Error", + ); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "WEBHOOK_INTERNAL_ERROR", + message: "Webhook processing failed", + }, + ], + }); + }); + + it("fails closed when Google Pub/Sub audience is not configured", async () => { + delete process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE; + delete process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB; + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const data = Buffer.from( + JSON.stringify({ packageName: "dev.hyo.app" }), + ).toString("base64"); + + const response = await createApp().request( + "/webhooks/openiap-kit_pk_mobile", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: { data, messageId: "message" } }), + }, + ); + + expect(response.status).toBe(503); + expect(error).toHaveBeenCalled(); + }); + + it("rejects malformed Google Pub/Sub message data in local dev mode", async () => { + delete process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE; + process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB = "1"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const response = await createApp().request( + "/webhooks/openiap-kit_pk_mobile", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + message: { data: "not base64", messageId: "message" }, + }), + }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + errors: [ + { + code: "INVALID_INPUT", + message: "Pub/Sub message.data is not base64-encoded JSON", + }, + ], + }); + }); + + it("accepts Google Pub/Sub lifecycle events in explicit local dev mode", async () => { + delete process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE; + process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB = "1"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(client, "action").mockResolvedValueOnce({ + type: "SUBSCRIPTION_RENEWED", + deduped: true, + }); + const data = Buffer.from( + JSON.stringify({ + packageName: "dev.hyo.app", + eventTimeMillis: "1700000000000", + subscriptionNotification: { + notificationType: 2, + purchaseToken: "opaque", + subscriptionId: "premium", + }, + }), + ).toString("base64"); + + const response = await createApp().request( + "/webhooks/openiap-kit_pk_mobile", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ message: { data, messageId: "message" } }), + }, + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + eventType: "SUBSCRIPTION_RENEWED", + deduped: true, + }); + }); }); describe("legacyUnsupportedEventReason", () => { diff --git a/packages/kit/server/server.test.ts b/packages/kit/server/server.test.ts new file mode 100644 index 000000000..3ad2dc0f0 --- /dev/null +++ b/packages/kit/server/server.test.ts @@ -0,0 +1,126 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const serverState = vi.hoisted(() => ({ + fetch: undefined as ((request: Request) => Promise) | undefined, + readFile: vi.fn(async () => "kit"), + stop: vi.fn(), +})); + +vi.mock("hono/bun", () => ({ + serveStatic: + () => + async (_context: unknown, next: () => Promise): Promise => + next(), +})); + +vi.mock("node:fs", () => ({ + promises: { readFile: serverState.readFile }, +})); + +describe("server entrypoint", () => { + const originalPort = process.env.PORT; + const originalStaticRoot = process.env.STATIC_ROOT; + const originalConvexUrl = process.env.VITE_KIT_CONVEX_URL; + const originalSigtermListeners = process.listeners("SIGTERM"); + const originalSigintListeners = process.listeners("SIGINT"); + + beforeAll(async () => { + process.env.PORT = "3010"; + process.env.STATIC_ROOT = "/tmp/openiap-kit-static"; + process.env.VITE_KIT_CONVEX_URL = "https://placeholder.convex.cloud"; + vi.stubGlobal("Bun", { + serve: vi.fn( + (options: { + port: number; + fetch: (request: Request) => Promise; + }) => { + serverState.fetch = options.fetch; + return { port: options.port, stop: serverState.stop }; + }, + ), + }); + + await import("./server"); + }); + + afterAll(() => { + for (const listener of process.listeners("SIGTERM")) { + if (!originalSigtermListeners.includes(listener)) { + process.removeListener("SIGTERM", listener); + } + } + for (const listener of process.listeners("SIGINT")) { + if (!originalSigintListeners.includes(listener)) { + process.removeListener("SIGINT", listener); + } + } + if (originalPort === undefined) delete process.env.PORT; + else process.env.PORT = originalPort; + if (originalStaticRoot === undefined) delete process.env.STATIC_ROOT; + else process.env.STATIC_ROOT = originalStaticRoot; + if (originalConvexUrl === undefined) { + delete process.env.VITE_KIT_CONVEX_URL; + } else { + process.env.VITE_KIT_CONVEX_URL = originalConvexUrl; + } + vi.unstubAllGlobals(); + }); + + it("serves health, hard API 404s, and the cached SPA shell", async () => { + const fetch = serverState.fetch; + expect(fetch).toBeDefined(); + if (!fetch) throw new Error("server fetch handler was not captured"); + + const health = await fetch(new Request("http://localhost/health")); + expect(health.status).toBe(200); + + const apiMiss = await fetch( + new Request("http://localhost/api/v1/removed-endpoint"), + ); + expect(apiMiss.status).toBe(404); + + const staticMiss = await fetch( + new Request("http://localhost/assets/missing.js"), + ); + expect(staticMiss.status).toBe(404); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const spa = await fetch(new Request("http://localhost/dashboard")); + expect(spa.status).toBe(200); + expect(await spa.text()).toBe("kit"); + } + expect(serverState.readFile).toHaveBeenCalledTimes(1); + }); + + it("drains once when shutdown signals arrive", () => { + const sigterm = process + .listeners("SIGTERM") + .find((listener) => !originalSigtermListeners.includes(listener)); + const sigint = process + .listeners("SIGINT") + .find((listener) => !originalSigintListeners.includes(listener)); + + expect(sigterm).toBeDefined(); + expect(sigint).toBeDefined(); + sigterm?.("SIGTERM"); + sigint?.("SIGINT"); + expect(serverState.stop).toHaveBeenCalledTimes(1); + }); + + it("caches a missing SPA shell failure", async () => { + serverState.readFile.mockClear(); + serverState.readFile.mockRejectedValueOnce(new Error("missing index")); + vi.resetModules(); + await import("./server"); + const fetch = serverState.fetch; + expect(fetch).toBeDefined(); + if (!fetch) throw new Error("server fetch handler was not captured"); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const response = await fetch(new Request("http://localhost/dashboard")); + expect(response.status).toBe(500); + expect(await response.text()).toContain("index.html missing"); + } + expect(serverState.readFile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/scripts/assert-lcov-coverage.mjs b/scripts/assert-lcov-coverage.mjs new file mode 100644 index 000000000..486dfa16e --- /dev/null +++ b/scripts/assert-lcov-coverage.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export function readLcovLineCoverage(source) { + let found = 0; + let hit = 0; + + for (const line of source.split(/\r?\n/)) { + if (line.startsWith("LF:")) found += Number(line.slice(3)); + if (line.startsWith("LH:")) hit += Number(line.slice(3)); + } + if (!Number.isFinite(found) || !Number.isFinite(hit) || found <= 0) { + throw new Error("LCOV report does not contain valid LF/LH line totals"); + } + if (hit < 0 || hit > found) { + throw new Error(`LCOV line totals are invalid: ${hit}/${found}`); + } + + return { found, hit, percentage: (hit / found) * 100 }; +} + +export function assertLcovLineCoverage(reportPath, minimum) { + if (!Number.isFinite(minimum) || minimum < 0 || minimum > 100) { + throw new Error(`Coverage minimum must be between 0 and 100: ${minimum}`); + } + const coverage = readLcovLineCoverage(fs.readFileSync(reportPath, "utf8")); + if (coverage.percentage < minimum) { + throw new Error( + `Line coverage ${coverage.percentage.toFixed(2)}% (${coverage.hit}/${coverage.found}) is below ${minimum.toFixed(2)}%`, + ); + } + return coverage; +} + +const isMain = + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isMain) { + const [reportPath, minimumInput] = process.argv.slice(2); + if (!reportPath || minimumInput === undefined) { + console.error( + "Usage: node scripts/assert-lcov-coverage.mjs ", + ); + process.exit(2); + } + + try { + const minimum = Number(minimumInput); + const coverage = assertLcovLineCoverage(reportPath, minimum); + console.log( + `Line coverage ${coverage.percentage.toFixed(2)}% (${coverage.hit}/${coverage.found}) meets ${minimum.toFixed(2)}%`, + ); + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : error}`); + process.exit(1); + } +} diff --git a/scripts/assert-lcov-coverage.test.mjs b/scripts/assert-lcov-coverage.test.mjs new file mode 100644 index 000000000..75d5aac37 --- /dev/null +++ b/scripts/assert-lcov-coverage.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, it } from "node:test"; + +import { + assertLcovLineCoverage, + readLcovLineCoverage, +} from "./assert-lcov-coverage.mjs"; + +describe("LCOV line coverage guard", () => { + const temporaryPaths = []; + + afterEach(() => { + for (const temporaryPath of temporaryPaths.splice(0)) { + rmSync(temporaryPath, { force: true, recursive: true }); + } + }); + + function report(source) { + const directory = mkdtempSync(join(tmpdir(), "openiap-lcov-")); + temporaryPaths.push(directory); + const reportPath = join(directory, "lcov.info"); + writeFileSync(reportPath, source); + return reportPath; + } + + it("sums line totals across source records", () => { + assert.deepEqual( + readLcovLineCoverage("SF:a.ts\nLF:6\nLH:5\nSF:b.ts\nLF:4\nLH:4\n"), + { found: 10, hit: 9, percentage: 90 }, + ); + }); + + it("accepts the exact minimum and rejects lower coverage", () => { + assert.doesNotThrow(() => + assertLcovLineCoverage(report("LF:10\nLH:9\n"), 90), + ); + assert.throws( + () => assertLcovLineCoverage(report("LF:1000\nLH:899\n"), 90), + /89\.90%.*below 90\.00%/, + ); + }); + + it("rejects malformed reports and invalid minimums", () => { + assert.throws( + () => readLcovLineCoverage("TN:\n"), + /valid LF\/LH line totals/, + ); + assert.throws( + () => assertLcovLineCoverage(report("LF:1\nLH:1\n"), 101), + /between 0 and 100/, + ); + }); +}); diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 815cc85c3..7b470a3fd 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -37,6 +37,11 @@ execFileSync( ["--test", path.resolve(root, "scripts/audit-android-api-compat.test.mjs")], { stdio: "inherit" }, ); +execFileSync( + process.execPath, + ["--test", path.resolve(root, "scripts/assert-lcov-coverage.test.mjs")], + { stdio: "inherit" }, +); execFileSync( process.execPath, [ @@ -590,6 +595,19 @@ function checkFrameworkCiAndCoverageBadges() { testJob: "analyze-and-test", workflowFile: "ci-flutter-inapp-purchase.yml", }, + { + codecovTargetPath: "packages/kit/server", + codecovConfigPush: false, + componentId: "iapkit-server", + componentName: "IAPKit Server", + coveragePath: "packages/kit/server", + generatedCoverageFile: null, + libraryPath: "packages/kit", + readmePath: "packages/kit/README.md", + testCommand: "run: bun run test:coverage", + testJob: "verify", + workflowFile: "deploy-kit.yml", + }, ]; function extractHttpsUrls(source) { @@ -780,7 +798,9 @@ function checkFrameworkCiAndCoverageBadges() { expectSameSet("Codecov flags", expectedComponentIds, flagIds); expectSameSet("Codecov components", expectedComponentIds, componentIds); for (const contract of contracts) { - const generatedCoveragePath = `${contract.coveragePath}/${contract.generatedCoverageFile}`; + const generatedCoveragePath = contract.generatedCoverageFile + ? `${contract.coveragePath}/${contract.generatedCoverageFile}` + : null; const flagBlock = [ ` ${contract.componentId}:`, " paths:", @@ -805,11 +825,28 @@ function checkFrameworkCiAndCoverageBadges() { `codecov.yml must map component ${contract.componentId} to its matching path and flag`, ); } - if (!codecovConfig.includes(` - "${generatedCoveragePath}"`)) { + if ( + generatedCoveragePath && + !codecovConfig.includes(` - "${generatedCoveragePath}"`) + ) { fail( `codecov.yml must ignore generated coverage file ${generatedCoveragePath}`, ); } + const statusBlock = [ + ` ${contract.componentId}:`, + " target: 90%", + " threshold: 0%", + " informational: false", + " flags:", + ` - ${contract.componentId}`, + ].join("\n"); + const statusOccurrences = codecovConfig.split(statusBlock).length - 1; + if (statusOccurrences !== 2) { + fail( + `codecov.yml project and patch statuses must enforce 90% for ${contract.componentId}`, + ); + } } } @@ -829,6 +866,7 @@ function checkFrameworkCiAndCoverageBadges() { "permissions:\n contents: read\n id-token: write", "fetch-depth: 0\n persist-credentials: false", contract.testCommand, + "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90", "uses: codecov/codecov-action@v7", "use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}", "fail_ci_if_error: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}", @@ -850,13 +888,32 @@ function checkFrameworkCiAndCoverageBadges() { `${workflowPath} ${contract.testJob} must upload exactly one coverage report`, ); } + const coverageAssertionIndex = testJob.indexOf( + "run: node ../../scripts/assert-lcov-coverage.mjs coverage/lcov.info 90", + ); + const uploadIndex = testJob.indexOf("uses: codecov/codecov-action@v7"); + if (coverageAssertionIndex < 0 || uploadIndex <= coverageAssertionIndex) { + fail( + `${workflowPath} must enforce LCOV coverage before uploading coverage`, + ); + } if (/^\s+token:/m.test(testJob)) { fail(`${workflowPath} must use Codecov OIDC without a stored token`); } - if ((workflowSource.match(/- "codecov\.yml"/g) ?? []).length !== 2) { + const eventBlock = (eventName) => + workflowSource.match( + new RegExp(`^ ${eventName}:\\n[\\s\\S]*?(?=^ [a-z_]+:|^jobs:)`, "m"), + )?.[0] ?? ""; + if (!eventBlock("pull_request").includes('- "codecov.yml"')) { + fail(`${workflowPath} pull_request paths must include root codecov.yml`); + } + const pushIncludesCodecov = eventBlock("push").includes('- "codecov.yml"'); + if (contract.codecovConfigPush === false && pushIncludesCodecov) { fail( - `${workflowPath} pull_request and push paths must both include root codecov.yml`, + `${workflowPath} must not deploy production for a codecov.yml-only push`, ); + } else if (contract.codecovConfigPush !== false && !pushIncludesCodecov) { + fail(`${workflowPath} push paths must include root codecov.yml`); } if (contract.componentId === "flutter-inapp-purchase") { @@ -892,7 +949,7 @@ function checkFrameworkCiAndCoverageBadges() { (url) => url.hostname === "app.codecov.io" && url.pathname === - `/gh/hyodotdev/openiap/tree/main/${contract.libraryPath}`, + `/gh/hyodotdev/openiap/tree/main/${contract.codecovTargetPath ?? contract.libraryPath}`, ); if (!codecovBadge) { fail(