Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,15 @@ extension PaywallViewController {
closeReason: PaywallCloseReason,
completion: (() -> Void)? = nil
) {
// Ignore redundant dismiss calls after a terminal purchase/restore.
switch paywallResult {
case .purchased, .restored:
completion?()
return
case .declined, .none:
break
}

dismissCompletionBlock = completion
paywallResult = result
paywall.closeReason = closeReason
Expand Down
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 {
Expand Down Expand Up @@ -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,
Comment on lines +134 to +136
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 nonisolated(unsafe) bypasses Swift concurrency checks

nonisolated(unsafe) var results disables actor-isolation enforcement on RecordingDelegate. If the delegate is ever called off the main actor the mutation would be a data race. Since the enclosing test suite is @MainActor and PaywallViewController also runs on the main actor the access is safe in practice, but marking RecordingDelegate itself @MainActor would be cleaner and restore compiler-enforced isolation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Tests/SuperwallKitTests/Paywall/View Controller/PaywallViewControllerTests.swift
Line: 134-136

Comment:
**`nonisolated(unsafe)` bypasses Swift concurrency checks**

`nonisolated(unsafe) var results` disables actor-isolation enforcement on `RecordingDelegate`. If the delegate is ever called off the main actor the mutation would be a data race. Since the enclosing test suite is `@MainActor` and `PaywallViewController` also runs on the main actor the access is safe in practice, but marking `RecordingDelegate` itself `@MainActor` would be cleaner and restore compiler-enforced isolation.

How can I resolve this? If you propose a fix, please make it concise.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Prompt To Fix With AI
This 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)
}
}
Loading