From 8e1de91def84c15c4f6f25900c863043ae29aedb Mon Sep 17 00:00:00 2001
From: Hyo
Date: Tue, 11 Aug 2026 07:12:52 +0900
Subject: [PATCH 1/4] test: enforce 90 percent SDK coverage
Add blocking per-component Codecov and local LCOV gates for React Native, Expo, Flutter, and IAPKit. Expand behavioral regression tests, fix Flutter purchase-error handler mapping, and remove unreachable internal header compatibility branches.
---
.github/workflows/ci-expo-iap.yml | 3 +
.../workflows/ci-flutter-inapp-purchase.yml | 3 +
.github/workflows/ci-react-native-iap.yml | 3 +
.github/workflows/deploy-kit.yml | 24 +-
bun.lock | 33 +-
codecov.yml | 58 ++
.../src/__tests__/index.kepler.test.ts | 132 ++++
.../expo-iap/src/__tests__/kit-api.test.ts | 300 ++++++++
libraries/expo-iap/src/kit-api.ts | 69 +-
.../lib/flutter_inapp_purchase.dart | 16 +-
.../test/coverage_regression_test.dart | 667 ++++++++++++++++++
.../test/fetch_products_all_test.dart | 18 +
.../test/helpers_coverage_test.dart | 275 ++++++++
.../src/__tests__/index.kepler.test.ts | 219 +++++-
.../src/__tests__/index.test.ts | 414 +++++++++++
.../src/__tests__/kit-api.test.ts | 304 ++++++++
.../utils/coverage-regression.test.ts | 59 ++
.../src/__tests__/utils/errorMapping.test.ts | 91 +++
.../src/__tests__/utils/type-bridge.test.ts | 158 +++++
libraries/react-native-iap/src/kit-api.ts | 69 +-
packages/gql/src/kit-api.ts | 69 +-
packages/kit/README.md | 5 +
packages/kit/package.json | 2 +
packages/kit/server/api/v1/webhooks.test.ts | 181 ++++-
packages/kit/server/server.test.ts | 118 ++++
scripts/assert-lcov-coverage.mjs | 60 ++
scripts/assert-lcov-coverage.test.mjs | 56 ++
scripts/audit-non-godot-parity.mjs | 58 +-
28 files changed, 3298 insertions(+), 166 deletions(-)
create mode 100644 libraries/flutter_inapp_purchase/test/coverage_regression_test.dart
create mode 100644 libraries/flutter_inapp_purchase/test/helpers_coverage_test.dart
create mode 100644 libraries/react-native-iap/src/__tests__/utils/coverage-regression.test.ts
create mode 100644 packages/kit/server/server.test.ts
create mode 100644 scripts/assert-lcov-coverage.mjs
create mode 100644 scripts/assert-lcov-coverage.test.mjs
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..55013db23 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,135 @@ 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,
+ );
+ const purchaseSub = Kepler.purchaseUpdatedListener(listener);
+ const errorSub = Kepler.purchaseErrorListener(listener);
+ expect(purchaseSub).toBe(subscription);
+ expect(errorSub).toBe(subscription);
+ module.addListener.mock.calls[1][1]({productId: 'premium'});
+ module.addListener.mock.calls[2][1]({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':
+
+
+
+
+
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..ca97e1cda 100644
--- a/packages/kit/server/api/v1/webhooks.test.ts
+++ b/packages/kit/server/api/v1/webhooks.test.ts
@@ -1,5 +1,6 @@
-import { beforeAll, describe, expect, it } from "vitest";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { Hono } from "hono";
+import { client } from "../../convex";
let helpers: typeof import("./webhooks");
@@ -8,6 +9,12 @@ beforeAll(async () => {
helpers = await import("./webhooks");
});
+afterEach(() => {
+ delete process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE;
+ delete process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB;
+ vi.restoreAllMocks();
+});
+
describe("pubSubOidcAudiences", () => {
it("accepts concrete push endpoint audience when configured for the origin", () => {
const audiences = helpers.pubSubOidcAudiences(
@@ -33,6 +40,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 +173,172 @@ 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 () => {
+ 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 () => {
+ 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 () => {
+ 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..467559ea1
--- /dev/null
+++ b/packages/kit/server/server.test.ts
@@ -0,0 +1,118 @@
+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 originalSigtermListeners = process.listeners("SIGTERM");
+ const originalSigintListeners = process.listeners("SIGINT");
+
+ beforeAll(async () => {
+ process.env.PORT = "3010";
+ process.env.STATIC_ROOT = "/tmp/openiap-kit-static";
+ 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;
+ 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.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(2);
+ });
+});
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..645e3b048 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 }}",
@@ -853,10 +891,20 @@ function checkFrameworkCiAndCoverageBadges() {
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 +940,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(
From e989714548b31c7c2283c5cec0b70a99aed514bd Mon Sep 17 00:00:00 2001
From: Hyo
Date: Tue, 11 Aug 2026 07:26:52 +0900
Subject: [PATCH 2/4] test(kit): isolate Convex test environment
---
packages/kit/server/api/v1/webhooks.test.ts | 22 +++++++++++++++++++--
packages/kit/server/server.test.ts | 7 +++++++
2 files changed, 27 insertions(+), 2 deletions(-)
diff --git a/packages/kit/server/api/v1/webhooks.test.ts b/packages/kit/server/api/v1/webhooks.test.ts
index ca97e1cda..687e4423d 100644
--- a/packages/kit/server/api/v1/webhooks.test.ts
+++ b/packages/kit/server/api/v1/webhooks.test.ts
@@ -1,14 +1,32 @@
-import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import {
+ afterAll,
+ afterEach,
+ beforeAll,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
import { Hono } from "hono";
-import { client } from "../../convex";
+const originalConvexUrl = process.env.VITE_KIT_CONVEX_URL;
+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(() => {
delete process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE;
delete process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB;
diff --git a/packages/kit/server/server.test.ts b/packages/kit/server/server.test.ts
index 467559ea1..79b7b0a3d 100644
--- a/packages/kit/server/server.test.ts
+++ b/packages/kit/server/server.test.ts
@@ -20,12 +20,14 @@ vi.mock("node:fs", () => ({
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: {
@@ -56,6 +58,11 @@ describe("server entrypoint", () => {
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();
});
From f23971609bbaa30506cf49d5e7b16ee1f216b7fb Mon Sep 17 00:00:00 2001
From: Hyo
Date: Tue, 11 Aug 2026 07:35:17 +0900
Subject: [PATCH 3/4] test: harden isolated coverage gates
---
packages/kit/server/api/v1/webhooks.test.ts | 21 +++++++++++++++++++--
packages/kit/server/server.test.ts | 3 ++-
scripts/audit-non-godot-parity.mjs | 9 +++++++++
3 files changed, 30 insertions(+), 3 deletions(-)
diff --git a/packages/kit/server/api/v1/webhooks.test.ts b/packages/kit/server/api/v1/webhooks.test.ts
index 687e4423d..e6882bea4 100644
--- a/packages/kit/server/api/v1/webhooks.test.ts
+++ b/packages/kit/server/api/v1/webhooks.test.ts
@@ -10,6 +10,10 @@ import {
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");
@@ -28,8 +32,17 @@ afterAll(() => {
});
afterEach(() => {
- delete process.env.GOOGLE_PUBSUB_PUSH_AUDIENCE;
- delete process.env.KIT_ALLOW_UNAUTHENTICATED_PUBSUB;
+ 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();
});
@@ -278,6 +291,8 @@ describe("webhooksRoutes", () => {
});
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" }),
@@ -297,6 +312,7 @@ describe("webhooksRoutes", () => {
});
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(() => {});
@@ -323,6 +339,7 @@ describe("webhooksRoutes", () => {
});
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({
diff --git a/packages/kit/server/server.test.ts b/packages/kit/server/server.test.ts
index 79b7b0a3d..3ad2dc0f0 100644
--- a/packages/kit/server/server.test.ts
+++ b/packages/kit/server/server.test.ts
@@ -108,6 +108,7 @@ describe("server entrypoint", () => {
});
it("caches a missing SPA shell failure", async () => {
+ serverState.readFile.mockClear();
serverState.readFile.mockRejectedValueOnce(new Error("missing index"));
vi.resetModules();
await import("./server");
@@ -120,6 +121,6 @@ describe("server entrypoint", () => {
expect(response.status).toBe(500);
expect(await response.text()).toContain("index.html missing");
}
- expect(serverState.readFile).toHaveBeenCalledTimes(2);
+ expect(serverState.readFile).toHaveBeenCalledTimes(1);
});
});
diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs
index 645e3b048..7b470a3fd 100644
--- a/scripts/audit-non-godot-parity.mjs
+++ b/scripts/audit-non-godot-parity.mjs
@@ -888,6 +888,15 @@ 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`);
}
From a94ead5135b49d9083a7613a0065582ae2a761b3 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Tue, 11 Aug 2026 07:46:55 +0900
Subject: [PATCH 4/4] fix: harden listener cleanup and test isolation
---
.../src/__tests__/index.kepler.test.ts | 10 +-
.../src/__tests__/index.kepler.test.ts | 31 ++--
.../src/__tests__/index.test.ts | 57 +++++--
.../src/__tests__/utils/errorMapping.test.ts | 8 +-
.../src/__tests__/utils/type-bridge.test.ts | 140 ++++++++----------
libraries/react-native-iap/src/index.ts | 20 +++
6 files changed, 154 insertions(+), 112 deletions(-)
diff --git a/libraries/expo-iap/src/__tests__/index.kepler.test.ts b/libraries/expo-iap/src/__tests__/index.kepler.test.ts
index 55013db23..f87b81bce 100644
--- a/libraries/expo-iap/src/__tests__/index.kepler.test.ts
+++ b/libraries/expo-iap/src/__tests__/index.kepler.test.ts
@@ -117,12 +117,18 @@ describe('Amazon Vega public API', () => {
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);
- module.addListener.mock.calls[1][1]({productId: 'premium'});
- module.addListener.mock.calls[2][1]({code: 'network-error'});
+ purchaseHandler?.({productId: 'premium'});
+ errorHandler?.({code: 'network-error'});
expect(listener).toHaveBeenCalledTimes(2);
await expect(Kepler.initConnection()).resolves.toBe(true);
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 e8b8b182c..9106ed12e 100644
--- a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts
+++ b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts
@@ -11,7 +11,7 @@ describe('Amazon Vega public API', () => {
const fetchProductsNative = jest.fn().mockResolvedValue([]);
const requestPurchaseNative = jest.fn().mockResolvedValue([]);
const getAvailablePurchasesNative = jest.fn().mockResolvedValue([]);
- const module = {
+ const vegaModule = {
initConnection: jest.fn().mockResolvedValue(true),
endConnection: jest.fn().mockResolvedValue(true),
fetchProducts: fetchProductsNative,
@@ -33,7 +33,7 @@ describe('Amazon Vega public API', () => {
beforeEach(() => {
jest.clearAllMocks();
- (getVegaIapModule as jest.Mock).mockReturnValue(module);
+ (getVegaIapModule as jest.Mock).mockReturnValue(vegaModule);
});
it('returns false when offer-code redemption is unsupported', async () => {
@@ -155,13 +155,13 @@ describe('Amazon Vega public API', () => {
}),
).resolves.toEqual({ok: true});
- expect(module.initConnection).toHaveBeenCalledWith(null);
- expect(module.getActiveSubscriptions).toHaveBeenCalledWith(undefined);
- expect(module.hasActiveSubscriptions).toHaveBeenCalledWith(['premium']);
+ expect(vegaModule.initConnection).toHaveBeenCalledWith(null);
+ expect(vegaModule.getActiveSubscriptions).toHaveBeenCalledWith(undefined);
+ expect(vegaModule.hasActiveSubscriptions).toHaveBeenCalledWith(['premium']);
});
it('normalizes product queries and maps subscriptions', async () => {
- fetchProductsNative.mockResolvedValue([
+ fetchProductsNative.mockResolvedValueOnce([
{
id: 'premium',
title: 'Premium',
@@ -209,7 +209,7 @@ describe('Amazon Vega public API', () => {
purchase: {productId: 'coins', purchaseToken: 'receipt'} as any,
isConsumable: true,
});
- expect(module.finishTransaction).toHaveBeenCalledWith({
+ expect(vegaModule.finishTransaction).toHaveBeenCalledWith({
android: {purchaseToken: 'receipt', isConsumable: true},
});
});
@@ -219,15 +219,18 @@ describe('Amazon Vega public API', () => {
await expect(IAP.acknowledgePurchaseAndroid('receipt')).resolves.toBe(true);
await expect(IAP.consumePurchaseAndroid('receipt')).resolves.toBe(true);
- expect(module.restorePurchases).toHaveBeenCalledTimes(1);
- expect(module.acknowledgePurchaseAndroid).toHaveBeenCalledWith('receipt');
- expect(module.consumePurchaseAndroid).toHaveBeenCalledWith('receipt');
+ 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 = module.addPurchaseUpdatedListener.mock.calls[0][0];
+ const nativeListener =
+ vegaModule.addPurchaseUpdatedListener.mock.calls[0][0];
nativeListener({
id: 'purchase',
@@ -243,13 +246,13 @@ describe('Amazon Vega public API', () => {
subscription.remove();
expect(purchaseListener).toHaveBeenCalledTimes(1);
- expect(module.removePurchaseUpdatedListener).toHaveBeenCalledWith(7);
+ 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 = module.addPurchaseErrorListener.mock.calls[0][0];
+ const nativeListener = vegaModule.addPurchaseErrorListener.mock.calls[0][0];
nativeListener({});
subscription.remove();
@@ -258,7 +261,7 @@ describe('Amazon Vega public API', () => {
code: 'service-error',
message: 'Amazon Vega purchase failed',
});
- expect(module.removePurchaseErrorListener).toHaveBeenCalledWith(
+ expect(vegaModule.removePurchaseErrorListener).toHaveBeenCalledWith(
nativeListener,
);
});
diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts
index b874a83eb..ee852ff3b 100644
--- a/libraries/react-native-iap/src/__tests__/index.test.ts
+++ b/libraries/react-native-iap/src/__tests__/index.test.ts
@@ -1667,6 +1667,12 @@ describe('Public API (src/index.ts)', () => {
expect(mockIap.addUserChoiceBillingListenerAndroid).toHaveBeenCalledTimes(
1,
);
+ expect(
+ mockIap.removeUserChoiceBillingListenerAndroid,
+ ).toHaveBeenCalledTimes(1);
+ expect(
+ mockIap.removeUserChoiceBillingListenerAndroid,
+ ).toHaveBeenCalledWith(nativeListener);
});
it('returns an inert subscription outside Android', () => {
@@ -3110,11 +3116,27 @@ describe('Public API (src/index.ts)', () => {
});
describe('native failure normalization', () => {
- const iosFailureCases: Array<{
+ 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',
@@ -3199,7 +3221,10 @@ describe('Public API (src/index.ts)', () => {
it.each(iosFailureCases)('normalizes $name failures', async (testCase) => {
const nativeError = new Error('native failure');
- mockIap[testCase.nativeMethod] = jest.fn().mockRejectedValue(nativeError);
+ replaceNativeMethod(
+ testCase.nativeMethod,
+ jest.fn().mockRejectedValue(nativeError),
+ );
await expect(testCase.invoke()).rejects.toMatchObject({
message: 'native failure',
@@ -3207,18 +3232,20 @@ describe('Public API (src/index.ts)', () => {
});
it('normalizes connection lifecycle failures', async () => {
- mockIap.initConnection = jest
- .fn()
- .mockRejectedValueOnce(new Error('init failed'));
+ replaceNativeMethod(
+ 'initConnection',
+ jest.fn().mockRejectedValueOnce(new Error('init failed')),
+ );
await expect(IAP.initConnection()).rejects.toMatchObject({
message: 'init failed',
});
- mockIap.initConnection = jest.fn().mockResolvedValue(true);
+ replaceNativeMethod('initConnection', jest.fn().mockResolvedValue(true));
expect(IAP.isNitroReady()).toBe(true);
- mockIap.endConnection = jest
- .fn()
- .mockRejectedValueOnce(new Error('end failed'));
+ replaceNativeMethod(
+ 'endConnection',
+ jest.fn().mockRejectedValueOnce(new Error('end failed')),
+ );
await expect(IAP.endConnection()).rejects.toMatchObject({
message: 'end failed',
});
@@ -3257,7 +3284,10 @@ describe('Public API (src/index.ts)', () => {
])('surfaces Android $name failures', async (testCase) => {
(Platform as any).OS = 'android';
const nativeError = new Error('android failure');
- mockIap[testCase.nativeMethod] = jest.fn().mockRejectedValue(nativeError);
+ replaceNativeMethod(
+ testCase.nativeMethod,
+ jest.fn().mockRejectedValue(nativeError),
+ );
await expect(testCase.invoke()).rejects.toBe(nativeError);
});
@@ -3276,7 +3306,10 @@ describe('Public API (src/index.ts)', () => {
},
])('surfaces iOS $name failures', async (testCase) => {
const nativeError = new Error('external purchase failure');
- mockIap[testCase.nativeMethod] = jest.fn().mockRejectedValue(nativeError);
+ replaceNativeMethod(
+ testCase.nativeMethod,
+ jest.fn().mockRejectedValue(nativeError),
+ );
await expect(testCase.invoke()).rejects.toBe(nativeError);
});
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 e098fa9e2..ef7e82375 100644
--- a/libraries/react-native-iap/src/__tests__/utils/errorMapping.test.ts
+++ b/libraries/react-native-iap/src/__tests__/utils/errorMapping.test.ts
@@ -176,9 +176,13 @@ describe('utils/errorMapping', () => {
[ErrorCode.ServiceDisconnected, true],
[ErrorCode.BillingUnavailable, true],
[ErrorCode.UserCancelled, false],
- [undefined, false],
])('classifies network error %s', (code, expected) => {
- expect(isNetworkError(code ? {code} : null)).toBe(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([
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 2d97dd358..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(
@@ -179,33 +188,28 @@ describe('type-bridge utilities', () => {
});
it('normalizes native aliases and malformed optional metadata', () => {
- const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
- try {
- 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(warn).toHaveBeenCalled();
- } finally {
- warn.mockRestore();
- }
+ 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([
@@ -253,16 +257,11 @@ describe('type-bridge utilities', () => {
});
it('warns when casting an in-app product as a subscription', () => {
- const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
- try {
- const result = convertProductToProductSubscription(
- convertNitroProductToProduct(product()),
- );
- expect(result.id).toBe('com.example.product');
- expect(warn).toHaveBeenCalled();
- } finally {
- warn.mockRestore();
- }
+ const result = convertProductToProductSubscription(
+ convertNitroProductToProduct(product()),
+ );
+ expect(result.id).toBe('com.example.product');
+ expect(console.warn).toHaveBeenCalled();
});
describe('convertNitroPurchaseToPurchase', () => {
@@ -492,23 +491,15 @@ describe('type-bridge utilities', () => {
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', () => {
@@ -517,26 +508,18 @@ describe('type-bridge utilities', () => {
});
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', () => {
@@ -570,14 +553,7 @@ describe('type-bridge utilities', () => {
['renewalInfoIOS', {renewalInfoIOS: 'invalid'}],
['offerIOS', {offerIOS: 1}],
] as const)('rejects invalid %s metadata', (_field, overrides) => {
- const consoleErrorSpy = jest
- .spyOn(console, 'error')
- .mockImplementation(() => undefined);
- try {
- expect(validateNitroPurchase(purchase(overrides as never))).toBe(false);
- } finally {
- consoleErrorSpy.mockRestore();
- }
+ expect(validateNitroPurchase(purchase(overrides as never))).toBe(false);
});
});
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,
+ );
+ }
},
};
};