-
Notifications
You must be signed in to change notification settings - Fork 54
Fix dismiss post purchase race #461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,11 @@ | ||
| // | ||
| // PaywallViewControllerDrawerTests.swift | ||
| // PaywallViewControllerTests.swift | ||
| // SuperwallKitTests | ||
| // | ||
| // Created by Claude on 08/01/2025. | ||
| // | ||
|
|
||
| import Testing | ||
| import Foundation | ||
| import StoreKit | ||
| @testable import SuperwallKit | ||
|
|
||
| struct PaywallViewControllerDrawerTests { | ||
|
|
@@ -128,3 +127,87 @@ struct PaywallViewControllerDrawerTests { | |
| #expect(PaywallPresentationStyleObjc.none.toSwift() == .none) | ||
| } | ||
| } | ||
|
|
||
| @MainActor | ||
| struct PaywallViewControllerDismissIdempotencyTests { | ||
| final class RecordingDelegate: PaywallViewControllerDelegate { | ||
| nonisolated(unsafe) var results: [PaywallResult] = [] | ||
| func paywall( | ||
| _ paywall: PaywallViewController, | ||
| didFinishWith result: PaywallResult, | ||
| shouldDismiss: Bool | ||
| ) { | ||
| results.append(result) | ||
| } | ||
| } | ||
|
|
||
| private func makeMock() -> PaywallViewControllerMock { | ||
| let dependencyContainer = DependencyContainer() | ||
| let messageHandler = PaywallMessageHandler( | ||
| receiptManager: dependencyContainer.receiptManager, | ||
| factory: dependencyContainer, | ||
| permissionHandler: FakePermissionHandler(), | ||
| customCallbackRegistry: dependencyContainer.customCallbackRegistry | ||
| ) | ||
| let webView = SWWebView( | ||
| isMac: false, | ||
| messageHandler: messageHandler, | ||
| isOnDeviceCacheEnabled: true, | ||
| factory: dependencyContainer | ||
| ) | ||
| return PaywallViewControllerMock( | ||
| paywall: .stub(), | ||
| deviceHelper: dependencyContainer.deviceHelper, | ||
| factory: dependencyContainer, | ||
| storage: dependencyContainer.storage, | ||
| network: dependencyContainer.network, | ||
| webView: webView, | ||
| webEntitlementRedeemer: dependencyContainer.webEntitlementRedeemer, | ||
| cache: nil, | ||
| paywallArchiveManager: nil | ||
| ) | ||
| } | ||
|
|
||
| @Test("`.closed` dismiss after a successful purchase is ignored") | ||
| func closedEventAfterPurchaseIsIgnored() async throws { | ||
| let paywallVc = makeMock() | ||
| let recorder = RecordingDelegate() | ||
| paywallVc.delegate = PaywallViewControllerDelegateAdapter( | ||
| swiftDelegate: recorder, | ||
| objcDelegate: nil | ||
| ) | ||
|
|
||
| let product = StoreProduct( | ||
|
Comment on lines
+177
to
+180
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The 100 ms sleep is used to wait for the async The same concern applies to the restore test at line 207. Prompt To Fix With AIThis is a comment left during a code review.
Path: Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift
Line: 177-180
Comment:
**Time-based async assertion is fragile**
The 100 ms sleep is used to wait for the async `dismissView()` delegate call to complete. In slow CI environments or under heavy load this window can be missed, causing the assertion to see 0 results and fail spuriously. Consider structuring the test so the delegate call is awaited directly via a `withCheckedContinuation` or an `AsyncStream`-based expectation instead of a fixed sleep.
The same concern applies to the restore test at line 207.
How can I resolve this? If you propose a fix, please make it concise. |
||
| sk1Product: MockSkProduct(productIdentifier: "com.example.test") | ||
| ) | ||
|
|
||
| paywallVc.dismiss(result: .purchased(product), closeReason: .systemLogic) | ||
| paywallVc.dismiss(result: .declined, closeReason: .manualClose) | ||
|
|
||
| try await Task.sleep(nanoseconds: 100_000_000) | ||
|
|
||
| #expect(recorder.results.count == 1) | ||
| if case .purchased = recorder.results.first { | ||
| } else { | ||
| Issue.record("Expected delegate to receive .purchased only, got \(recorder.results)") | ||
| } | ||
| } | ||
|
|
||
| @Test("`.closed` dismiss after a restore is ignored") | ||
| func closedEventAfterRestoreIsIgnored() async throws { | ||
| let paywallVc = makeMock() | ||
| let recorder = RecordingDelegate() | ||
| paywallVc.delegate = PaywallViewControllerDelegateAdapter( | ||
| swiftDelegate: recorder, | ||
| objcDelegate: nil | ||
| ) | ||
|
|
||
| paywallVc.dismiss(result: .restored, closeReason: .systemLogic) | ||
| paywallVc.dismiss(result: .declined, closeReason: .manualClose) | ||
|
|
||
| try await Task.sleep(nanoseconds: 100_000_000) | ||
|
|
||
| #expect(recorder.results.count == 1) | ||
| #expect(recorder.results.first == .restored) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nonisolated(unsafe)bypasses Swift concurrency checksnonisolated(unsafe) var resultsdisables actor-isolation enforcement onRecordingDelegate. If the delegate is ever called off the main actor the mutation would be a data race. Since the enclosing test suite is@MainActorandPaywallViewControlleralso runs on the main actor the access is safe in practice, but markingRecordingDelegateitself@MainActorwould be cleaner and restore compiler-enforced isolation.Prompt To Fix With AI