diff --git a/.github/actions/ios-toolchain/action.yml b/.github/actions/ios-toolchain/action.yml new file mode 100644 index 0000000..0b892bc --- /dev/null +++ b/.github/actions/ios-toolchain/action.yml @@ -0,0 +1,46 @@ +name: iOS toolchain +description: Selects Xcode and a simulator, and resolves the package graph. + +# The test jobs run in parallel and each needs the same four steps. A composite +# action keeps the Xcode selection and the simulator lookup written once, so the +# jobs cannot drift onto different runtimes and disagree about a failure. + +runs: + using: composite + steps: + - name: Select latest Xcode + shell: bash + run: | + XCODE=$(ls /Applications | grep -E '^Xcode_[0-9]' | sort -V | tail -1) + sudo xcode-select -s "/Applications/$XCODE/Contents/Developer" + echo "Using $XCODE" + xcodebuild -version + swift --version + + - name: Resolve dependencies + shell: bash + run: | + xcodebuild -resolvePackageDependencies \ + -scheme PayabliSDK-Package \ + -clonedSourcePackagesDirPath .build/checkouts + + - name: Select simulator + shell: bash + run: | + SIMULATOR_ID=$(xcrun simctl list devices available -j \ + | python3 -c " + import json, sys + d = json.load(sys.stdin)['devices'] + iphones = [ + v for k, vals in d.items() + if 'iOS' in k + for v in vals + if 'iPhone' in v['name'] and v['isAvailable'] + ] + if not iphones: + print('error: no available iPhone simulator found', file=sys.stderr) + sys.exit(1) + print(sorted(iphones, key=lambda x: x['name'])[-1]['udid']) + ") + echo "Using simulator: $SIMULATOR_ID" + echo "SIMULATOR_ID=$SIMULATOR_ID" >> "$GITHUB_ENV" diff --git a/.github/actions/lint-tools/action.yml b/.github/actions/lint-tools/action.yml new file mode 100644 index 0000000..ca54679 --- /dev/null +++ b/.github/actions/lint-tools/action.yml @@ -0,0 +1,42 @@ +name: Lint tools +description: Installs swiftformat and swiftlint, verified by checksum. + +# Pinned by content, not by name. Both tools decide whether the lint job passes, +# and .swiftformat names the version its four disabled rules were checked +# against, so a change in either would alter the tree or redden every pull +# request without a commit here. +# +# A release tag is not immutable: a publisher can delete an asset and upload +# another under the same URL. The checksums below are what makes the bytes +# fixed, and they are verified before anything is unpacked or run. Raising a +# version means replacing its checksum in the same change, with `swiftformat .` +# re-run alongside it. + +runs: + using: composite + steps: + - name: Install lint tools + shell: bash + env: + SWIFTFORMAT_VERSION: 0.62.1 + SWIFTFORMAT_SHA256: 7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a + SWIFTLINT_VERSION: 0.65.0 + SWIFTLINT_SHA256: d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6 + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/tools" + + curl -sSfL -o "$RUNNER_TEMP/swiftformat.zip" \ + "https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFTFORMAT_VERSION}/swiftformat.zip" + echo "${SWIFTFORMAT_SHA256} ${RUNNER_TEMP}/swiftformat.zip" | shasum -a 256 -c - + + curl -sSfL -o "$RUNNER_TEMP/swiftlint.zip" \ + "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" + echo "${SWIFTLINT_SHA256} ${RUNNER_TEMP}/swiftlint.zip" | shasum -a 256 -c - + + unzip -q -j -o "$RUNNER_TEMP/swiftformat.zip" -d "$RUNNER_TEMP/tools" + unzip -q -j -o "$RUNNER_TEMP/swiftlint.zip" -d "$RUNNER_TEMP/tools" + chmod +x "$RUNNER_TEMP/tools/swiftformat" "$RUNNER_TEMP/tools/swiftlint" + echo "$RUNNER_TEMP/tools" >> "$GITHUB_PATH" + "$RUNNER_TEMP/tools/swiftformat" --version + "$RUNNER_TEMP/tools/swiftlint" version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53e0cb4..e9b3fc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,53 +10,174 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# Every job here runs the pull request's own code, so the token it inherits is +# pinned rather than left to the repository's default. That default is read-only +# today and is a setting, not a property of this file; without this line, raising +# it would silently hand a write to code the branch controls, which `checkout` +# leaves in `.git/config` for whatever runs next. +permissions: + contents: read + +# Four jobs, run at once. The two suites are the whole cost of this workflow and +# neither reads anything the other writes, so run in sequence they only add up. +# They cannot share a build: both compile the SDK, but one is the package's own +# test action and the other consumes the package as a dependency of +# Example/PayabliDemo.xcodeproj, and Xcode compiles a dependency with +# -suppress-warnings. Same intermediates path, different flags, so pointing both +# at one -derivedDataPath makes them invalidate each other's objects rather than +# reuse them. +# +# Nothing here is granted a secret or a write. Posting the change report and +# running the analysis both need one, and both live in `pr-reports.yml`, which +# this run triggers when it finishes. That file is read from the default branch, +# so a pull request cannot edit the jobs that hold the tokens; every job below +# is read from the pull request's own revision, which is why none of them may +# hold one. + jobs: - test: - name: Build & Test + lint: + name: Lint runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 + with: + # Nothing here pushes, so the token is not left in `.git/config` for + # the branch's own scripts to find. + persist-credentials: false + + - uses: ./.github/actions/lint-tools - - name: Select latest Xcode + # No `--config`: naming a config file makes SwiftLint ignore nested ones, + # and Tests/.swiftlint.yml is what relaxes the rules XCTest fixtures break. + - name: Lint run: | - XCODE=$(ls /Applications | grep -E '^Xcode_[0-9]' | sort -V | tail -1) - sudo xcode-select -s "/Applications/$XCODE/Contents/Developer" - echo "Using $XCODE" - xcodebuild -version - swift --version + swiftlint + swiftformat --lint . + + # Reports, never judges. A branch that runs `swiftformat .` puts hundreds of + # files in the diff, and the file count alone does not say which of them + # changed what the code does. + # + # continue-on-error, and the script exits 0 whatever it finds: a report that + # can redden a pull request is a gate, and this is not one. + # + # This job runs the pull request's own code — the local action and the script + # under Scripts/ — so it is read-only and holds no token. Posting the report + # needs one, and that happens in `pr-reports.yml`, which runs from the default + # branch where this branch cannot edit it. + changes: + name: Change report + runs-on: macos-15 + if: github.event_name == 'pull_request' + continue-on-error: true + permissions: + contents: read + + steps: + # The classification reads both sides of the change, so it needs history. + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false - - name: Install xcpretty - run: gem install xcpretty --no-document + - uses: ./.github/actions/lint-tools - - name: Resolve dependencies + - name: Classify the diff run: | - xcodebuild -resolvePackageDependencies \ - -scheme PayabliSDK-Package \ - -clonedSourcePackagesDirPath .build/checkouts + ./Scripts/classify-changes.sh \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}" \ + > report.md + cat report.md >> "$GITHUB_STEP_SUMMARY" + + # Only the report. Which pull request it belongs to is read from the + # triggering run's event by `pr-reports.yml`, because anything uploaded + # here is chosen by the branch, and that workflow holds the tokens. + - name: Upload the report + uses: actions/upload-artifact@v4 + with: + name: change-report + path: report.md + + test-sdk: + name: SDK tests + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Nothing here pushes, so the token is not left in `.git/config` for + # the branch's own scripts to find. + persist-credentials: false + + - uses: ./.github/actions/ios-toolchain - - name: Test + - name: Test the SDK run: | - SIMULATOR_ID=$(xcrun simctl list devices available -j \ - | python3 -c " - import json, sys - d = json.load(sys.stdin)['devices'] - iphones = [ - v for k, vals in d.items() - if 'iOS' in k - for v in vals - if 'iPhone' in v['name'] and v['isAvailable'] - ] - if not iphones: - print('error: no available iPhone simulator found', file=sys.stderr) - sys.exit(1) - print(sorted(iphones, key=lambda x: x['name'])[-1]['udid']) - ") - echo "Using simulator: $SIMULATOR_ID" xcodebuild test \ -scheme PayabliSDK-Package \ -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ -clonedSourcePackagesDirPath .build/checkouts \ - CODE_SIGNING_ALLOWED=NO \ - | xcpretty && exit ${PIPESTATUS[0]} + -enableCodeCoverage YES \ + -resultBundlePath SDKTests.xcresult \ + -quiet \ + CODE_SIGNING_ALLOWED=NO + + # -quiet names the failing tests and stops there, so the assertion comes + # back out of the result bundle. + - name: Report test failures + if: failure() + run: ./Scripts/print-test-failures.sh SDKTests.xcresult + + # Sources only. sonar-project.properties measures that root, and an + # .xcresult also covers the test files themselves and the vendored + # card-reader source the suite touches. + - name: Convert coverage + run: | + ./Scripts/xccov-to-sonarqube-generic.sh --include Sources/ \ + SDKTests.xcresult > coverage.xml + + # Only the coverage, for the same reason as the report above. + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage + path: coverage.xml + + test-demo: + name: Sample app step sequences + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Nothing here pushes, so the token is not left in `.git/config` for + # the branch's own scripts to find. + persist-credentials: false + + - uses: ./.github/actions/ios-toolchain + + # The sample app's step sequences, which decide what each screen offers + # next. A separate scheme, because this bundle has no host application: + # Secrets.swift is gitignored and is a member of the app target, so the app + # itself cannot be built here. + - name: Test the sample app's step sequences + run: | + xcodebuild test \ + -project Example/PayabliDemo/PayabliDemo.xcodeproj \ + -scheme PayabliDemoFlowTests \ + -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ + -clonedSourcePackagesDirPath .build/checkouts \ + -enableCodeCoverage YES \ + -resultBundlePath DemoFlowTests.xcresult \ + -quiet \ + CODE_SIGNING_ALLOWED=NO + + - name: Report test failures + if: failure() + run: ./Scripts/print-test-failures.sh DemoFlowTests.xcresult diff --git a/.github/workflows/pr-reports.yml b/.github/workflows/pr-reports.yml new file mode 100644 index 0000000..25a553a --- /dev/null +++ b/.github/workflows/pr-reports.yml @@ -0,0 +1,199 @@ +name: PR reports + +# Everything that needs a token, kept out of reach of the code it reports on. +# +# A `pull_request` workflow runs the head revision's copy of its own file, and a +# same-repository pull request is granted the repository's secrets, so a branch +# can add a step to a job in `ci.yml` and read whatever that job holds. Splitting +# the token into a job that checks out nothing does not close it: the job's steps +# are still written in the branch's copy of the file. +# +# GitHub triggers `workflow_run` only for a workflow file that exists on the +# default branch, and runs that copy. So the two jobs below cannot be edited by +# the pull request they are reporting on. +# +# The consequence to know: a change to this file does not take effect until it +# is on the default branch, and cannot be exercised from a pull request. + +on: + workflow_run: + workflows: [CI] + types: [completed] + +# Granted per job, never here. +permissions: {} + +jobs: + comment: + name: Change report comment + runs-on: ubuntu-latest + # `ci.yml` builds the report on pull requests only, and `ci.yml` cancels a + # run in progress when the next push arrives, which leaves no artifact to + # download. A failed run still has one, because the report job is not a gate. + # + # Same-repository only. A branch in a fork has no entry in `pull_requests` + # below, and this job would have nothing it could trust to say which pull + # request it is reporting on. + if: >- + github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.conclusion != 'cancelled' + && github.event.workflow_run.head_repository.full_name == github.repository + # One of these at a time per pull request. `ci.yml` cancels a superseded run, + # but cancelling is not instant: a run already at its last step finishes, and + # the push that superseded it produces a second. Both would then list the + # comments, both delete what they found, and the loser of that race deletes a + # comment that is already gone and reddens on it. The newer report is the one + # worth having, so it takes the older one's place. + concurrency: + group: change-report-${{ github.event.workflow_run.pull_requests[0].number }} + cancel-in-progress: true + permissions: + pull-requests: write + actions: read + + steps: + # From the run that triggered this one, which is not the run this job is + # part of, so the id and a token are both required. + - name: Download the report + uses: actions/download-artifact@v4 + with: + name: change-report + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + # One report on the pull request, and always the newest thing on the page. + # Editing in place kept it to one comment and left it wherever it was first + # posted, which on a long review is pages above the discussion and marked + # only "edited". Every earlier copy is deleted and a fresh one posted. + # Which pull request this is comes from the event, never from the artifact. + # `ci.yml` runs from the branch, so anything it uploads is chosen by whoever + # opened the pull request: a number in a file would let one branch aim this + # job's write token at a different pull request and rewrite its comments. + # GitHub fills in `pull_requests` from the triggering run, and a branch + # cannot write to it. + - name: Comment on the pull request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR: ${{ github.event.workflow_run.pull_requests[0].number }} + run: | + set -euo pipefail + pr=$(printf '%s' "$PR" | grep -E '^[0-9]+$') || { + echo "the triggering run named no pull request" >&2 + exit 1 + } + # The marker alone is not enough to delete by: a reviewer quoting the + # report would be quoting the marker, and this holds a write token. Only + # this workflow's own comments are ever removed. + # + # jq slurps the pages into one array. --jq would run once per page, so + # a match on an earlier page would be missed. + existing=$(gh api "repos/$REPO/issues/$pr/comments" --paginate \ + | jq -s 'add + | map(select(.user.login == "github-actions[bot]" + and (.body | contains(""))) + | .id) + | .[]') + for id in $existing; do + gh api -X DELETE "repos/$REPO/issues/comments/$id" >/dev/null + echo "deleted comment $id" + done + gh api -X POST "repos/$REPO/issues/$pr/comments" -F body=@report.md >/dev/null + echo "posted the report" + + sonar: + name: SonarCloud + runs-on: macos-15 + # No coverage is produced by a run whose suite failed. Same-repository only: + # a fork's pull request has no entry in `pull_requests`, so there would be + # nothing trustworthy to decorate, and this job would be checking a fork's + # revision out while holding the analysis token. + if: >- + github.event.workflow_run.conclusion == 'success' + && github.event.workflow_run.head_repository.full_name == github.repository + # The same race, for the same reason. Two analyses of one pull request reach + # the server in whichever order they finish, so the later revision's numbers + # can be overwritten by the earlier one's. A push run names no pull request + # and is keyed by its branch instead. + concurrency: + group: >- + sonar-${{ github.event.workflow_run.pull_requests[0].number + || github.event.workflow_run.head_branch }} + cancel-in-progress: true + permissions: + contents: read + actions: read + + steps: + # The revision the triggering run tested, not the default branch this + # workflow is read from. Analysis reads the history to decide what is new. + # + # Checking the branch out is not running it: no step here builds or + # executes anything from the tree, and the coverage the scanner reads was + # produced by the run that triggered this one. + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - name: Download coverage + uses: actions/download-artifact@v4 + with: + name: coverage + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + # From the event, never from the artifact. `ci.yml` runs from the branch, so + # a number it uploaded would be one the branch chose, and the scanner would + # decorate whichever pull request that named while analysing this revision. + # A `push` run names none, and that is what selects a branch analysis. + # + # Refs are checked rather than trusted: they reach the scanner as command + # arguments, and a ref carrying anything but the characters git needs is + # refused instead of pasted into the list. + - name: Identify the pull request + id: pr + env: + PR: ${{ github.event.workflow_run.pull_requests[0].number }} + PR_BRANCH: ${{ github.event.workflow_run.pull_requests[0].head.ref }} + PR_BASE: ${{ github.event.workflow_run.pull_requests[0].base.ref }} + run: | + set -euo pipefail + # Where the analysis goes is decided here, not by the branch. The + # scanner reads `sonar-project.properties` out of the revision it is + # analysing, and that file names the server and the project: a branch + # could point `sonar.host.url` at one of its own and be handed + # SONAR_TOKEN, or change the identity and publish into another project. + # A property given on the command line wins over the file, and this file + # is the one a pull request cannot edit. + # + # These three duplicate `sonar-project.properties` on purpose. What the + # branch may still choose is what gets measured, which is the rest of + # that file. + trusted="-Dsonar.host.url=https://sonarcloud.io" + trusted="$trusted -Dsonar.organization=payabli" + trusted="$trusted -Dsonar.projectKey=payabli_sdk-ios" + + if [ -z "$PR" ]; then + echo "args=$trusted" >> "$GITHUB_OUTPUT" + echo "analysing a branch, not a pull request" + exit 0 + fi + number=$(printf '%s' "$PR" | grep -E '^[0-9]+$') || number="" + branch=$(printf '%s' "$PR_BRANCH" | grep -E '^[A-Za-z0-9._/-]+$') || branch="" + base=$(printf '%s' "$PR_BASE" | grep -E '^[A-Za-z0-9._/-]+$') || base="" + if [ -z "$number" ] || [ -z "$branch" ] || [ -z "$base" ]; then + echo "the triggering run named a pull request this cannot use" >&2 + exit 1 + fi + echo "args=$trusted -Dsonar.pullrequest.key=$number -Dsonar.pullrequest.branch=$branch -Dsonar.pullrequest.base=$base" >> "$GITHUB_OUTPUT" + + # Pinned to a commit, not a tag. `v5` is mutable, and this step is handed + # SONAR_TOKEN, so a replaced action would hold a repository secret. + - name: Analyze + uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5.3.2 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + args: ${{ steps.pr.outputs.args }} diff --git a/.gitignore b/.gitignore index f2f7c92..6ba7644 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ docs/ # Claude AI settings, and the worktree location every repo in this workspace uses. .claude/settings.local.json .claude/worktrees/ + +# Written by a test run with coverage, and by the script that feeds Sonar. +*.xcresult/ +coverage.xml diff --git a/.swiftformat b/.swiftformat index 992dd59..ca75c24 100644 --- a/.swiftformat +++ b/.swiftformat @@ -10,5 +10,16 @@ --trimwhitespace always --semicolons never --header ignore ---disable redundantSelf,redundantReturn +# Four rules below rewrite code, not layout. Verified on swiftformat 0.62.1. +# +# hoistAwait, hoistTry move the keyword to the start of the expression. Across an +# async autoclosure that changes what the code means: +# `await XCTAssertThrowsErrorAsync(try await charge(ttp))` loses its inner await +# and stops compiling, because the autoclosure is its own async context. +# +# noForceUnwrapInTests, noForceTryInTests rewrite `Decimal(string: "25.00")!` as +# `try XCTUnwrap(...)`. That is a different test — a nil fails it instead of +# crashing it — and it needs the case to be `throws`. A formatting pass is the +# wrong place to decide either. +--disable redundantSelf,redundantReturn,hoistAwait,hoistTry,noForceUnwrapInTests,noForceTryInTests --exclude .build,.swiftpm,Example/PayabliDemo/build,ThirdParty diff --git a/.swiftlint.yml b/.swiftlint.yml index 2fa9748..6672774 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -6,9 +6,9 @@ # relaxes rules that conflict with idiomatic XCTest patterns (force # unwraps on fixtures, large fixture tuples, etc.). # -# CI runs `swiftlint --strict`, so every warning is treated as an error. -# Keep warning thresholds calibrated to the real shape of the codebase so -# strict mode stays useful instead of getting disabled. +# CI runs `swiftlint`, which fails on a serious violation and reports the rest. +# `--strict`, which would treat every warning as an error, exits 2 on the 29 +# warnings currently in the tree. disabled_rules: - trailing_whitespace diff --git a/Bridges/ReactNative/PayabliSDKModule.swift b/Bridges/ReactNative/PayabliSDKModule.swift index 3adf01c..d9f0b65 100644 --- a/Bridges/ReactNative/PayabliSDKModule.swift +++ b/Bridges/ReactNative/PayabliSDKModule.swift @@ -515,7 +515,9 @@ public final class PayabliSDKModule: RCTEventEmitter { private extension NSError { func rnCode(default fallback: String) -> String { - if domain == "com.payabli.ttp" { return "TTP_\(self.code)" } + if domain == "com.payabli.ttp" { + return "TTP_\(self.code)" + } return fallback } diff --git a/Example/PayabliDemo/App/PayabliDemoQAApp.swift b/Example/PayabliDemo/App/PayabliDemoQAApp.swift index a445299..b973c91 100644 --- a/Example/PayabliDemo/App/PayabliDemoQAApp.swift +++ b/Example/PayabliDemo/App/PayabliDemoQAApp.swift @@ -12,8 +12,10 @@ struct PayabliDemoQAApp: App { accessTokenProvider: { return try await Secrets.fetchPaymentMethodAccessToken() }, - diagnostics: .qaLogging(enabled: Secrets.paymentMethodDiagnosticsEnabled, - store: .paymentMethod) + diagnostics: .qaLogging( + enabled: Secrets.paymentMethodDiagnosticsEnabled, + store: .paymentMethod + ) ) @StateObject private var paymentCapture = PayabliPayInPaymentFlow( @@ -22,8 +24,10 @@ struct PayabliDemoQAApp: App { accessTokenProvider: { return try await Secrets.fetchPaymentCaptureAccessToken() }, - diagnostics: .qaLogging(enabled: Secrets.paymentCaptureDiagnosticsEnabled, - store: .paymentCapture), + diagnostics: .qaLogging( + enabled: Secrets.paymentCaptureDiagnosticsEnabled, + store: .paymentCapture + ), operation: .capture, requestConfiguration: PaymentCaptureQAView.freshRequestConfiguration() ) @@ -39,6 +43,15 @@ struct PayabliDemoQAApp: App { environment: DemoConfiguration.environment ) + /// One owner for the token probes, so a tab that has finished its backend + /// step still reflects an answer another tab has since had. One entry per + /// token function, because a backend may scope them separately. + @StateObject private var tokenProbes = TokenProbeResults( + fetchCardPresent: { try await Secrets.fetchAccessToken() }, + fetchStoredMethod: { try await Secrets.fetchPaymentMethodAccessToken() }, + fetchCapture: { try await Secrets.fetchPaymentCaptureAccessToken() } + ) + var body: some Scene { WindowGroup { TabView { @@ -65,9 +78,9 @@ struct PayabliDemoQAApp: App { // The app-wide tint. The palette lives in one Swift file rather than an // asset catalogue, so it is set here instead of by an AccentColor asset. .tint(.payabliPrimary) + .environmentObject(tokenProbes) } } - } #Preview { @@ -126,5 +139,5 @@ struct PayabliDemoQAApp: App { Label("Config", systemImage: "gearshape") } } + .environmentObject(TokenProbeResults.inert()) } - diff --git a/Example/PayabliDemo/Config/FlowTests.xcconfig b/Example/PayabliDemo/Config/FlowTests.xcconfig new file mode 100644 index 0000000..fd42035 --- /dev/null +++ b/Example/PayabliDemo/Config/FlowTests.xcconfig @@ -0,0 +1,21 @@ +// The step sequences under Flow/, tested on their own. +// +// No TEST_HOST: the app target cannot compile on a clean checkout, because +// Secrets.swift is gitignored and is a member of its Sources phase. The settings +// below clear what Shared.xcconfig sets for the app. + +PRODUCT_BUNDLE_IDENTIFIER = com.payabli.example.app.flowtests +GENERATE_INFOPLIST_FILE = YES +INFOPLIST_FILE = +CODE_SIGN_ENTITLEMENTS = + +IPHONEOS_DEPLOYMENT_TARGET = 16.7 +SDKROOT = iphoneos +TARGETED_DEVICE_FAMILY = 1,2 +SWIFT_VERSION = 5.0 + +CODE_SIGNING_ALLOWED = NO +CODE_SIGN_STYLE = Manual +DEVELOPMENT_TEAM = + +LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @loader_path/Frameworks diff --git a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift index 566e9cc..865c7f4 100644 --- a/Example/PayabliDemo/Configuration/ConfigurationQAView.swift +++ b/Example/PayabliDemo/Configuration/ConfigurationQAView.swift @@ -10,7 +10,7 @@ import SwiftUI /// The card-not-present rows read from `PayInSharedConfiguration`, the same /// source the forms use, so this screen cannot drift from the real behaviour. struct ConfigurationQAView: View { - @State private var tokenCheckText = "" + @EnvironmentObject private var tokenProbes: TokenProbeResults @State private var healthCheckText = "" @State private var isWorking = false @@ -45,14 +45,13 @@ struct ConfigurationQAView: View { QADetailRow(label: "App ID", value: Secrets.appId) QADetailRow( label: "Environment", - value: "\(DemoConfiguration.nameFor(DemoConfiguration.environment)) · " + (DemoConfiguration.environment.baseURL.host ?? "—") + value: "\(DemoConfiguration.nameFor(DemoConfiguration.environment)) · " + + (DemoConfiguration.environment.baseURL.host ?? "—") + " · " + DemoConfiguration.environmentSource ) } } - - // MARK: - Token endpoint private var tokenEndpointSection: some View { @@ -75,21 +74,45 @@ struct ConfigurationQAView: View { ) QADetailRow(label: "Resolved by", value: DemoConfiguration.TokenServer.explanation) - HStack(spacing: 12) { - Button { runTokenCheck() } label: { - Label("Check token", systemImage: "key.horizontal") - } - .buttonStyle(.bordered) - .disabled(isWorking) + // Both probes live here, outside any step sequence, so either can be + // re-run at any time. The sequence on a payment tab hides its own + // probe once the step is done, and a stored method or a captured + // payment leaves it that way for the rest of the run. + // One per row, each sized to its label. Side by side the longer + // label wraps mid-word at this width. + Button { runTokenCheck() } label: { + Label("Check card-present token", systemImage: "key.horizontal") + } + .buttonStyle(.bordered) + // `isWorking` is this screen's own. A probe started on a tab is in + // flight here too, and only the shared answer says so. + .disabled(isWorking || tokenProbes.isRunning(.cardPresent)) - Button { runHealthCheck() } label: { - Label("Health", systemImage: "heart.text.square") - } - .buttonStyle(.bordered) - .disabled(isWorking) + Button { runCardNotPresentTokenCheck() } label: { + Label("Check card-not-present tokens", systemImage: "key.horizontal") } + .buttonStyle(.bordered) + .disabled( + isWorking + || tokenProbes.isRunning(.storedMethod) + || tokenProbes.isRunning(.capture) + ) + + Button { runHealthCheck() } label: { + Label("Local server health", systemImage: "heart.text.square") + } + .buttonStyle(.bordered) + .disabled(isWorking) - ForEach([tokenCheckText, healthCheckText].filter { !$0.isEmpty }, id: \.self) { line in + ForEach( + [ + tokenProbes.display(for: .cardPresent), + tokenProbes.display(for: .storedMethod), + tokenProbes.display(for: .capture), + healthCheckText + ].filter { !$0.isEmpty }, + id: \.self + ) { line in Text(line) .font(.caption) .foregroundColor(.payabliOnSurfaceVariant) @@ -189,7 +212,6 @@ struct ConfigurationQAView: View { // MARK: - Chrome - @ViewBuilder private func section( _ title: String, note: String?, @@ -209,18 +231,22 @@ struct ConfigurationQAView: View { // MARK: - Actions - /// Reports only *that* a token arrived. Never the value. private func runTokenCheck() { isWorking = true - tokenCheckText = "Checking token…" Task { defer { isWorking = false } - do { - _ = try await Secrets.fetchAccessToken() - tokenCheckText = "✓ Token endpoint returned a token" - } catch { - tokenCheckText = "✗ Token endpoint failed: \(error.localizedDescription)" - } + await tokenProbes.probeCardPresent() + } + } + + /// Both card-not-present endpoints, because the two tabs submit with + /// different token functions and this screen answers for both. + private func runCardNotPresentTokenCheck() { + isWorking = true + Task { + defer { isWorking = false } + await tokenProbes.probeStoredMethod() + await tokenProbes.probeCapture() } } @@ -246,4 +272,5 @@ struct ConfigurationQAView: View { #Preview { ConfigurationQAView() + .environmentObject(TokenProbeResults.inert()) } diff --git a/Example/PayabliDemo/Configuration/DemoConfiguration.swift b/Example/PayabliDemo/Configuration/DemoConfiguration.swift index 9787b3d..042630d 100644 --- a/Example/PayabliDemo/Configuration/DemoConfiguration.swift +++ b/Example/PayabliDemo/Configuration/DemoConfiguration.swift @@ -7,7 +7,6 @@ import PayabliSDKCore /// credentials, so it must not become the home of ordinary settings. Anything /// here is safe to commit and safe to show on the Configuration screen. enum DemoConfiguration { - /// Which Payabli backend every SDK facade in this app talks to. /// /// Sandbox by default: it is the environment an integrator can reach. Pass @@ -23,8 +22,12 @@ enum DemoConfiguration { static let environment: PayabliEnvironment = resolvedEnvironment() static let environmentSource: String = { - if argumentEnvironment() != nil { return "launch argument" } - if rememberedEnvironment() != nil { return "remembered from a launch argument" } + if argumentEnvironment() != nil { + return "launch argument" + } + if rememberedEnvironment() != nil { + return "remembered from a launch argument" + } return "default" }() @@ -82,7 +85,6 @@ enum DemoConfiguration { /// Running against a device needs the server bound past loopback and the /// Local Network permission; see `LocalTokenServer/README.md`. enum TokenServer { - static let defaultPort = 8787 static let accessTokenPath = "/payabli/access-token" @@ -97,7 +99,8 @@ enum DemoConfiguration { static var source: Source { if let raw = UserDefaults.standard.string(forKey: overrideKey), - !raw.trimmingCharacters(in: .whitespaces).isEmpty { + !raw.trimmingCharacters(in: .whitespaces).isEmpty + { return .override(raw.trimmingCharacters(in: .whitespaces)) } return TapToPayPreflight.runtimeEnvironment == .simulator @@ -108,7 +111,7 @@ enum DemoConfiguration { /// Base URL, e.g. `http://127.0.0.1:8787`. static var baseURL: URL { switch source { - case .override(let raw): + case let .override(raw): return normalized(raw) case .simulatorLoopback: return url(host: "127.0.0.1", port: defaultPort) @@ -149,7 +152,8 @@ enum DemoConfiguration { return parsed } if let colon = raw.lastIndex(of: ":"), - let port = Int(raw[raw.index(after: colon)...]) { + let port = Int(raw[raw.index(after: colon)...]) + { return url(host: String(raw[raw.startIndex ..< colon]), port: port) } return url(host: raw, port: defaultPort) diff --git a/Example/PayabliDemo/Debug/DebugPrefill.swift b/Example/PayabliDemo/Debug/DebugPrefill.swift index 1851263..9520f4d 100644 --- a/Example/PayabliDemo/Debug/DebugPrefill.swift +++ b/Example/PayabliDemo/Debug/DebugPrefill.swift @@ -1,161 +1,161 @@ #if DEBUG -import PayabliSDKPayInPaymentFlow -import SwiftUI -import UIKit + import PayabliSDKPayInPaymentFlow + import SwiftUI + import UIKit -/// Debug-only convenience that fills the PayIn form from `DebugPrefill.json`. -/// -/// The SDK owns the form's field state and offers no way to seed it, so each -/// field is found by the `accessibilityIdentifier` the SDK assigns -/// (`payabli.payInPaymentFlow.field.`) and fed through the SDK's -/// own input path, as if typed. -/// -/// This file is Debug-only, and `Config/Release.xcconfig` keeps the JSON out of -/// Release builds. -/// -/// The expiration field is a wheel picker rather than a text field, so it cannot -/// be prefilled and has to be chosen by hand. -enum DebugPrefill { - /// Runtime-read prefill values. Only the fields that map to editable text - /// inputs are applied; `cardExpiration` is decoded for documentation but is - /// entered manually (picker field). - struct Values: Decodable { - var cardholderName: String? - var cardNumber: String? - var cardCvv: String? - var cardZip: String? - var cardExpiration: String? - var firstName: String? - var lastName: String? - var billingEmail: String? - var customerNumber: String? - var achHolder: String? - var achRouting: String? - var achAccount: String? - } - - /// Decoded once from `DebugPrefill.json` in the app bundle. - static let values: Values? = { - guard let url = Bundle.main.url(forResource: "DebugPrefill", withExtension: "json") else { - print("[DebugPrefill] DebugPrefill.json not found in bundle") - return nil - } - do { - let data = try Data(contentsOf: url) - return try JSONDecoder().decode(Values.self, from: data) - } catch { - print("[DebugPrefill] Failed to decode DebugPrefill.json: \(error)") - return nil + /// Debug-only convenience that fills the PayIn form from `DebugPrefill.json`. + /// + /// The SDK owns the form's field state and offers no way to seed it, so each + /// field is found by the `accessibilityIdentifier` the SDK assigns + /// (`payabli.payInPaymentFlow.field.`) and fed through the SDK's + /// own input path, as if typed. + /// + /// This file is Debug-only, and `Config/Release.xcconfig` keeps the JSON out of + /// Release builds. + /// + /// The expiration field is a wheel picker rather than a text field, so it cannot + /// be prefilled and has to be chosen by hand. + enum DebugPrefill { + /// Runtime-read prefill values. Only the fields that map to editable text + /// inputs are applied; `cardExpiration` is decoded for documentation but is + /// entered manually (picker field). + struct Values: Decodable { + var cardholderName: String? + var cardNumber: String? + var cardCvv: String? + var cardZip: String? + var cardExpiration: String? + var firstName: String? + var lastName: String? + var billingEmail: String? + var customerNumber: String? + var achHolder: String? + var achRouting: String? + var achAccount: String? } - }() - /// Fills every visible text field whose identifier matches a value in the - /// JSON. Safe to call repeatedly; missing fields are skipped. - @MainActor - static func fill() { - guard let values else { return } + /// Decoded once from `DebugPrefill.json` in the app bundle. + static let values: Values? = { + guard let url = Bundle.main.url(forResource: "DebugPrefill", withExtension: "json") else { + print("[DebugPrefill] DebugPrefill.json not found in bundle") + return nil + } + do { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(Values.self, from: data) + } catch { + print("[DebugPrefill] Failed to decode DebugPrefill.json: \(error)") + return nil + } + }() + + /// Fills every visible text field whose identifier matches a value in the + /// JSON. Safe to call repeatedly; missing fields are skipped. + @MainActor + static func fill() { + guard let values else { return } - let mapping: [(PayabliPayInPaymentFlowField, String?)] = [ - (.cardholderName, values.cardholderName), - (.cardNumber, values.cardNumber), - (.cardCvv, values.cardCvv), - (.cardZip, values.cardZip), - (.firstName, values.firstName), - (.lastName, values.lastName), - (.billingEmail, values.billingEmail), - (.customerNumber, values.customerNumber), - (.achHolder, values.achHolder), - (.achRouting, values.achRouting), - (.achAccount, values.achAccount) - ] + let mapping: [(PayabliPayInPaymentFlowField, String?)] = [ + (.cardholderName, values.cardholderName), + (.cardNumber, values.cardNumber), + (.cardCvv, values.cardCvv), + (.cardZip, values.cardZip), + (.firstName, values.firstName), + (.lastName, values.lastName), + (.billingEmail, values.billingEmail), + (.customerNumber, values.customerNumber), + (.achHolder, values.achHolder), + (.achRouting, values.achRouting), + (.achAccount, values.achAccount) + ] - let textFields = onScreenTextFields() - for (field, value) in mapping { - guard let value, !value.isEmpty else { continue } - let identifier = "payabli.payInPaymentFlow.field.\(field.rawValue)" - guard let textField = textFields.first(where: { $0.accessibilityIdentifier == identifier }) else { - continue + let textFields = onScreenTextFields() + for (field, value) in mapping { + guard let value, !value.isEmpty else { continue } + let identifier = "payabli.payInPaymentFlow.field.\(field.rawValue)" + guard let textField = textFields.first(where: { $0.accessibilityIdentifier == identifier }) else { + continue + } + inject(value, into: textField) } - inject(value, into: textField) } - } - /// Feeds `value` into `textField` through the SDK's own input path. - /// - /// A protected field ignores direct `.text` assignment, so the value goes in - /// through the delegate, as a paste over the whole field. - /// - /// The delegate's answer has to be honoured: a protected field applies the - /// change itself and says `false`, an unprotected one says `true` and leaves - /// the applying to the caller. - private static func inject(_ value: String, into textField: UITextField) { - let current = (textField.text ?? "") as NSString - let fullRange = NSRange(location: 0, length: current.length) - let shouldApply = textField.delegate?.textField?( - textField, - shouldChangeCharactersIn: fullRange, - replacementString: value - ) ?? true + /// Feeds `value` into `textField` through the SDK's own input path. + /// + /// A protected field ignores direct `.text` assignment, so the value goes in + /// through the delegate, as a paste over the whole field. + /// + /// The delegate's answer has to be honoured: a protected field applies the + /// change itself and says `false`, an unprotected one says `true` and leaves + /// the applying to the caller. + private static func inject(_ value: String, into textField: UITextField) { + let current = (textField.text ?? "") as NSString + let fullRange = NSRange(location: 0, length: current.length) + let shouldApply = textField.delegate?.textField?( + textField, + shouldChangeCharactersIn: fullRange, + replacementString: value + ) ?? true - guard shouldApply else { return } - textField.text = current.replacingCharacters(in: fullRange, with: value) - textField.sendActions(for: .editingChanged) - } + guard shouldApply else { return } + textField.text = current.replacingCharacters(in: fullRange, with: value) + textField.sendActions(for: .editingChanged) + } - /// Text fields of the frontmost presentation only. - /// - /// A presented sheet does not unmount the form behind it, and both carry the - /// same accessibility identifiers, so searching every window and taking the - /// first match could prefill the covered form instead of the sheet. Which one - /// won depended on view order, which made the sheet prefill nondeterministic. - private static func onScreenTextFields() -> [UITextField] { - let windows = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) + /// Text fields of the frontmost presentation only. + /// + /// A presented sheet does not unmount the form behind it, and both carry the + /// same accessibility identifiers, so searching every window and taking the + /// first match could prefill the covered form instead of the sheet. Which one + /// won depended on view order, which made the sheet prefill nondeterministic. + private static func onScreenTextFields() -> [UITextField] { + let windows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) - let root = windows.first(where: \.isKeyWindow) ?? windows.last - var result: [UITextField] = [] - if let host = topmostViewController(from: root?.rootViewController) { - collectTextFields(in: host.view, into: &result) - } - // A form outside any view controller still has to be reachable. - if result.isEmpty, let root { - collectTextFields(in: root, into: &result) + let root = windows.first(where: \.isKeyWindow) ?? windows.last + var result: [UITextField] = [] + if let host = topmostViewController(from: root?.rootViewController) { + collectTextFields(in: host.view, into: &result) + } + // A form outside any view controller still has to be reachable. + if result.isEmpty, let root { + collectTextFields(in: root, into: &result) + } + return result } - return result - } - private static func topmostViewController( - from controller: UIViewController? - ) -> UIViewController? { - guard let controller else { return nil } - if let presented = controller.presentedViewController { - return topmostViewController(from: presented) + private static func topmostViewController( + from controller: UIViewController? + ) -> UIViewController? { + guard let controller else { return nil } + if let presented = controller.presentedViewController { + return topmostViewController(from: presented) + } + return controller } - return controller - } - private static func collectTextFields(in view: UIView, into result: inout [UITextField]) { - if let textField = view as? UITextField { - result.append(textField) - } - for subview in view.subviews { - collectTextFields(in: subview, into: &result) + private static func collectTextFields(in view: UIView, into result: inout [UITextField]) { + if let textField = view as? UITextField { + result.append(textField) + } + for subview in view.subviews { + collectTextFields(in: subview, into: &result) + } } } -} -/// Debug-only button that triggers ``DebugPrefill/fill()``. -struct DebugPrefillButton: View { - var body: some View { - Button { - DebugPrefill.fill() - } label: { - Label("Prefill test data (Debug)", systemImage: "wand.and.stars") - .frame(maxWidth: .infinity) + /// Debug-only button that triggers ``DebugPrefill/fill()``. + struct DebugPrefillButton: View { + var body: some View { + Button { + DebugPrefill.fill() + } label: { + Label("Prefill test data (Debug)", systemImage: "wand.and.stars") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .tint(.payabliWarning) } - .buttonStyle(.bordered) - .tint(.payabliWarning) } -} #endif diff --git a/Example/PayabliDemo/Flow/PayInSteps.swift b/Example/PayabliDemo/Flow/PayInSteps.swift new file mode 100644 index 0000000..26715c6 --- /dev/null +++ b/Example/PayabliDemo/Flow/PayInSteps.swift @@ -0,0 +1,112 @@ +/// How far a card-not-present screen has got. +/// +/// - Parameters: +/// - tokenCheck: what the token probe last said. +/// - hasResult: a submission returned. The component keeps this forever and +/// exposes no reset. +/// - resultAcknowledged: the result has been read and another entry is wanted. +/// - isSubmitting: the SDK is submitting. +/// - submitFailed: the last submission failed, or came back carrying nothing. +struct PayInProgress { + var tokenCheck: TokenCheck = .notRun + var hasResult = false + var resultAcknowledged = false + var isSubmitting = false + var submitFailed = false +} + +/// The three steps of a card-not-present screen. +struct PayInFlowSteps { + let backend: FlowStep + let form: FlowStep + let result: FlowStep + + var all: [FlowStep] { + [backend, form, result] + } +} + +/// What a card-not-present screen asks for, in the order the SDK needs it. +enum PayInSteps { + /// Storing an instrument, which returns a token to reuse. + static func forStoringMethod(_ progress: PayInProgress) -> PayInFlowSteps { + steps( + progress, + formTitle: "Enter the card or ACH details", + resultTitle: "Stored method", + resultDetail: "A successful submit returns a reusable stored-method id." + ) + } + + /// Taking a payment now. + static func forCapture(_ progress: PayInProgress) -> PayInFlowSteps { + steps( + progress, + formTitle: "Enter the payment details", + resultTitle: "Transaction", + resultDetail: "A successful submit returns an approved transaction id." + ) + } + + private static func steps( + _ progress: PayInProgress, + formTitle: String, + resultTitle: String, + resultDetail: String + ) -> PayInFlowSteps { + let showingFinishedResult = progress.hasResult && !progress.resultAcknowledged + + let backend: StepStatus = { + // A submission in flight got a token to submit with, so this step is + // finished for as long as it lasts. The probe is shared, so another + // tab can answer for this endpoint mid-submit; letting that unfinish + // this step would block the form, hide the row, and deallocate the + // view model holding what was typed. The answer applies once the + // submission is over, which is when it says anything about the next + // one. + if progress.isSubmitting { + return .done + } + switch progress.tokenCheck { + // Before the outcome, or the step offers its button over a request + // already in flight. + case .checking: return .inProgress + // The probe outranks a submission that succeeded earlier. The + // component never clears `lastResult`, so one payment would otherwise + // prove the backend for the life of the app. + case .unreachable: return .failed + case .reachable: return .done + case .notRun: return progress.hasResult ? .done : .current + } + }() + + let form: StepStatus = { + guard backend.isFinished else { return .blocked } + if progress.isSubmitting { + return .inProgress + } + if progress.submitFailed { + return .failed + } + return showingFinishedResult ? .done : .current + }() + + // From the step before. `hasResult` can be true while the form is still + // asking for something. + let result: StepStatus = form.isFinished ? .current : .blocked + + return PayInFlowSteps( + backend: FlowStep( + title: "Reach the token backend", + detail: "The SDK asks your backend for a short-lived access token before it submits.", + status: backend + ), + form: FlowStep( + title: formTitle, + detail: "The SDK owns these fields; clear PAN never reaches the host app.", + status: form + ), + result: FlowStep(title: resultTitle, detail: resultDetail, status: result) + ) + } +} diff --git a/Example/PayabliDemo/Flow/StepStatus.swift b/Example/PayabliDemo/Flow/StepStatus.swift new file mode 100644 index 0000000..1bbbedb --- /dev/null +++ b/Example/PayabliDemo/Flow/StepStatus.swift @@ -0,0 +1,77 @@ +/// Where one step of a flow has got to. +enum StepStatus { + /// Finished, and nothing more to do here. + case done + /// The next thing to do. + case current + /// Underway inside the SDK; the app is waiting, not the person. + case inProgress + /// Cannot run until an earlier step finishes. + case blocked + /// Genuinely does not apply to this device or session. + case notNeeded + /// Attempted and failed. + case failed + + /// Whether this step shows its controls. + /// + /// A working step keeps them: the SDK's form owns its typed values in a + /// `@StateObject`, so hiding the row discards them. + var showsContent: Bool { + self == .current || self == .failed || self == .inProgress + } + + /// Whether this step is the one asking for something. Narrower than + /// `showsContent`. + var isActionable: Bool { + self == .current || self == .failed + } + + /// Whether the step after this one is free to proceed. A skipped step counts as + /// finished. + /// + /// A step that reads the state underneath instead can offer itself alongside + /// an earlier step that is still asking for something. + var isFinished: Bool { + self == .done || self == .notNeeded + } +} + +/// One step, as a screen describes it. +/// +/// - Parameters: +/// - title: what the person is doing. +/// - detail: what the SDK does at this point, in one line. +struct FlowStep { + let title: String + let detail: String + let status: StepStatus +} + +/// What the token probe last said. +/// +/// Each screen records the probe as display text, and this is where the text +/// becomes an answer. +enum TokenCheck { + /// The probe has not been run. + case notRun + /// The probe is in flight. + case checking + /// The endpoint returned a token. + case reachable + /// The endpoint did not. + case unreachable + + static func classify(_ text: String) -> TokenCheck { + if text.hasPrefix("✓") { + return .reachable + } + if text.hasPrefix("✗") { + return .unreachable + } + if text.hasPrefix("Checking") { + return .checking + } + return .notRun + } +} diff --git a/Example/PayabliDemo/Flow/TapToPaySteps.swift b/Example/PayabliDemo/Flow/TapToPaySteps.swift new file mode 100644 index 0000000..ae6c831 --- /dev/null +++ b/Example/PayabliDemo/Flow/TapToPaySteps.swift @@ -0,0 +1,248 @@ +import PayabliSDKTapToPay + +/// Which half of the activation step happened. +/// +/// Activation is two SDK calls. `sessionState` cannot tell them apart: both land +/// in `.error`, except a revoked attestation, which lands in `.idle`. +enum TapToPayActivationOutcome { + case none + case activationFailed + /// The attestation behind the activation was revoked. `activateDevice` + /// resets the session to `.idle` for this case alone, and the way out is a + /// fresh cold attestation, which is the enable step's job. + case attestationRevoked + case enableFailed + case succeeded +} + +/// The four steps of taking a contactless payment, in the order the SDK needs +/// them. +struct TapToPayFlowSteps { + let token: FlowStep + let enable: FlowStep + let activation: FlowStep + let charge: FlowStep + + /// The one control the screen offers, or none while the SDK is working. + /// + /// A step reports where it has got to; this says which control a person can + /// press. They are separate because a step reports a failure it cannot + /// retry: a refused activation leaves the session `.error`, and + /// `activateDevice` throws `.invalidState` for anything but + /// `.pendingActivation`, so the activation step shows the reason while + /// recovery is the way forward. + /// + /// Recovery lives here for the same reason. It is not a step, and while it + /// was a flag of its own it appeared beside a step still asking for + /// something. + let nextAction: TapToPayAction? + + /// Why recovery is on offer, which is what the section says out loud. + /// + /// Two reasons and two controls, one each, as it stands. The sentence is + /// keyed on the reason because a control serves whichever reasons share it: + /// `.reinitialize` answered both an expired session and a refused activation + /// until the DeviceCheck path was traced, and the sentence beside it named + /// expiry for both. + let recovery: TapToPayRecovery? + + var all: [FlowStep] { + [token, enable, activation, charge] + } +} + +/// What the screen is recovering from. +enum TapToPayRecovery { + /// The session expired holding its attested identity. + case sessionExpired + /// The session errored. Some of the paths here clear the attested identity + /// and the rest keep it, and which one ran is not knowable from the outside, + /// so recovery runs the setup that works for both. + /// + /// A failed activation is one of them. `.activationFailed` does not + /// establish that `/activate` reached the backend: `generateAssertion` + /// clears the cached key and device on a DeviceCheck error and throws before + /// the request is sent, and that error is not a `PayabliTTPError`, so it + /// arrives as a plain `.activationFailed` with the identity already gone. + /// Only a 401 from `/activate` itself is reported as `.attestationRevoked`. + case sessionErrored +} + +/// A control on the Tap to Pay screen. +enum TapToPayAction { + case checkToken + case enableTerminal + case enterActivationCode + case charge + /// `reinitializeIfNeeded`, which re-runs config and the reader and skips + /// attestation. Only sound where the attested identity is still held. + case reinitialize + /// `initialize`, the full setup. It re-uses a cached attestation and runs a + /// cold one when there is none, so it is the way back from a session that + /// still holds its attested identity and from one that has lost it. + case reattest +} + +/// What taking a contactless payment asks for. +enum TapToPaySteps { + /// - Parameters: + /// - tokenCheck: what the token probe last said. + /// - session: where the terminal session has got to. + /// - outcome: what the last activation attempt did. + static func forCharging( + tokenCheck: TokenCheck, + session: PayabliTTPSessionState, + activation outcome: TapToPayActivationOutcome + ) -> TapToPayFlowSteps { + // States the session cannot reach without a successful authenticated + // request. `.attestingDevice` is set before that request, and `.error` is + // where a failing token provider lands. + let sessionProvesBackend = switch session { + case .fetchingConfig, .initializingReader, .ready, .pendingActivation, .reinitializing: + true + default: + false + } + + let token: StepStatus = { + switch tokenCheck { + // Before the outcome, or the step offers its button over a request + // already in flight. + case .checking: return .inProgress + // The probe outranks the session, which says only that the backend + // answered at some point in the past. + case .unreachable: return .failed + case .reachable: return .done + case .notRun: return sessionProvesBackend ? .done : .current + } + }() + + let enable: StepStatus = { + guard token.isFinished else { return .blocked } + switch session { + case .ready: return .done + case .attestingDevice, .fetchingConfig, .initializingReader, .reinitializing: return .inProgress + // Activation is a separate step, so reaching it means this one + // finished — unless the enable that follows a successful activation + // is what failed. `/config` answering 403 again puts the session back + // to `.pendingActivation`, and the reason for that failure is written + // to this step. + case .pendingActivation: return outcome == .enableFailed ? .failed : .done + // `activateDevice` calls markError when it is refused, so the session + // reads `.error` for a failure that belongs to the step after this + // one. Taking it here would block activation, which is where the + // reason and the retry are rendered. Expiry is not activation's + // doing, so a stale outcome does not move it. + case .error: return outcome == .activationFailed ? .done : .failed + case .sessionExpired: return .failed + // A recorded activation failure at `.idle` is a revoked attestation: + // it is the one failure `activateDevice` resets rather than marks, + // and re-attesting from scratch is this step's own action. + case .idle: + return outcome == .attestationRevoked || outcome == .activationFailed + ? .failed + : .current + @unknown default: return .current + } + }() + + let activation: StepStatus = { + // Ordered. The outcome outlives the session it belongs to, so reading + // it first lets a stale failure report itself while the sequence is + // still on the step before. + guard enable == .done else { return .blocked } + // A session reports `.ready` for a device that was activated and for + // one that never had to be. Only the caller can tell them apart. + if outcome == .succeeded { + return .done + } + if session == .ready { + return .notNeeded + } + // What is left is `.pendingActivation`, or the `.error` the step + // before handed on because a refused activation put it there. + return outcome == .activationFailed ? .failed : .current + }() + + // From the step before. + let charge: StepStatus = { + guard activation.isFinished else { return .blocked } + return session == .ready ? .current : .blocked + }() + + // Ordered like the steps, and for the same reason: the first thing that + // wants attention is the only thing offered. Re-initialize sits behind + // the token step because it re-runs config, which a backend known to be + // down cannot answer. + // Derived before `nextAction`, which reads it, so the control and the + // sentence beside it cannot disagree about what went wrong. + let recovery: TapToPayRecovery? = { + guard !token.isActionable, token.isFinished else { return nil } + if session == .sessionExpired { + return .sessionExpired + } + if session == .error { + return .sessionErrored + } + return nil + }() + + let nextAction: TapToPayAction? = { + if token.isActionable { + return .checkToken + } + guard token.isFinished else { return nil } + // A session that expired still holds its attested identity, so + // re-running config and the reader is enough, and so does a refused + // activation, whose request reached the backend. `.error` otherwise + // is where a config 401 lands, and that path clears the attestation + // cache, so skipping attestation would fail on the assertion every + // time and offer the same control again. + if let recovery { + return recovery == .sessionErrored ? .reattest : .reinitialize + } + // `.failed` as well as `.current`: an enable that failed is retried + // from its own row, and this is the state a broken session does not + // cover. + if enable.isActionable { + return .enableTerminal + } + guard enable.isFinished else { return nil } + // `.failed` as well as `.current`, as with the enable step above: a + // refused code leaves the session `.pendingActivation`, which is the + // one state `activateDevice` accepts, so another code is the way on. + if activation.isActionable { + return .enterActivationCode + } + guard activation.isFinished else { return nil } + return charge == .current ? .charge : nil + }() + + return TapToPayFlowSteps( + token: FlowStep( + title: "Reach the token backend", + detail: "The SDK calls your backend for a fresh access token whenever it needs one.", + status: token + ), + enable: FlowStep( + title: "Enable the terminal", + detail: "Attests the device, fetches the merchant config, and prepares the reader.", + status: enable + ), + activation: FlowStep( + title: "Activate the device", + detail: session == .pendingActivation + ? "Activate the device with the code provided by the Paypoint Device Management dashboard." + : "Only when the backend registers the device as pending.", + status: activation + ), + charge: FlowStep( + title: "Charge a card", + detail: "Presents Apple's Tap to Pay sheet. Hold a card to the top of the phone.", + status: charge + ), + nextAction: nextAction, + recovery: recovery + ) + } +} diff --git a/Example/PayabliDemo/FlowTests/PayInStepsTests.swift b/Example/PayabliDemo/FlowTests/PayInStepsTests.swift new file mode 100644 index 0000000..96a7237 --- /dev/null +++ b/Example/PayabliDemo/FlowTests/PayInStepsTests.swift @@ -0,0 +1,226 @@ +import XCTest + +/// The two card-not-present sequences, over every combination they can be asked +/// for. Both entry points go through the whole space, since they share a +/// derivation. +final class PayInStepsTests: XCTestCase { + private struct Entry { + let name: String + let build: (PayInProgress) -> PayInFlowSteps + } + + private let entries = [ + Entry(name: "storing a method", build: PayInSteps.forStoringMethod), + Entry(name: "capturing a payment", build: PayInSteps.forCapture) + ] + + private let everyProgress: [PayInProgress] = { + let checks: [TokenCheck] = [.notRun, .checking, .reachable, .unreachable] + return checks.flatMap { check in + [false, true].flatMap { hasResult in + [false, true].flatMap { acknowledged in + [false, true].flatMap { submitting in + [false, true].map { failed in + PayInProgress( + tokenCheck: check, + hasResult: hasResult, + resultAcknowledged: acknowledged, + isSubmitting: submitting, + submitFailed: failed + ) + } + } + } + } + } + }() + + /// Enough of the progress to name the case a failure came from. + private func describe(_ entry: Entry, _ progress: PayInProgress) -> String { + """ + \(entry.name): token \(progress.tokenCheck), \ + result \(progress.hasResult), acknowledged \(progress.resultAcknowledged), \ + submitting \(progress.isSubmitting), failed \(progress.submitFailed) + """ + } + + private func each(_ body: (Entry, PayInProgress, PayInFlowSteps) -> Void) { + for entry in entries { + for progress in everyProgress { + body(entry, progress, entry.build(progress)) + } + } + } + + func testTheSpaceIsTheSizeItClaims() { + XCTAssertEqual(everyProgress.count, 4 * 2 * 2 * 2 * 2) + } + + // MARK: - Invariants, over the whole space + + func testNoTwoStepsShowTheirControlsAtOnce() { + each { entry, progress, steps in + let showing = steps.all.filter(\.status.showsContent) + XCTAssertLessThanOrEqual( + showing.count, 1, + "\(describe(entry, progress)) renders \(showing.map(\.title))" + ) + } + } + + func testNoTwoStepsAskForSomethingAtOnce() { + each { entry, progress, steps in + let actionable = steps.all.filter(\.status.isActionable) + XCTAssertLessThanOrEqual( + actionable.count, 1, + "\(describe(entry, progress)) asks for \(actionable.map(\.title))" + ) + } + } + + func testAFailureIsNeverReportedByMoreThanOneStep() { + each { entry, progress, steps in + let failed = steps.all.filter { $0.status == .failed } + XCTAssertLessThanOrEqual( + failed.count, 1, + "\(describe(entry, progress)) reports \(failed.count) failures" + ) + } + } + + func testEveryStepAfterAnUnfinishedOneIsBlocked() { + each { entry, progress, steps in + let all = steps.all + guard let firstUnfinished = all.firstIndex(where: { !$0.status.isFinished }) else { return } + for step in all.dropFirst(firstUnfinished + 1) { + XCTAssertEqual( + step.status, .blocked, + "\(describe(entry, progress)) let \(step.title) run past \(all[firstUnfinished].title)" + ) + } + } + } + + func testEveryStepSomeoneCanActOnShowsItsControls() { + each { entry, progress, steps in + for step in steps.all where step.status.isActionable { + XCTAssertTrue( + step.status.showsContent, + "\(describe(entry, progress)): \(step.title) asks for something it does not show" + ) + } + } + } + + func testAResultIsOfferedOnlyWhenEveryStepBeforeItHasFinished() { + each { entry, progress, steps in + guard steps.result.status.isActionable else { return } + XCTAssertTrue(steps.backend.status.isFinished, "\(describe(entry, progress)) offered a result") + XCTAssertTrue(steps.form.status.isFinished, "\(describe(entry, progress)) offered a result") + } + } + + func testEveryStepSaysWhatItIs() { + each { entry, progress, steps in + XCTAssertEqual(steps.all.count, 3, "\(describe(entry, progress))") + for step in steps.all { + XCTAssertFalse(step.title.isEmpty, "\(describe(entry, progress))") + XCTAssertFalse(step.detail.isEmpty, "\(describe(entry, progress))") + } + } + } + + // MARK: - The order, one point at a time + + func testAnUnprovenBackendKeepsTheSequenceOnTheProbe() { + let steps = PayInSteps.forCapture(PayInProgress()) + XCTAssertEqual(steps.backend.status, .current) + XCTAssertEqual(steps.form.status, .blocked) + XCTAssertEqual(steps.result.status, .blocked) + } + + func testAProbeInFlightIsTheAppWorkingNotThePerson() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .checking)) + XCTAssertEqual(steps.backend.status, .inProgress) + XCTAssertFalse(steps.backend.status.isActionable) + } + + func testAProvenBackendHandsTheSequenceToTheForm() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .reachable)) + XCTAssertEqual(steps.backend.status, .done) + XCTAssertEqual(steps.form.status, .current) + } + + func testASubmissionInFlightKeepsWhatTheFormIsHolding() { + // A hidden row is a deallocated view model, so a decline would come back + // to an empty form. + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .reachable, isSubmitting: true)) + XCTAssertEqual(steps.form.status, .inProgress) + XCTAssertTrue(steps.form.status.showsContent) + XCTAssertFalse(steps.form.status.isActionable) + } + + func testAProbeLandingMidSubmissionDoesNotHideTheForm() { + // The Configuration tab shares this probe, so it can answer while a + // submission is in flight here. A blocked form is a deallocated view + // model and the typed values go with it. + for check in [TokenCheck.unreachable, .checking, .notRun, .reachable] { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: check, isSubmitting: true)) + XCTAssertEqual(steps.backend.status, .done, "token \(check)") + XCTAssertEqual(steps.form.status, .inProgress, "token \(check)") + XCTAssertTrue(steps.form.status.showsContent, "token \(check)") + XCTAssertFalse(steps.form.status.isActionable, "token \(check)") + } + } + + func testAProbeFailureAppliesOnceTheSubmissionFinishes() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .unreachable, isSubmitting: false)) + XCTAssertEqual(steps.backend.status, .failed) + XCTAssertEqual(steps.form.status, .blocked) + } + + func testAFailedSubmissionIsReportedByTheStepThatTookIt() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .reachable, submitFailed: true)) + XCTAssertEqual(steps.form.status, .failed) + XCTAssertEqual(steps.result.status, .blocked) + } + + func testAFinishedSubmissionHandsTheSequenceToItsResult() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .reachable, hasResult: true)) + XCTAssertEqual(steps.form.status, .done) + XCTAssertEqual(steps.result.status, .current) + } + + func testStartingOverHandsTheSequenceBackToTheForm() { + let steps = PayInSteps.forCapture( + PayInProgress(tokenCheck: .reachable, hasResult: true, resultAcknowledged: true) + ) + XCTAssertEqual(steps.form.status, .current) + XCTAssertEqual(steps.result.status, .blocked) + } + + func testTheLatestProbeOutranksASubmissionThatSucceededEarlier() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .unreachable, hasResult: true)) + XCTAssertEqual(steps.backend.status, .failed) + XCTAssertEqual(steps.form.status, .blocked) + XCTAssertEqual(steps.result.status, .blocked) + } + + func testASubmissionProvesTheBackendWhenNoProbeHasRun() { + let steps = PayInSteps.forCapture(PayInProgress(tokenCheck: .notRun, hasResult: true)) + XCTAssertEqual(steps.backend.status, .done) + XCTAssertEqual(steps.result.status, .current) + } + + func testTheTwoEntryPointsDifferOnlyInWhatTheyCallTheirSteps() { + let progress = PayInProgress(tokenCheck: .reachable, hasResult: true) + let storing = PayInSteps.forStoringMethod(progress) + let capturing = PayInSteps.forCapture(progress) + + XCTAssertEqual(storing.all.map(\.status), capturing.all.map(\.status)) + XCTAssertEqual(storing.backend.title, capturing.backend.title) + XCTAssertNotEqual(storing.form.title, capturing.form.title) + XCTAssertNotEqual(storing.result.title, capturing.result.title) + XCTAssertNotEqual(storing.result.detail, capturing.result.detail) + } +} diff --git a/Example/PayabliDemo/FlowTests/StepStatusTests.swift b/Example/PayabliDemo/FlowTests/StepStatusTests.swift new file mode 100644 index 0000000..f069a57 --- /dev/null +++ b/Example/PayabliDemo/FlowTests/StepStatusTests.swift @@ -0,0 +1,45 @@ +import PayabliSDKTapToPay +import XCTest + +/// The vocabulary the sequences are written in. +final class StepStatusTests: XCTestCase { + func testOnlyDoneAndNotNeededReleaseTheStepAfter() { + XCTAssertTrue(StepStatus.done.isFinished) + XCTAssertTrue(StepStatus.notNeeded.isFinished) + XCTAssertFalse(StepStatus.current.isFinished) + XCTAssertFalse(StepStatus.inProgress.isFinished) + XCTAssertFalse(StepStatus.blocked.isFinished) + XCTAssertFalse(StepStatus.failed.isFinished) + } + + // MARK: - The text the screens actually hold + + func testClassifyReadsTheStringsTheScreensWrite() { + // Every screen stores the probe's outcome as display text and passes it + // through here. The combinatorial suites construct `TokenCheck` values + // directly, so this boundary is the one place a changed prefix would go + // unnoticed while every invariant stayed green. + XCTAssertEqual(TokenCheck.classify(""), .notRun) + XCTAssertEqual(TokenCheck.classify("Checking…"), .checking) + XCTAssertEqual(TokenCheck.classify("✓ Token endpoint returned a token"), .reachable) + XCTAssertEqual(TokenCheck.classify("✗ Token endpoint failed: timed out"), .unreachable) + } + + func testClassifyTreatsAnythingElseAsNotRun() { + for text in ["checking", "Checked", "OK", "✓", "✗", " ✓ leading space", "error"] { + let expected: TokenCheck = text == "✓" ? .reachable : text == "✗" ? .unreachable : .notRun + XCTAssertEqual(TokenCheck.classify(text), expected, "\(text)") + } + } + + func testTheDemoKnowsEverySessionStateTheSDKHas() { + // `PayabliTTPSessionState` is `@objc`, so it cannot be `CaseIterable` and + // the list below is written out. A tenth state fails here first. + XCTAssertEqual(everyTapToPaySession.count, 9) + XCTAssertNil(PayabliTTPSessionState(rawValue: 9)) + } +} + +/// The nine session states, by raw value, since the enum is `@objc`. +let everyTapToPaySession: [PayabliTTPSessionState] = + (0 ... 8).compactMap(PayabliTTPSessionState.init(rawValue:)) diff --git a/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift new file mode 100644 index 0000000..ed02ca2 --- /dev/null +++ b/Example/PayabliDemo/FlowTests/TapToPayStepsTests.swift @@ -0,0 +1,478 @@ +import PayabliSDKTapToPay +import XCTest + +/// The Tap to Pay sequence, over every combination it can be asked for. +final class TapToPayStepsTests: XCTestCase { + /// What the probe said, where the session is, and what activation did. + private struct Combination: CustomStringConvertible { + let tokenCheck: TokenCheck + let session: PayabliTTPSessionState + let outcome: TapToPayActivationOutcome + + var description: String { + "token \(tokenCheck), session \(sessionName(session)), activation \(outcome)" + } + } + + private let everyCombination: [Combination] = { + let checks: [TokenCheck] = [.notRun, .checking, .reachable, .unreachable] + let outcomes: [TapToPayActivationOutcome] = + [.none, .activationFailed, .attestationRevoked, .enableFailed, .succeeded] + return checks.flatMap { check in + everyTapToPaySession.flatMap { session in + outcomes.map { Combination(tokenCheck: check, session: session, outcome: $0) } + } + } + }() + + private func steps(_ combination: Combination) -> TapToPayFlowSteps { + TapToPaySteps.forCharging( + tokenCheck: combination.tokenCheck, + session: combination.session, + activation: combination.outcome + ) + } + + func testTheSpaceIsTheSizeItClaims() { + XCTAssertEqual(everyCombination.count, 4 * 9 * 5) + } + + // MARK: - Invariants, over the whole space + + func testNoTwoStepsShowTheirControlsAtOnce() { + for combination in everyCombination { + let showing = steps(combination).all.filter(\.status.showsContent) + XCTAssertLessThanOrEqual( + showing.count, 1, + "\(combination) renders \(showing.map(\.title))" + ) + } + } + + func testNoTwoStepsAskForSomethingAtOnce() { + for combination in everyCombination { + let actionable = steps(combination).all.filter(\.status.isActionable) + XCTAssertLessThanOrEqual( + actionable.count, 1, + "\(combination) asks for \(actionable.map(\.title))" + ) + } + } + + func testAFailureIsNeverReportedByMoreThanOneStep() { + for combination in everyCombination { + let failed = steps(combination).all.filter { $0.status == .failed } + XCTAssertLessThanOrEqual( + failed.count, 1, + "\(combination) reports \(failed.count) failures: \(failed.map(\.title))" + ) + } + } + + func testRecoveryIsGivenAReasonExactlyWhenItIsOffered() { + for combination in everyCombination { + let sequence = steps(combination) + let offered = sequence.nextAction == .reinitialize || sequence.nextAction == .reattest + XCTAssertEqual( + sequence.recovery != nil, offered, + "\(combination) offers \(String(describing: sequence.nextAction)) " + + "with reason \(String(describing: sequence.recovery))" + ) + } + } + + func testEveryRecoveryReasonMatchesTheControlBesideIt() { + // The screen writes one sentence per reason, so a reason paired with the + // wrong control is a sentence describing something that did not happen. + for combination in everyCombination { + let sequence = steps(combination) + guard let recovery = sequence.recovery else { continue } + let expected: TapToPayAction = recovery == .sessionErrored ? .reattest : .reinitialize + // Two reasons, two controls, and the mapping is total. + XCTAssertEqual( + sequence.nextAction, expected, + "\(combination) pairs \(recovery) with \(String(describing: sequence.nextAction))" + ) + } + } + + // MARK: - Each reason, one at a time + + func testAnExpiredSessionSaysItExpired() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, + session: .sessionExpired, + activation: .none + ) + XCTAssertEqual(sequence.recovery, .sessionExpired) + XCTAssertEqual(sequence.nextAction, .reinitialize) + } + + func testAnyOtherErrorRunsTheFullSetup() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, + session: .error, + activation: .none + ) + XCTAssertEqual(sequence.recovery, .sessionErrored) + XCTAssertEqual(sequence.nextAction, .reattest) + } + + func testAFailingTokenProbeOffersNoRecovery() { + // Recovery sits behind the token step, which is the first thing to fix. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .unreachable, + session: .sessionExpired, + activation: .none + ) + XCTAssertNil(sequence.recovery) + XCTAssertEqual(sequence.nextAction, .checkToken) + } + + func testEveryStepAfterAnUnfinishedOneIsBlocked() { + for combination in everyCombination { + let all = steps(combination).all + guard let firstUnfinished = all.firstIndex(where: { !$0.status.isFinished }) else { continue } + for step in all.dropFirst(firstUnfinished + 1) { + XCTAssertEqual( + step.status, .blocked, + "\(combination) let \(step.title) run past \(all[firstUnfinished].title)" + ) + } + } + } + + func testEveryStepSomeoneCanActOnShowsItsControls() { + for combination in everyCombination { + for step in steps(combination).all where step.status.isActionable { + XCTAssertTrue( + step.status.showsContent, + "\(combination): \(step.title) asks for something it does not show" + ) + } + } + } + + func testAChargeIsOfferedOnlyWhenEveryStepBeforeItHasFinished() { + for combination in everyCombination { + let sequence = steps(combination) + guard sequence.charge.status.isActionable else { continue } + XCTAssertTrue(sequence.token.status.isFinished, "\(combination) offered a charge") + XCTAssertEqual(sequence.enable.status, .done, "\(combination) offered a charge") + XCTAssertTrue(sequence.activation.status.isFinished, "\(combination) offered a charge") + XCTAssertEqual(combination.session, .ready, "\(combination) offered a charge") + } + } + + func testTheActivationCodeIsOfferedOnlyWhereTheSDKAcceptsIt() { + // `activateDevice` throws `.invalidState` for any session but + // `.pendingActivation`. + for combination in everyCombination + where steps(combination).nextAction == .enterActivationCode + { + XCTAssertEqual( + combination.session, .pendingActivation, + "\(combination) offered the activation code" + ) + } + } + + func testAChargeIsOfferedOnlyByAReadyTerminal() { + for combination in everyCombination where steps(combination).nextAction == .charge { + XCTAssertEqual(combination.session, .ready, "\(combination) offered a charge") + } + } + + func testRecoveryWaitsForTheTokenStepLikeEverythingElse() { + // Recovery re-runs config, which a backend known to be down cannot + // answer, and offering it beside the probe's own retry is two next + // actions again. + for combination in everyCombination + where steps(combination).nextAction == .reinitialize + || steps(combination).nextAction == .reattest + { + let sequence = steps(combination) + XCTAssertTrue(sequence.token.status.isFinished, "\(combination) offered recovery") + XCTAssertTrue( + combination.session == .error || combination.session == .sessionExpired, + "\(combination) offered recovery" + ) + } + } + + func testAnErroredSessionIsOfferedTheFullSetup() { + // Two paths clear the attested identity and mark the session `.error`: + // a config 401, and a `generateAssertion` that fails on a DeviceCheck + // error, which surfaces as `.activationFailed`. `reinitializeIfNeeded` + // skips attestation and asserts against an identity that is gone, so it + // would fail and offer itself again. + for combination in everyCombination where combination.session == .error { + let sequence = steps(combination) + // A failed probe holds the sequence, and one in flight offers + // nothing at all. + guard sequence.token.status.isFinished else { continue } + XCTAssertEqual(sequence.nextAction, .reattest, "\(combination)") + } + } + + func testAFailedActivationIsOfferedTheFullSetup() { + // `.activationFailed` does not establish that `/activate` reached the + // backend. `generateAssertion` clears the cached key and device on a + // DeviceCheck error and throws before the request is sent, and that error + // is not a `PayabliTTPError`, so it arrives here indistinguishable from a + // decline. Only a 401 from `/activate` is reported as + // `.attestationRevoked`. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .error, activation: .activationFailed + ) + XCTAssertEqual(sequence.recovery, .sessionErrored) + XCTAssertEqual(sequence.nextAction, .reattest) + } + + func testAnExpiredSessionKeepsTheCheaperRecovery() { + // Expiry does not touch the attested identity, so config and the reader + // are all that need re-running. + for combination in everyCombination where combination.session == .sessionExpired { + let sequence = steps(combination) + guard sequence.token.status.isFinished else { continue } + XCTAssertEqual(sequence.nextAction, .reinitialize, "\(combination)") + } + } + + func testAFailedProbeKeepsTheOnlyActionOnTheProbe() { + for session in everyTapToPaySession { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .unreachable, session: session, activation: .activationFailed + ) + XCTAssertEqual( + sequence.nextAction, .checkToken, + "session \(sessionName(session)) moved past a failed probe" + ) + } + } + + func testAnEnableThatFailedAfterActivationKeepsItsOwnStep() { + // Activation succeeded and the enable that follows it did not. `/config` + // answering 403 again leaves the session `.pendingActivation`, and the + // reason is written to the enable step, which says so: "see step 2". + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .pendingActivation, activation: .enableFailed + ) + XCTAssertEqual(sequence.enable.status, .failed) + XCTAssertTrue(sequence.enable.status.showsContent) + XCTAssertEqual(sequence.activation.status, .blocked) + XCTAssertEqual(sequence.nextAction, .enableTerminal) + } + + func testARecordedEnableFailureIsAnsweredByTheEnableStep() { + for combination in everyCombination + where combination.outcome == .enableFailed && steps(combination).token.status.isFinished + { + let sequence = steps(combination) + guard sequence.enable.status.isFinished else { continue } + XCTAssertNotEqual( + combination.session, .pendingActivation, + "\(combination) finished the enable step over a recorded enable failure" + ) + } + } + + func testARevokedAttestationIsTheEnableStepsFailure() { + // `activateDevice` resets to `.idle` for a revoked attestation and marks + // an error for every other refusal, so this is the one activation + // failure whose remedy is a fresh cold attestation. + for outcome in [TapToPayActivationOutcome.attestationRevoked, .activationFailed] { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .idle, activation: outcome + ) + XCTAssertEqual(sequence.enable.status, .failed, "\(outcome)") + XCTAssertTrue(sequence.enable.status.showsContent, "\(outcome)") + XCTAssertEqual(sequence.activation.status, .blocked, "\(outcome)") + XCTAssertEqual(sequence.nextAction, .enableTerminal, "\(outcome)") + } + } + + func testNoCombinationShowsAFailureWithNothingToDo() { + // A step that reports a failure and offers no control, with no recovery + // either, is a screen a person cannot leave. + for combination in everyCombination { + let sequence = steps(combination) + guard sequence.all.contains(where: { $0.status == .failed }) else { continue } + XCTAssertNotNil( + sequence.nextAction, + "\(combination) reports a failure and offers nothing" + ) + } + } + + func testEveryStepSaysWhatItIs() { + for combination in everyCombination { + let all = steps(combination).all + XCTAssertEqual(all.count, 4, "\(combination)") + for step in all { + XCTAssertFalse(step.title.isEmpty, "\(combination)") + XCTAssertFalse(step.detail.isEmpty, "\(combination)") + } + } + } + + // MARK: - The order, one point at a time + + func testAnUnprovenBackendKeepsTheSequenceOnTheProbe() { + let sequence = TapToPaySteps.forCharging(tokenCheck: .notRun, session: .idle, activation: .none) + XCTAssertEqual(sequence.token.status, .current) + XCTAssertEqual(sequence.enable.status, .blocked) + } + + func testAProbeInFlightIsTheAppWorkingNotThePerson() { + let sequence = TapToPaySteps.forCharging(tokenCheck: .checking, session: .idle, activation: .none) + XCTAssertEqual(sequence.token.status, .inProgress) + XCTAssertFalse(sequence.token.status.isActionable) + } + + func testAProvenBackendHandsTheSequenceToTheTerminal() { + let sequence = TapToPaySteps.forCharging(tokenCheck: .reachable, session: .idle, activation: .none) + XCTAssertEqual(sequence.token.status, .done) + XCTAssertEqual(sequence.enable.status, .current) + } + + func testStartingTheTerminalIsTheAppWaitingNotThePerson() { + for session in [PayabliTTPSessionState.attestingDevice, .fetchingConfig, .initializingReader, .reinitializing] { + let sequence = TapToPaySteps.forCharging(tokenCheck: .reachable, session: session, activation: .none) + XCTAssertEqual(sequence.enable.status, .inProgress, "\(sessionName(session))") + XCTAssertFalse(sequence.enable.status.isActionable, "\(sessionName(session))") + } + } + + func testASessionThatStoppedKeepsTheStepThatCanStartItAgain() { + for session in [PayabliTTPSessionState.error, .sessionExpired] { + let sequence = TapToPaySteps.forCharging(tokenCheck: .reachable, session: session, activation: .none) + XCTAssertEqual(sequence.enable.status, .failed, "\(sessionName(session))") + XCTAssertTrue(sequence.enable.status.showsContent, "\(sessionName(session))") + } + } + + func testADeviceAwaitingRegistrationIsAskedForACodeAndCannotChargeYet() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .pendingActivation, activation: .none + ) + XCTAssertEqual(sequence.enable.status, .done) + XCTAssertEqual(sequence.activation.status, .current) + XCTAssertEqual(sequence.charge.status, .blocked) + } + + func testARefusedActivationIsReportedByTheActivationStepWhenTheSessionRecordsTheError() { + // `activateDevice` calls markError on failure, so the session reads + // `.error` while the step that failed is activation. Blaming the enable + // step for it blocks activation, which is where the reason and the retry + // are rendered. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .error, activation: .activationFailed + ) + XCTAssertEqual(sequence.enable.status, .done) + XCTAssertEqual(sequence.activation.status, .failed) + XCTAssertTrue(sequence.activation.status.showsContent) + XCTAssertEqual(sequence.charge.status, .blocked) + // The reason shows; the full setup is the way forward. + XCTAssertEqual(sequence.nextAction, .reattest) + } + + func testNoRecordedActivationFailureIsAnsweredByAnEarlierStep() { + for combination in everyCombination + where combination.outcome == .activationFailed && combination.session == .error + { + let sequence = steps(combination) + // A probe that failed legitimately holds the whole sequence. + guard sequence.token.status.isFinished else { continue } + XCTAssertEqual( + sequence.activation.status, .failed, + "\(combination) answered an activation failure somewhere else" + ) + XCTAssertTrue(sequence.activation.status.showsContent, "\(combination)") + } + } + + func testAnExpiredSessionIsStillTheEnableStepsFailure() { + // Session expiry is not activation's doing, so a stale outcome must not + // move that failure onto the step after it. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .sessionExpired, activation: .activationFailed + ) + XCTAssertEqual(sequence.enable.status, .failed) + XCTAssertEqual(sequence.activation.status, .blocked) + } + + func testARefusedActivationKeepsItsOwnStepAndBlocksTheCharge() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .pendingActivation, activation: .activationFailed + ) + XCTAssertEqual(sequence.activation.status, .failed) + XCTAssertTrue(sequence.activation.status.showsContent) + XCTAssertEqual(sequence.charge.status, .blocked) + // The session is still the one state `activateDevice` accepts, so the + // reason comes with another go rather than a dead end. + XCTAssertEqual(sequence.nextAction, .enterActivationCode) + } + + func testARecordedActivationFailureStaysQuietUntilTheSequenceReachesActivation() { + // Stale here: the terminal is starting, so the outcome describes a + // session that is gone. `.idle` is not stale and is covered separately — + // it is the state a revoked attestation leaves behind. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .attestingDevice, activation: .activationFailed + ) + XCTAssertEqual(sequence.enable.status, .inProgress) + XCTAssertEqual(sequence.activation.status, .blocked) + XCTAssertNil(sequence.nextAction) + } + + func testAnActivationThatSucceededReadsAsDoneNotAsOneThatNeverApplied() { + let sequence = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .ready, activation: .succeeded + ) + XCTAssertEqual(sequence.activation.status, .done) + } + + func testATerminalThatNeverNeededActivationSaysSoRatherThanPretendingItIsDone() { + let sequence = TapToPaySteps.forCharging(tokenCheck: .reachable, session: .ready, activation: .none) + XCTAssertEqual(sequence.activation.status, .notNeeded) + XCTAssertEqual(sequence.charge.status, .current) + } + + func testAFailedProbeHoldsTheWholeSequenceEvenWithAReadyTerminal() { + // Enable the terminal, then point the probe at a backend that is down. + let sequence = TapToPaySteps.forCharging( + tokenCheck: .unreachable, session: .ready, activation: .none + ) + XCTAssertEqual(sequence.token.status, .failed) + XCTAssertEqual(sequence.enable.status, .blocked) + XCTAssertEqual(sequence.activation.status, .blocked) + XCTAssertEqual(sequence.charge.status, .blocked) + } + + func testTheActivationStepSaysWhereTheCodeComesFromOnlyWhenOneIsWanted() { + let pending = TapToPaySteps.forCharging( + tokenCheck: .reachable, session: .pendingActivation, activation: .none + ) + let idle = TapToPaySteps.forCharging(tokenCheck: .reachable, session: .idle, activation: .none) + XCTAssertTrue(pending.activation.detail.contains("Device Management")) + XCTAssertNotEqual(idle.activation.detail, pending.activation.detail) + } +} + +/// `PayabliTTPSessionState` is `@objc`, so `String(describing:)` renders a raw +/// value rather than the case name, and a failure message has to name the state. +func sessionName(_ state: PayabliTTPSessionState) -> String { + switch state { + case .idle: return "idle" + case .attestingDevice: return "attestingDevice" + case .fetchingConfig: return "fetchingConfig" + case .initializingReader: return "initializingReader" + case .ready: return "ready" + case .sessionExpired: return "sessionExpired" + case .reinitializing: return "reinitializing" + case .pendingActivation: return "pendingActivation" + case .error: return "error" + @unknown default: return "state(\(state.rawValue))" + } +} diff --git a/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift b/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift new file mode 100644 index 0000000..36728df --- /dev/null +++ b/Example/PayabliDemo/FlowTests/TokenProbeResultsTests.swift @@ -0,0 +1,197 @@ +import XCTest + +/// The probes are shared across tabs, so two runs of one can be in flight at +/// once and finish in either order. These pin the rule that the run started last +/// is the one that publishes, and that the three probes are separate answers. +@MainActor +final class TokenProbeResultsTests: XCTestCase { + /// Holds each fetch until the test releases it, so the finishing order is the + /// test's choice. Ordering asserted against real timing would be a coin toss. + private actor Latch { + private var registered = 0 + private var held: [Int: CheckedContinuation] = [:] + + /// Takes the next run number and registers it in one call. Numbering in + /// one call and registering in another lets a `release` land in between, + /// where it finds nothing to resume and is dropped, and the run it was + /// meant for then waits for a release that has already happened. Nothing + /// suspends between the two lines below, so a run the test can count is + /// a run the test can release. + func hold() async -> Int { + registered += 1 + let run = registered + await withCheckedContinuation { held[run] = $0 } + return run + } + + func count() -> Int { + registered + } + + func release(_ run: Int) { + held.removeValue(forKey: run)?.resume() + } + } + + private struct Refused: Error, LocalizedError { + var errorDescription: String? { + "refused" + } + } + + private let succeeded = "✓ Card-present token endpoint returned a token" + private let failed = "✗ Card-present token endpoint failed: refused" + + /// Bounded, so a condition that never holds fails with a name instead of + /// running the suite into its timeout. + private func waitUntil( + _ description: String, + _ condition: () async -> Bool, + file: StaticString = #filePath, + line: UInt = #line + ) async { + for _ in 0 ..< 1000 { + if await condition() { + return + } + await Task.yield() + } + XCTFail("never became true: \(description)", file: file, line: line) + } + + func testAnEarlierRunFinishingLastDoesNotOverwriteTheLatestAnswer() async { + let latch = Latch() + // The first run refuses and the second succeeds, so which one published + // is visible in the text rather than inferred. + let store = TokenProbeResults( + fetchCardPresent: { + let run = await latch.hold() + if run == 1 { + throw Refused() + } + return "token" + }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + + let first = Task { await store.probeCardPresent() } + await waitUntil("the first run registers") { await latch.count() >= 1 } + let second = Task { await store.probeCardPresent() } + await waitUntil("the second run registers") { await latch.count() >= 2 } + + await latch.release(2) + await second.value + XCTAssertEqual(store.cardPresent, succeeded) + + await latch.release(1) + await first.value + XCTAssertEqual( + store.cardPresent, succeeded, + "the first run published over an answer given after it started" + ) + } + + /// The probe is shared, so this run can have been started from another tab + /// while a payer is part way through the form here. A backend step that stops + /// being finished blocks the form row, and `StepRow` takes the row's content + /// down with the `@StateObject` holding what was typed. + func testARunInFlightKeepsTheAnswerTheLastOneSettledOn() async { + let latch = Latch() + let store = TokenProbeResults( + fetchCardPresent: { + let run = await latch.hold() + _ = run + return "token" + }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + + let first = Task { await store.probeCardPresent() } + await waitUntil("the first run registers") { await latch.count() >= 1 } + await latch.release(1) + await first.value + XCTAssertEqual(store.check(.cardPresent), .reachable) + + let second = Task { await store.probeCardPresent() } + await waitUntil("the second run registers") { await latch.count() >= 2 } + + XCTAssertTrue(store.isRunning(.cardPresent)) + XCTAssertEqual( + store.check(.cardPresent), .reachable, + "a run in flight retracted the answer the step had already acted on" + ) + XCTAssertEqual(store.display(for: .cardPresent), "Checking…") + + await latch.release(2) + await second.value + XCTAssertFalse(store.isRunning(.cardPresent)) + XCTAssertEqual(store.check(.cardPresent), .reachable) + } + + func testTheFirstRunOfAllReportsItselfAsChecking() async { + let latch = Latch() + let store = TokenProbeResults( + fetchCardPresent: { + _ = await latch.hold() + return "token" + }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + + let run = Task { await store.probeCardPresent() } + await waitUntil("the run registers") { await latch.count() >= 1 } + + XCTAssertEqual(store.check(.cardPresent), .checking) + + await latch.release(1) + await run.value + } + + func testASingleRunStillPublishes() async { + let store = TokenProbeResults( + fetchCardPresent: { "token" }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + + await store.probeCardPresent() + + XCTAssertEqual(store.cardPresent, succeeded) + } + + func testAFailureIsPublishedLikeAnyOtherAnswer() async { + let store = TokenProbeResults( + fetchCardPresent: { throw Refused() }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + + await store.probeCardPresent() + + XCTAssertEqual(store.cardPresent, failed) + } + + /// The two card-not-present tabs submit with different token functions, so + /// one answering must not answer for the other. + func testTheThreeProbesAreSeparateAnswers() async { + let store = TokenProbeResults( + fetchCardPresent: { "token" }, + fetchStoredMethod: { "token" }, + fetchCapture: { throw Refused() } + ) + + await store.probeStoredMethod() + + XCTAssertEqual(store.storedMethod, "✓ Stored-method token endpoint returned a token") + XCTAssertEqual(store.cardPresent, "") + XCTAssertEqual(store.capture, "") + + await store.probeCapture() + + XCTAssertEqual(store.capture, "✗ Capture token endpoint failed: refused") + XCTAssertEqual(store.storedMethod, "✓ Stored-method token endpoint returned a token") + } +} diff --git a/Example/PayabliDemo/PayIn/PayInSharedConfiguration.swift b/Example/PayabliDemo/PayIn/PayInSharedConfiguration.swift index ef94b39..8fd0d81 100644 --- a/Example/PayabliDemo/PayIn/PayInSharedConfiguration.swift +++ b/Example/PayabliDemo/PayIn/PayInSharedConfiguration.swift @@ -11,7 +11,6 @@ import SwiftUI /// The Configuration screen reads these same values, so what it displays cannot /// drift from what the forms actually use. enum PayInSharedConfiguration { - // MARK: - Methods static let allowedMethods: [PayabliPayInPaymentFlowMethodType] = [.card, .ach] diff --git a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift index 1e526cb..5f303e9 100644 --- a/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentCaptureQAView.swift @@ -7,11 +7,10 @@ struct PaymentCaptureQAView: View { @ObservedObject var paymentFlow: PayabliPayInPaymentFlow @StateObject private var diagnosticsStore = DiagnosticsStore.paymentCapture + @EnvironmentObject private var tokenProbes: TokenProbeResults @State private var resultText = "" - @State private var tokenCheckText = "" @State private var resultAcknowledged = false @State private var submitFailed = false - @State private var isCheckingToken = false @State private var capturedResult: PayabliPayInPaymentFlowResult? @State private var isPaymentCaptureSheetPresented = false @State private var isPaymentCaptureResultViewPresented = false @@ -25,32 +24,26 @@ struct PaymentCaptureQAView: View { Text("Steps") .font(.headline) - QAStepRow( - index: 1, - title: "Reach the token backend", - detail: "The SDK asks your backend for a short-lived access token before it submits.", - status: tokenStepStatus - ) { + StepRow(index: 1, step: steps.backend) { VStack(alignment: .leading, spacing: 6) { Button { runTokenCheck() } label: { Label("Check token endpoint", systemImage: "key.horizontal") } .buttonStyle(.bordered) - .disabled(isCheckingToken) - if !tokenCheckText.isEmpty { - Text(tokenCheckText) + // The probe is shared, so a run started on another + // tab is in flight here too. The store is what knows + // that; a local flag does not. + .disabled(tokenProbes.isRunning(.capture)) + if !tokenProbes.display(for: .capture).isEmpty { + Text(tokenProbes.display(for: .capture)) .font(.caption) - .foregroundColor(tokenCheckText.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(tokenProbes.display(for: .capture) + .hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) } } } - QAStepRow( - index: 2, - title: "Enter the payment details", - detail: "The SDK owns these fields; clear PAN never reaches the host app.", - status: formStepStatus - ) { + StepRow(index: 2, step: steps.form) { VStack(alignment: .leading, spacing: 12) { Button { isPaymentCaptureSheetPresented = true @@ -61,7 +54,7 @@ struct PaymentCaptureQAView: View { .buttonStyle(.bordered) #if DEBUG - DebugPrefillButton() + DebugPrefillButton() #endif PayabliPayInPaymentFlowView( @@ -71,19 +64,26 @@ struct PaymentCaptureQAView: View { onError: handleError ) .payabliPayInPaymentFlowStyle(style) + + // The step that failed shows why. A failed form + // blocks the result row, which is the only other + // place this text renders, so leaving it there + // offers a retry with no reason beside it. + if submitFailed { + Text(resultText) + .font(.footnote) + .foregroundColor(.payabliError) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } } } - QAStepRow( - index: 3, - title: "Transaction", - detail: "A successful submit returns an approved transaction id.", - status: resultStepStatus - ) { + StepRow(index: 3, step: steps.result) { VStack(alignment: .leading, spacing: 10) { Text(resultText.isEmpty ? "Nothing captured yet." : resultText) .font(.footnote) - .foregroundColor(submitFailed ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(.payabliOnSurfaceVariant) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) @@ -120,34 +120,34 @@ struct PaymentCaptureQAView: View { ) #if DEBUG .onChange(of: isPaymentCaptureSheetPresented) { isPresented in - guard isPresented else { return } - // Let the sheet's fields mount before injecting values. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - DebugPrefill.fill() + guard isPresented else { return } + // Let the sheet's fields mount before injecting values. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + DebugPrefill.fill() + } } - } #endif } - // MARK: - Step status + // MARK: - The sequence /// `PayabliPayInPaymentFlow` publishes only `isSubmitting` and `lastResult`, - /// so these three derive from those plus the token check — never from state - /// tracked separately, which could disagree with the component. - /// True once the backend is known reachable — either the check was run, or - /// a submit already succeeded, which proves it just as well. - private var backendProven: Bool { - tokenCheckText.hasPrefix("✓") || paymentFlow.lastResult != nil - } - - /// The component keeps `lastResult` forever and exposes no reset, so a - /// finished submit would pin the flow on step 3 with no way back. This - /// records that the result has been read and another entry is wanted. - private var showingFinishedResult: Bool { - paymentFlow.lastResult != nil && !resultAcknowledged + /// so the sequence derives from those plus the token probe. + private var steps: PayInFlowSteps { + PayInSteps.forCapture( + PayInProgress( + tokenCheck: tokenProbes.check(.capture), + hasResult: paymentFlow.lastResult != nil, + resultAcknowledged: resultAcknowledged, + isSubmitting: paymentFlow.isSubmitting, + submitFailed: submitFailed + ) + ) } - /// Hands the flow back to step 2 for another entry. + /// Hands the flow back to step 2 for another entry. The component keeps + /// `lastResult` forever and exposes no reset, so a finished submit would + /// otherwise pin the sequence on step 3. private func startAnother() { resultAcknowledged = true submitFailed = false @@ -155,27 +155,6 @@ struct PaymentCaptureQAView: View { paymentFlow.configure(requestConfiguration: Self.freshRequestConfiguration()) } - private var tokenStepStatus: QAStepStatus { - if tokenCheckText.hasPrefix("✗") { return .failed } - return backendProven ? .done : .current - } - - private var formStepStatus: QAStepStatus { - // Exactly one step is ever `.current`, so this waits rather than - // competing with step 1 for attention. - guard backendProven else { return .blocked } - if paymentFlow.isSubmitting { return .inProgress } - if submitFailed { return .failed } - return showingFinishedResult ? .done : .current - } - - private var resultStepStatus: QAStepStatus { - // A failure belongs to the step that produced it. Marking this one failed - // too would give the sequence two actionable failures. - if submitFailed { return .blocked } - return showingFinishedResult ? .current : .blocked - } - /// A capture's request configuration, with a key minted per submission. /// /// The app builds one of these at launch for the initial submit. Reusing that @@ -197,19 +176,8 @@ struct PaymentCaptureQAView: View { ) } - /// Reports only that a token arrived. Never the token itself. private func runTokenCheck() { - isCheckingToken = true - tokenCheckText = "Checking…" - Task { - defer { isCheckingToken = false } - do { - _ = try await Secrets.fetchPaymentCaptureAccessToken() - tokenCheckText = "✓ Token endpoint returned a token" - } catch { - tokenCheckText = "✗ \(error.localizedDescription)" - } - } + Task { await tokenProbes.probeCapture() } } private var configuration: PayabliPayInPaymentFlowFormConfiguration { @@ -326,7 +294,9 @@ struct PaymentCaptureQAView: View { ) } - private var style: PayabliPayInPaymentFlowStyle { PayInSharedConfiguration.style } + private var style: PayabliPayInPaymentFlowStyle { + PayInSharedConfiguration.style + } private var fieldsWithHiddenLabels: [PayabliPayInPaymentFlowField] { PayInSharedConfiguration.fieldsWithHiddenLabels @@ -386,7 +356,6 @@ struct PaymentCaptureQAView: View { } } - #Preview { PaymentCaptureQAView( paymentFlow: PayabliPayInPaymentFlow( @@ -407,5 +376,5 @@ struct PaymentCaptureQAView: View { ) ) ) + .environmentObject(TokenProbeResults.inert()) } - diff --git a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift index 8d1fe35..acfab52 100644 --- a/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift +++ b/Example/PayabliDemo/PayIn/PaymentMethodQAView.swift @@ -7,11 +7,10 @@ struct PaymentMethodQAView: View { @ObservedObject var paymentFlow: PayabliPayInPaymentFlow @StateObject private var diagnosticsStore = DiagnosticsStore.paymentMethod + @EnvironmentObject private var tokenProbes: TokenProbeResults @State private var resultText = "" - @State private var tokenCheckText = "" @State private var resultAcknowledged = false @State private var submitFailed = false - @State private var isCheckingToken = false @State private var isPaymentMethodAddedViewPresented = false @State private var isPaymentMethodSheetPresented = false @@ -24,32 +23,26 @@ struct PaymentMethodQAView: View { Text("Steps") .font(.headline) - QAStepRow( - index: 1, - title: "Reach the token backend", - detail: "The SDK asks your backend for a short-lived access token before it submits.", - status: tokenStepStatus - ) { + StepRow(index: 1, step: steps.backend) { VStack(alignment: .leading, spacing: 6) { Button { runTokenCheck() } label: { Label("Check token endpoint", systemImage: "key.horizontal") } .buttonStyle(.bordered) - .disabled(isCheckingToken) - if !tokenCheckText.isEmpty { - Text(tokenCheckText) + // The probe is shared, so a run started on another + // tab is in flight here too. The store is what knows + // that; a local flag does not. + .disabled(tokenProbes.isRunning(.storedMethod)) + if !tokenProbes.display(for: .storedMethod).isEmpty { + Text(tokenProbes.display(for: .storedMethod)) .font(.caption) - .foregroundColor(tokenCheckText.hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(tokenProbes.display(for: .storedMethod) + .hasPrefix("✗") ? .payabliError : .payabliOnSurfaceVariant) } } } - QAStepRow( - index: 2, - title: "Enter the card or ACH details", - detail: "The SDK owns these fields; clear PAN never reaches the host app.", - status: formStepStatus - ) { + StepRow(index: 2, step: steps.form) { VStack(alignment: .leading, spacing: 12) { Button { isPaymentMethodSheetPresented = true @@ -60,7 +53,7 @@ struct PaymentMethodQAView: View { .buttonStyle(.bordered) #if DEBUG - DebugPrefillButton() + DebugPrefillButton() #endif PayabliPayInPaymentFlowView( @@ -70,19 +63,26 @@ struct PaymentMethodQAView: View { onError: handleError ) .payabliPayInPaymentFlowStyle(style) + + // The step that failed shows why. A failed form + // blocks the result row, which is the only other + // place this text renders, so leaving it there + // offers a retry with no reason beside it. + if submitFailed { + Text(resultText) + .font(.footnote) + .foregroundColor(.payabliError) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } } } - QAStepRow( - index: 3, - title: "Stored method", - detail: "A successful submit returns a reusable stored-method id.", - status: resultStepStatus - ) { + StepRow(index: 3, step: steps.result) { VStack(alignment: .leading, spacing: 10) { Text(resultText.isEmpty ? "Nothing stored yet." : resultText) .font(.footnote) - .foregroundColor(submitFailed ? .payabliError : .payabliOnSurfaceVariant) + .foregroundColor(.payabliOnSurfaceVariant) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) @@ -117,74 +117,42 @@ struct PaymentMethodQAView: View { ) #if DEBUG .onChange(of: isPaymentMethodSheetPresented) { isPresented in - guard isPresented else { return } - // Let the sheet's fields mount before injecting values. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - DebugPrefill.fill() + guard isPresented else { return } + // Let the sheet's fields mount before injecting values. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + DebugPrefill.fill() + } } - } #endif } - // MARK: - Step status + // MARK: - The sequence /// `PayabliPayInPaymentFlow` publishes only `isSubmitting` and `lastResult`, - /// so these three derive from those plus the token check — never from state - /// tracked separately, which could disagree with the component. - /// True once the backend is known reachable — either the check was run, or - /// a submit already succeeded, which proves it just as well. - private var backendProven: Bool { - tokenCheckText.hasPrefix("✓") || paymentFlow.lastResult != nil - } - - /// The component keeps `lastResult` forever and exposes no reset, so a - /// finished submit would pin the flow on step 3 with no way back. This - /// records that the result has been read and another entry is wanted. - private var showingFinishedResult: Bool { - paymentFlow.lastResult != nil && !resultAcknowledged + /// so the sequence derives from those plus the token probe. + private var steps: PayInFlowSteps { + PayInSteps.forStoringMethod( + PayInProgress( + tokenCheck: tokenProbes.check(.storedMethod), + hasResult: paymentFlow.lastResult != nil, + resultAcknowledged: resultAcknowledged, + isSubmitting: paymentFlow.isSubmitting, + submitFailed: submitFailed + ) + ) } - /// Hands the flow back to step 2 for another entry. + /// Hands the flow back to step 2 for another entry. The component keeps + /// `lastResult` forever and exposes no reset, so a finished submit would + /// otherwise pin the sequence on step 3. private func startAnother() { resultAcknowledged = true submitFailed = false resultText = "" } - private var tokenStepStatus: QAStepStatus { - if tokenCheckText.hasPrefix("✗") { return .failed } - return backendProven ? .done : .current - } - - private var formStepStatus: QAStepStatus { - // Exactly one step is ever `.current`, so this waits rather than - // competing with step 1 for attention. - guard backendProven else { return .blocked } - if paymentFlow.isSubmitting { return .inProgress } - if submitFailed { return .failed } - return showingFinishedResult ? .done : .current - } - - private var resultStepStatus: QAStepStatus { - // A failure belongs to the step that produced it. Marking this one failed - // too would give the sequence two actionable failures. - if submitFailed { return .blocked } - return showingFinishedResult ? .current : .blocked - } - - /// Reports only that a token arrived. Never the token itself. private func runTokenCheck() { - isCheckingToken = true - tokenCheckText = "Checking…" - Task { - defer { isCheckingToken = false } - do { - _ = try await Secrets.fetchPaymentMethodAccessToken() - tokenCheckText = "✓ Token endpoint returned a token" - } catch { - tokenCheckText = "✗ \(error.localizedDescription)" - } - } + Task { await tokenProbes.probeStoredMethod() } } private var configuration: PayabliPayInPaymentFlowFormConfiguration { @@ -274,7 +242,9 @@ struct PaymentMethodQAView: View { ) } - private var style: PayabliPayInPaymentFlowStyle { PayInSharedConfiguration.style } + private var style: PayabliPayInPaymentFlowStyle { + PayInSharedConfiguration.style + } private var fieldsWithHiddenLabels: [PayabliPayInPaymentFlowField] { PayInSharedConfiguration.fieldsWithHiddenLabels @@ -332,4 +302,5 @@ struct PaymentMethodQAView: View { environment: DemoConfiguration.environment ) ) + .environmentObject(TokenProbeResults.inert()) } diff --git a/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj b/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj index bfc31dc..f709f9d 100644 --- a/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj +++ b/Example/PayabliDemo/PayabliDemo.xcodeproj/project.pbxproj @@ -8,7 +8,10 @@ /* Begin PBXBuildFile section */ A1B2C3D4E5F60000000000E2 /* QAContextLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */; }; - A1B2C3D4E5F60000000000D2 /* QAStepRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000D1 /* QAStepRow.swift */; }; + A1B2C3D4E5F60000000000D2 /* StepRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000D1 /* StepRow.swift */; }; + A1B2C3D4E5F60000000000F2 /* TokenProbeResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000F1 /* TokenProbeResults.swift */; }; + A1B2C3D4E5F60000000000F3 /* TokenProbeResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000F1 /* TokenProbeResults.swift */; }; + A1B2C3D4E5F60000000000F5 /* TokenProbeResultsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60000000000F4 /* TokenProbeResultsTests.swift */; }; 0F5E8CC7E7A24D19A71B0A1C /* PaymentMethodAddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E8CC6E7A24D19A71B0A1C /* PaymentMethodAddedView.swift */; }; 1C7A9E9B6D2F4C0F8A1B3D5E /* PaymentCaptureQAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1E0F9A8C7D6E5F4A3B2C1D /* PaymentCaptureQAView.swift */; }; 3F9C8D7E6A5B4C3D2E1F0A9B /* PayabliSDKExampleAggregate in Frameworks */ = {isa = PBXBuildFile; productRef = 4A0D9E8F7B6C5D4E3F2A1B0C /* PayabliSDKExampleAggregate */; }; @@ -30,9 +33,31 @@ E1D2C3B4A5960718293A4B01 /* DebugPrefill.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D2C3B4A5960718293A4B02 /* DebugPrefill.swift */; }; E1D2C3B4A5960718293A4B03 /* DebugPrefill.json in Resources */ = {isa = PBXBuildFile; fileRef = E1D2C3B4A5960718293A4B04 /* DebugPrefill.json */; }; F10000000000000000000030 /* PayabliSDKExampleAggregate in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 4A0D9E8F7B6C5D4E3F2A1B0C /* PayabliSDKExampleAggregate */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + F20000000000000000000002 /* StepStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000001 /* StepStatus.swift */; }; + F20000000000000000000004 /* TapToPaySteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000003 /* TapToPaySteps.swift */; }; + F20000000000000000000006 /* PayInSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000005 /* PayInSteps.swift */; }; + F20000000000000000000012 /* StepStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000001 /* StepStatus.swift */; }; + F20000000000000000000013 /* TapToPaySteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000003 /* TapToPaySteps.swift */; }; + F20000000000000000000014 /* PayInSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000005 /* PayInSteps.swift */; }; + F20000000000000000000022 /* StepStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000021 /* StepStatusTests.swift */; }; + F20000000000000000000024 /* TapToPayStepsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000023 /* TapToPayStepsTests.swift */; }; + F20000000000000000000026 /* PayInStepsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000025 /* PayInStepsTests.swift */; }; + F20000000000000000000040 /* PayabliSDKExampleAggregate in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000041 /* PayabliSDKExampleAggregate */; }; + F20000000000000000000042 /* PayabliSDKExampleAggregate in Embed Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000041 /* PayabliSDKExampleAggregate */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ + F20000000000000000000043 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + F20000000000000000000042 /* PayabliSDKExampleAggregate in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; F10000000000000000000031 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -48,7 +73,9 @@ /* Begin PBXFileReference section */ A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QAContextLine.swift; sourceTree = ""; }; - A1B2C3D4E5F60000000000D1 /* QAStepRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = QAStepRow.swift; sourceTree = ""; }; + A1B2C3D4E5F60000000000D1 /* StepRow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepRow.swift; sourceTree = ""; }; + A1B2C3D4E5F60000000000F1 /* TokenProbeResults.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokenProbeResults.swift; sourceTree = ""; }; + A1B2C3D4E5F60000000000F4 /* TokenProbeResultsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokenProbeResultsTests.swift; sourceTree = ""; }; 0F5E8CC6E7A24D19A71B0A1C /* PaymentMethodAddedView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PaymentMethodAddedView.swift; sourceTree = ""; }; 1DADA588F49DF0C4A6611302 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4C921D1F3EE7130C23911757 /* Secrets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; @@ -75,10 +102,26 @@ F1000000000000000000000D /* Debug-XCFramework.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = "Debug-XCFramework.xcconfig"; sourceTree = ""; }; F10000000000000000000020 /* check-sdk-linkage.sh */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.script.sh; path = "check-sdk-linkage.sh"; sourceTree = ""; }; F201F43C477597B328446F93 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + F20000000000000000000001 /* StepStatus.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepStatus.swift; sourceTree = ""; }; + F20000000000000000000003 /* TapToPaySteps.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TapToPaySteps.swift; sourceTree = ""; }; + F20000000000000000000005 /* PayInSteps.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PayInSteps.swift; sourceTree = ""; }; + F20000000000000000000021 /* StepStatusTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepStatusTests.swift; sourceTree = ""; }; + F20000000000000000000023 /* TapToPayStepsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TapToPayStepsTests.swift; sourceTree = ""; }; + F20000000000000000000025 /* PayInStepsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PayInStepsTests.swift; sourceTree = ""; }; + F20000000000000000000030 /* FlowTests.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; path = FlowTests.xcconfig; sourceTree = ""; }; + F20000000000000000000050 /* PayabliDemoFlowTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PayabliDemoFlowTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F427A5EA0CEF029F22AF2170 /* PayabliDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PayabliDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + F20000000000000000000044 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F20000000000000000000040 /* PayabliSDKExampleAggregate in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 57ED6F126A7F63E859E6B9C1 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -112,6 +155,7 @@ isa = PBXGroup; children = ( F427A5EA0CEF029F22AF2170 /* PayabliDemo.app */, + F20000000000000000000050 /* PayabliDemoFlowTests.xctest */, ); name = Products; sourceTree = ""; @@ -130,6 +174,8 @@ E1000000000000000000000A /* App */, F10000000000000000000010 /* Config */, F10000000000000000000021 /* Scripts */, + F20000000000000000000010 /* Flow */, + F20000000000000000000020 /* FlowTests */, E1000000000000000000000B /* Configuration */, E1000000000000000000000C /* TapToPay */, E10000000000000000000020 /* Shared */, @@ -225,6 +271,7 @@ F1000000000000000000000B /* Debug.xcconfig */, F1000000000000000000000C /* Release.xcconfig */, F1000000000000000000000D /* Debug-XCFramework.xcconfig */, + F20000000000000000000030 /* FlowTests.xcconfig */, ); path = Config; sourceTree = ""; @@ -241,15 +288,57 @@ isa = PBXGroup; children = ( D10000000000000000000005 /* QADetailRow.swift */, - A1B2C3D4E5F60000000000D1 /* QAStepRow.swift */, + A1B2C3D4E5F60000000000D1 /* StepRow.swift */, A1B2C3D4E5F60000000000E1 /* QAContextLine.swift */, + A1B2C3D4E5F60000000000F1 /* TokenProbeResults.swift */, ); path = Shared; sourceTree = ""; }; + F20000000000000000000010 /* Flow */ = { + isa = PBXGroup; + children = ( + F20000000000000000000001 /* StepStatus.swift */, + F20000000000000000000003 /* TapToPaySteps.swift */, + F20000000000000000000005 /* PayInSteps.swift */, + ); + path = Flow; + sourceTree = ""; + }; + F20000000000000000000020 /* FlowTests */ = { + isa = PBXGroup; + children = ( + F20000000000000000000021 /* StepStatusTests.swift */, + F20000000000000000000023 /* TapToPayStepsTests.swift */, + F20000000000000000000025 /* PayInStepsTests.swift */, + A1B2C3D4E5F60000000000F4 /* TokenProbeResultsTests.swift */, + ); + path = FlowTests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + F20000000000000000000060 /* PayabliDemoFlowTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = F20000000000000000000070 /* Build configuration list for PBXNativeTarget "PayabliDemoFlowTests" */; + buildPhases = ( + F20000000000000000000045 /* Sources */, + F20000000000000000000044 /* Frameworks */, + F20000000000000000000043 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PayabliDemoFlowTests; + packageProductDependencies = ( + F20000000000000000000041 /* PayabliSDKExampleAggregate */, + ); + productName = PayabliDemoFlowTests; + productReference = F20000000000000000000050 /* PayabliDemoFlowTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 475485E1E243C7CA6B159E47 /* PayabliDemo */ = { isa = PBXNativeTarget; buildConfigurationList = 87189C26EFA0A693AC05A10A /* Build configuration list for PBXNativeTarget "PayabliDemo" */; @@ -298,6 +387,7 @@ projectRoot = ""; targets = ( 475485E1E243C7CA6B159E47 /* PayabliDemo */, + F20000000000000000000060 /* PayabliDemoFlowTests */, ); }; /* End PBXProject section */ @@ -335,6 +425,21 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + F20000000000000000000045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F20000000000000000000012 /* StepStatus.swift in Sources */, + F20000000000000000000013 /* TapToPaySteps.swift in Sources */, + F20000000000000000000014 /* PayInSteps.swift in Sources */, + F20000000000000000000022 /* StepStatusTests.swift in Sources */, + F20000000000000000000024 /* TapToPayStepsTests.swift in Sources */, + F20000000000000000000026 /* PayInStepsTests.swift in Sources */, + A1B2C3D4E5F60000000000F3 /* TokenProbeResults.swift in Sources */, + A1B2C3D4E5F60000000000F5 /* TokenProbeResultsTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 1511487013BC083FF8294541 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -351,8 +456,12 @@ 1C7A9E9B6D2F4C0F8A1B3D5E /* PaymentCaptureQAView.swift in Sources */, A1B2C3D4E5F60000000000A2 /* PaymentTapToPayQAView.swift in Sources */, A1B2C3D4E5F60000000000B2 /* TapToPayPreflight.swift in Sources */, - A1B2C3D4E5F60000000000D2 /* QAStepRow.swift in Sources */, + F20000000000000000000002 /* StepStatus.swift in Sources */, + F20000000000000000000004 /* TapToPaySteps.swift in Sources */, + F20000000000000000000006 /* PayInSteps.swift in Sources */, + A1B2C3D4E5F60000000000D2 /* StepRow.swift in Sources */, A1B2C3D4E5F60000000000E2 /* QAContextLine.swift in Sources */, + A1B2C3D4E5F60000000000F2 /* TokenProbeResults.swift in Sources */, 0F5E8CC7E7A24D19A71B0A1C /* PaymentMethodAddedView.swift in Sources */, C9B9D43650B2230A8582B93C /* Secrets.swift in Sources */, E1D2C3B4A5960718293A4B01 /* DebugPrefill.swift in Sources */, @@ -363,6 +472,30 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + F20000000000000000000071 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F20000000000000000000030 /* FlowTests.xcconfig */; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + F20000000000000000000072 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F20000000000000000000030 /* FlowTests.xcconfig */; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + F20000000000000000000073 /* Debug-XCFramework */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F20000000000000000000030 /* FlowTests.xcconfig */; + buildSettings = { + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = "Debug-XCFramework"; + }; 261639CBD142F4F5DABF734B /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = F1000000000000000000000C /* Release.xcconfig */; @@ -627,6 +760,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + F20000000000000000000070 /* Build configuration list for PBXNativeTarget "PayabliDemoFlowTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F20000000000000000000071 /* Debug */, + F20000000000000000000072 /* Release */, + F20000000000000000000073 /* Debug-XCFramework */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 87189C26EFA0A693AC05A10A /* Build configuration list for PBXNativeTarget "PayabliDemo" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -657,6 +800,11 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + F20000000000000000000041 /* PayabliSDKExampleAggregate */ = { + isa = XCSwiftPackageProductDependency; + package = 9D53E7C2DDF2C3C9B8D4F07B /* XCLocalSwiftPackageReference "../.." */; + productName = PayabliSDKExampleAggregate; + }; 4A0D9E8F7B6C5D4E3F2A1B0C /* PayabliSDKExampleAggregate */ = { isa = XCSwiftPackageProductDependency; package = 9D53E7C2DDF2C3C9B8D4F07B /* XCLocalSwiftPackageReference "../.." */; diff --git a/Example/PayabliDemo/PayabliDemo.xcodeproj/xcshareddata/xcschemes/PayabliDemoFlowTests.xcscheme b/Example/PayabliDemo/PayabliDemo.xcodeproj/xcshareddata/xcschemes/PayabliDemoFlowTests.xcscheme new file mode 100644 index 0000000..8a42af1 --- /dev/null +++ b/Example/PayabliDemo/PayabliDemo.xcodeproj/xcshareddata/xcschemes/PayabliDemoFlowTests.xcscheme @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Example/PayabliDemo/Shared/QAStepRow.swift b/Example/PayabliDemo/Shared/StepRow.swift similarity index 66% rename from Example/PayabliDemo/Shared/QAStepRow.swift rename to Example/PayabliDemo/Shared/StepRow.swift index 74e5883..29a103f 100644 --- a/Example/PayabliDemo/Shared/QAStepRow.swift +++ b/Example/PayabliDemo/Shared/StepRow.swift @@ -1,23 +1,7 @@ import SwiftUI -/// One step of a QA flow, with its own status and action. -/// -/// Shared by every payment tab so they read the same way. The steps mirror the -/// order the SDK enforces, so the next thing to do is the only thing offered. -enum QAStepStatus { - /// Finished, and nothing more to do here. - case done - /// The next thing to do. - case current - /// Underway inside the SDK; the app is waiting, not the person. - case inProgress - /// Cannot run until an earlier step finishes. - case blocked - /// Genuinely does not apply to this device or session. - case notNeeded - /// Attempted and failed. - case failed - +/// How a status looks. The status itself is in `Flow/StepStatus.swift`. +private extension StepStatus { var label: String { switch self { case .done: return "done" @@ -52,21 +36,14 @@ enum QAStepStatus { } } -/// One row of the sequence. -struct QAStepRow: View { +/// One row of the sequence. It renders a step; it never decides one. +struct StepRow: View { let index: Int - let title: String - let detail: String - let status: QAStepStatus + let step: FlowStep @ViewBuilder var content: Content - /// Only the step being acted on shows its controls. Anything else would put - /// the reader back in front of buttons that do not apply yet. - private var showsContent: Bool { - switch status { - case .current, .failed: return true - case .done, .inProgress, .blocked, .notNeeded: return false - } + private var status: StepStatus { + step.status } var body: some View { @@ -77,7 +54,7 @@ struct QAStepRow: View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { - Text("\(index). \(title)") + Text("\(index). \(step.title)") .font(.subheadline.weight(.semibold)) .foregroundColor(status == .blocked || status == .notNeeded ? .payabliOnSurfaceVariant @@ -87,13 +64,13 @@ struct QAStepRow: View { .font(.caption) .foregroundColor(status.tint) } - Text(detail) + Text(step.detail) .font(.caption) .foregroundColor(.payabliOnSurfaceVariant) } } - if showsContent { + if status.showsContent { content .padding(.leading, 28) } diff --git a/Example/PayabliDemo/Shared/TokenProbeResults.swift b/Example/PayabliDemo/Shared/TokenProbeResults.swift new file mode 100644 index 0000000..320ed86 --- /dev/null +++ b/Example/PayabliDemo/Shared/TokenProbeResults.swift @@ -0,0 +1,149 @@ +import SwiftUI + +/// The latest answer from each token probe, shared by every screen that runs one +/// or derives a step from one. +/// +/// Each screen used to keep its own copy, which made the answer unshareable in +/// both directions. A probe run on the Configuration tab could not reach the tab +/// whose sequence reads it, and a tab whose backend step had already finished +/// renders no content, so its own probe button was gone. Between them a failing +/// probe could not be made to outrank an earlier success anywhere except a unit +/// test. +/// +/// Three probes, because they are three token functions. `Secrets.swift.sample` +/// forwards the capture one to the stored-method one and says that holds for the +/// sample "unless your backend separates scopes"; `Secrets.swift` is per +/// developer, so a tab reporting on an endpoint it never calls would be +/// answering a different question. +/// +/// The fetches are supplied rather than called directly, so the ordering rule +/// below can be tested without a backend. +/// +/// Each probe reports only *that* a token arrived. Never the token. +@MainActor +final class TokenProbeResults: ObservableObject { + typealias Fetch = @Sendable () async throws -> String + + enum Probe: Hashable { + /// The partner token Tap to Pay attests with. + case cardPresent + /// The token the stored-method tab submits with. + case storedMethod + /// The token the capture tab submits with. + case capture + } + + /// The last answer each probe settled on. A run in flight does not clear it: + /// the probe is shared, so a run started on the Configuration tab would + /// otherwise retract a verdict a payment tab had already acted on, and a + /// backend step that stops being finished takes the form row down with it, + /// along with the `@StateObject` holding what a payer had typed. + @Published private(set) var cardPresent = "" + @Published private(set) var storedMethod = "" + @Published private(set) var capture = "" + + /// Which probes are in flight, kept apart from the answers for the reason + /// above. A screen reads it to disable the control that would start another. + @Published private(set) var running: Set = [] + + private let fetches: [Probe: Fetch] + + /// Which run of each probe is current. `@MainActor` serialises the writes but + /// suspends at the fetch, so two probes started from different tabs + /// interleave and can finish in either order. Without this, a slower earlier + /// request publishes over the answer a later one already gave, and the shared + /// state reports something other than the latest. + private var generations: [Probe: Int] = [:] + + init( + fetchCardPresent: @escaping Fetch, + fetchStoredMethod: @escaping Fetch, + fetchCapture: @escaping Fetch + ) { + fetches = [ + .cardPresent: fetchCardPresent, + .storedMethod: fetchStoredMethod, + .capture: fetchCapture + ] + } + + /// A store whose probes answer immediately and reach no backend, for previews. + static func inert() -> TokenProbeResults { + TokenProbeResults( + fetchCardPresent: { "" }, + fetchStoredMethod: { "" }, + fetchCapture: { "" } + ) + } + + func answer(for probe: Probe) -> String { + switch probe { + case .cardPresent: cardPresent + case .storedMethod: storedMethod + case .capture: capture + } + } + + func isRunning(_ probe: Probe) -> Bool { + running.contains(probe) + } + + /// What a step sequence should read. A run in flight is the answer only while + /// there is no earlier one to keep; after that the earlier verdict stands + /// until the new one lands, so the sequence does not step backwards and take + /// the form down while a payer is filling it in. + func check(_ probe: Probe) -> TokenCheck { + let answer = answer(for: probe) + if answer.isEmpty, isRunning(probe) { + return .checking + } + return TokenCheck.classify(answer) + } + + /// What a step row should show. Unlike `check`, this does report a run in + /// flight over an earlier answer, because a row a person is looking at should + /// say the button they pressed is doing something. + func display(for probe: Probe) -> String { + isRunning(probe) ? "Checking…" : answer(for: probe) + } + + func probeCardPresent() async { + await run(.cardPresent, named: "Card-present token endpoint") + } + + func probeStoredMethod() async { + await run(.storedMethod, named: "Stored-method token endpoint") + } + + func probeCapture() async { + await run(.capture, named: "Capture token endpoint") + } + + private func run(_ probe: Probe, named name: String) async { + let generation = (generations[probe] ?? 0) + 1 + generations[probe] = generation + running.insert(probe) + + let answer: String + do { + _ = try await fetches[probe]?() + answer = "✓ \(name) returned a token" + } catch { + answer = "✗ \(name) failed: \(error.localizedDescription)" + } + + // A later run of this probe has already answered, so this one is stale + // and leaves both the answer and the in-flight set to that later run. + guard generations[probe] == generation else { return } + running.remove(probe) + publish(answer, to: probe) + } + + private func publish(_ text: String, to probe: Probe) { + switch probe { + case .cardPresent: cardPresent = text + case .storedMethod: storedMethod = text + case .capture: capture = text + } + } +} diff --git a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift index 93ffaa1..e6f7f7a 100644 --- a/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift +++ b/Example/PayabliDemo/TapToPay/PaymentTapToPayQAView.swift @@ -16,28 +16,17 @@ import SwiftUI struct PaymentTapToPayQAView: View { @ObservedObject var terminal: PayabliTTP + @EnvironmentObject private var tokenProbes: TokenProbeResults @State private var amountText = "1.00" @State private var activationCode = "" @State private var enableMessage = "" @State private var activationMessage = "" @State private var chargeMessage = "" - @State private var tokenCheckText = "" @State private var eventLog: [TapToPayQAEventEntry] = [] @State private var eventToken: PayabliTTPEventToken? @State private var isActivationPresented = false @State private var isActivationHelpPresented = false - @State private var activationOutcome = ActivationOutcome.none - - /// Which half of the activation step failed. - /// - /// Activation is two SDK calls. `sessionState` cannot tell them apart: both - /// land in `.error`, except a revoked attestation, which lands in `.idle`. - private enum ActivationOutcome { - case none - case activationFailed - case enableFailed - case succeeded - } + @State private var activationOutcome = TapToPayActivationOutcome.none @State private var isWorking = false @FocusState private var focusedField: Field? @@ -81,74 +70,68 @@ struct PaymentTapToPayQAView: View { // MARK: - The sequence - /// The order the SDK enforces, made visible. Every status is derived from - /// `sessionState` rather than tracked separately, so the screen cannot - /// disagree with the session it is describing. + /// The order the SDK enforces, made visible. Derived in one place, so no two + /// steps can disagree about which is next. + private var steps: TapToPayFlowSteps { + TapToPaySteps.forCharging( + tokenCheck: tokenProbes.check(.cardPresent), + session: terminal.sessionState, + activation: activationOutcome + ) + } + private var stepsSection: some View { VStack(alignment: .leading, spacing: 10) { Text("Steps") .font(.headline) - QAStepRow( - index: 1, - title: "Reach the token backend", - detail: "The SDK calls your backend for a fresh access token whenever it needs one.", - status: tokenStepStatus - ) { + StepRow(index: 1, step: steps.token) { VStack(alignment: .leading, spacing: 6) { Button { runTokenCheck() } label: { Label("Check token endpoint", systemImage: "key.horizontal") } .buttonStyle(.bordered) - .disabled(isWorking) - if !tokenCheckText.isEmpty { - Text(tokenCheckText) + // `isWorking` covers this screen's own operations. The probe + // is shared, so a run started on another tab is this step's + // `.inProgress` and only the derived step knows it. + .disabled(isWorking || tokenProbes.isRunning(.cardPresent)) + if !tokenProbes.display(for: .cardPresent).isEmpty { + Text(tokenProbes.display(for: .cardPresent)) .font(.caption) .foregroundColor(.payabliOnSurfaceVariant) } } } - QAStepRow( - index: 2, - title: "Enable the terminal", - detail: "Attests the device, fetches the merchant config, and prepares the reader.", - status: enableStepStatus - ) { + StepRow(index: 2, step: steps.enable) { VStack(alignment: .leading, spacing: 6) { - Button { runEnableTerminal() } label: { - Label("Enable Terminal", systemImage: "wave.3.right") - .frame(maxWidth: .infinity) + if steps.nextAction == .enableTerminal { + Button { runEnableTerminal() } label: { + Label("Enable Terminal", systemImage: "wave.3.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isWorking) } - .buttonStyle(.borderedProminent) - .disabled(isWorking) stepOutcome(enableMessage) } } - QAStepRow( - index: 3, - title: "Activate the device", - detail: activationDetail, - status: activationStepStatus - ) { + StepRow(index: 3, step: steps.activation) { VStack(alignment: .leading, spacing: 6) { - Button { isActivationPresented = true } label: { - Label("Enter activation code", systemImage: "checkmark.shield") - .frame(maxWidth: .infinity) + if steps.nextAction == .enterActivationCode { + Button { isActivationPresented = true } label: { + Label("Enter activation code", systemImage: "checkmark.shield") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isWorking) } - .buttonStyle(.borderedProminent) - .disabled(isWorking) stepOutcome(activationMessage) } } - QAStepRow( - index: 4, - title: "Charge a card", - detail: "Presents Apple's Tap to Pay sheet. Hold a card to the top of the phone.", - status: chargeStepStatus - ) { + StepRow(index: 4, step: steps.charge) { VStack(alignment: .leading, spacing: 8) { HStack { Text("$") @@ -157,32 +140,48 @@ struct PaymentTapToPayQAView: View { .focused($focusedField, equals: .amount) .textFieldStyle(.roundedBorder) } - Button { runCharge() } label: { - Label("Charge (tap card)", systemImage: "creditcard.and.123") - .frame(maxWidth: .infinity) + if steps.nextAction == .charge { + Button { runCharge() } label: { + Label("Charge (tap card)", systemImage: "creditcard.and.123") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isWorking) } - .buttonStyle(.borderedProminent) - .disabled(isWorking) stepOutcome(chargeMessage) } } } } + private func recoveryDetail(_ recovery: TapToPayRecovery) -> String { + switch recovery { + case .sessionExpired: + "The session expired. This re-runs config and reader setup without a fresh attestation." + case .sessionErrored: + "The session errored. This runs the full setup, re-using the attested identity when it is still held and attesting again when it is not." + } + } + /// Recovery is not part of the sequence, so it only appears when the session /// is in a state it can actually repair. @ViewBuilder private var recoverySection: some View { - if isRecoverable { + if let recovery = steps.recovery { VStack(alignment: .leading, spacing: 8) { Text("Recovery") .font(.headline) - Text("The session expired or errored. This re-runs config and reader setup without a fresh attestation.") + Text(recoveryDetail(recovery)) .font(.caption) .foregroundColor(.payabliOnSurfaceVariant) - Button { runReinitialize() } label: { - Label("Re-initialize", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity) + Button { + steps.nextAction == .reinitialize ? runReinitialize() : runEnableTerminal() + } label: { + Label( + steps.nextAction == .reinitialize ? "Re-initialize" : "Run full setup", + systemImage: "arrow.clockwise" + ) + .frame(maxWidth: .infinity) } .buttonStyle(.bordered) .disabled(isWorking) @@ -193,73 +192,6 @@ struct PaymentTapToPayQAView: View { } } - // MARK: - Step status, derived from the session - - /// True once the backend is known reachable — either because the check was - /// run here, or because the SDK already fetched a token to get past `idle`. - private var backendProven: Bool { - if tokenCheckText.hasPrefix("✓") { return true } - // Only states the session cannot reach without a successful authenticated - // request. `.attestingDevice` is set before that request, and `.error` is - // where a failing token provider lands, so neither proves anything. - switch terminal.sessionState { - case .fetchingConfig, .initializingReader, .ready, .pendingActivation, .reinitializing: - return true - default: - return false - } - } - - private var tokenStepStatus: QAStepStatus { - if tokenCheckText.hasPrefix("✗") { return .failed } - return backendProven ? .done : .current - } - - private var enableStepStatus: QAStepStatus { - // Exactly one step is ever `.current`, so this stays blocked until the - // backend is proven rather than competing with step 1 for attention. - guard backendProven else { return .blocked } - switch terminal.sessionState { - case .ready: return .done - case .attestingDevice, .fetchingConfig, .initializingReader, .reinitializing: return .inProgress - case .pendingActivation: return .done - case .error, .sessionExpired: return .failed - case .idle: return .current - @unknown default: return .current - } - } - - private var activationStepStatus: QAStepStatus { - // A failed activation must stay actionable: `.failed` is the only other - // status whose content renders, so blocking it would hide the reason and - // the retry together. Read from the recorded outcome rather than from - // `sessionState`, which cannot distinguish the two calls this step makes - // and reports `.idle` for a revoked attestation. - if activationOutcome == .activationFailed { return .failed } - switch terminal.sessionState { - case .pendingActivation: return .current - case .ready: return .notNeeded - default: return .blocked - } - } - - private var activationDetail: String { - return terminal.sessionState == .pendingActivation - ? "Activate the device with the code provided by the Paypoint Device Management dashboard." - : "Only when the backend registers the device as pending." - } - - private var chargeStepStatus: QAStepStatus { - terminal.isReady ? .current : .blocked - } - - private var isRecoverable: Bool { - switch terminal.sessionState { - case .sessionExpired, .error: return true - default: return false - } - } - private var activationSheet: some View { NavigationStack { Form { @@ -493,6 +425,19 @@ struct PaymentTapToPayQAView: View { defer { isWorking = false } do { try await terminal.activateDevice(activationCode: code) + } catch let error as PayabliTTPError { + // A revoked attestation resets the session to `.idle`, and the + // way out is a fresh cold attestation. The reason goes to the + // step that offers it. + if case .attestationRevoked = error { + activationOutcome = .attestationRevoked + enableMessage = "✗ \(error.localizedDescription)" + activationMessage = "✗ Attestation revoked — re-enable the terminal, see step 2." + } else { + activationOutcome = .activationFailed + activationMessage = "✗ \(error.localizedDescription)" + } + return } catch { activationOutcome = .activationFailed activationMessage = "✗ \(error.localizedDescription)" @@ -521,18 +466,11 @@ struct PaymentTapToPayQAView: View { } /// Confirms the partner backend answers before `initialize()` depends on it. - /// Reports only that a token arrived — never the token itself. private func runTokenCheck() { isWorking = true - tokenCheckText = "Checking…" Task { defer { isWorking = false } - do { - _ = try await Secrets.fetchAccessToken() - tokenCheckText = "✓ Token endpoint returned a token" - } catch { - tokenCheckText = "✗ Token endpoint failed: \(error.localizedDescription)" - } + await tokenProbes.probeCardPresent() } } diff --git a/Example/PayabliDemo/TapToPay/TapToPayPreflight.swift b/Example/PayabliDemo/TapToPay/TapToPayPreflight.swift index 67aac54..9475b7c 100644 --- a/Example/PayabliDemo/TapToPay/TapToPayPreflight.swift +++ b/Example/PayabliDemo/TapToPay/TapToPayPreflight.swift @@ -1,7 +1,7 @@ import DeviceCheck import Foundation #if canImport(ProximityReader) -import ProximityReader + import ProximityReader #endif /// Credential-independent pre-flight checks for the Tap to Pay tab. @@ -14,7 +14,6 @@ import ProximityReader /// /// Nothing in here reaches the network and nothing reads a secret. enum TapToPayPreflight { - // MARK: - Result model struct Check: Identifiable { @@ -54,15 +53,15 @@ enum TapToPayPreflight { /// (`arm64` / `x86_64`); a device reports a model such as `iPhone17,1`. static var runtimeEnvironment: RuntimeEnvironment { #if targetEnvironment(simulator) - return .simulator - #else - if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil { - return .simulator - } - if ["arm64", "x86_64", "i386"].contains(machineIdentifier) { return .simulator - } - return .physicalDevice + #else + if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil { + return .simulator + } + if ["arm64", "x86_64", "i386"].contains(machineIdentifier) { + return .simulator + } + return .physicalDevice #endif } @@ -93,12 +92,12 @@ enum TapToPayPreflight { /// against `runtimeEnvironment`. static var tapToPayHardwareSupported: Bool? { #if canImport(ProximityReader) - if #available(iOS 16.7, *) { - return PaymentCardReader.isSupported - } - return false + if #available(iOS 16.7, *) { + return PaymentCardReader.isSupported + } + return false #else - return nil + return nil #endif } @@ -157,7 +156,8 @@ enum TapToPayPreflight { return team } if let applicationIdentifier = entitlements["application-identifier"] as? String, - let prefix = applicationIdentifier.split(separator: ".").first { + let prefix = applicationIdentifier.split(separator: ".").first + { return String(prefix) } return nil @@ -204,8 +204,8 @@ enum TapToPayPreflight { title: "Host: \(environment.label)", detail: environment == .simulator ? "uname reports \(machineIdentifier). Tap to Pay needs a physical iPhone XS " - + "or later on iOS 16.7+; a Simulator cannot attest or read a card even " - + "with a valid team, token, and entitlement." + + "or later on iOS 16.7+; a Simulator cannot attest or read a card even " + + "with a valid team, token, and entitlement." : "uname reports \(machineIdentifier).", status: environment == .simulator ? .fail : .pass ) diff --git a/Example/PayabliDemo/Theme/PayabliDemoColors.swift b/Example/PayabliDemo/Theme/PayabliDemoColors.swift index 9510454..72384b2 100644 --- a/Example/PayabliDemo/Theme/PayabliDemoColors.swift +++ b/Example/PayabliDemo/Theme/PayabliDemoColors.swift @@ -7,34 +7,34 @@ import UIKit /// its source. The Android sample app carries the same values under the same names; the two demos /// are meant to screenshot as one product. enum PayabliPalette { - static let black: UInt32 = 0x00_00_00 - static let white: UInt32 = 0xFF_FF_FF - static let deepBlue: UInt32 = 0x02_0B_27 + static let black: UInt32 = 0x000000 + static let white: UInt32 = 0xFFFFFF + static let deepBlue: UInt32 = 0x020B27 - static let blue1: UInt32 = 0x04_C3_FF - static let blue3: UInt32 = 0xDD_F7_FF - static let blue4: UInt32 = 0x00_1C_6E + static let blue1: UInt32 = 0x04C3FF + static let blue3: UInt32 = 0xDDF7FF + static let blue4: UInt32 = 0x001C6E - static let teal2: UInt32 = 0xA7_FC_FF - static let teal4: UInt32 = 0x00_55_58 + static let teal2: UInt32 = 0xA7FCFF + static let teal4: UInt32 = 0x005558 - static let cinnamon2: UInt32 = 0xFF_AE_B7 - static let cinnamon4: UInt32 = 0x68_0A_04 + static let cinnamon2: UInt32 = 0xFFAEB7 + static let cinnamon4: UInt32 = 0x680A04 - static let lemon1: UInt32 = 0xFF_C8_5C - static let lemon4: UInt32 = 0x63_42_00 + static let lemon1: UInt32 = 0xFFC85C + static let lemon4: UInt32 = 0x634200 - static let neutral1: UInt32 = 0x13_1D_3A - static let neutral3: UInt32 = 0x3C_47_6B - static let neutral4: UInt32 = 0x57_61_80 - static let neutral5: UInt32 = 0x89_92_AC - static let neutral6: UInt32 = 0xC5_CB_DB - static let neutral7: UInt32 = 0xEF_F0_F7 - static let neutral8: UInt32 = 0xF9_F9_FF + static let neutral1: UInt32 = 0x131D3A + static let neutral3: UInt32 = 0x3C476B + static let neutral4: UInt32 = 0x576180 + static let neutral5: UInt32 = 0x8992AC + static let neutral6: UInt32 = 0xC5CBDB + static let neutral7: UInt32 = 0xEFF0F7 + static let neutral8: UInt32 = 0xF9F9FF /// Two steps the guide does not name, blended between the tones on either side of them. - static let lightContainerHigh: UInt32 = 0xDD_E0_EB - static let darkContainerHigh: UInt32 = 0x1A_26_4A + static let lightContainerHigh: UInt32 = 0xDDE0EB + static let darkContainerHigh: UInt32 = 0x1A264A } /// The roles the demo draws with. diff --git a/Scripts/classify-changes.sh b/Scripts/classify-changes.sh new file mode 100755 index 0000000..37c4004 --- /dev/null +++ b/Scripts/classify-changes.sh @@ -0,0 +1,375 @@ +#!/usr/bin/env bash +# +# Sizes the review surface of a change: how much of a diff is a semantic change +# to shipped code, and how much is inert. +# +# ./Scripts/classify-changes.sh main HEAD +# ./Scripts/classify-changes.sh main HEAD >> "$GITHUB_STEP_SUMMARY" +# +# A branch that runs `swiftformat .` puts hundreds of files in the diff and a +# file count says nothing about what to read. Every modified file under Sources/ +# is normalised by running the formatter over its base revision, so what remains +# in the diff is the author's edit. Those lines are then split into declarations, +# executable statements and comments, because only the first two can change +# behaviour and only the first can break a consumer. +# +# Reports and never judges. Writes Markdown, exits 0 whatever it finds. + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BASE_REF="${1:?usage: $0 }" +HEAD_REF="${2:?usage: $0 }" + +cd "$REPO_ROOT" + +BASE=$(git merge-base "$BASE_REF" "$HEAD_REF" 2>/dev/null) || BASE="$BASE_REF" +HEAD_SHA=$(git rev-parse "$HEAD_REF") + +# The marker CI finds this comment by, so each run edits the one comment +# instead of posting another. Invisible when the Markdown renders. +echo "" +echo "## Change report" +echo +echo "\`$(git rev-parse --short "$BASE")…$(git rev-parse --short "$HEAD_SHA")\` · $(git diff --name-only "$BASE..$HEAD_SHA" | wc -l | tr -d ' ') files" +echo + +# ---------------------------------------------------------------- review surface + +# Sources/ is the library that ships to consumers. Nothing else in the tree is +# linked into their app, so a change outside it cannot alter released behaviour. +classify_path() { + case "$1" in + Sources/*) echo "Production code (ships in the SDK)" ;; + Tests/*) echo "Test code" ;; + Example/*) echo "Sample app" ;; + Bridges/*) echo "Bridge wrappers" ;; + .github/*|Scripts/*|*.yml|*.xcconfig|.swiftformat|.swiftlint.yml|sonar-project.properties|Package.swift|.gitignore) + echo "Build, CI and tooling" ;; + *) echo "Other" ;; + esac +} + +echo "### Review surface" +echo +echo "| Category | Files |" +echo "| --- | ---: |" +while IFS= read -r path; do + classify_path "$path" +done < <(git diff --name-only "$BASE..$HEAD_SHA") | sort | uniq -c | sort -rn \ + | sed -E 's/^ *([0-9]+) (.*)$/| \2 | \1 |/' +echo + +# ------------------------------------------------------------- lifecycle of files + +# A rename similarity below 100 means the file moved and was edited in the same +# commit, which a reviewer reading only the new path would miss. +echo "### Files added, deleted and renamed" +echo +LIFECYCLE=$(git diff --name-status --find-renames "$BASE..$HEAD_SHA" | grep -vE '^M' || true) +if [ -z "$LIFECYCLE" ]; then + echo "None. Every changed file already existed at the base revision, so no" + echo "compilation unit entered or left the build." +else + added=$(printf '%s\n' "$LIFECYCLE" | grep -c '^A' || true) + deleted=$(printf '%s\n' "$LIFECYCLE" | grep -c '^D' || true) + renamed=$(printf '%s\n' "$LIFECYCLE" | grep -c '^R' || true) + echo "$added added, $deleted deleted, $renamed renamed." + echo + echo '```' + printf '%s\n' "$LIFECYCLE" + echo '```' + if printf '%s\n' "$LIFECYCLE" | grep -qE '^R0[0-9][0-9]' ; then + echo + echo "A rename shown below \`R100\` was edited as well as moved." + fi +fi +echo + +# ------------------------------------------------------------------ shipped code + +# Swift only. Sources/ also carries Markdown, and prose about a public type +# reads as a declaration to a grep. +# +# A modified file and a renamed one both carry an edit, and git reports the +# rename as `R` rather than `M`. Reading only `M` skips a file that was renamed +# and edited in the same commit, which is the case most in need of review. Each +# is therefore held as an old path at the base and a new path at the head; for a +# modified file the two are the same. +SOURCE_PAIRS=$(git diff --name-status --find-renames "$BASE..$HEAD_SHA" -- Sources/ \ + | awk -F'\t' ' + $1 ~ /^M/ && $2 ~ /\.swift$/ { print $2 "\t" $2 } + $1 ~ /^R/ && $3 ~ /\.swift$/ { print $2 "\t" $3 } + ' || true) +CHANGED_SOURCES=$(printf '%s\n' "$SOURCE_PAIRS" | awk -F'\t' 'NF == 2 { print $2 }' | grep . || true) +ADDED_SOURCES=$(git diff --name-only --diff-filter=A "$BASE..$HEAD_SHA" -- Sources/ | grep '\.swift$' || true) +DELETED_SOURCES=$(git diff --name-only --diff-filter=D "$BASE..$HEAD_SHA" -- Sources/ | grep '\.swift$' || true) +if [ -z "$CHANGED_SOURCES" ] && [ -z "$ADDED_SOURCES" ] && [ -z "$DELETED_SOURCES" ]; then + echo "### Production code" + echo + echo "Nothing under \`Sources/\` changed, so released behaviour is unchanged and" + echo "the public surface is untouched." + exit 0 +fi + +if ! command -v swiftformat >/dev/null 2>&1; then + echo "### Production code" + echo + echo "swiftformat is not on PATH, so the diff could not be normalised and the" + echo "semantic classification was skipped. Modified under \`Sources/\`:" + echo '```' + printf '%s\n' "$CHANGED_SOURCES" + echo '```' + exit 0 +fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Every declaration in a file that a consumer can see, one per line. +# +# Searching changed lines for the word `public` finds a minority of the surface. +# An enum's cases carry the enum's visibility and name it nowhere, so all of +# `PayabliTTPEvent` is invisible to that search; so is `activateDevice`, a member +# of a `public extension` that needs no keyword of its own. Swift's default +# differs by container, so the container is what gets tracked: +# +# public extension, public protocol a member is public +# public enum a `case` is public, a `func` is internal +# public struct, class, actor a member is internal +# +# An explicit `private`, `fileprivate` or `internal` always wins. +# +# A text scan, not a compiler: it does not resolve conditional compilation, and +# a declaration split across lines is read by its first line. It is enough to +# say which declarations to look at, which is what the section it feeds claims. +public_surface() { + awk ' + function trim(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); return s } + # Braces inside a string literal are text, not scope. Counting them shifts + # the depth for the rest of the file and every later visibility with it. + function scan(s, i, c, inq, esc) { + opens = 0; closes = 0; inq = 0; esc = 0 + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (esc) { esc = 0; continue } + if (c == "\\") { esc = 1; continue } + if (c == "\"") { inq = !inq; continue } + if (inq) continue + if (c == "{") opens++ + if (c == "}") closes++ + } + } + BEGIN { depth = 0; memberpub[0] = 0; casepub[0] = 0 } + { + line = $0 + if (inblock) { + if (line ~ /\*\//) { sub(/^.*\*\//, "", line); inblock = 0 } else { next } + } + gsub(/\/\*[^*]*\*\//, "", line) + if (line ~ /\/\*/) { sub(/\/\*.*$/, "", line); inblock = 1 } + sub(/\/\/.*$/, "", line) + t = trim(line) + if (t == "") next + + lowered = (t ~ /(^|[[:space:]])(private|fileprivate|internal)([[:space:](]|$)/) + raised = (t ~ /(^|[[:space:]])(public|open)([[:space:]]|$)/) + is_case = (t ~ /^case[[:space:]]/) + is_decl = (t ~ /(^|[[:space:]])(func|var|let|init|subscript|typealias|associatedtype|class|struct|enum|protocol|extension|actor)([[:space:]<(:]|$)/) + + visible = 0 + if (raised && !lowered) visible = 1 + else if (!lowered) { + if (is_case && casepub[depth]) visible = 1 + else if (is_decl && memberpub[depth]) visible = 1 + } + + if (visible && (is_decl || is_case)) print t + + scan(line) + if (opens > 0) { + newmember = 0; newcase = 0 + if (visible) { + if (t ~ /(^|[[:space:]])(extension|protocol)([[:space:]]|$)/) newmember = 1 + else if (t ~ /(^|[[:space:]])enum([[:space:]]|$)/) newcase = 1 + } + for (i = 0; i < opens; i++) { + depth++ + memberpub[depth] = newmember + casepub[depth] = newcase + } + } + for (i = 0; i < closes; i++) if (depth > 0) depth-- + } + ' "$1" +} + +inert=0 # files whose entire diff the formatter would have produced +doc_only=0 # files whose remaining diff is comments and blank lines +semantic=0 # files with a changed declaration or statement +rows="" # per-file detail for the files that carry a semantic change +doc_rows="" +api_added="" +api_removed="" + +while IFS=$'\t' read -r old_path path; do + [ -n "$path" ] || continue + mkdir -p "$WORK/$(dirname "$old_path")" + git show "$BASE:$old_path" > "$WORK/$old_path" 2>/dev/null || continue + # Normalising the base revision is what separates the author's edit from the + # formatter's. Without it every reformatted file looks like a rewrite. + swiftformat "$WORK/$old_path" --config "$REPO_ROOT/.swiftformat" --quiet >/dev/null 2>&1 + git show "$HEAD_SHA:$path" > "$WORK/head-version" 2>/dev/null || continue + + if cmp -s "$WORK/$old_path" "$WORK/head-version"; then + inert=$((inert + 1)) + continue + fi + + changed=$(diff "$WORK/$old_path" "$WORK/head-version" | grep -E '^[<>]' || true) + + # Both sides of the diff, counted. An earlier version netted out a line that + # was removed from one place and added unchanged in another, on the grounds + # that a move compiles to what it compiled to before. That is not true of a + # statement: order is behaviour, so validation moved to after the network + # call it guards is a change made entirely of unaltered lines, and netting + # reported it as nothing to read. + side() { # $1 = < or >, $2 = keep|drop comments + local filter='^(///|//|/\*|\*/|\*)' + printf '%s\n' "$changed" | grep -E "^$1" | sed -E 's/^.[[:space:]]*//' \ + | if [ "$2" = drop ]; then grep -vE "$filter"; else grep -E "$filter"; fi \ + | grep -v '^[[:space:]]*$' | sed -E 's/[[:space:]]+/ /g' | sort + } + code=$(( $(side '<' drop | grep -c . || true) + $(side '>' drop | grep -c . || true) )) + comments=$(( $(side '<' keep | grep -c . || true) + $(side '>' keep | grep -c . || true) )) + + # The contract consumers compile against, which the repository holds in + # common with the SDK for Android. Both sides are the whole visible surface + # of the file, so a declaration that moved within it is not reported as a + # change, and one that changed shape is reported from both ends. + public_surface "$WORK/$old_path" | sort > "$WORK/api-base" + public_surface "$WORK/head-version" | sort > "$WORK/api-head" + added_api=$(comm -13 "$WORK/api-base" "$WORK/api-head") + removed_api=$(comm -23 "$WORK/api-base" "$WORK/api-head") + [ -n "$added_api" ] && api_added="${api_added}${path}"$'\n'"${added_api}"$'\n' + [ -n "$removed_api" ] && api_removed="${api_removed}${path}"$'\n'"${removed_api}"$'\n' + + if [ "$code" -gt 0 ]; then + semantic=$((semantic + 1)) + rows="${rows}${code} ${comments} ${path}"$'\n' + else + doc_only=$((doc_only + 1)) + doc_rows="${doc_rows}${comments} ${path}"$'\n' + fi +done <<< "$SOURCE_PAIRS" + +# A file that entered or left the build carries its whole contents as added or +# removed API. The classification above speaks only for modified files, so +# without this the report would call a new public type source compatible. +while IFS= read -r path; do + [ -n "$path" ] || continue + git show "$HEAD_SHA:$path" > "$WORK/lifecycle-version" 2>/dev/null || continue + decls=$(public_surface "$WORK/lifecycle-version") + [ -n "$decls" ] && api_added="${api_added}${path} (file added)"$'\n'"${decls}"$'\n' +done <<< "$ADDED_SOURCES" + +while IFS= read -r path; do + [ -n "$path" ] || continue + git show "$BASE:$path" > "$WORK/lifecycle-version" 2>/dev/null || continue + decls=$(public_surface "$WORK/lifecycle-version") + [ -n "$decls" ] && api_removed="${api_removed}${path} (file deleted)"$'\n'"${decls}"$'\n' +done <<< "$DELETED_SOURCES" + +modified_total=$(printf '%s\n' "$CHANGED_SOURCES" | grep -c . || true) +added_total=$(printf '%s\n' "$ADDED_SOURCES" | grep -c . || true) +deleted_total=$(printf '%s\n' "$DELETED_SOURCES" | grep -c . || true) + +echo "### Production code (\`Sources/\`)" +echo +echo "$modified_total modified, $added_total added, $deleted_total deleted, classified by whether the change can alter behaviour." +echo +echo "| Classification | Files | Reviewer action |" +echo "| --- | ---: | --- |" +echo "| Formatting only, semantically inert | $inert | None. Reproduced by running the formatter over the base revision. |" +echo "| Comments and documentation only | $doc_only | Read for accuracy. Compiles to the same code. |" +echo "| Declarations or statements changed | $semantic | Review. This is where behaviour can change. |" +if [ "$added_total" -gt 0 ]; then + echo "| Files added | $added_total | Review in full. Every line is new. |" +fi +if [ "$deleted_total" -gt 0 ]; then + echo "| Files deleted | $deleted_total | Confirm nothing depended on them. |" +fi +echo + +if [ "$semantic" -gt 0 ]; then + echo "#### Files that can change behaviour" + echo + echo "\`Code\` counts declarations and executable statements that differ once" + echo "formatting is normalised, on both sides of the diff, so an altered line" + echo "counts twice and a line that only moved still counts. Order is behaviour:" + echo "validation moved to after the network call it guards is a change made" + echo "entirely of unaltered lines. \`Docs\` is the same count for comments." + echo + echo "| Code | Docs | File |" + echo "| ---: | ---: | --- |" + printf '%s' "$rows" | sort -rn | awk -F'\t' 'NF == 3 { printf "| %s | %s | `%s` |\n", $1, $2, $3 }' + echo +fi + +if [ "$doc_only" -gt 0 ]; then + echo "#### Documentation-only files" + echo + echo "| Docs | File |" + echo "| ---: | --- |" + printf '%s' "$doc_rows" | sort -rn | awk -F'\t' 'NF == 2 { printf "| %s | `%s` |\n", $1, $2 }' + echo +fi + +# ------------------------------------------------------------ public API surface + +echo "### Public API surface" +echo +if [ -z "$api_added" ] && [ -z "$api_removed" ]; then + echo "No declaration a consumer can see was added or removed, across modified," + echo "renamed, added and deleted files under \`Sources/\`. This counts a member of a" + echo "\`public extension\` and an enum's cases, neither of which carries the keyword." + echo "It is a text scan rather than a compiled comparison, so read it as nothing" + echo "found to look at, not as a source-compatibility guarantee." +else + echo "Declarations a consumer can see were added or removed. The public surface is a" + echo "contract shared with the SDK for Android, so a change here is a change to both." + if [ -n "$api_removed" ]; then + echo + echo "Removed or altered:" + echo '```' + printf '%s' "$api_removed" + echo '```' + fi + if [ -n "$api_added" ]; then + echo + echo "Added or altered:" + echo '```' + printf '%s' "$api_added" + echo '```' + fi +fi +echo + +# ------------------------------------------------------------------ test balance + +TEST_FILES=$(git diff --name-only "$BASE..$HEAD_SHA" -- Tests/ Example/PayabliDemo/FlowTests/ | grep -c . || true) +echo "### Tests" +echo +# An added or deleted production file is a behaviour change with no counterpart +# in `semantic`, which counts edits to files that existed on both sides. Reading +# `semantic` alone lets a change that adds a whole implementation with no test +# report that no test is implied. +production_changed=$((semantic + added_total + deleted_total)) +if [ "$production_changed" -eq 0 ]; then + echo "No production file changed a declaration or a statement, so no new test is implied." +elif [ "$TEST_FILES" -eq 0 ]; then + echo "$production_changed production files changed behaviour and no test file changed. Worth" + echo "confirming the existing suite covers the new paths." +else + echo "$production_changed production files changed behaviour, alongside $TEST_FILES changed test files." +fi diff --git a/Scripts/print-test-failures.sh b/Scripts/print-test-failures.sh new file mode 100755 index 0000000..d1ae89e --- /dev/null +++ b/Scripts/print-test-failures.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Prints the assertion and its file and line for every failed test in an +# .xcresult bundle. +# +# ./Scripts/print-test-failures.sh SDKTests.xcresult +# +# `xcodebuild -quiet` names the failing tests and stops there. A name on its own +# sends the reader to a local reproduction to find out what the assertion said, +# which is the part CI already knows. Nothing here is third party: the bundle is +# already written by -resultBundlePath and xcresulttool ships with Xcode. + +set -euo pipefail + +if [ $# -eq 0 ]; then + echo "usage: $0 [...]" >&2 + exit 2 +fi + +for bundle in "$@"; do + if [ ! -e "$bundle" ]; then + echo "no result bundle at $bundle" >&2 + continue + fi + echo "=== $bundle ===" + xcrun xcresulttool get test-results tests --path "$bundle" | python3 -c ' +import json +import sys + +report = json.load(sys.stdin) +failures = [] + + +def walk(nodes, test=None, failed=False): + for node in nodes: + kind = node.get("nodeType") + name = node.get("name", "") + if kind == "Test Case": + test = name + # A skip reason is also carried as a Failure Message, and a skipped + # test is not a failing one. + failed = node.get("result") == "Failed" + elif kind == "Failure Message" and failed: + failures.append((test, name)) + walk(node.get("children") or [], test, failed) + + +walk(report.get("testNodes") or []) +for test, message in failures: + label = test or "unknown test" + print(f" {label}\n {message}") +if not failures: + print(" no failed tests recorded in this bundle") +' +done diff --git a/Scripts/xccov-to-sonarqube-generic.sh b/Scripts/xccov-to-sonarqube-generic.sh new file mode 100755 index 0000000..607b75e --- /dev/null +++ b/Scripts/xccov-to-sonarqube-generic.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# Converts an .xcresult coverage report into SonarQube's generic test coverage +# format, which is what `sonar.coverageReportPaths` reads. +# +# ./Scripts/xccov-to-sonarqube-generic.sh TestResults.xcresult > coverage.xml +# ./Scripts/xccov-to-sonarqube-generic.sh --include Sources/ a.xcresult b.xcresult +# +# `--include` is repeatable and keeps only files under the given repo-relative +# prefixes. A coverage report should describe what the analysis measures: an +# .xcresult also covers the test files themselves and any vendored source the +# suite touched, and handing those to Sonar asks it to reconcile files it holds +# as tests, or does not hold at all. +# +# One `xccov view --archive --json` call per bundle returns every file's line +# table at once. SonarSource's reference script instead runs `--file-list` and +# then `--file` per file, which is one process per source file and took 117s of +# the CI job for a report this size. +# +# Nothing else reads xccov, so a change to Xcode's output shows up here first: +# the script exits non-zero unless it emitted at least one , rather +# than handing Sonar a report that reads as zero coverage. Counting files rather +# than lines would not catch it — a per-line format change leaves the file list +# intact and every element empty. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export REPO_ROOT +INCLUDE=() + +while [ $# -gt 0 ]; do + case "$1" in + --include) + [ $# -ge 2 ] || { echo "error: --include needs a prefix" >&2; exit 2; } + INCLUDE+=("$2"); shift 2 ;; + *) break ;; + esac +done + +if [ $# -eq 0 ]; then + echo "usage: $0 [--include ]... [...]" >&2 + exit 2 +fi + +# Newline-separated, because a prefix is a path and the array may be empty. +INCLUDE_PREFIXES="" +if [ ${#INCLUDE[@]} -gt 0 ]; then + INCLUDE_PREFIXES="$(printf '%s\n' "${INCLUDE[@]}")" +fi +export INCLUDE_PREFIXES + +exec python3 - "$@" <<'PY' +import json +import os +import subprocess +import sys +from xml.sax.saxutils import quoteattr + +repo_root = os.environ["REPO_ROOT"] +prefixes = [p for p in os.environ.get("INCLUDE_PREFIXES", "").split("\n") if p] + + +def included(path): + # No --include keeps everything, so the script stays usable on its own. + return not prefixes or any(path.startswith(p) for p in prefixes) + + +# path -> {line number: covered}. Merged across bundles, so a line a second +# suite reached is covered even where the first never entered it. +coverage = {} + +for bundle in sys.argv[1:]: + options = ["--archive"] if ".xcresult" in bundle else [] + # xccov's own message says which bundle and why, and it goes to a captured + # stderr, so it is reported rather than replaced by a traceback. + result = subprocess.run( + ["xcrun", "xccov", "view", *options, "--json", bundle], + capture_output=True, text=True, check=False, + ) + if result.returncode != 0: + sys.stderr.write(result.stderr) + sys.exit(f"error: xccov could not read {bundle}") + report = json.loads(result.stdout) + + # xccov keys the archive report by absolute file path. Anything else is a + # format this script has not been read against, and guessing at it is how a + # report comes back empty but successful. + if not isinstance(report, dict) or not all(isinstance(v, list) for v in report.values()): + sys.exit(f"error: unexpected xccov --json shape from {bundle}") + + for absolute, lines in report.items(): + relative = absolute[len(repo_root) + 1:] if absolute.startswith(repo_root + "/") else absolute + if not included(relative): + continue + file_lines = coverage.setdefault(relative, {}) + for line in lines: + if not line.get("isExecutable"): + continue + covered = line.get("executionCount", 0) > 0 + number = line["line"] + file_lines[number] = file_lines.get(number, False) or covered + +emitted = 0 +out = sys.stdout +out.write('\n') +for path in sorted(coverage): + lines = coverage[path] + # A file with nothing coverable contributes no element. Emitting an empty + # would still count toward a file-based guard. + if not lines: + continue + out.write(f" \n") + for number in sorted(lines): + covered = "true" if lines[number] else "false" + out.write(f' \n') + emitted += 1 + out.write(" \n") +out.write("\n") + +if emitted == 0: + sys.exit(f"error: no coverable lines parsed from {' '.join(sys.argv[1:])} after --include filtering") +PY diff --git a/Sources/PayabliSDKCore/Auth/PayabliAuth.swift b/Sources/PayabliSDKCore/Auth/PayabliAuth.swift index 4d1d2fb..893bead 100644 --- a/Sources/PayabliSDKCore/Auth/PayabliAuth.swift +++ b/Sources/PayabliSDKCore/Auth/PayabliAuth.swift @@ -9,8 +9,8 @@ public actor PayabliAuth { /// other callers await the same Task result. private var inFlightRefresh: Task? - // Multicasts every successful token rotation. Producers append on every - // refresh; consumers iterate as long as they want. + /// Multicasts every successful token rotation. Producers append on every + /// refresh; consumers iterate as long as they want. private var tokenChangeContinuations: [UUID: AsyncStream.Continuation] = [:] public init(config: PayabliConfig) { @@ -45,8 +45,7 @@ public actor PayabliAuth { let task = Task { [logger] in logger.info("Refreshing access token via partner tokenProvider") - let fresh = try await provider() - return fresh + return try await provider() } inFlightRefresh = task diff --git a/Sources/PayabliSDKCore/Auth/SessionTierValidator.swift b/Sources/PayabliSDKCore/Auth/SessionTierValidator.swift index 4a4479d..64ec07e 100644 --- a/Sources/PayabliSDKCore/Auth/SessionTierValidator.swift +++ b/Sources/PayabliSDKCore/Auth/SessionTierValidator.swift @@ -12,8 +12,7 @@ import Foundation /// /// Components pass in their static requirements; a mismatch throws /// `PayabliGenericError(.permissionDenied)`. -internal enum SessionTierValidator { - +enum SessionTierValidator { static func validate( component: any PayabliComponent.Type, against config: PayabliConfig diff --git a/Sources/PayabliSDKCore/Concurrency/EventMulticaster.swift b/Sources/PayabliSDKCore/Concurrency/EventMulticaster.swift index 4188dce..51f4ac7 100644 --- a/Sources/PayabliSDKCore/Concurrency/EventMulticaster.swift +++ b/Sources/PayabliSDKCore/Concurrency/EventMulticaster.swift @@ -3,7 +3,6 @@ import Foundation /// Multicast emitter for any `Sendable` event type. Every concurrent caller /// of `stream()` receives all subsequent events. public final class EventMulticaster: @unchecked Sendable { - private final class Subscription: @unchecked Sendable { let id = UUID() let continuation: AsyncStream.Continuation @@ -41,7 +40,9 @@ public final class EventMulticaster: @unchecked Sendable { lock.lock() let snapshot = subscribers lock.unlock() - for sub in snapshot { sub.continuation.yield(event) } + for sub in snapshot { + sub.continuation.yield(event) + } } /// Terminates every active stream. Typical use is during SDK teardown so @@ -52,11 +53,14 @@ public final class EventMulticaster: @unchecked Sendable { let snapshot = subscribers subscribers.removeAll() lock.unlock() - for sub in snapshot { sub.continuation.finish() } + for sub in snapshot { + sub.continuation.finish() + } } private func remove(_ id: UUID) { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } subscribers.removeAll { $0.id == id } } } diff --git a/Sources/PayabliSDKCore/Models/PayabliError.swift b/Sources/PayabliSDKCore/Models/PayabliError.swift index 55e6b4f..92a6222 100644 --- a/Sources/PayabliSDKCore/Models/PayabliError.swift +++ b/Sources/PayabliSDKCore/Models/PayabliError.swift @@ -74,8 +74,13 @@ public struct PayabliValidationError: PayabliError, Decodable { public let errors: [String: [PayabliFieldError]]? public let token: String? - public var code: PayabliErrorCode { .validation } - public var reason: String { title ?? "Validation failed" } + public var code: PayabliErrorCode { + .validation + } + + public var reason: String { + title ?? "Validation failed" + } enum CodingKeys: String, CodingKey { case type, title, status, detail, instance, errors, token @@ -90,8 +95,13 @@ public struct PayabliServerError: PayabliError, Decodable { public let detail: String? public let instance: String? - public var code: PayabliErrorCode { .unknown } - public var reason: String { title ?? "Internal server error" } + public var code: PayabliErrorCode { + .unknown + } + + public var reason: String { + title ?? "Internal server error" + } } /// HTTP 402 declined payment. See PRD §8.1.1 "Declined Response". @@ -101,8 +111,13 @@ public struct PayabliDeclineError: PayabliError, Decodable { public let explanation: String? public let action: String? - public var code: PayabliErrorCode { .unknown } - public var detail: String? { explanation } + public var code: PayabliErrorCode { + .unknown + } + + public var detail: String? { + explanation + } enum CodingKeys: String, CodingKey { case reason, explanation, action @@ -120,10 +135,10 @@ public enum PayabliPaymentError: Error, Sendable { public var asPayabliError: any PayabliError { switch self { - case .decline(let err): return err - case .validation(let err): return err - case .server(let err): return err - case .generic(let err): return err + case let .decline(err): return err + case let .validation(err): return err + case let .server(err): return err + case let .generic(err): return err } } } diff --git a/Sources/PayabliSDKCore/Networking/AuthenticatedTransport.swift b/Sources/PayabliSDKCore/Networking/AuthenticatedTransport.swift index b0b6f50..16ca17b 100644 --- a/Sources/PayabliSDKCore/Networking/AuthenticatedTransport.swift +++ b/Sources/PayabliSDKCore/Networking/AuthenticatedTransport.swift @@ -7,16 +7,16 @@ import Foundation /// /// Endpoint clients that need bearer auth depend on this transport rather /// than open-coding the header / retry dance themselves. -internal struct AuthenticatedTransport: PayabliTransport { +struct AuthenticatedTransport: PayabliTransport { private let base: any PayabliTransport private let auth: PayabliAuth - internal init(base: any PayabliTransport, auth: PayabliAuth) { + init(base: any PayabliTransport, auth: PayabliAuth) { self.base = base self.auth = auth } - public func perform(_ request: PayabliRequest) async throws -> PayabliResponse { + func perform(_ request: PayabliRequest) async throws -> PayabliResponse { let token = await auth.currentAccessToken() let firstAttempt = try await base.perform(authorize(request, with: token)) @@ -33,7 +33,7 @@ internal struct AuthenticatedTransport: PayabliTransport { return secondAttempt } - public func performV2( + func performV2( _ request: PayabliRequest, decoding: T.Type ) async throws -> PayabliV2Envelope { diff --git a/Sources/PayabliSDKCore/Networking/PayabliService.swift b/Sources/PayabliSDKCore/Networking/PayabliService.swift index 8e8b747..246474b 100644 --- a/Sources/PayabliSDKCore/Networking/PayabliService.swift +++ b/Sources/PayabliSDKCore/Networking/PayabliService.swift @@ -28,7 +28,7 @@ public final class PayabliService: PayabliTransport, Sendable { self.logger = PayabliLogger(category: .network) } - internal static func makeDefaultSession() -> URLSession { + static func makeDefaultSession() -> URLSession { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = defaultRequestTimeout config.timeoutIntervalForResource = defaultRequestTimeout * 3 @@ -150,7 +150,7 @@ public func mapPayabliHTTPError( response: PayabliResponse, override: ((Int) -> (any Error)?)? = nil ) throws { - guard !(200..<300).contains(response.statusCode) else { return } + guard !(200 ..< 300).contains(response.statusCode) else { return } // Component-specific override takes priority. if let customError = override?(response.statusCode) { diff --git a/Sources/PayabliSDKCore/Networking/ResponseEnvelope.swift b/Sources/PayabliSDKCore/Networking/ResponseEnvelope.swift index 5d7f04a..f79b687 100644 --- a/Sources/PayabliSDKCore/Networking/ResponseEnvelope.swift +++ b/Sources/PayabliSDKCore/Networking/ResponseEnvelope.swift @@ -1,6 +1,7 @@ import Foundation // MARK: - Legacy "isSuccess / responseData" envelope + // // Used by the `/api/v2/device/...` family (attestation, activation) and by // `/api/v2/device/taptopay/config/{entry}`. Business-level failures come back @@ -25,7 +26,6 @@ import Foundation /// } /// ``` public enum PayabliEnvelope { - /// Thin "peek" of the response body: only `isSuccess` and the top-level /// `responseText`. Used to decide between the success and decline decodes /// without committing to the full payload shape. @@ -109,8 +109,12 @@ public struct PayabliV2Envelope: Decodable { public let data: Data? /// `true` if `code` starts with `"A"` (Approved family). - public var isApproved: Bool { code.hasPrefix("A") } + public var isApproved: Bool { + code.hasPrefix("A") + } /// `true` if `code` starts with `"D"` (Declined family). - public var isDeclined: Bool { code.hasPrefix("D") } + public var isDeclined: Bool { + code.hasPrefix("D") + } } diff --git a/Sources/PayabliSDKCore/Networking/RetryPolicy.swift b/Sources/PayabliSDKCore/Networking/RetryPolicy.swift index a4e804a..e839084 100644 --- a/Sources/PayabliSDKCore/Networking/RetryPolicy.swift +++ b/Sources/PayabliSDKCore/Networking/RetryPolicy.swift @@ -48,7 +48,7 @@ public struct RetryPolicy: Sendable { guard attempt > 1 else { return 0 } let exponent = Double(attempt - 2) let backoff = min(baseDelay * pow(multiplier, exponent), maxDelay) - let jitter = Double.random(in: 0...maxJitter) + let jitter = Double.random(in: 0 ... maxJitter) return backoff + jitter } @@ -58,7 +58,7 @@ public struct RetryPolicy: Sendable { // Non-retryable: 400, 401, 403, 404. switch statusCode { case 408: return true // request timeout - case 500...599: return true + case 500 ... 599: return true default: return false } } @@ -71,7 +71,9 @@ public struct RetryPolicy: Sendable { /// `policy.maxAttempts`. Other errors propagate immediately. public struct RetryableError: Error { public let underlying: Error - public init(_ underlying: Error) { self.underlying = underlying } + public init(_ underlying: Error) { + self.underlying = underlying + } } public enum Retry { @@ -80,7 +82,7 @@ public enum Retry { _ operation: @Sendable (_ attempt: Int) async throws -> T ) async throws -> T { var lastUnderlying: Error? - for attempt in 1...policy.maxAttempts { + for attempt in 1 ... policy.maxAttempts { let delay = policy.delay(forAttempt: attempt) if delay > 0 { try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) @@ -89,7 +91,9 @@ public enum Retry { return try await operation(attempt) } catch let retryable as RetryableError { lastUnderlying = retryable.underlying - if attempt == policy.maxAttempts { throw retryable.underlying } + if attempt == policy.maxAttempts { + throw retryable.underlying + } continue } catch { throw error diff --git a/Sources/PayabliSDKCore/Public/PayabliEnvironment.swift b/Sources/PayabliSDKCore/Public/PayabliEnvironment.swift index 7c65710..08f1aae 100644 --- a/Sources/PayabliSDKCore/Public/PayabliEnvironment.swift +++ b/Sources/PayabliSDKCore/Public/PayabliEnvironment.swift @@ -6,9 +6,9 @@ import Foundation /// See PRD §8.2 for base URLs. @objc public enum PayabliEnvironment: Int, Sendable { #if DEBUG - /// Developer-only environment pointing at a local tunnel. Available only - /// in DEBUG builds — never shipped in a release binary. - case local = 0 + /// Developer-only environment pointing at a local tunnel. Available only + /// in DEBUG builds — never shipped in a release binary. + case local = 0 #endif case qa = 1 case sandbox = 2 @@ -19,8 +19,8 @@ import Foundation // swiftlint:disable force_unwrapping switch self { #if DEBUG - case .local: - return URL(string: "https://wallets-test.ngrok.app")! + case .local: + return URL(string: "https://wallets-test.ngrok.app")! #endif case .qa: return URL(string: "https://api-qa.payabli.com")! diff --git a/Sources/PayabliSDKCore/Telemetry/TelemetryClient.swift b/Sources/PayabliSDKCore/Telemetry/TelemetryClient.swift index 53a738f..9bccb89 100644 --- a/Sources/PayabliSDKCore/Telemetry/TelemetryClient.swift +++ b/Sources/PayabliSDKCore/Telemetry/TelemetryClient.swift @@ -1,5 +1,5 @@ -import Foundation import CryptoKit +import Foundation /// Transport abstraction — production uses `URLSession` to POST to the Payabli /// telemetry endpoint; tests inject an in-memory sink. @@ -89,5 +89,7 @@ public actor TelemetryClient { await transport.send(batch) } - public var bufferedCount: Int { buffer.count } + public var bufferedCount: Int { + buffer.count + } } diff --git a/Sources/PayabliSDKCore/Telemetry/TelemetryEvent.swift b/Sources/PayabliSDKCore/Telemetry/TelemetryEvent.swift index 3082702..3ac1461 100644 --- a/Sources/PayabliSDKCore/Telemetry/TelemetryEvent.swift +++ b/Sources/PayabliSDKCore/Telemetry/TelemetryEvent.swift @@ -41,34 +41,34 @@ public struct TelemetryEvent: Encodable, Sendable { /// Catalog of event names emitted by the SDK (PRD §24.3). public enum TelemetryEventName { // Tokenization - public static let tokenizationStarted = "tokenization.started" + public static let tokenizationStarted = "tokenization.started" public static let tokenizationSucceeded = "tokenization.succeeded" - public static let tokenizationFailed = "tokenization.failed" + public static let tokenizationFailed = "tokenization.failed" public static let tokenizationCancelled = "tokenization.cancelled" - public static let formPresented = "form.presented" - public static let formValidationError = "form.validationError" + public static let formPresented = "form.presented" + public static let formValidationError = "form.validationError" // TTP lifecycle - public static let ttpInitializeStarted = "ttp.initialize.started" - public static let ttpInitializeSucceeded = "ttp.initialize.succeeded" - public static let ttpInitializeFailed = "ttp.initialize.failed" - public static let ttpAttestationStarted = "ttp.attestation.started" - public static let ttpAttestationSucceeded = "ttp.attestation.succeeded" - public static let ttpAttestationFailed = "ttp.attestation.failed" - public static let ttpChargeStarted = "ttp.charge.started" - public static let ttpChargeSucceeded = "ttp.charge.succeeded" - public static let ttpChargeFailed = "ttp.charge.failed" - public static let ttpNfcStarted = "ttp.nfc.started" - public static let ttpNfcSucceeded = "ttp.nfc.succeeded" - public static let ttpNfcFailed = "ttp.nfc.failed" - public static let ttpReinitializeStarted = "ttp.reinitialize.started" + public static let ttpInitializeStarted = "ttp.initialize.started" + public static let ttpInitializeSucceeded = "ttp.initialize.succeeded" + public static let ttpInitializeFailed = "ttp.initialize.failed" + public static let ttpAttestationStarted = "ttp.attestation.started" + public static let ttpAttestationSucceeded = "ttp.attestation.succeeded" + public static let ttpAttestationFailed = "ttp.attestation.failed" + public static let ttpChargeStarted = "ttp.charge.started" + public static let ttpChargeSucceeded = "ttp.charge.succeeded" + public static let ttpChargeFailed = "ttp.charge.failed" + public static let ttpNfcStarted = "ttp.nfc.started" + public static let ttpNfcSucceeded = "ttp.nfc.succeeded" + public static let ttpNfcFailed = "ttp.nfc.failed" + public static let ttpReinitializeStarted = "ttp.reinitialize.started" public static let ttpReinitializeSucceeded = "ttp.reinitialize.succeeded" - public static let ttpStateChanged = "ttp.session.stateChanged" + public static let ttpStateChanged = "ttp.session.stateChanged" // System - public static let sdkInitialized = "sdk.initialized" - public static let telemetryDisabled = "sdk.telemetryDisabled" - public static let authTokenAcquired = "auth.tokenAcquired" - public static let authTokenFailed = "auth.tokenFailed" + public static let sdkInitialized = "sdk.initialized" + public static let telemetryDisabled = "sdk.telemetryDisabled" + public static let authTokenAcquired = "auth.tokenAcquired" + public static let authTokenFailed = "auth.tokenFailed" public static let authTokenRefreshed = "auth.tokenRefreshed" } diff --git a/Sources/PayabliSDKPayInPaymentFlow/PayabliPayInPaymentFlowFormConfiguration+Signature.swift b/Sources/PayabliSDKPayInPaymentFlow/PayabliPayInPaymentFlowFormConfiguration+Signature.swift index d84c3b4..9770e0a 100644 --- a/Sources/PayabliSDKPayInPaymentFlow/PayabliPayInPaymentFlowFormConfiguration+Signature.swift +++ b/Sources/PayabliSDKPayInPaymentFlow/PayabliPayInPaymentFlowFormConfiguration+Signature.swift @@ -69,7 +69,7 @@ private extension PayabliPayInPaymentFlowPaymentSummaryConfiguration { } } -private extension Dictionary where Key == PayabliPayInPaymentFlowField, Value == String { +private extension [PayabliPayInPaymentFlowField: String] { var payabliViewModelSignature: String { map { "\($0.key.rawValue)=\($0.value)" } .sorted() diff --git a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader+Errors.swift b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader+Errors.swift index 64b04da..a89b436 100644 --- a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader+Errors.swift +++ b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader+Errors.swift @@ -1,57 +1,62 @@ import Foundation import PayabliSDKCore #if canImport(PayabliCardReaderCore) -import PayabliCardReaderCore -import ProximityReader + import PayabliCardReaderCore + import ProximityReader #endif // MARK: - Card reader error mapping extension FiservCardReader { - /// Prefix inside `.nfcFailed(reason:)` that marks a user-cancel, so hosts /// can distinguish it from a hard failure by substring. static let cancellationReasonPrefix = "cancelled:" #if canImport(PayabliCardReaderCore) - /// Translates a card-reader / ProximityReader error into `PayabliTTPError`. - /// `fallback` picks the case (setup vs. NFC) for non-cancel errors. - static func mapError( - _ error: Error, - fallback: (String) -> PayabliTTPError - ) -> PayabliTTPError { - if let pte = error as? PayabliTTPError { return pte } - - if (error as NSError).code == NSUserCancelledError { - return .nfcFailed( - reason: "\(cancellationReasonPrefix) user dismissed Tap to Pay sheet" - ) - } + /// Translates a card-reader / ProximityReader error into `PayabliTTPError`. + /// `fallback` picks the case (setup vs. NFC) for non-cancel errors. + static func mapError( + _ error: Error, + fallback: (String) -> PayabliTTPError + ) -> PayabliTTPError { + if let pte = error as? PayabliTTPError { + return pte + } + + if (error as NSError).code == NSUserCancelledError { + return .nfcFailed( + reason: "\(cancellationReasonPrefix) user dismissed Tap to Pay sheet" + ) + } - if #available(iOS 16.4, *), error is PaymentCardReaderError { - let desc = error.localizedDescription - if desc.lowercased().contains("version") { - return .readerSetupFailed(reason: "OS version not supported: \(desc)") + if #available(iOS 16.4, *), error is PaymentCardReaderError { + let desc = error.localizedDescription + if desc.lowercased().contains("version") { + return .readerSetupFailed(reason: "OS version not supported: \(desc)") + } } + + let detail = extractReaderDetail(error) + return fallback(detail.isEmpty ? error.localizedDescription : detail) } - let detail = extractReaderDetail(error) - return fallback(detail.isEmpty ? error.localizedDescription : detail) - } - - /// `FiservTTPCardReaderError.localizedDescription` is a stored property - /// that shadows (doesn't override) `Error.localizedDescription`, so the - /// usual accessor returns a generic NSError message. Read the real - /// `title` + `localizedDescription` via reflection. - static func extractReaderDetail(_ error: Error) -> String { - let mirror = Mirror(reflecting: error) - let title = mirror.children.first(where: { $0.label == "title" })?.value as? String - let desc = mirror.children.first(where: { $0.label == "localizedDescription" })?.value as? String - if let title, let desc { return "\(title): \(desc)" } - if let desc { return desc } - return "" - } + /// `FiservTTPCardReaderError.localizedDescription` is a stored property + /// that shadows (doesn't override) `Error.localizedDescription`, so the + /// usual accessor returns a generic NSError message. Read the real + /// `title` + `localizedDescription` via reflection. + static func extractReaderDetail(_ error: Error) -> String { + let mirror = Mirror(reflecting: error) + let title = mirror.children.first(where: { $0.label == "title" })?.value as? String + let desc = mirror.children.first(where: { $0.label == "localizedDescription" })?.value as? String + if let title, let desc { + return "\(title): \(desc)" + } + if let desc { + return desc + } + return "" + } #endif } diff --git a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift index dd8f700..e80360e 100644 --- a/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift +++ b/Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift @@ -1,8 +1,8 @@ import Foundation import PayabliSDKCore #if canImport(PayabliCardReaderCore) -import PayabliCardReaderCore -import ProximityReader + import PayabliCardReaderCore + import ProximityReader #endif /// Card-reader adapter for `TapToPayProvider` (PRD FR-11B), backed by the @@ -18,11 +18,12 @@ import ProximityReader /// /// Error mapping: see `FiservCardReader+Errors.swift`. public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { - - public static var providerId: String { "fiserv" } + public static var providerId: String { + "fiserv" + } /// `/config` `credentials` block — maps 1:1 to `FiservTTPConfig`. - internal struct Credentials: Sendable { + struct Credentials: Sendable { let secretKey: String let apiKey: String let environment: String @@ -64,13 +65,13 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { private let logger = PayabliLogger(category: .taptopay) #if canImport(PayabliCardReaderCore) - private var reader: FiservTTPCardReader? + private var reader: FiservTTPCardReader? #endif public init() {} /// Injects `Credentials` directly. Facade path uses `configure(credentials:)`. - internal func setCredentials(_ credentials: Credentials) { + func setCredentials(_ credentials: Credentials) { lock.lock() self.credentials = credentials lock.unlock() @@ -127,112 +128,107 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { /// is fetched, so must not require credentials. public func checkEligibility() async -> Result { #if canImport(PayabliCardReaderCore) - if #available(iOS 16.7, *) { - guard PaymentCardReader.isSupported else { - return .failure(.readerSetupFailed( - reason: "Tap to Pay hardware not supported on this device" - )) + if #available(iOS 16.7, *) { + guard PaymentCardReader.isSupported else { + return .failure(.readerSetupFailed( + reason: "Tap to Pay hardware not supported on this device" + )) + } + return .success(()) } - return .success(()) - } - return .failure(.readerSetupFailed(reason: "Tap to Pay requires iOS 16.7+")) + return .failure(.readerSetupFailed(reason: "Tap to Pay requires iOS 16.7+")) #else - return .failure(.readerSetupFailed(reason: "Tap to Pay is iOS-only")) + return .failure(.readerSetupFailed(reason: "Tap to Pay is iOS-only")) #endif } public func prepareReader() async throws { #if canImport(PayabliCardReaderCore) - let creds = try requireCredentials() - let newReader = try buildReader(credentials: creds) - - // Drop our copy; credentials now live inside `newReader` (NFR-5D). - lock.lock() - credentials = nil - lock.unlock() - - logger.info("[fiserv.prepare] → requesting session") - do { - try await newReader.requestSessionToken() - - let linked = try await newReader.isAccountLinked() - if !linked { - try await newReader.linkAccount() + let creds = try requireCredentials() + let newReader = try buildReader(credentials: creds) + + // Drop our copy; credentials now live inside `newReader` (NFR-5D). + lock.lock() + credentials = nil + lock.unlock() + + logger.info("[fiserv.prepare] → requesting session") + do { + try await newReader.requestSessionToken() + + let linked = try await newReader.isAccountLinked() + if !linked { + try await newReader.linkAccount() + } + + try await newReader.initializeSession() + logger.info("[fiserv.prepare] ← reader ready (linked=\(linked))") + } catch { + clearAllState() + throw Self.mapError(error) { .readerSetupFailed(reason: $0) } } - - try await newReader.initializeSession() - logger.info("[fiserv.prepare] ← reader ready (linked=\(linked))") - } catch { - clearAllState() - throw Self.mapError(error) { .readerSetupFailed(reason: $0) } - } #else - throw PayabliTTPError.readerSetupFailed(reason: "Tap to Pay is iOS-only") + throw PayabliTTPError.readerSetupFailed(reason: "Tap to Pay is iOS-only") #endif } public func startReading(_ request: CardReadRequest) async throws -> CardReadResult { #if canImport(PayabliCardReaderCore) - lock.lock() - let activeReader = reader - lock.unlock() - guard let reader = activeReader else { - throw PayabliTTPError.readerSetupFailed(reason: "Reader not prepared") - } + lock.lock() + let activeReader = reader + lock.unlock() + guard let reader = activeReader else { + throw PayabliTTPError.readerSetupFailed(reason: "Reader not prepared") + } - let amount = request.amount.rounded(2, .bankers) - let details = Models.TransactionDetailsRequest( - merchantTransactionId: request.merchantTransactionId, - merchantOrderId: request.merchantOrderId ?? request.merchantTransactionId, - merchantInvoiceNumber: request.merchantInvoiceNumber, - captureFlag: true, - createToken: false - ) + let amount = request.amount.rounded(2, .bankers) + let details = Models.TransactionDetailsRequest( + merchantTransactionId: request.merchantTransactionId, + merchantOrderId: request.merchantOrderId ?? request.merchantTransactionId, + merchantInvoiceNumber: request.merchantInvoiceNumber, + captureFlag: true, + createToken: false + ) - logger.info( - "[fiserv.charges] → amount=\(amount) " + - "currency=\(credentials?.currencyCode ?? "?") " + - "merchantTxId=\(request.merchantTransactionId) " + - "merchantOrderId=\(request.merchantOrderId ?? "") " + - "invoice=\(request.merchantInvoiceNumber ?? "")" - ) - logger.info( - "[fiserv.charges] customer={firstName=\(request.customer.firstName ?? "") " + - "lastName=\(request.customer.lastName ?? "") " + - "customerNumber=\(request.customer.customerNumber ?? "")} " + - "invoice={invoiceNumber=\(request.invoice.invoiceNumber ?? "")}" - ) - // The atomic card-reader API has no slot for customer data; it - // ships only at /initiate and in the logs above. - - let started = Date() - let response: Models.CommerceHubResponse - do { - response = try await reader.charges( - amount: amount, - transactionType: .sale, - transactionDetailsRequest: details + logger.info( + "[fiserv.charges] → amount=\(amount) " + + "currency=\(credentials?.currencyCode ?? "?") " + + "merchantTxId=\(request.merchantTransactionId) " + + "merchantOrderId=\(request.merchantOrderId ?? "") " + + "invoice=\(request.merchantInvoiceNumber ?? "")" ) - } catch { - throw Self.mapError(error) { .nfcFailed(reason: $0) } - } + // The atomic card-reader API has no slot for customer data; it ships + // at /initiate only. A single-argument message renders `.public`, so + // the line above carries the invoice number and nothing naming the + // customer. + + let started = Date() + let response: Models.CommerceHubResponse + do { + response = try await reader.charges( + amount: amount, + transactionType: .sale, + transactionDetailsRequest: details + ) + } catch { + throw Self.mapError(error) { .nfcFailed(reason: $0) } + } - let elapsedMs = Int(Date().timeIntervalSince(started) * 1000) - let responseJSON = try Self.encode(response) - let cardNetwork = Self.extractCardNetwork(from: responseJSON) - logger.info("[fiserv.charges] ← OK (\(elapsedMs)ms) bytes=\(responseJSON.count) cardNetwork=\(cardNetwork ?? "")") - if let pretty = Self.prettyPrintJSON(responseJSON) { - logger.info("[fiserv.charges] responseBody:\n\(pretty)") - } - return CardReadResult( - provider: Self.providerId, - encryptedPayload: Data(), - cardNetwork: cardNetwork, - providerMetadata: [:], - providerResponseJSON: responseJSON - ) + let elapsedMs = Int(Date().timeIntervalSince(started) * 1000) + let responseJSON = try Self.encode(response) + let cardNetwork = Self.extractCardNetwork(from: responseJSON) + // `CommerceHubResponse` carries `paymentTokens.tokenData` and the + // card's expiry, so the log gets the shape of the response. + logger.info("[fiserv.charges] ← OK (\(elapsedMs)ms) bytes=\(responseJSON.count) cardNetwork=\(cardNetwork ?? "")") + return CardReadResult( + provider: Self.providerId, + encryptedPayload: Data(), + cardNetwork: cardNetwork, + providerMetadata: [:], + providerResponseJSON: responseJSON + ) #else - throw PayabliTTPError.nfcFailed(reason: "Tap to Pay is iOS-only") + throw PayabliTTPError.nfcFailed(reason: "Tap to Pay is iOS-only") #endif } @@ -254,90 +250,80 @@ public final class FiservCardReader: TapToPayProvider, @unchecked Sendable { private func clearAllState() { lock.lock() #if canImport(PayabliCardReaderCore) - reader?.finalize() - reader = nil + reader?.finalize() + reader = nil #endif credentials = nil lock.unlock() } #if canImport(PayabliCardReaderCore) - private func requireCredentials() throws -> Credentials { - lock.lock(); defer { lock.unlock() } - guard let creds = credentials else { - throw PayabliTTPError.readerSetupFailed(reason: "Missing provider credentials") + private func requireCredentials() throws -> Credentials { + lock.lock() + defer { lock.unlock() } + guard let creds = credentials else { + throw PayabliTTPError.readerSetupFailed(reason: "Missing provider credentials") + } + return creds } - return creds - } - /// Builds a fresh `FiservTTPCardReader` (vendored from PayabliCardReaderCore). - /// Tears down the previous instance first — Apple's `PaymentCardReader` - /// allows only one per process. - private func buildReader(credentials creds: Credentials) throws -> FiservTTPCardReader { - lock.lock() - reader?.finalize() - reader = nil - lock.unlock() + /// Builds a fresh `FiservTTPCardReader` (vendored from PayabliCardReaderCore). + /// Tears down the previous instance first — Apple's `PaymentCardReader` + /// allows only one per process. + private func buildReader(credentials creds: Credentials) throws -> FiservTTPCardReader { + lock.lock() + reader?.finalize() + reader = nil + lock.unlock() + + let config = FiservTTPConfig( + secretKey: creds.secretKey, + apiKey: creds.apiKey, + environment: creds.environment.lowercased() == "production" ? .Production : .Sandbox, + currencyCode: creds.currencyCode, + merchantId: creds.merchantId, + appleTtpMerchantId: creds.appleTtpMerchantId, + merchantName: creds.merchantName, + merchantCategoryCode: creds.merchantCategoryCode, + terminalId: creds.terminalId, + terminalProfileId: creds.terminalProfileId + ) - let config = FiservTTPConfig( - secretKey: creds.secretKey, - apiKey: creds.apiKey, - environment: creds.environment.lowercased() == "production" ? .Production : .Sandbox, - currencyCode: creds.currencyCode, - merchantId: creds.merchantId, - appleTtpMerchantId: creds.appleTtpMerchantId, - merchantName: creds.merchantName, - merchantCategoryCode: creds.merchantCategoryCode, - terminalId: creds.terminalId, - terminalProfileId: creds.terminalProfileId - ) + let newReader = FiservTTPCardReader(configuration: config) - let newReader = FiservTTPCardReader(configuration: config) + lock.lock() + reader = newReader + lock.unlock() - lock.lock() - reader = newReader - lock.unlock() - - return newReader - } - - private static func encode(_ value: T) throws -> Data { - do { - return try JSONEncoder().encode(value) - } catch { - throw PayabliTTPError.nfcFailed( - reason: "Failed to encode provider response: \(error.localizedDescription)" - ) + return newReader } - } - /// Re-encodes `json` with `.prettyPrinted` + `.sortedKeys` for log output. - /// Returns `nil` if `json` isn't valid JSON. - private static func prettyPrintJSON(_ json: Data) -> String? { - guard - let obj = try? JSONSerialization.jsonObject(with: json), - let pretty = try? JSONSerialization.data( - withJSONObject: obj, - options: [.prettyPrinted, .sortedKeys] - ) - else { return nil } - return String(data: pretty, encoding: .utf8) - } + private static func encode(_ value: some Encodable) throws -> Data { + do { + return try JSONEncoder().encode(value) + } catch { + throw PayabliTTPError.nfcFailed( + reason: "Failed to encode provider response: \(error.localizedDescription)" + ) + } + } - /// Pulls `card.brand` out of the CommerceHub response for - /// `CardReadResult.cardNetwork`. Tolerates minor schema drift. - private static func extractCardNetwork(from json: Data) -> String? { - guard - let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] - else { return nil } - if let pm = (obj["source"] as? [String: Any]) ?? (obj["paymentSources"] as? [String: Any]) { - if let brand = pm["brand"] as? String { return brand } - if let card = pm["card"] as? [String: Any], let brand = card["brand"] as? String { - return brand + /// Pulls `card.brand` out of the CommerceHub response for + /// `CardReadResult.cardNetwork`. Tolerates minor schema drift. + private static func extractCardNetwork(from json: Data) -> String? { + guard + let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] + else { return nil } + if let pm = (obj["source"] as? [String: Any]) ?? (obj["paymentSources"] as? [String: Any]) { + if let brand = pm["brand"] as? String { + return brand + } + if let card = pm["card"] as? [String: Any], let brand = card["brand"] as? String { + return brand + } } + return nil } - return nil - } #endif } diff --git a/Sources/PayabliSDKTapToPay/AppAttestService+Activation.swift b/Sources/PayabliSDKTapToPay/AppAttestService+Activation.swift index 5912f18..c55fab1 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService+Activation.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService+Activation.swift @@ -3,9 +3,8 @@ import PayabliSDKCore // MARK: - Device activation (PRD §9.7) -extension AppAttestService { - - public func activateDevice(activationCode: String, entry: String) async throws { +public extension AppAttestService { + func activateDevice(activationCode: String, entry: String) async throws { guard let deviceId = storage.string(forKey: PayabliKeychainKey.deviceId) else { throw PayabliTTPError.attestationFailed(reason: "Missing deviceId — run initialize() before activateDevice") } diff --git a/Sources/PayabliSDKTapToPay/AppAttestService+Attest.swift b/Sources/PayabliSDKTapToPay/AppAttestService+Attest.swift index 6807f45..b0302e6 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService+Attest.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService+Attest.swift @@ -1,11 +1,10 @@ -import Foundation import CryptoKit +import Foundation import PayabliSDKCore // MARK: - Attestation flow (PRD §18.1) & per-request assertions (PRD §18.2) extension AppAttestService { - /// Apple's DeviceCheck / App Attest error domain (`DCError`). Bridged as a /// string so this file needs no `import DeviceCheck` and stays testable on /// hosts where `DCAppAttestService` is unavailable. @@ -104,7 +103,8 @@ extension AppAttestService { public func generateAssertion() async throws -> AssertionHeaders { guard let storedKeyId = storage.string(forKey: PayabliKeychainKey.keyId), - let deviceId = storage.string(forKey: PayabliKeychainKey.deviceId) else { + let deviceId = storage.string(forKey: PayabliKeychainKey.deviceId) + else { throw PayabliTTPError.attestationFailed(reason: "Missing attestation state") } let keyId = AppAttestKeyId(storedKeyId) diff --git a/Sources/PayabliSDKTapToPay/AppAttestService+Defaults.swift b/Sources/PayabliSDKTapToPay/AppAttestService+Defaults.swift index 69de7c4..fd5c3cc 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService+Defaults.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService+Defaults.swift @@ -1,36 +1,36 @@ import Foundation #if canImport(UIKit) -import UIKit + import UIKit #endif // MARK: - Default hardware identifier providers + // // Injectable `@Sendable` closures; macOS test builds fall back to stand-ins. extension AppAttestService { - - internal static var defaultHardwareId: @Sendable () -> String { + static var defaultHardwareId: @Sendable () -> String { { #if canImport(UIKit) - return UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + return UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString #else - return UUID().uuidString + return UUID().uuidString #endif } } - internal static var defaultDeviceName: @Sendable () -> String { + static var defaultDeviceName: @Sendable () -> String { { #if canImport(UIKit) - return UIDevice.current.name + return UIDevice.current.name #else - return "macOS" + return "macOS" #endif } } - internal static var defaultModel: @Sendable () -> String { + static var defaultModel: @Sendable () -> String { { var sysinfo = utsname() uname(&sysinfo) @@ -41,12 +41,12 @@ extension AppAttestService { } } - internal static var defaultOSVersion: @Sendable () -> String { + static var defaultOSVersion: @Sendable () -> String { { #if canImport(UIKit) - return UIDevice.current.systemVersion + return UIDevice.current.systemVersion #else - return ProcessInfo.processInfo.operatingSystemVersionString + return ProcessInfo.processInfo.operatingSystemVersionString #endif } } diff --git a/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift b/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift index ab24979..58a2e56 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService+Requests.swift @@ -2,13 +2,13 @@ import Foundation import PayabliSDKCore // MARK: - Attestation networking helpers + // // Concrete-endpoint wrappers (`postChallenge`, `postRegister`, `postAttest`) // plus the shared machinery used by every authenticated POST in the // attestation/activation family. extension AppAttestService { - func postChallenge(entry: String) async throws -> ChallengeResponse { try await postAttestationRequest( path: "/api/v2/device/taptopay/challenge", @@ -35,9 +35,9 @@ extension AppAttestService { /// Authenticated POST that expects a non-empty `responseData` of type /// `Payload`. Throws if the envelope is missing it. - func postAttestationRequest( + func postAttestationRequest( path: String, - body: Body, + body: some Encodable, label: String, assertion: AssertionHeaders? = nil, makeDeclineError: @escaping (_ code: Int?, _ reason: String) -> PayabliTTPError = { _, reason in @@ -71,9 +71,9 @@ extension AppAttestService { /// Authenticated POST for endpoints that do not return a `responseData` /// body (only an `isSuccess` acknowledgement). - func postAttestationRequestExpectingNoBody( + func postAttestationRequestExpectingNoBody( path: String, - body: Body, + body: some Encodable, label: String, assertion: AssertionHeaders? = nil, makeDeclineError: @escaping (_ code: Int?, _ reason: String) -> PayabliTTPError = { _, reason in @@ -99,9 +99,9 @@ extension AppAttestService { /// `transport` decorator (`AuthenticatedTransport`); this method only /// appends the App Attest assertion headers that are specific to the /// attestation/activation endpoint family. - private func performAuthenticatedPOST( + private func performAuthenticatedPOST( path: String, - body: Body, + body: some Encodable, label: String, assertion: AssertionHeaders?, makeDeclineError: (_ code: Int?, _ reason: String) -> PayabliTTPError @@ -117,14 +117,10 @@ extension AppAttestService { jsonBody: body ) - let headersDump = request.headers - .map { "\($0.key): \($0.value)" } - .sorted() - .joined(separator: " | ") - let bodyDump = request.body.flatMap { String(data: $0, encoding: .utf8) } ?? "" - logger.info("[\(label)] → POST \(request.path)") - logger.info("[\(label)] headers: \(headersDump)") - logger.info("[\(label)] body: \(bodyDump)") + // These bodies and headers are authentication material: `/activate` + // carries the activation code, the others carry the App Attest key, + // attestation and assertion. Endpoint and size only. + logger.info("[\(label)] → POST \(request.path) bytes=\(request.body?.count ?? 0)") let response: PayabliResponse do { @@ -134,8 +130,7 @@ extension AppAttestService { throw error } - let responseBody = String(data: response.body, encoding: .utf8) ?? "" - logger.info("[\(label)] ← [\(response.statusCode)] body: \(responseBody)") + logger.info("[\(label)] ← [\(response.statusCode)] bytes=\(response.body.count)") do { try mapPayabliHTTPError(response: response) diff --git a/Sources/PayabliSDKTapToPay/AppAttestService.swift b/Sources/PayabliSDKTapToPay/AppAttestService.swift index d1ae433..2d2d6ab 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestService.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestService.swift @@ -29,7 +29,6 @@ import PayabliSDKCore /// - `AppAttestService+Defaults.swift` — hardware-identifier providers /// - `AppAttestWireFormat.swift` — request/response DTOs public final class AppAttestService: DeviceAttestationService, @unchecked Sendable { - let transport: any PayabliTransport let attestor: AppAttestor let storage: SecureStorage @@ -57,7 +56,7 @@ public final class AppAttestService: DeviceAttestationService, @unchecked Sendab ) } - internal init( + init( transport: any PayabliTransport, attestor: AppAttestor, storage: SecureStorage, diff --git a/Sources/PayabliSDKTapToPay/AppAttestWireFormat.swift b/Sources/PayabliSDKTapToPay/AppAttestWireFormat.swift index c4c62d5..2e4ac59 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestWireFormat.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestWireFormat.swift @@ -1,6 +1,7 @@ import Foundation // MARK: - Backend wire types (PRD §8.2) + // // Endpoint-specific DTOs for the attestation family. Generic envelope // scaffolding lives in `PayabliSDKCore.PayabliEnvelope`. diff --git a/Sources/PayabliSDKTapToPay/AppAttestor.swift b/Sources/PayabliSDKTapToPay/AppAttestor.swift index 74ca094..c7bbed9 100644 --- a/Sources/PayabliSDKTapToPay/AppAttestor.swift +++ b/Sources/PayabliSDKTapToPay/AppAttestor.swift @@ -1,7 +1,7 @@ import Foundation #if canImport(DeviceCheck) -import DeviceCheck + import DeviceCheck #endif // MARK: - Domain types @@ -24,20 +24,26 @@ public struct AppAttestKeyId: Hashable, Sendable, Codable, CustomStringConvertib try container.encode(rawValue) } - public var description: String { rawValue } + public var description: String { + rawValue + } } /// SHA-256 hash fed into `attestKey` / `generateAssertion` — 32 bytes. public struct ClientDataHash: Hashable, Sendable { public let rawValue: Data - public init(_ rawValue: Data) { self.rawValue = rawValue } + public init(_ rawValue: Data) { + self.rawValue = rawValue + } } /// CBOR attestation blob returned by `attestKey`. Wire-format: base64 string. public struct AttestationObject: Hashable, Sendable, Codable { public let rawValue: Data - public init(_ rawValue: Data) { self.rawValue = rawValue } + public init(_ rawValue: Data) { + self.rawValue = rawValue + } public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() @@ -56,15 +62,22 @@ public struct AttestationObject: Hashable, Sendable, Codable { try container.encode(rawValue.base64EncodedString()) } - public var base64: String { rawValue.base64EncodedString() } + public var base64: String { + rawValue.base64EncodedString() + } } /// CBOR assertion blob returned by `generateAssertion`. Emitted as base64 /// in the `X-App-Assertion` header. public struct AppAttestAssertion: Hashable, Sendable { public let rawValue: Data - public init(_ rawValue: Data) { self.rawValue = rawValue } - public var base64: String { rawValue.base64EncodedString() } + public init(_ rawValue: Data) { + self.rawValue = rawValue + } + + public var base64: String { + rawValue.base64EncodedString() + } } // MARK: - Protocol @@ -84,61 +97,70 @@ public protocol AppAttestor: Sendable { // MARK: - Production impl #if canImport(DeviceCheck) -/// Production `AppAttestor` backed by `DCAppAttestService`. -/// -/// `DCAppAttestService` itself is iOS 14+ / macOS 11.3+, which is well below -/// this package's declared minimums (iOS 16.7 / macOS 12), so no extra -/// `@available` gate is required. -public final class RealAppAttestor: AppAttestor, @unchecked Sendable { - public init() {} - - public var isSupported: Bool { - DCAppAttestService.shared.isSupported - } + /// Production `AppAttestor` backed by `DCAppAttestService`. + /// + /// `DCAppAttestService` itself is iOS 14+ / macOS 11.3+, which is well below + /// this package's declared minimums (iOS 16.7 / macOS 12), so no extra + /// `@available` gate is required. + public final class RealAppAttestor: AppAttestor, @unchecked Sendable { + public init() {} + + public var isSupported: Bool { + DCAppAttestService.shared.isSupported + } - public func generateKey() async throws -> AppAttestKeyId { - try await withCheckedThrowingContinuation { continuation in - DCAppAttestService.shared.generateKey { keyId, error in - if let error { continuation.resume(throwing: error); return } - guard let keyId else { - continuation.resume(throwing: PayabliTTPError.attestationFailed( - reason: "generateKey returned nil" - )) - return + public func generateKey() async throws -> AppAttestKeyId { + try await withCheckedThrowingContinuation { continuation in + DCAppAttestService.shared.generateKey { keyId, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let keyId else { + continuation.resume(throwing: PayabliTTPError.attestationFailed( + reason: "generateKey returned nil" + )) + return + } + continuation.resume(returning: AppAttestKeyId(keyId)) } - continuation.resume(returning: AppAttestKeyId(keyId)) } } - } - public func attestKey(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AttestationObject { - try await withCheckedThrowingContinuation { continuation in - DCAppAttestService.shared.attestKey(keyId.rawValue, clientDataHash: clientDataHash.rawValue) { attestation, error in - if let error { continuation.resume(throwing: error); return } - guard let attestation else { - continuation.resume(throwing: PayabliTTPError.attestationFailed( - reason: "attestKey returned nil" - )) - return + public func attestKey(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AttestationObject { + try await withCheckedThrowingContinuation { continuation in + DCAppAttestService.shared.attestKey(keyId.rawValue, clientDataHash: clientDataHash.rawValue) { attestation, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let attestation else { + continuation.resume(throwing: PayabliTTPError.attestationFailed( + reason: "attestKey returned nil" + )) + return + } + continuation.resume(returning: AttestationObject(attestation)) } - continuation.resume(returning: AttestationObject(attestation)) } } - } - public func generateAssertion(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AppAttestAssertion { - try await withCheckedThrowingContinuation { continuation in - DCAppAttestService.shared.generateAssertion(keyId.rawValue, clientDataHash: clientDataHash.rawValue) { assertion, error in - if let error { continuation.resume(throwing: error); return } - guard let assertion else { - continuation.resume(throwing: PayabliTTPError.attestationFailed( - reason: "generateAssertion returned nil" - )) - return + public func generateAssertion(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AppAttestAssertion { + try await withCheckedThrowingContinuation { continuation in + DCAppAttestService.shared.generateAssertion(keyId.rawValue, clientDataHash: clientDataHash.rawValue) { assertion, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let assertion else { + continuation.resume(throwing: PayabliTTPError.attestationFailed( + reason: "generateAssertion returned nil" + )) + return + } + continuation.resume(returning: AppAttestAssertion(assertion)) } - continuation.resume(returning: AppAttestAssertion(assertion)) } } } -} #endif diff --git a/Sources/PayabliSDKTapToPay/EventMulticasterAlias.swift b/Sources/PayabliSDKTapToPay/EventMulticasterAlias.swift index 3d0be69..dd879c4 100644 --- a/Sources/PayabliSDKTapToPay/EventMulticasterAlias.swift +++ b/Sources/PayabliSDKTapToPay/EventMulticasterAlias.swift @@ -1,4 +1,4 @@ import PayabliSDKCore /// TapToPay convenience alias for `EventMulticaster`. -internal typealias TTPEventMulticaster = EventMulticaster +typealias TTPEventMulticaster = EventMulticaster diff --git a/Sources/PayabliSDKTapToPay/KeychainStorage.swift b/Sources/PayabliSDKTapToPay/KeychainStorage.swift index fc94642..de9a38a 100644 --- a/Sources/PayabliSDKTapToPay/KeychainStorage.swift +++ b/Sources/PayabliSDKTapToPay/KeychainStorage.swift @@ -35,7 +35,7 @@ public struct KeychainStorage: SecureStorage, Sendable { return String(data: data, encoding: .utf8) } - internal func data(forKey key: String) -> Data? { + func data(forKey key: String) -> Data? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -58,7 +58,7 @@ public struct KeychainStorage: SecureStorage, Sendable { try set(data, forKey: key) } - internal func set(_ data: Data, forKey key: String) throws { + func set(_ data: Data, forKey key: String) throws { let baseQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -93,7 +93,7 @@ public struct KeychainStorage: SecureStorage, Sendable { _ = SecItemDelete(query as CFDictionary) } - internal func removeAll() { + func removeAll() { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP+Activation.swift b/Sources/PayabliSDKTapToPay/PayabliTTP+Activation.swift index 1162845..a492847 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP+Activation.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP+Activation.swift @@ -4,8 +4,7 @@ import PayabliSDKCore // MARK: - Device activation (PRD §9.7) @MainActor -extension PayabliTTP { - +public extension PayabliTTP { /// Activate a pending device using an activation code supplied by the /// partner out-of-band (e.g. an admin dashboard). /// @@ -14,7 +13,7 @@ extension PayabliTTP { /// `.attestationRevoked` the session is reset to `.idle` (not `.error`) /// so the caller can immediately re-run `initialize()` for a fresh cold /// attestation — `.sessionExpired` is also emitted in that sub-case. - public func activateDevice(activationCode: String) async throws { + func activateDevice(activationCode: String) async throws { guard sessionState == .pendingActivation else { throw PayabliTTPError.invalidState( current: sessionState, @@ -59,7 +58,7 @@ extension PayabliTTP { /// /// The completion handler is always invoked on the main thread because /// the entire `PayabliTTP` surface is `@MainActor`. - @objc public func activateDevice( + @objc func activateDevice( activationCode: String, completion: @escaping (NSError?) -> Void ) { diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift index 3e1cd8e..2bde805 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift @@ -12,7 +12,6 @@ private enum TTPUpdateOutcome { @MainActor extension PayabliTTP { - /// Charge a transaction. v1.0 supports `.sale` only (FR-11D.1). /// /// Threads the `paymentDetails` / `customer` / `invoice` / `orderDescription` @@ -94,7 +93,8 @@ extension PayabliTTP { // would kill a healthy session over a dead one's failure. if generation == readerSessionGeneration, readerFailureInvalidatesSession(error), - sessionManager.transition(to: .sessionExpired) { + sessionManager.transition(to: .sessionExpired) + { syncPublished() multicaster.emit(.sessionExpired) } @@ -116,7 +116,7 @@ extension PayabliTTP { case .succeeded: multicaster.emit(.updateCompleted(paymentTransId: paymentTransId)) return TransactionResult(paymentTransId: paymentTransId) - case .failed(let reason): + case let .failed(reason): throw PayabliTTPError.updateFailed(reason: reason) } } @@ -198,7 +198,6 @@ extension PayabliTTP { ) async -> TTPUpdateOutcome { let body = TTPTransactionClient.updateBody(for: payload) let logger = self.logger - let bodyDump = String(data: body, encoding: .utf8) ?? "" let path = "/api/v2/MoneyIn/update/\(paymentTransId)" let transport = self.session.transport @@ -209,16 +208,11 @@ extension PayabliTTP { headers: ["Content-Type": "application/json"], body: body ) - let headersDump = request.headers - .map { "\($0.key): \($0.value)" } - .sorted() - .joined(separator: " | ") - logger.info("[update/\(attempt)] → PATCH \(path)") - logger.info("[update/\(attempt)] headers: \(headersDump)") - logger.info("[update/\(attempt)] body: \(bodyDump)") + // The success body carries the provider's whole response, and with + // it `paymentTokens.tokenData` and the card's expiry. Size only. + logger.info("[update/\(attempt)] → PATCH \(path) bytes=\(body.count)") let response = try await transport.perform(request) - let responseBody = String(data: response.body, encoding: .utf8) ?? "" - logger.info("[update/\(attempt)] ← [\(response.statusCode)] body: \(responseBody)") + logger.info("[update/\(attempt)] ← [\(response.statusCode)] bytes=\(response.body.count)") return response } @@ -226,7 +220,9 @@ extension PayabliTTP { try await Retry.run(policy: retryPolicy) { [retryPolicy] attempt in let response = try await performOnce(attempt: String(attempt)) - if (200..<300).contains(response.statusCode) { return } + if (200 ..< 300).contains(response.statusCode) { + return + } if retryPolicy.isRetryable(statusCode: response.statusCode) { throw RetryableError(PayabliTTPError.updateFailed( reason: "HTTP \(response.statusCode)" @@ -242,45 +238,62 @@ extension PayabliTTP { } } - /// Two-line log: a `.public` summary that lands in shared OS logs, plus - /// a `.private` detail line carrying PII (billing/shipping/email/phone) - /// that's redacted in shared logs but visible in the developer's local - /// stream. + /// Two lines: the charge summary, then which customer fields the caller + /// populated. No customer value is emitted at any privacy level. private func logChargeStart( paymentDetails: PayabliTTPPaymentDetails, customer: PayabliTTPCustomerData, invoice: PayabliTTPInvoiceData, orderDescription: String? ) { + // Charge metadata. The single-argument overload renders the whole string + // `.public`, so only fields that carry no subject belong in it. logger.info( "[charge] → amount=\(paymentDetails.amount) serviceFee=\(paymentDetails.serviceFee) " + - "currency=\(paymentDetails.currency ?? "") " + - "customer={firstName=\(customer.firstName ?? "") " + - "lastName=\(customer.lastName ?? "") " + - "customerNumber=\(customer.customerNumber ?? "") " + - "customerId=\(customer.customerId.map(String.init) ?? "") " + - "company=\(customer.company ?? "")} " + - "invoice={invoiceNumber=\(invoice.invoiceNumber ?? "")} " + - "orderDescription=\(orderDescription ?? "")" + "currency=\(paymentDetails.currency ?? "") " + + "invoice={invoiceNumber=\(invoice.invoiceNumber ?? "")} " + + "orderDescription=\(orderDescription ?? "")" ) guard !customer.isEmpty else { return } - let pii = "email=\(customer.email ?? "") " + - "phone=\(customer.phone ?? "") " + - "billing.address1=\(customer.billingAddress1 ?? "") " + - "billing.address2=\(customer.billingAddress2 ?? "") " + - "billing.city=\(customer.billingCity ?? "") " + - "billing.state=\(customer.billingState ?? "") " + - "billing.zip=\(customer.billingZip ?? "") " + - "billing.country=\(customer.billingCountry ?? "") " + - "billing.email=\(customer.billingEmail ?? "") " + - "billing.phone=\(customer.billingPhone ?? "") " + - "shipping.address1=\(customer.shippingAddress1 ?? "") " + - "shipping.address2=\(customer.shippingAddress2 ?? "") " + - "shipping.city=\(customer.shippingCity ?? "") " + - "shipping.state=\(customer.shippingState ?? "") " + - "shipping.zip=\(customer.shippingZip ?? "") " + - "shipping.country=\(customer.shippingCountry ?? "")" - logger.info("[charge] customerPII", private: pii) + logger.info("[charge] customer \(customer.redactedFieldSummary)") + } +} + +extension PayabliTTPCustomerData { + /// Which fields the caller set, never what they hold: each renders + /// `[REDACTED]` when set and `[nil]` when not. + /// + /// `.private` redacts a value in a shared log and still delivers it to a + /// local stream and to a sysdiagnose, so a cardholder name has no privacy + /// level it may be logged at. The same holds for the contact and address + /// fields beside it, which the Android core also emits redacted. + var redactedFieldSummary: String { + let fields: [(String, String?)] = [ + ("firstName", firstName), + ("lastName", lastName), + ("customerNumber", customerNumber), + ("customerId", customerId.map(String.init)), + ("company", company), + ("email", email), + ("phone", phone), + ("billing.address1", billingAddress1), + ("billing.address2", billingAddress2), + ("billing.city", billingCity), + ("billing.state", billingState), + ("billing.zip", billingZip), + ("billing.country", billingCountry), + ("billing.email", billingEmail), + ("billing.phone", billingPhone), + ("shipping.address1", shippingAddress1), + ("shipping.address2", shippingAddress2), + ("shipping.city", shippingCity), + ("shipping.state", shippingState), + ("shipping.zip", shippingZip), + ("shipping.country", shippingCountry) + ] + return fields + .map { "\($0.0)=\(PayabliLogger.redactFully($0.1))" } + .joined(separator: " ") } } diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP+Initialize.swift b/Sources/PayabliSDKTapToPay/PayabliTTP+Initialize.swift index 91fa027..523b829 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP+Initialize.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP+Initialize.swift @@ -5,7 +5,6 @@ import PayabliSDKCore @MainActor extension PayabliTTP { - // MARK: - Public entrypoints /// One-call startup: @@ -45,11 +44,17 @@ extension PayabliTTP { let task = Task { @MainActor in // Its failure belongs to its own caller. - if let previous { _ = try? await previous.value } + if let previous { + _ = try? await previous.value + } try await work() } inFlightSessionSetup = (kind, task, id) - defer { if inFlightSessionSetup?.id == id { inFlightSessionSetup = nil } } + defer { + if inFlightSessionSetup?.id == id { + inFlightSessionSetup = nil + } + } try await task.value } @@ -160,7 +165,7 @@ extension PayabliTTP { // MARK: - Phase 0 — eligibility private func runEligibility() async throws { - guard case .failure(let err) = await provider.checkEligibility() else { return } + guard case let .failure(err) = await provider.checkEligibility() else { return } sessionManager.markError(err) syncPublished() throw err diff --git a/Sources/PayabliSDKTapToPay/PayabliTTP.swift b/Sources/PayabliSDKTapToPay/PayabliTTP.swift index b3547dd..85c021a 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTP.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTP.swift @@ -37,7 +37,6 @@ import PayabliSDKCore @objc(PayabliTTP) @MainActor public final class PayabliTTP: NSObject, ObservableObject { - // MARK: - Dependencies let entryPoint: String @@ -55,10 +54,10 @@ public final class PayabliTTP: NSObject, ObservableObject { let transactionClient: TTPTransactionClient let configClient: TTPConfigClient - // Deviceid cached from attestation state (used as initiate `device:`). + /// Deviceid cached from attestation state (used as initiate `device:`). var cachedDeviceId: String? - // Session state + /// Session state let sessionManager = SessionManager() /// Bumped every time a reader is prepared. A charge captures it before the @@ -131,116 +130,116 @@ public final class PayabliTTP: NSObject, ObservableObject { ) } - /// PRD §19.1 convenience init. Wires the default `FiservCardReader` - /// provider and a real `AppAttestService` with Keychain-backed storage. - /// - /// The host supplies the server-minted `accessToken` and an optional - /// `tokenProvider` callback for refreshes (see `PayabliConfig`). - /// - /// Only available where Apple's `DeviceCheck` framework is importable. - /// The package minimums (iOS 16.7 from PayabliCardReaderCore / ProximityReader, - /// and macOS 12 from `Package.swift`) are both well above `DCAppAttestService`'s - /// own floor (iOS 14 / macOS 11.3), so no extra `@available` gate is needed. - /// Platforms without `DeviceCheck` must use the designated init with a - /// custom `DeviceAttestationService`. #if canImport(DeviceCheck) - public convenience init( - accessToken: String, - tokenProvider: PayabliTokenRefresh? = nil, - entryPoint: String, - appId: String, - environment: PayabliEnvironment - ) { - let config = PayabliConfig( - accessToken: accessToken, - tokenProvider: tokenProvider, - entryPoint: entryPoint, - environment: environment - ) - let payabliSession = PayabliSession(config: config) - let storage: SecureStorage = KeychainStorage() - let attestation = AppAttestService( - transport: payabliSession.transport, - attestor: RealAppAttestor(), - storage: storage - ) - self.init( - session: payabliSession, - appId: appId, - provider: FiservCardReader(), - attestation: attestation - ) - } + /// PRD §19.1 convenience init. Wires the default `FiservCardReader` + /// provider and a real `AppAttestService` with Keychain-backed storage. + /// + /// The host supplies the server-minted `accessToken` and an optional + /// `tokenProvider` callback for refreshes (see `PayabliConfig`). + /// + /// Only available where Apple's `DeviceCheck` framework is importable. + /// The package minimum of iOS 16.7, set by PayabliCardReaderCore and + /// ProximityReader, is well above `DCAppAttestService`'s own floor of + /// iOS 14, so no extra `@available` gate is needed. A platform without + /// `DeviceCheck` uses the designated init with a custom + /// `DeviceAttestationService`. + public convenience init( + accessToken: String, + tokenProvider: PayabliTokenRefresh? = nil, + entryPoint: String, + appId: String, + environment: PayabliEnvironment + ) { + let config = PayabliConfig( + accessToken: accessToken, + tokenProvider: tokenProvider, + entryPoint: entryPoint, + environment: environment + ) + let payabliSession = PayabliSession(config: config) + let storage: SecureStorage = KeychainStorage() + let attestation = AppAttestService( + transport: payabliSession.transport, + attestor: RealAppAttestor(), + storage: storage + ) + self.init( + session: payabliSession, + appId: appId, + provider: FiservCardReader(), + attestation: attestation + ) + } #endif - /// `@objc`-friendly convenience init for ObjC / MAUI / sharpie consumers - /// that can't represent the Swift `PayabliTokenRefresh` (`@Sendable () async - /// throws -> String`) closure. - /// - /// Token refresh is exposed here as a completion-style block: - /// `tokenRefreshHandler` receives a `(token, error) -> Void` callback that - /// the host invokes exactly once with either a fresh access token or an - /// `NSError` — the SDK bridges that to the underlying async closure - /// internally. Pass `nil` to disable silent refresh; the SDK will surface - /// `tokenExpired` instead. - /// - /// All other parameters mirror the Swift convenience init exactly. Swift - /// callers that need an `async throws` token provider should keep using - /// the Swift-only convenience init above. #if canImport(DeviceCheck) - @objc public convenience init( - accessToken: String, - tokenRefreshHandler: ((@escaping (String?, NSError?) -> Void) -> Void)?, - entryPoint: String, - appId: String, - environment: PayabliEnvironment - ) { - let bridged: PayabliTokenRefresh? = tokenRefreshHandler.map { handler in - // ObjC blocks are heap-allocated and copy-on-capture, so the - // bridged closure can safely be `@Sendable` even though Swift - // does not infer `@Sendable` for the input handler type. - let sendable = UncheckedSendableBox(handler) - return { @Sendable in - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - // Guard against the ObjC host invoking the completion - // block more than once — that would resume the same - // continuation twice and crash. We honor only the first - // invocation (success or failure) and silently drop any - // subsequent calls. A `Locked` keeps this thread- - // safe in case the host dispatches the callback from a - // background queue. - let resumed = Locked(false) - sendable.value { token, error in - let firstCall = resumed.withLock { hasResumed in - guard !hasResumed else { return false } - hasResumed = true - return true - } - guard firstCall else { return } - if let error { - continuation.resume(throwing: error) - } else if let token { - continuation.resume(returning: token) - } else { - continuation.resume(throwing: NSError( - domain: "com.payabli.ttp", - code: -1, - userInfo: [NSLocalizedDescriptionKey: - "tokenRefreshHandler returned nil token and nil error"] - )) + /// `@objc`-friendly convenience init for ObjC / MAUI / sharpie consumers + /// that can't represent the Swift `PayabliTokenRefresh` (`@Sendable () + /// async throws -> String`) closure. + /// + /// Token refresh is exposed here as a completion-style block: + /// `tokenRefreshHandler` receives a `(token, error) -> Void` callback + /// that the host invokes exactly once with either a fresh access token + /// or an `NSError` — the SDK bridges that to the underlying async + /// closure internally. Pass `nil` to disable silent refresh; the SDK + /// surfaces `tokenExpired` instead. + /// + /// All other parameters mirror the Swift convenience init exactly. A + /// Swift caller that needs an `async throws` token provider uses the + /// Swift-only convenience init above. + @objc public convenience init( + accessToken: String, + tokenRefreshHandler: ((@escaping (String?, NSError?) -> Void) -> Void)?, + entryPoint: String, + appId: String, + environment: PayabliEnvironment + ) { + let bridged: PayabliTokenRefresh? = tokenRefreshHandler.map { handler in + // ObjC blocks are heap-allocated and copy-on-capture, so the + // bridged closure can safely be `@Sendable` even though Swift + // does not infer `@Sendable` for the input handler type. + let sendable = UncheckedSendableBox(handler) + return { @Sendable in + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + // Guard against the ObjC host invoking the completion + // block more than once — that would resume the same + // continuation twice and crash. We honor only the first + // invocation (success or failure) and silently drop any + // subsequent calls. A `Locked` keeps this thread- + // safe in case the host dispatches the callback from a + // background queue. + let resumed = Locked(false) + sendable.value { token, error in + let firstCall = resumed.withLock { hasResumed in + guard !hasResumed else { return false } + hasResumed = true + return true + } + guard firstCall else { return } + if let error { + continuation.resume(throwing: error) + } else if let token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError( + domain: "com.payabli.ttp", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "tokenRefreshHandler returned nil token and nil error"] + )) + } } } } } + self.init( + accessToken: accessToken, + tokenProvider: bridged, + entryPoint: entryPoint, + appId: appId, + environment: environment + ) } - self.init( - accessToken: accessToken, - tokenProvider: bridged, - entryPoint: entryPoint, - appId: appId, - environment: environment - ) - } #endif // MARK: - Events diff --git a/Sources/PayabliSDKTapToPay/PayabliTTPEvent.swift b/Sources/PayabliSDKTapToPay/PayabliTTPEvent.swift index e89174c..864f726 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTPEvent.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTPEvent.swift @@ -72,10 +72,10 @@ public enum PayabliTTPError: Error, Sendable { case activationFailed = 17 } -extension PayabliTTPEvent { +public extension PayabliTTPEvent { /// Returns the `PayabliTTPEventCode` for this event — used by the /// `addEventListener(handler:)` ObjC bridge. - public var code: PayabliTTPEventCode { + var code: PayabliTTPEventCode { switch self { case .attestationStarted: return .attestationStarted case .attestationCompleted: return .attestationCompleted @@ -106,15 +106,15 @@ extension PayabliTTPEvent { /// - `.nfcFailed`, `.activationFailed` → `["error": String]` /// - `.updateFailed` → `["paymentTransId": String, "error": String]` /// - all other cases → empty `[:]` - public var payload: [String: Any] { + var payload: [String: Any] { switch self { - case .chargeInitiated(let paymentTransId), - .updateCompleted(let paymentTransId): + case let .chargeInitiated(paymentTransId), + let .updateCompleted(paymentTransId): return ["paymentTransId": paymentTransId] - case .nfcFailed(let error), - .activationFailed(let error): + case let .nfcFailed(error), + let .activationFailed(error): return ["error": error] - case .updateFailed(let paymentTransId, let error): + case let .updateFailed(paymentTransId, error): return ["paymentTransId": paymentTransId, "error": error] case .attestationStarted, .attestationCompleted, @@ -142,7 +142,9 @@ extension PayabliTTPEvent { /// part of the public API: do not reorder or renumber. New cases must be /// appended at the end with a new code. extension PayabliTTPError: CustomNSError, LocalizedError { - public static var errorDomain: String { "com.payabli.ttp" } + public static var errorDomain: String { + "com.payabli.ttp" + } public var errorCode: Int { switch self { @@ -167,23 +169,23 @@ extension PayabliTTPError: CustomNSError, LocalizedError { switch self { case .notInitialized: return [NSLocalizedDescriptionKey: "PayabliTTP has not been initialized"] - case .invalidState(let current, let attempted): + case let .invalidState(current, attempted): return [NSLocalizedDescriptionKey: "Invalid state \(current) for \(attempted)"] - case .notReady(let current): + case let .notReady(current): return [NSLocalizedDescriptionKey: "Reader not ready (state: \(current))"] case .devicePendingActivation: return [NSLocalizedDescriptionKey: "Device is pending activation"] case .tokenExpired: return [NSLocalizedDescriptionKey: "Access token expired"] - case .attestationRevoked(let reason), - .attestationFailed(let reason), - .configFailed(let reason), - .readerSetupFailed(let reason), - .nfcFailed(let reason), - .initiateFailed(let reason), - .updateFailed(let reason), - .activationFailed(let reason), - .networkError(let reason): + case let .attestationRevoked(reason), + let .attestationFailed(reason), + let .configFailed(reason), + let .readerSetupFailed(reason), + let .nfcFailed(reason), + let .initiateFailed(reason), + let .updateFailed(reason), + let .activationFailed(reason), + let .networkError(reason): return [NSLocalizedDescriptionKey: reason] } } diff --git a/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData+ObjC.swift b/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData+ObjC.swift index c4c464e..8da8e07 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData+ObjC.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData+ObjC.swift @@ -1,6 +1,7 @@ import Foundation // MARK: - ObjC companions for transaction-data structs + // // These `*ObjC` classes are thin wrappers around the Swift value types in // `PayabliTTPTransactionData.swift` / `PayabliTTPTypes.swift` so ObjC, MAUI diff --git a/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData.swift b/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData.swift index b148b3c..1002642 100644 --- a/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData.swift +++ b/Sources/PayabliSDKTapToPay/PayabliTTPTransactionData.swift @@ -188,7 +188,9 @@ public struct PayabliTTPInvoiceData: Sendable, Equatable { self.invoiceNumber = PayabliTTPInvoiceData.sanitize(invoiceNumber) } - public var isEmpty: Bool { invoiceNumber == nil } + public var isEmpty: Bool { + invoiceNumber == nil + } private static func sanitize(_ value: String?) -> String? { guard let value else { return nil } diff --git a/Sources/PayabliSDKTapToPay/ReaderFailureClassification.swift b/Sources/PayabliSDKTapToPay/ReaderFailureClassification.swift index fe691fe..bf229bf 100644 --- a/Sources/PayabliSDKTapToPay/ReaderFailureClassification.swift +++ b/Sources/PayabliSDKTapToPay/ReaderFailureClassification.swift @@ -1,6 +1,6 @@ import Foundation #if canImport(ProximityReader) -import ProximityReader + import ProximityReader #endif /// Whether the reader session is gone, or only this read failed. A dead session @@ -13,9 +13,9 @@ import ProximityReader /// only the case name arrives, in text. func readerFailureInvalidatesSession(_ error: Error) -> Bool { #if canImport(ProximityReader) - if let readError = error as? PaymentCardReaderSession.ReadError { - return sessionLevelReadErrorNames.contains(String(describing: readError)) - } + if let readError = error as? PaymentCardReaderSession.ReadError { + return sessionLevelReadErrorNames.contains(String(describing: readError)) + } #endif let text = failureText(of: error) @@ -43,8 +43,8 @@ private let sessionLevelReadErrorNames: Set = [ private func failureText(of error: Error) -> String { if let ttpError = error as? PayabliTTPError { switch ttpError { - case .nfcFailed(let reason), - .readerSetupFailed(let reason): + case let .nfcFailed(reason), + let .readerSetupFailed(reason): return reason default: return String(describing: ttpError) diff --git a/Sources/PayabliSDKTapToPay/SessionManager.swift b/Sources/PayabliSDKTapToPay/SessionManager.swift index dbf2513..d1cdd4b 100644 --- a/Sources/PayabliSDKTapToPay/SessionManager.swift +++ b/Sources/PayabliSDKTapToPay/SessionManager.swift @@ -1,5 +1,5 @@ -import Foundation import Combine +import Foundation /// Manages the 9-state TTP session lifecycle (PRD §17). /// @@ -8,13 +8,13 @@ import Combine /// SwiftUI observation (§17.4). /// Which entry point is building the session, so the two can be told apart when /// one is already running. -internal enum SessionSetupKind { +enum SessionSetupKind { case initialize case reinitialize } @MainActor -internal final class SessionManager: ObservableObject { +final class SessionManager: ObservableObject { @Published private(set) var sessionState: PayabliTTPSessionState = .idle @Published private(set) var isReady: Bool = false private(set) var lastError: Error? @@ -64,7 +64,9 @@ internal final class SessionManager: ObservableObject { to target: PayabliTTPSessionState ) -> Bool { // Identity (re-entering same state) is allowed but not counted. - if current == target { return true } + if current == target { + return true + } switch (current, target) { // Starting over is always reachable. `initialize()` is the documented diff --git a/Sources/PayabliSDKTapToPay/TTPConfigClient.swift b/Sources/PayabliSDKTapToPay/TTPConfigClient.swift index 0d52c79..702b042 100644 --- a/Sources/PayabliSDKTapToPay/TTPConfigClient.swift +++ b/Sources/PayabliSDKTapToPay/TTPConfigClient.swift @@ -47,20 +47,19 @@ public final class TTPConfigClient: Sendable { headers: headers.asDictionary ) - let headersDump = headers.asDictionary - .map { "\($0.key): \($0.value)" } - .sorted() - .joined(separator: " | ") + // The headers carry the App Attest assertion, key id and device id. logger.info("[config] → GET \(request.path)") - logger.info("[config] headers: \(headersDump)") let response = try await transport.perform(request) - let responseBody = String(data: response.body, encoding: .utf8) ?? "" - logger.info("[config] ← [\(response.statusCode)] body: \(responseBody)") + // This body is `ConfigCredentialsPayload`, whose `credentials` block + // carries the card reader's secretKey and apiKey. The log gets its shape. + logger.info("[config] ← [\(response.statusCode)] bytes=\(response.body.count)") try mapPayabliHTTPError(response: response) { code in - if code == 403 { return PayabliTTPError.devicePendingActivation } + if code == 403 { + return PayabliTTPError.devicePendingActivation + } return nil } @@ -81,10 +80,11 @@ public final class TTPConfigClient: Sendable { let decoder = JSONDecoder() guard let envelope = try? decoder.decode( - PayabliEnvelope.Success.self, - from: response.body - ), - let credentials = envelope.responseData?.credentials else { + PayabliEnvelope.Success.self, + from: response.body + ), + let credentials = envelope.responseData?.credentials + else { logger.error("[config] payload decode failed") throw PayabliTTPError.configFailed(reason: "Invalid config envelope") } diff --git a/Sources/PayabliSDKTapToPay/TTPConfigWireFormat.swift b/Sources/PayabliSDKTapToPay/TTPConfigWireFormat.swift index 7cec1c3..1596128 100644 --- a/Sources/PayabliSDKTapToPay/TTPConfigWireFormat.swift +++ b/Sources/PayabliSDKTapToPay/TTPConfigWireFormat.swift @@ -1,6 +1,7 @@ import Foundation // MARK: - /config wire types (PRD §8.2) + // // Generic envelope scaffolding (`Status`, `DeclineEnvelope`, `Success`, // `declineOutcome(from:)`) lives in `PayabliSDKCore/Networking/ResponseEnvelope.swift` diff --git a/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift b/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift index 93b0980..b8f1b2f 100644 --- a/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift +++ b/Sources/PayabliSDKTapToPay/TTPTransactionClient.swift @@ -47,16 +47,10 @@ public final class TTPTransactionClient: Sendable { jsonBody: body ) - let headersDump = request.headers - .map { "\($0.key): \($0.value)" } - .sorted() - .joined(separator: " | ") - let bodyDump = request.body.flatMap { String(data: $0, encoding: .utf8) } ?? "" - logger.info("[initiate] → POST \(request.path)") - logger.info("[initiate] headers: \(headersDump)") - // Body carries PII (billing/shipping/email/phone) — keep redacted in - // shared OS logs. - logger.info("[initiate] body", private: bodyDump) + // `customerData` is the caller's whole customer record, so the body is + // reported by size. The headers carry the App Attest assertion, key id + // and device id, and are not logged either. + logger.info("[initiate] → POST \(request.path) bytes=\(request.body?.count ?? 0)") let envelope: PayabliV2Envelope do { @@ -66,7 +60,10 @@ public final class TTPTransactionClient: Sendable { throw error } - logger.info("[initiate] ← isApproved=\(envelope.isApproved) code=\(envelope.code) paymentTransId=\(envelope.data?.paymentTransId ?? "")") + logger + .info( + "[initiate] ← isApproved=\(envelope.isApproved) code=\(envelope.code) paymentTransId=\(envelope.data?.paymentTransId ?? "")" + ) guard envelope.isApproved, let data = envelope.data else { throw PayabliTTPError.initiateFailed(reason: envelope.reason ?? envelope.code) @@ -121,11 +118,11 @@ public final class TTPTransactionClient: Sendable { static func updateBody(for payload: TTPUpdatePayload) -> Data { let encoder = JSONEncoder() switch payload { - case .success(let result): + case let .success(result): let body = UpdateSuccessBody(providerResponse: providerResponse(from: result)) return (try? encoder.encode(body)) ?? Data() - case .nfcFailure(let description): + case let .nfcFailure(description): let body = UpdateErrorBody(error: .init( title: "NFC Tap Failed", description: description, diff --git a/Sources/PayabliSDKTapToPay/TTPTransactionWireFormat.swift b/Sources/PayabliSDKTapToPay/TTPTransactionWireFormat.swift index e8a1abf..a0c38f0 100644 --- a/Sources/PayabliSDKTapToPay/TTPTransactionWireFormat.swift +++ b/Sources/PayabliSDKTapToPay/TTPTransactionWireFormat.swift @@ -87,8 +87,8 @@ struct InitiateCustomerData: Encodable { } struct InitiatePaymentMethod: Encodable { - let method: String // "device" (POI-device-backed TTP flow) - let device: String // Payabli deviceId (from /attest or /activate) + let method: String // "device" (POI-device-backed TTP flow) + let device: String // Payabli deviceId (from /attest or /activate) } struct InitiateInvoiceData: Encodable { @@ -155,6 +155,7 @@ struct UpdateErrorBody: Encodable { let description: String let failureReason: String } + let error: ErrorDetail } @@ -175,13 +176,13 @@ enum ProviderResponsePayload: Encodable { func encode(to encoder: Encoder) throws { switch self { - case .opaqueJSON(let json): + case let .opaqueJSON(json): // Re-parse the provider's JSON bytes into a `JSONValue` tree so it // merges cleanly into the outer encoder (same output as writing // the bytes directly, but type-safe). let value = try JSONDecoder().decode(JSONValue.self, from: json) try value.encode(to: encoder) - case .payloadOnly(let payload): + case let .payloadOnly(payload): try payload.encode(to: encoder) } } @@ -190,7 +191,7 @@ enum ProviderResponsePayload: Encodable { /// Structured shape sent by payload-only adapters. struct PayloadOnlyProviderResponse: Encodable { let provider: String - let encryptedPayload: String // base64 + let encryptedPayload: String // base64 let cardNetwork: String? let providerMetadata: [String: String] } @@ -208,12 +209,30 @@ private enum JSONValue: Codable { init(from decoder: Decoder) throws { let c = try decoder.singleValueContainer() - if c.decodeNil() { self = .null; return } - if let v = try? c.decode(Bool.self) { self = .bool(v); return } - if let v = try? c.decode(Double.self) { self = .number(v); return } - if let v = try? c.decode(String.self) { self = .string(v); return } - if let v = try? c.decode([JSONValue].self) { self = .array(v); return } - if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return } + if c.decodeNil() { + self = .null + return + } + if let v = try? c.decode(Bool.self) { + self = .bool(v) + return + } + if let v = try? c.decode(Double.self) { + self = .number(v) + return + } + if let v = try? c.decode(String.self) { + self = .string(v) + return + } + if let v = try? c.decode([JSONValue].self) { + self = .array(v) + return + } + if let v = try? c.decode([String: JSONValue].self) { + self = .object(v) + return + } throw DecodingError.dataCorruptedError( in: c, debugDescription: "Unknown JSON value" ) @@ -223,11 +242,11 @@ private enum JSONValue: Codable { var c = encoder.singleValueContainer() switch self { case .null: try c.encodeNil() - case .bool(let v): try c.encode(v) - case .number(let v): try c.encode(v) - case .string(let v): try c.encode(v) - case .array(let v): try c.encode(v) - case .object(let v): try c.encode(v) + case let .bool(v): try c.encode(v) + case let .number(v): try c.encode(v) + case let .string(v): try c.encode(v) + case let .array(v): try c.encode(v) + case let .object(v): try c.encode(v) } } } diff --git a/Sources/PayabliSDKTapToPay/_ObjCBridging.swift b/Sources/PayabliSDKTapToPay/_ObjCBridging.swift index b822c81..1c49910 100644 --- a/Sources/PayabliSDKTapToPay/_ObjCBridging.swift +++ b/Sources/PayabliSDKTapToPay/_ObjCBridging.swift @@ -11,7 +11,9 @@ import Foundation /// the check explicitly at the boundary. struct UncheckedSendableBox: @unchecked Sendable { let value: Value - init(_ value: Value) { self.value = value } + init(_ value: Value) { + self.value = value + } } /// Tiny `NSLock`-backed reference cell used as a one-shot guard when @@ -24,7 +26,9 @@ final class Locked: @unchecked Sendable { private let lock = NSLock() private var value: Value - init(_ value: Value) { self.value = value } + init(_ value: Value) { + self.value = value + } /// Mutates and returns whatever the caller derives from the protected /// state, atomically. Use the inout argument to read+write. diff --git a/Sources/PayabliSDKTestUtils/InMemorySecureStorage.swift b/Sources/PayabliSDKTestUtils/InMemorySecureStorage.swift index fccc01f..667f395 100644 --- a/Sources/PayabliSDKTestUtils/InMemorySecureStorage.swift +++ b/Sources/PayabliSDKTestUtils/InMemorySecureStorage.swift @@ -12,17 +12,20 @@ public final class InMemorySecureStorage: SecureStorage, @unchecked Sendable { public init() {} public func string(forKey key: String) -> String? { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } return store[key] } public func set(_ value: String, forKey key: String) throws { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } store[key] = value } public func remove(forKey key: String) { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } store.removeValue(forKey: key) } } diff --git a/Sources/PayabliSDKTestUtils/MockAppAttestor.swift b/Sources/PayabliSDKTestUtils/MockAppAttestor.swift index fdcdc99..fb35182 100644 --- a/Sources/PayabliSDKTestUtils/MockAppAttestor.swift +++ b/Sources/PayabliSDKTestUtils/MockAppAttestor.swift @@ -71,7 +71,9 @@ public final class MockAppAttestor: AppAttestor, @unchecked Sendable { public func generateKey() async throws -> AppAttestKeyId { return try lock.withLock { _generateKeyCalls += 1 - if let error = _generateKeyError { throw error } + if let error = _generateKeyError { + throw error + } return _generatedKeyId } } @@ -79,7 +81,9 @@ public final class MockAppAttestor: AppAttestor, @unchecked Sendable { public func attestKey(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AttestationObject { return try lock.withLock { _attestKeyCalls += 1 - if let error = _attestKeyError { throw error } + if let error = _attestKeyError { + throw error + } return _attestationPayload } } @@ -87,7 +91,9 @@ public final class MockAppAttestor: AppAttestor, @unchecked Sendable { public func generateAssertion(_ keyId: AppAttestKeyId, clientDataHash: ClientDataHash) async throws -> AppAttestAssertion { return try lock.withLock { _generateAssertionCalls += 1 - if let error = _generateAssertionError { throw error } + if let error = _generateAssertionError { + throw error + } return _assertionPayload } } diff --git a/Sources/PayabliSDKTestUtils/MockDeviceAttestationService.swift b/Sources/PayabliSDKTestUtils/MockDeviceAttestationService.swift index 19ad7bc..c7b78fc 100644 --- a/Sources/PayabliSDKTestUtils/MockDeviceAttestationService.swift +++ b/Sources/PayabliSDKTestUtils/MockDeviceAttestationService.swift @@ -56,13 +56,13 @@ public final class MockDeviceAttestationService: DeviceAttestationService, @unch return _attestResult } switch result { - case .success(let value): + case let .success(value): lock.withLock { _isAlreadyAttested = true _cachedDeviceId = value.deviceId } return value - case .failure(let err): + case let .failure(err): throw err } } @@ -82,7 +82,9 @@ public final class MockDeviceAttestationService: DeviceAttestationService, @unch _activateCalls += 1 return _activationResult } - if case .failure(let err) = result { throw err } + if case let .failure(err) = result { + throw err + } } public func clearCache() { diff --git a/Sources/PayabliSDKTestUtils/MockTapToPayProvider.swift b/Sources/PayabliSDKTestUtils/MockTapToPayProvider.swift index 313b613..5f64fea 100644 --- a/Sources/PayabliSDKTestUtils/MockTapToPayProvider.swift +++ b/Sources/PayabliSDKTestUtils/MockTapToPayProvider.swift @@ -4,7 +4,9 @@ import PayabliSDKTapToPay /// Mock `TapToPayProvider` for unit tests that exercise the TTP session and /// charge flows without requiring a physical NFC reader. public final class MockTapToPayProvider: TapToPayProvider, @unchecked Sendable { - public static var providerId: String { "mock" } + public static var providerId: String { + "mock" + } private let lock = NSLock() @@ -82,7 +84,9 @@ public final class MockTapToPayProvider: TapToPayProvider, @unchecked Sendable { _lastConfiguredCredentials = credentials return _configureResult } - if case .failure(let err) = result { throw err } + if case let .failure(err) = result { + throw err + } } public func prepareReader() async throws { @@ -90,7 +94,9 @@ public final class MockTapToPayProvider: TapToPayProvider, @unchecked Sendable { _prepareReaderCalls += 1 return _prepareReaderResult } - if case .failure(let err) = result { throw err } + if case let .failure(err) = result { + throw err + } } public func startReading(_ request: CardReadRequest) async throws -> CardReadResult { @@ -99,8 +105,8 @@ public final class MockTapToPayProvider: TapToPayProvider, @unchecked Sendable { return _readingResult } switch result { - case .success(let value): return value - case .failure(let err): throw err + case let .success(value): return value + case let .failure(err): throw err } } diff --git a/Sources/PayabliSDKTestUtils/StubURLProtocol.swift b/Sources/PayabliSDKTestUtils/StubURLProtocol.swift index 7f13a39..7fd50e1 100644 --- a/Sources/PayabliSDKTestUtils/StubURLProtocol.swift +++ b/Sources/PayabliSDKTestUtils/StubURLProtocol.swift @@ -18,8 +18,13 @@ public final class StubURLProtocol: URLProtocol { public nonisolated(unsafe) static var handler: Handler? - override public class func canInit(with request: URLRequest) -> Bool { true } - override public class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override public class func canInit(with request: URLRequest) -> Bool { + true + } + + override public class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } override public func startLoading() { guard let handler = Self.handler else { @@ -61,7 +66,9 @@ public final class StubURLProtocol: URLProtocol { var buffer = [UInt8](repeating: 0, count: 4096) while stream.hasBytesAvailable { let read = stream.read(&buffer, maxLength: buffer.count) - if read <= 0 { break } + if read <= 0 { + break + } data.append(buffer, count: read) } return data diff --git a/Tests/PayabliSDKCoreTests/AuthenticatedTransportTests.swift b/Tests/PayabliSDKCoreTests/AuthenticatedTransportTests.swift index c8bb9b5..7d54453 100644 --- a/Tests/PayabliSDKCoreTests/AuthenticatedTransportTests.swift +++ b/Tests/PayabliSDKCoreTests/AuthenticatedTransportTests.swift @@ -1,8 +1,7 @@ -import XCTest @testable import PayabliSDKCore +import XCTest final class AuthenticatedTransportTests: XCTestCase { - func testInjectsBearerHeaderOnEveryRequest() async throws { let mock = MockTransport(scripted: [ .response(statusCode: 200, body: Data("{}".utf8)) @@ -104,7 +103,9 @@ actor MockTransport: PayabliTransport { self.scripted = scripted } - func captured() -> [PayabliRequest] { requests } + func captured() -> [PayabliRequest] { + requests + } func perform(_ request: PayabliRequest) async throws -> PayabliResponse { requests.append(request) diff --git a/Tests/PayabliSDKCoreTests/EventMulticasterTests.swift b/Tests/PayabliSDKCoreTests/EventMulticasterTests.swift index 077fa00..2660124 100644 --- a/Tests/PayabliSDKCoreTests/EventMulticasterTests.swift +++ b/Tests/PayabliSDKCoreTests/EventMulticasterTests.swift @@ -1,8 +1,7 @@ -import XCTest import PayabliSDKCore +import XCTest final class EventMulticasterTests: XCTestCase { - func testSingleSubscriberReceivesEvents() async { let multicaster = EventMulticaster() let stream = multicaster.stream() @@ -31,12 +30,16 @@ final class EventMulticasterTests: XCTestCase { let collect1 = Task { var events: [Int] = [] - for await evt in stream1 { events.append(evt) } + for await evt in stream1 { + events.append(evt) + } return events } let collect2 = Task { var events: [Int] = [] - for await evt in stream2 { events.append(evt) } + for await evt in stream2 { + events.append(evt) + } return events } @@ -58,7 +61,9 @@ final class EventMulticasterTests: XCTestCase { let collectTask = Task { var events: [String] = [] - for await evt in stream { events.append(evt) } + for await evt in stream { + events.append(evt) + } return events } diff --git a/Tests/PayabliSDKCoreTests/PayabliAuthTests.swift b/Tests/PayabliSDKCoreTests/PayabliAuthTests.swift index 97f0df3..7ab3cef 100644 --- a/Tests/PayabliSDKCoreTests/PayabliAuthTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliAuthTests.swift @@ -1,8 +1,7 @@ -import XCTest import PayabliSDKCore +import XCTest final class PayabliAuthTests: XCTestCase { - // MARK: - Helpers private func makeConfig( @@ -19,7 +18,7 @@ final class PayabliAuthTests: XCTestCase { // MARK: - Initial token - func testInitialTokenComesFromConfig() async throws { + func testInitialTokenComesFromConfig() async { let auth = PayabliAuth(config: makeConfig(accessToken: "seed")) let token = await auth.currentAccessToken() XCTAssertEqual(token, "seed") @@ -122,5 +121,7 @@ final class PayabliAuthTests: XCTestCase { /// Simple actor counter for tracking concurrent calls in tests. private actor Counter { private(set) var value: Int = 0 - func increment() { value += 1 } + func increment() { + value += 1 + } } diff --git a/Tests/PayabliSDKCoreTests/PayabliEnvironmentTests.swift b/Tests/PayabliSDKCoreTests/PayabliEnvironmentTests.swift index ba2c664..9ec03bd 100644 --- a/Tests/PayabliSDKCoreTests/PayabliEnvironmentTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliEnvironmentTests.swift @@ -1,10 +1,10 @@ -import XCTest import PayabliSDKCore +import XCTest final class PayabliEnvironmentTests: XCTestCase { func testBaseURLsMatchPRD() { #if DEBUG - XCTAssertEqual(PayabliEnvironment.local.baseURL.absoluteString, "https://wallets-test.ngrok.app") + XCTAssertEqual(PayabliEnvironment.local.baseURL.absoluteString, "https://wallets-test.ngrok.app") #endif XCTAssertEqual(PayabliEnvironment.qa.baseURL.absoluteString, "https://api-qa.payabli.com") XCTAssertEqual(PayabliEnvironment.sandbox.baseURL.absoluteString, "https://api-sandbox.payabli.com") diff --git a/Tests/PayabliSDKCoreTests/PayabliErrorCodeMappingTests.swift b/Tests/PayabliSDKCoreTests/PayabliErrorCodeMappingTests.swift index e006332..fb3ce9c 100644 --- a/Tests/PayabliSDKCoreTests/PayabliErrorCodeMappingTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliErrorCodeMappingTests.swift @@ -1,8 +1,7 @@ -import XCTest import PayabliSDKCore +import XCTest final class PayabliErrorCodeMappingTests: XCTestCase { - func testValidationErrorCodeMapsToValidation() throws { // Construct a PayabliValidationError via JSON decoding (the only // way to build one — it has no public memberwise init). diff --git a/Tests/PayabliSDKCoreTests/PayabliSDKCoreTests.swift b/Tests/PayabliSDKCoreTests/PayabliSDKCoreTests.swift index da693e2..b84b867 100644 --- a/Tests/PayabliSDKCoreTests/PayabliSDKCoreTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliSDKCoreTests.swift @@ -1,5 +1,5 @@ -import XCTest import PayabliSDKCore +import XCTest final class PayabliSDKCoreTests: XCTestCase { func testVersionIsPopulated() { diff --git a/Tests/PayabliSDKCoreTests/PayabliServiceTests.swift b/Tests/PayabliSDKCoreTests/PayabliServiceTests.swift index aee8cc0..2c276a1 100644 --- a/Tests/PayabliSDKCoreTests/PayabliServiceTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliServiceTests.swift @@ -1,6 +1,6 @@ -import XCTest import PayabliSDKCore import PayabliSDKTestUtils +import XCTest private struct FakeData: Decodable, Sendable { let paymentTransId: String @@ -60,7 +60,7 @@ final class PayabliServiceTests: XCTestCase { do { _ = try await service().performV2(request, decoding: FakeData.self) XCTFail("Expected error") - } catch PayabliPaymentError.validation(let err) { + } catch let PayabliPaymentError.validation(err) { XCTAssertEqual(err.status, 400) XCTAssertNotNil(err.errors?["paymentMethod.cardnumber"]) } catch { @@ -104,7 +104,7 @@ final class PayabliServiceTests: XCTestCase { do { _ = try await service().performV2(request, decoding: FakeData.self) XCTFail("Expected error") - } catch PayabliPaymentError.decline(let err) { + } catch let PayabliPaymentError.decline(err) { XCTAssertEqual(err.rawCode, "D0001") XCTAssertEqual(err.reason, "Card Declined") } catch { @@ -125,7 +125,7 @@ final class PayabliServiceTests: XCTestCase { do { _ = try await service().performV2(request, decoding: FakeData.self) XCTFail("Expected error") - } catch PayabliPaymentError.server(let err) { + } catch let PayabliPaymentError.server(err) { XCTAssertEqual(err.status, 500) } catch { XCTFail("Wrong error: \(error)") diff --git a/Tests/PayabliSDKCoreTests/PayabliSessionTests.swift b/Tests/PayabliSDKCoreTests/PayabliSessionTests.swift index 1353cae..695d9b1 100644 --- a/Tests/PayabliSDKCoreTests/PayabliSessionTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliSessionTests.swift @@ -1,6 +1,6 @@ -import XCTest import PayabliSDKCore import PayabliSDKTestUtils +import XCTest final class PayabliSessionTests: XCTestCase { func testSessionExposesAuthAndService() async { diff --git a/Tests/PayabliSDKCoreTests/PayabliTransportTests.swift b/Tests/PayabliSDKCoreTests/PayabliTransportTests.swift index 434bf55..a93805a 100644 --- a/Tests/PayabliSDKCoreTests/PayabliTransportTests.swift +++ b/Tests/PayabliSDKCoreTests/PayabliTransportTests.swift @@ -1,10 +1,10 @@ -import XCTest import PayabliSDKCore +import XCTest final class PayabliTransportTests: XCTestCase { func testPayabliServiceConformsToPayabliTransport() { let service = PayabliService(environment: .sandbox) - let _: any PayabliTransport = service // compile-time conformance proof + let _: any PayabliTransport = service // compile-time conformance proof XCTAssertTrue(true) } } diff --git a/Tests/PayabliSDKCoreTests/RetryPolicyTests.swift b/Tests/PayabliSDKCoreTests/RetryPolicyTests.swift index d2c9949..1bb69a9 100644 --- a/Tests/PayabliSDKCoreTests/RetryPolicyTests.swift +++ b/Tests/PayabliSDKCoreTests/RetryPolicyTests.swift @@ -1,8 +1,7 @@ -import XCTest import PayabliSDKCore +import XCTest final class RetryPolicyTests: XCTestCase { - func testDefaultParameters() { let policy = RetryPolicy.default XCTAssertEqual(policy.maxAttempts, 3) @@ -33,7 +32,7 @@ final class RetryPolicyTests: XCTestCase { func testJitterBounds() { let policy = RetryPolicy(maxAttempts: 3, baseDelay: 1, maxDelay: 8, multiplier: 2, maxJitter: 0.5) - for _ in 0..<20 { + for _ in 0 ..< 20 { let delay = policy.delay(forAttempt: 2) XCTAssertTrue(delay >= 1.0 && delay <= 1.5) } @@ -57,7 +56,13 @@ final class RetryPolicyTests: XCTestCase { func testRetryRunSucceedsOnFirstAttempt() async throws { var attempts = 0 - let result = try await Retry.run(policy: RetryPolicy(maxAttempts: 3, baseDelay: 0, maxDelay: 0, multiplier: 1, maxJitter: 0)) { attempt in + let result = try await Retry.run(policy: RetryPolicy( + maxAttempts: 3, + baseDelay: 0, + maxDelay: 0, + multiplier: 1, + maxJitter: 0 + )) { attempt in attempts += 1 return attempt } @@ -67,7 +72,13 @@ final class RetryPolicyTests: XCTestCase { func testRetryRunRetriesRetryableErrors() async throws { var attempts = 0 - let result = try await Retry.run(policy: RetryPolicy(maxAttempts: 3, baseDelay: 0, maxDelay: 0, multiplier: 1, maxJitter: 0)) { attempt in + let result = try await Retry.run(policy: RetryPolicy( + maxAttempts: 3, + baseDelay: 0, + maxDelay: 0, + multiplier: 1, + maxJitter: 0 + )) { attempt in attempts += 1 if attempt < 3 { throw RetryableError(NSError(domain: "test", code: 500)) diff --git a/Tests/PayabliSDKCoreTests/TelemetryClientTests.swift b/Tests/PayabliSDKCoreTests/TelemetryClientTests.swift index cca5689..12381b5 100644 --- a/Tests/PayabliSDKCoreTests/TelemetryClientTests.swift +++ b/Tests/PayabliSDKCoreTests/TelemetryClientTests.swift @@ -1,9 +1,8 @@ -import XCTest import PayabliSDKCore import PayabliSDKTestUtils +import XCTest final class TelemetryClientTests: XCTestCase { - private func makeClient( enabled: Bool = true, batchSize: Int = 20 diff --git a/Tests/PayabliSDKPayInPaymentFlowTests/PayabliPaymentCaptureTests.swift b/Tests/PayabliSDKPayInPaymentFlowTests/PayabliPaymentCaptureTests.swift index b8b541e..b29bb33 100644 --- a/Tests/PayabliSDKPayInPaymentFlowTests/PayabliPaymentCaptureTests.swift +++ b/Tests/PayabliSDKPayInPaymentFlowTests/PayabliPaymentCaptureTests.swift @@ -1,6 +1,5 @@ @testable import PayabliSDKCore @testable import PayabliSDKPayInPaymentFlow -import PayabliSDKPayInPaymentFlow import SwiftUI import XCTest diff --git a/Tests/PayabliSDKPayInPaymentFlowTests/PaymentCaptureClientTests.swift b/Tests/PayabliSDKPayInPaymentFlowTests/PaymentCaptureClientTests.swift index 4952f46..c003c1a 100644 --- a/Tests/PayabliSDKPayInPaymentFlowTests/PaymentCaptureClientTests.swift +++ b/Tests/PayabliSDKPayInPaymentFlowTests/PaymentCaptureClientTests.swift @@ -1,6 +1,5 @@ @testable import PayabliSDKCore @testable import PayabliSDKPayInPaymentFlow -import PayabliSDKPayInPaymentFlow import XCTest final class PayInPaymentFlowClientTests: XCTestCase { diff --git a/Tests/PayabliSDKTapToPayTests/AppAttestServiceTests.swift b/Tests/PayabliSDKTapToPayTests/AppAttestServiceTests.swift index dcf4a93..ad1ff07 100644 --- a/Tests/PayabliSDKTapToPayTests/AppAttestServiceTests.swift +++ b/Tests/PayabliSDKTapToPayTests/AppAttestServiceTests.swift @@ -1,7 +1,7 @@ -import XCTest @testable import PayabliSDKCore @testable import PayabliSDKTapToPay import PayabliSDKTestUtils +import XCTest final class AppAttestServiceTests: XCTestCase { override func tearDown() { @@ -35,8 +35,12 @@ final class AppAttestServiceTests: XCTestCase { } private func response(_ status: Int, body: Data, url: URL) -> (HTTPURLResponse, Data) { - (HTTPURLResponse(url: url, statusCode: status, httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": "application/json"])!, body) + (HTTPURLResponse( + url: url, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )!, body) } private static func envelope(responseData: [String: Any]) -> Data { @@ -57,14 +61,20 @@ final class AppAttestServiceTests: XCTestCase { pathsBox.append(request.url!.path) switch request.url!.path { case "/api/v2/device/taptopay/challenge": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"]) + ) case "/api/v2/device/taptopay/register": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["deviceId": "dev_1"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["deviceId": "dev_1"]) + ) case "/api/v2/device/taptopay/attest": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["ok": true])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["ok": true]) + ) default: XCTFail("unexpected path: \(request.url!.path)") return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, Data()) @@ -101,14 +111,20 @@ final class AppAttestServiceTests: XCTestCase { pathsBox.append(request.url!.path) switch request.url!.path { case "/api/v2/device/taptopay/challenge": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["challengeId": "c", "challenge": "Y2hhbGxlbmdl"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["challengeId": "c", "challenge": "Y2hhbGxlbmdl"]) + ) case "/api/v2/device/taptopay/register": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["deviceId": "dev_pending", "status": "pending"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["deviceId": "dev_pending", "status": "pending"]) + ) case "/api/v2/device/taptopay/attest": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["ok": true])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["ok": true]) + ) default: XCTFail("unexpected path: \(request.url!.path)") return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, Data()) @@ -147,11 +163,15 @@ final class AppAttestServiceTests: XCTestCase { StubURLProtocol.handler = { request in switch request.url!.path { case "/api/v2/device/taptopay/challenge": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"]) + ) case "/api/v2/device/taptopay/register": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["deviceId": "dev_1"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["deviceId": "dev_1"]) + ) default: XCTFail("attest must not reach \(request.url!.path) once attestKey fails") return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, Data()) @@ -185,18 +205,24 @@ final class AppAttestServiceTests: XCTestCase { StubURLProtocol.handler = { request in switch request.url!.path { case "/api/v2/device/taptopay/challenge": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["challengeId": "c_1", "challenge": "Y2hhbGxlbmdl"]) + ) case "/api/v2/device/taptopay/register": // Fail the first /register (pre-attest); succeed the second. if registerAttempts.increment() == 1 { return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: "HTTP/1.1", headerFields: nil)!, Data()) } - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["deviceId": "dev_1"])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["deviceId": "dev_1"]) + ) case "/api/v2/device/taptopay/attest": - return (HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, - Self.envelope(responseData: ["ok": true])) + return ( + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!, + Self.envelope(responseData: ["ok": true]) + ) default: XCTFail("unexpected path: \(request.url!.path)") return (HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!, Data()) @@ -215,8 +241,11 @@ final class AppAttestServiceTests: XCTestCase { } XCTAssertEqual(attestor.generateKeyCalls, 1) XCTAssertFalse(sut.isAlreadyAttested) - XCTAssertEqual(storage.string(forKey: PayabliKeychainKey.pendingKeyId), "mock_keyId", - "a pre-attest failure must keep the generated key for reuse") + XCTAssertEqual( + storage.string(forKey: PayabliKeychainKey.pendingKeyId), + "mock_keyId", + "a pre-attest failure must keep the generated key for reuse" + ) // Attempt 2 succeeds and must REUSE the pending key — no new generateKey. let result = try await sut.attest(entry: "myEntry", appId: "x") @@ -224,8 +253,10 @@ final class AppAttestServiceTests: XCTestCase { XCTAssertEqual(attestor.generateKeyCalls, 1, "the pending key should be reused, not regenerated") XCTAssertEqual(attestor.attestKeyCalls, 1) XCTAssertTrue(sut.isAlreadyAttested) - XCTAssertNil(storage.string(forKey: PayabliKeychainKey.pendingKeyId), - "pending slot must be cleared once attestation completes") + XCTAssertNil( + storage.string(forKey: PayabliKeychainKey.pendingKeyId), + "pending slot must be cleared once attestation completes" + ) } // MARK: - Assertion generation @@ -299,7 +330,7 @@ final class AppAttestServiceTests: XCTestCase { // MARK: - clearCache - func testClearCacheRemovesKeychainState() async throws { + func testClearCacheRemovesKeychainState() throws { let storage = InMemorySecureStorage() try storage.set("a", forKey: PayabliKeychainKey.keyId) try storage.set("b", forKey: PayabliKeychainKey.deviceId) @@ -315,11 +346,14 @@ private final class PathsBox: @unchecked Sendable { private let lock = NSLock() private var storage: [String] = [] var values: [String] { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } return storage } + func append(_ s: String) { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } storage.append(s) } } @@ -331,7 +365,8 @@ private final class CountBox: @unchecked Sendable { private var count = 0 /// Increments and returns the new value (first call returns 1). func increment() -> Int { - lock.lock(); defer { lock.unlock() } + lock.lock() + defer { lock.unlock() } count += 1 return count } diff --git a/Tests/PayabliSDKTapToPayTests/CustomerFieldRedactionTests.swift b/Tests/PayabliSDKTapToPayTests/CustomerFieldRedactionTests.swift new file mode 100644 index 0000000..0b6bff0 --- /dev/null +++ b/Tests/PayabliSDKTapToPayTests/CustomerFieldRedactionTests.swift @@ -0,0 +1,90 @@ +@testable import PayabliSDKTapToPay +import XCTest + +/// `redactedFieldSummary` is what the charge path logs in place of the customer +/// record. The guarantee is that it names which fields were set and carries no +/// value from any of them. +final class CustomerFieldRedactionTests: XCTestCase { + /// Distinct from every field name, so a value found in the summary cannot be + /// the field's own label. + private static let values = (1 ... 20).map { "zzsentinel\($0)" } + private static let customerId = 987_654_321 + + private static func populated() -> PayabliTTPCustomerData { + let v = values + return PayabliTTPCustomerData( + firstName: v[0], + lastName: v[1], + customerNumber: v[2], + email: v[3], + phone: v[4], + customerId: customerId, + company: v[5], + billingAddress1: v[6], + billingAddress2: v[7], + billingCity: v[8], + billingState: v[9], + billingZip: v[10], + billingCountry: v[11], + billingPhone: v[12], + billingEmail: v[13], + shippingAddress1: v[14], + shippingAddress2: v[15], + shippingCity: v[16], + shippingState: v[17], + shippingZip: v[18], + shippingCountry: v[19] + ) + } + + func testNoCustomerValueReachesTheSummary() { + let summary = Self.populated().redactedFieldSummary + + for value in Self.values { + XCTAssertFalse( + summary.contains(value), + "\(value) reached the log line: \(summary)" + ) + } + XCTAssertFalse( + summary.contains(String(Self.customerId)), + "customerId reached the log line: \(summary)" + ) + } + + func testEveryFieldOfAPopulatedCustomerRendersRedacted() { + let summary = Self.populated().redactedFieldSummary + + XCTAssertEqual( + summary.components(separatedBy: "[REDACTED]").count - 1, + 21, + "every field is set, so each renders redacted: \(summary)" + ) + XCTAssertFalse(summary.contains("[nil]"), summary) + } + + func testAnUnsetFieldIsDistinguishedFromASetOne() { + let summary = PayabliTTPCustomerData(firstName: "Ada").redactedFieldSummary + + XCTAssertTrue(summary.contains("firstName=[REDACTED]"), summary) + XCTAssertTrue(summary.contains("lastName=[nil]"), summary) + XCTAssertFalse(summary.contains("Ada"), summary) + } + + func testEveryFieldIsNamed() { + let summary = Self.populated().redactedFieldSummary + + let names = [ + "firstName", "lastName", "customerNumber", "customerId", "company", + "email", "phone", + "billing.address1", "billing.address2", "billing.city", + "billing.state", "billing.zip", "billing.country", + "billing.email", "billing.phone", + "shipping.address1", "shipping.address2", "shipping.city", + "shipping.state", "shipping.zip", "shipping.country" + ] + for name in names { + XCTAssertTrue(summary.contains("\(name)="), "\(name) is missing: \(summary)") + } + } +} diff --git a/Tests/PayabliSDKTapToPayTests/FiservCardReaderTests.swift b/Tests/PayabliSDKTapToPayTests/FiservCardReaderTests.swift index 721e7f0..b46af98 100644 --- a/Tests/PayabliSDKTapToPayTests/FiservCardReaderTests.swift +++ b/Tests/PayabliSDKTapToPayTests/FiservCardReaderTests.swift @@ -1,8 +1,7 @@ -import XCTest @testable import PayabliSDKTapToPay +import XCTest final class FiservCardReaderTests: XCTestCase { - func testProviderId() { XCTAssertEqual(FiservCardReader.providerId, "fiserv") } @@ -10,29 +9,29 @@ final class FiservCardReaderTests: XCTestCase { /// Eligibility is platform/hardware-only (PRD FR-11J.2) and is called before /// `/config` delivers credentials — so a fresh reader on a supported device /// must report `success`. - func testEligibilityIsPlatformOnly() async throws { + func testEligibilityIsPlatformOnly() async { let reader = FiservCardReader() let result = await reader.checkEligibility() #if os(iOS) - if #available(iOS 16.7, *) { - // On a real iPhone `success`; on an incompatible device - // `readerSetupFailed` — both are acceptable. We just assert the - // error (if any) is not about missing credentials. - if case .failure(let err) = result, case .readerSetupFailed(let reason) = err { - XCTAssertFalse( - reason.lowercased().contains("credentials"), - "eligibility should not require credentials" - ) + if #available(iOS 16.7, *) { + // On a real iPhone `success`; on an incompatible device + // `readerSetupFailed` — both are acceptable. We just assert the + // error (if any) is not about missing credentials. + if case let .failure(err) = result, case let .readerSetupFailed(reason) = err { + XCTAssertFalse( + reason.lowercased().contains("credentials"), + "eligibility should not require credentials" + ) + } + } else { + if case .success = result { + XCTFail("iOS < 16.7 must fail eligibility") + } } - } else { + #else if case .success = result { - XCTFail("iOS < 16.7 must fail eligibility") + XCTFail("non-iOS must fail eligibility") } - } - #else - if case .success = result { - XCTFail("non-iOS must fail eligibility") - } #endif } @@ -41,7 +40,7 @@ final class FiservCardReaderTests: XCTestCase { do { try await reader.prepareReader() XCTFail("expected readerSetupFailed") - } catch PayabliTTPError.readerSetupFailed(let reason) { + } catch let PayabliTTPError.readerSetupFailed(reason) { XCTAssertTrue( reason.lowercased().contains("credentials") || reason.lowercased().contains("ios-only"), "unexpected reason: \(reason)" @@ -76,7 +75,7 @@ final class FiservCardReaderTests: XCTestCase { "apiKey": "a" ]) XCTFail("expected readerSetupFailed") - } catch PayabliTTPError.readerSetupFailed(let reason) { + } catch let PayabliTTPError.readerSetupFailed(reason) { XCTAssertTrue(reason.contains("merchantId")) XCTAssertTrue(reason.contains("terminalId")) } catch { @@ -94,7 +93,7 @@ final class FiservCardReaderTests: XCTestCase { "terminalId": "t" ]) XCTFail("expected readerSetupFailed") - } catch PayabliTTPError.readerSetupFailed(let reason) { + } catch let PayabliTTPError.readerSetupFailed(reason) { XCTAssertTrue(reason.contains("merchantId")) } catch { XCTFail("wrong error: \(error)") diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPErrorNSErrorTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPErrorNSErrorTests.swift index d8440da..eb50c0a 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPErrorNSErrorTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPErrorNSErrorTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import PayabliSDKTapToPay +import XCTest /// Verifies that every `PayabliTTPError` case bridges to an `NSError` with /// the documented domain `"com.payabli.ttp"`, the documented stable per-case @@ -9,7 +9,6 @@ import XCTest /// in the middle of `PayabliTTPError` would silently renumber the rest, so /// these tests fail loudly to remind us to append-only. final class PayabliTTPErrorNSErrorTests: XCTestCase { - // MARK: - Domain func testAllErrorsUseTheTTPDomain() { @@ -41,8 +40,10 @@ final class PayabliTTPErrorNSErrorTests: XCTestCase { let nsError = sample.error as NSError let description = nsError.userInfo[NSLocalizedDescriptionKey] as? String XCTAssertNotNil(description, "Missing description for \(sample.error)") - XCTAssertFalse(description?.isEmpty ?? true, - "Empty description for \(sample.error)") + XCTAssertFalse( + description?.isEmpty ?? true, + "Empty description for \(sample.error)" + ) } } @@ -50,8 +51,10 @@ final class PayabliTTPErrorNSErrorTests: XCTestCase { let err = PayabliTTPError.invalidState(current: .ready, attempted: "charge") let description = (err as NSError).userInfo[NSLocalizedDescriptionKey] as? String XCTAssertNotNil(description) - XCTAssertTrue(description?.contains("charge") ?? false, - "Expected description to include attempted operation; got: \(description ?? "")") + XCTAssertTrue( + description?.contains("charge") ?? false, + "Expected description to include attempted operation; got: \(description ?? "")" + ) XCTAssertTrue(description?.contains("Invalid state") ?? false) } @@ -59,8 +62,10 @@ final class PayabliTTPErrorNSErrorTests: XCTestCase { let err = PayabliTTPError.notReady(current: .attestingDevice) let description = (err as NSError).userInfo[NSLocalizedDescriptionKey] as? String XCTAssertNotNil(description) - XCTAssertTrue(description?.contains("not ready") ?? false, - "Expected description to mention reader not ready; got: \(description ?? "")") + XCTAssertTrue( + description?.contains("not ready") ?? false, + "Expected description to mention reader not ready; got: \(description ?? "")" + ) } func testReasonBearingErrorsForwardTheirReason() { diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPEventCodeMappingTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPEventCodeMappingTests.swift index 0f39209..e14dafa 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPEventCodeMappingTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPEventCodeMappingTests.swift @@ -1,5 +1,5 @@ -import XCTest import PayabliSDKTapToPay +import XCTest /// Guards the public mapping between `PayabliTTPEvent` (Swift enum with /// associated values) and `PayabliTTPEventCode` (`@objc Int` enum) plus the @@ -11,7 +11,6 @@ import PayabliSDKTapToPay /// renumber the rest, breaking ObjC consumers that compare against literal /// codes — these tests fail loudly if that happens. final class PayabliTTPEventCodeMappingTests: XCTestCase { - func testEventCodeRawValuesAreStable() { XCTAssertEqual(PayabliTTPEventCode.attestationStarted.rawValue, 0) XCTAssertEqual(PayabliTTPEventCode.attestationCompleted.rawValue, 1) @@ -66,8 +65,10 @@ final class PayabliTTPEventCodeMappingTests: XCTestCase { .devicePendingActivation, .activationStarted, .activationCompleted ] for event in emptyCases { - XCTAssertTrue(event.payload.isEmpty, - "Expected empty payload for \(event.code)") + XCTAssertTrue( + event.payload.isEmpty, + "Expected empty payload for \(event.code)" + ) } } diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPObjCInteropTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPObjCInteropTests.swift index e240f4f..9831252 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPObjCInteropTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPObjCInteropTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import PayabliSDKTapToPay +import XCTest /// Round-trip tests for the ObjC companion classes that wrap /// `PayabliTTPCustomerData`, `PayabliTTPPaymentDetails`, @@ -10,7 +10,6 @@ import XCTest /// hold the same fields with the same labels as the Swift structs and that /// `toSwift()` produces an equivalent value. final class PayabliTTPObjCInteropTests: XCTestCase { - // MARK: - PayabliTTPCustomerDataObjC func testCustomerDataObjCRoundTripPreservesAllFields() { @@ -319,7 +318,7 @@ final class PayabliTTPObjCInteropTests: XCTestCase { /// truncate any customer record id above `Int32.max`, attaching the /// charge to the wrong customer. func testCustomerDataObjCCustomerIdPreserves64BitValue() { - let big = Int64(Int32.max) + 1 // 2_147_483_648 — overflows Int32 + let big = Int64(Int32.max) + 1 // 2_147_483_648 — overflows Int32 let objc = PayabliTTPCustomerDataObjC( firstName: nil, lastName: nil, customerNumber: nil, email: nil, phone: nil, diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift index d52e553..e294e5f 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPReaderSessionRecoveryTests.swift @@ -1,10 +1,10 @@ -import XCTest import PayabliSDKCore @testable import PayabliSDKTapToPay import PayabliSDKTestUtils +import XCTest #if canImport(ProximityReader) -import ProximityReader + import ProximityReader #endif /// A reader session that dies must leave the session repairable. @@ -13,7 +13,6 @@ import ProximityReader /// state says `.ready`, so a failure that leaves it there wedges the session. @MainActor final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { - override func setUp() { super.setUp() StubURLProtocol.handler = Self.chargeStubHandler @@ -89,19 +88,19 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { // MARK: - Both classification tiers - /// The typed tier. Reachable when the raw error propagates to the facade. + // The typed tier. Reachable when the raw error propagates to the facade. #if canImport(ProximityReader) - func testTypedReadErrorIsClassified() { - XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.noReaderSession)) - XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionExpired)) - XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerTokenExpired)) - XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionAuthenticationError)) - - XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readCancelled)) - XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.cardReadFailed)) - XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.paymentCardDeclined)) - XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionBusy)) - } + func testTypedReadErrorIsClassified() { + XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.noReaderSession)) + XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionExpired)) + XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerTokenExpired)) + XCTAssertTrue(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionAuthenticationError)) + + XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readCancelled)) + XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.cardReadFailed)) + XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.paymentCardDeclined)) + XCTAssertFalse(readerFailureInvalidatesSession(PaymentCardReaderSession.ReadError.readerSessionBusy)) + } #endif /// The text tier, which is the one that actually fires on device: the @@ -241,7 +240,9 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { await Task.yield() async let initializing: Void = ttp.initialize() - for _ in 0 ..< 20 { await Task.yield() } + for _ in 0 ..< 20 { + await Task.yield() + } provider.openGate() // The charge may fail: a host that re-initializes mid-charge takes @@ -338,7 +339,9 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { /// How long a call may take before it is treated as never returning. Sized /// for the slowest machine that runs this, not the fastest: an existing test /// in this suite takes eighteen seconds on CI and milliseconds locally. - private var boundSeconds: UInt64 { 60 } + private var boundSeconds: UInt64 { + 60 + } /// Every SDK call in this file goes through here. These tests cover a wedge /// and two concurrency guards, so their failure mode is not returning, and @@ -358,14 +361,23 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { let workTask = Task { @MainActor in do { let value = try await work() - if !once.done { once.done = true; continuation.resume(returning: value) } + if !once.done { + once.done = true + continuation.resume(returning: value) + } } catch { - if !once.done { once.done = true; continuation.resume(throwing: error) } + if !once.done { + once.done = true + continuation.resume(throwing: error) + } } } Task { @MainActor in - try? await Task.sleep(nanoseconds: 5_000_000_000) - if !once.done { once.done = true; continuation.resume(throwing: ChargeTimedOut()) } + try? await Task.sleep(nanoseconds: boundSeconds * 1_000_000_000) + if !once.done { + once.done = true + continuation.resume(throwing: ChargeTimedOut()) + } workTask.cancel() } } @@ -410,17 +422,15 @@ final class PayabliTTPReaderSessionRecoveryTests: XCTestCase { /// cannot decode. private static let chargeStubHandler: StubURLProtocol.Handler = { request in let path = request.url?.path ?? "" - let body: [String: Any] - - if path.contains("/MoneyIn/initiate") { - body = [ + let body: [String: Any] = if path.contains("/MoneyIn/initiate") { + [ "code": "A01", "data": ["paymentTransId": PayabliTTPReaderSessionRecoveryTests.paymentTransId] ] } else if path.contains("/MoneyIn/update/") { - body = ["code": "A01", "data": ["paymentTransId": PayabliTTPReaderSessionRecoveryTests.paymentTransId]] + ["code": "A01", "data": ["paymentTransId": PayabliTTPReaderSessionRecoveryTests.paymentTransId]] } else { - body = [ + [ "responseCode": 1, "isSuccess": true, "responseData": [ @@ -463,8 +473,8 @@ private final class ResumeOnce { /// `XCTAssertThrowsError` has no async form, and an autoclosure cannot be /// awaited, so the expression is taken as an async closure. -private func XCTAssertThrowsErrorAsync( - _ expression: @autoclosure () async throws -> T, +private func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> some Any, _ message: String = "expected an error", file: StaticString = #filePath, line: UInt = #line @@ -481,7 +491,6 @@ private func XCTAssertThrowsErrorAsync( } } - /// A provider that runs a caller-supplied step while a read is in flight, so the /// interleaving is deterministic instead of timing-dependent. /// `@unchecked` because every caller is on the main actor: the SDK surface is @@ -489,7 +498,9 @@ private func XCTAssertThrowsErrorAsync( /// instead would put the conformance across an isolation boundary, which is an /// error under the Swift 6 language mode. private final class InterleavingProvider: TapToPayProvider, @unchecked Sendable { - static var providerId: String { "interleaving" } + static var providerId: String { + "interleaving" + } var readingResult: Result = .failure( PayabliTTPError.nfcFailed(reason: "not configured") @@ -501,25 +512,35 @@ private final class InterleavingProvider: TapToPayProvider, @unchecked Sendable private var gate: CheckedContinuation? private var gateArmed = false - func armGate() { gateArmed = true } + func armGate() { + gateArmed = true + } func openGate() { gateArmed = false gate?.resume() gate = nil } + private(set) var prepareReaderCalls = 0 private(set) var configureCalls = 0 /// True if a second setup entered while one was still inside the provider. private(set) var sawOverlap = false private var inProvider = false - func checkEligibility() async -> Result { .success(()) } - func configure(credentials: [String: String]) throws { configureCalls += 1 } + func checkEligibility() async -> Result { + .success(()) + } + + func configure(credentials: [String: String]) throws { + configureCalls += 1 + } func prepareReader() async throws { prepareReaderCalls += 1 - if inProvider { sawOverlap = true } + if inProvider { + sawOverlap = true + } inProvider = true defer { inProvider = false } if gateArmed { @@ -528,6 +549,7 @@ private final class InterleavingProvider: TapToPayProvider, @unchecked Sendable await Task.yield() } } + func cancelReading() async {} func cleanUp() async {} diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPSessionInitTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPSessionInitTests.swift index 561ca52..659f22d 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPSessionInitTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPSessionInitTests.swift @@ -1,11 +1,11 @@ -import XCTest -@testable import PayabliSDKTapToPay @testable import PayabliSDKCore +@testable import PayabliSDKTapToPay import PayabliSDKTestUtils +import XCTest @MainActor final class PayabliTTPSessionInitTests: XCTestCase { - func testTwoFacadesShareTheSameSession() async { + func testTwoFacadesShareTheSameSession() { let config = PayabliConfig( accessToken: "shared-token", entryPoint: "demo", diff --git a/Tests/PayabliSDKTapToPayTests/PayabliTTPTests.swift b/Tests/PayabliSDKTapToPayTests/PayabliTTPTests.swift index d56ff59..c0d5164 100644 --- a/Tests/PayabliSDKTapToPayTests/PayabliTTPTests.swift +++ b/Tests/PayabliSDKTapToPayTests/PayabliTTPTests.swift @@ -1,11 +1,10 @@ -import XCTest import PayabliSDKCore @testable import PayabliSDKTapToPay import PayabliSDKTestUtils +import XCTest @MainActor final class PayabliTTPTests: XCTestCase { - override func setUp() { super.setUp() // Default config stub — individual tests may override. @@ -34,9 +33,12 @@ final class PayabliTTPTests: XCTestCase { "paymentToken": "payment_tok" ] let data = try JSONSerialization.data(withJSONObject: body) - return (HTTPURLResponse(url: request.url!, statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": "application/json"])!, data) + return (HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )!, data) } private func makeTTP( diff --git a/Tests/PayabliSDKTapToPayTests/SecureStorageTests.swift b/Tests/PayabliSDKTapToPayTests/SecureStorageTests.swift index b8f1f49..691dce9 100644 --- a/Tests/PayabliSDKTapToPayTests/SecureStorageTests.swift +++ b/Tests/PayabliSDKTapToPayTests/SecureStorageTests.swift @@ -1,9 +1,8 @@ -import XCTest @testable import PayabliSDKTapToPay import PayabliSDKTestUtils +import XCTest final class SecureStorageTests: XCTestCase { - // MARK: - InMemorySecureStorage func testInMemoryRoundTrip() throws { @@ -35,24 +34,24 @@ final class SecureStorageTests: XCTestCase { /// round-trip is covered by on-device QA (PRD §12.3). func testKeychainRoundTripIfAvailable() throws { #if os(macOS) && !targetEnvironment(simulator) - throw XCTSkip("Keychain services require a running keychaind; covered by device QA (§12.3).") + throw XCTSkip("Keychain services require a running keychaind; covered by device QA (§12.3).") #else - let storage = KeychainStorage(service: "com.payabli.tests.\(UUID().uuidString)") - defer { storage.removeAll() } + let storage = KeychainStorage(service: "com.payabli.tests.\(UUID().uuidString)") + defer { storage.removeAll() } - do { - try storage.set("hello_keychain", forKey: "sample_key") - } catch KeychainStorage.KeychainError.underlying(let status) { - throw XCTSkip(""" + do { + try storage.set("hello_keychain", forKey: "sample_key") + } catch let KeychainStorage.KeychainError.underlying(status) { + throw XCTSkip(""" Keychain unavailable in this test host (OSStatus \(status)); \ covered by device QA (§12.3). """) - } + } - XCTAssertEqual(storage.string(forKey: "sample_key"), "hello_keychain") + XCTAssertEqual(storage.string(forKey: "sample_key"), "hello_keychain") - storage.remove(forKey: "sample_key") - XCTAssertNil(storage.string(forKey: "sample_key")) + storage.remove(forKey: "sample_key") + XCTAssertNil(storage.string(forKey: "sample_key")) #endif } diff --git a/Tests/PayabliSDKTapToPayTests/SessionManagerTests.swift b/Tests/PayabliSDKTapToPayTests/SessionManagerTests.swift index 46c27a3..fef46c4 100644 --- a/Tests/PayabliSDKTapToPayTests/SessionManagerTests.swift +++ b/Tests/PayabliSDKTapToPayTests/SessionManagerTests.swift @@ -1,9 +1,8 @@ -import XCTest @testable import PayabliSDKTapToPay +import XCTest @MainActor final class SessionManagerTests: XCTestCase { - func testInitialStateIsIdle() { let sm = SessionManager() XCTAssertEqual(sm.sessionState, .idle) diff --git a/Tests/PayabliSDKTapToPayTests/TTPTransactionWireFormatTests.swift b/Tests/PayabliSDKTapToPayTests/TTPTransactionWireFormatTests.swift index 7d48e08..b7b9c88 100644 --- a/Tests/PayabliSDKTapToPayTests/TTPTransactionWireFormatTests.swift +++ b/Tests/PayabliSDKTapToPayTests/TTPTransactionWireFormatTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import PayabliSDKTapToPay +import XCTest /// Contract tests for the backend `PATCH /MoneyIn/update/{id}` wire format. /// The backend still expects the top-level key `fiservResponse`; the SDK's @@ -7,7 +7,6 @@ import XCTest /// `UpdateSuccessBody.CodingKeys`. These tests fail loudly if someone /// removes or changes the mapping without a coordinated backend rollout. final class TTPTransactionWireFormatTests: XCTestCase { - func test_updateSuccessBody_serializesOpaqueJSONUnderFiservResponseKey() throws { let innerJSON = Data(#"{"transactionId":"abc","status":"approved"}"#.utf8) let payload = ProviderResponsePayload.opaqueJSON(innerJSON) diff --git a/Tests/PayabliSDKTelemetryTests/PayabliSDKTelemetryTests.swift b/Tests/PayabliSDKTelemetryTests/PayabliSDKTelemetryTests.swift index 05e67fa..fe06ae6 100644 --- a/Tests/PayabliSDKTelemetryTests/PayabliSDKTelemetryTests.swift +++ b/Tests/PayabliSDKTelemetryTests/PayabliSDKTelemetryTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import PayabliSDKTelemetry +import XCTest final class PayabliSDKTelemetryTests: XCTestCase { func testVersionIsPopulated() { diff --git a/Tests/PayabliSDKTelemetryTests/TelemetryTransportTests.swift b/Tests/PayabliSDKTelemetryTests/TelemetryTransportTests.swift index 2a5eec0..691bce7 100644 --- a/Tests/PayabliSDKTelemetryTests/TelemetryTransportTests.swift +++ b/Tests/PayabliSDKTelemetryTests/TelemetryTransportTests.swift @@ -1,9 +1,8 @@ -import XCTest import PayabliSDKCore @testable import PayabliSDKTelemetry +import XCTest final class TelemetryTransportTests: XCTestCase { - // MARK: - Sentry bridge final class CapturingSentryBridge: PayabliSentryBridge, @unchecked Sendable { @@ -12,6 +11,7 @@ final class TelemetryTransportTests: XCTestCase { func addBreadcrumb(_ category: String, data: [String: Any]) { breadcrumbs.append((category, data)) } + func captureError(_ message: String, tags: [String: String], extra: [String: Any]) { errors.append((message, tags, extra)) } @@ -21,10 +21,24 @@ final class TelemetryTransportTests: XCTestCase { let bridge = CapturingSentryBridge() let transport = SentryTelemetryTransport(bridge: bridge) await transport.send([ - TelemetryEvent(sdkVersion: "1", sessionId: "s", deviceIdHash: nil, entry: "e", environment: "sandbox", - event: "ttp.charge.failed", properties: ["errorCode": "X"]), - TelemetryEvent(sdkVersion: "1", sessionId: "s", deviceIdHash: nil, entry: "e", environment: "sandbox", - event: "ttp.charge.started", properties: ["amount": "10"]) + TelemetryEvent( + sdkVersion: "1", + sessionId: "s", + deviceIdHash: nil, + entry: "e", + environment: "sandbox", + event: "ttp.charge.failed", + properties: ["errorCode": "X"] + ), + TelemetryEvent( + sdkVersion: "1", + sessionId: "s", + deviceIdHash: nil, + entry: "e", + environment: "sandbox", + event: "ttp.charge.started", + properties: ["amount": "10"] + ) ]) XCTAssertEqual(bridge.errors.count, 1) XCTAssertEqual(bridge.errors.first?.0, "ttp.charge.failed") @@ -40,15 +54,25 @@ final class TelemetryTransportTests: XCTestCase { func capture(_ event: String, distinctId: String, properties: [String: Any]) { captured.append((event, distinctId, properties)) } - func flush() { flushCount += 1 } + + func flush() { + flushCount += 1 + } } func testPostHogTransportCapturesWithHashedEntryAsDistinctId() async { let bridge = CapturingPostHogBridge() let transport = PostHogTelemetryTransport(bridge: bridge) await transport.send([ - TelemetryEvent(sdkVersion: "1", sessionId: "s", deviceIdHash: nil, entry: "partner_entry", - environment: "sandbox", event: "tokenization.started", properties: ["method": "card"]) + TelemetryEvent( + sdkVersion: "1", + sessionId: "s", + deviceIdHash: nil, + entry: "partner_entry", + environment: "sandbox", + event: "tokenization.started", + properties: ["method": "card"] + ) ]) XCTAssertEqual(bridge.captured.count, 1) let (name, distinctId, props) = bridge.captured.first! diff --git a/Tests/PayabliSDKTestUtilsTests/PayabliSDKTestUtilsTests.swift b/Tests/PayabliSDKTestUtilsTests/PayabliSDKTestUtilsTests.swift index e731013..f4eae72 100644 --- a/Tests/PayabliSDKTestUtilsTests/PayabliSDKTestUtilsTests.swift +++ b/Tests/PayabliSDKTestUtilsTests/PayabliSDKTestUtilsTests.swift @@ -1,5 +1,5 @@ -import XCTest import PayabliSDKTestUtils +import XCTest final class PayabliSDKTestUtilsTests: XCTestCase { func testStubURLProtocolMakeSessionReturnsConfiguredSession() { diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..18d3028 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,21 @@ +sonar.projectKey=payabli_sdk-ios +sonar.organization=payabli +sonar.host.url=https://sonarcloud.io +sonar.sourceEncoding=UTF-8 + +# The SDK, and nothing else. Example/ is the sample app, Bridges/ holds the +# Flutter, MAUI and React Native wrappers, and ThirdParty/ is vendored +# Fiserv/TTPPackage source that is byte-identical to upstream and may not be +# edited. None of them is what this project measures, and naming Sources and +# Tests alone keeps their issues and their uncovered lines out of the SDK's +# numbers. +sonar.sources=Sources +sonar.tests=Tests + +# Coverage only, so these still get issue detection. A SwiftUI view needs a +# rendering pass, so no unit test reaches its body, and TestUtils is fixtures +# that the suites exercise rather than target. +sonar.coverage.exclusions=Sources/PayabliSDKPayInPaymentFlow/**View.swift,Sources/PayabliSDKTestUtils/** + +# Written by Scripts/xccov-to-sonarqube-generic.sh from the SDK .xcresult bundle. +sonar.coverageReportPaths=coverage.xml