diff --git a/CHANGELOG.md b/CHANGELOG.md index 0962efffe..eca15b1de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/superwall/Superwall-iOS/releases) on GitHub. +## 4.14.2 + +### Enhancements + +- Adds multipage paywall navigation tracking by tracking a `paywall_page_view` event, which contains information about the page view. + ## 4.14.1 ### Enhancements diff --git a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bc7023ed9..8dcbeeb95 100644 --- a/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Advanced/Advanced.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/RevenueCat/purchases-ios.git", "state": { "branch": null, - "revision": "d9303831a73a13178b593f2ec2d2417252542f7c", - "version": "5.61.0" + "revision": "155ea739f45f54189ca83ee9088b373c1415d98b", + "version": "5.64.0" } }, { diff --git a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift index 98d5a94db..97dbd5a3f 100644 --- a/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Internal Tracking/Trackable Events/TrackableSuperwallEvent.swift @@ -468,6 +468,39 @@ enum InternalSuperwallEvent { } } + struct PaywallPageView: TrackableSuperwallEvent { + var superwallEvent: SuperwallEvent { + return .paywallPageView( + paywallInfo: paywallInfo, + data: data + ) + } + let paywallInfo: PaywallInfo + let data: PageViewData + + func getSuperwallParameters() async -> [String: Any] { + var params = await paywallInfo.placementParams() + params["page_node_id"] = data.pageNodeId + params["flow_position"] = data.flowPosition + params["page_name"] = data.pageName + params["navigation_node_id"] = data.navigationNodeId + params["navigation_type"] = data.navigationType + if let previousPageNodeId = data.previousPageNodeId { + params["previous_page_node_id"] = previousPageNodeId + } + if let previousFlowPosition = data.previousFlowPosition { + params["previous_flow_position"] = previousFlowPosition + } + if let timeOnPreviousPageMs = data.timeOnPreviousPageMs { + params["time_on_previous_page_ms"] = timeOnPreviousPageMs + } + return params + } + var audienceFilterParams: [String: Any] { + return paywallInfo.audienceFilterParams() + } + } + struct PaywallClose: TrackableSuperwallEvent { var superwallEvent: SuperwallEvent { return .paywallClose(paywallInfo: paywallInfo) diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift index 51454fa9e..f02caf9af 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEvent.swift @@ -281,6 +281,12 @@ public enum SuperwallEvent { /// When the test mode modal is closed. case testModeModalClose + /// When a user navigates to a page in a multi-page paywall. + case paywallPageView( + paywallInfo: PaywallInfo, + data: PageViewData + ) + var canImplicitlyTriggerPaywall: Bool { switch self { case .appInstall, @@ -478,6 +484,8 @@ extension SuperwallEvent { return .init(objcEvent: .testModeModalOpen) case .testModeModalClose: return .init(objcEvent: .testModeModalClose) + case .paywallPageView: + return .init(objcEvent: .paywallPageView) } } } diff --git a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift index 80a83f166..ce36084a7 100644 --- a/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift +++ b/Sources/SuperwallKit/Analytics/Superwall Placement/SuperwallEventObjc.swift @@ -257,6 +257,9 @@ public enum SuperwallEventObjc: Int, CaseIterable { /// When the test mode modal is closed. case testModeModalClose + /// When a user navigates to a page in a multi-page paywall. + case paywallPageView + public init(event: SuperwallEvent) { self = event.backingData.objcEvent } @@ -419,6 +422,8 @@ public enum SuperwallEventObjc: Int, CaseIterable { return "testModeModal_open" case .testModeModalClose: return "testModeModal_close" + case .paywallPageView: + return "paywall_page_view" } } } diff --git a/Sources/SuperwallKit/Misc/Constants.swift b/Sources/SuperwallKit/Misc/Constants.swift index 60206882a..41e6bcf49 100644 --- a/Sources/SuperwallKit/Misc/Constants.swift +++ b/Sources/SuperwallKit/Misc/Constants.swift @@ -18,5 +18,5 @@ let sdkVersion = """ */ let sdkVersion = """ -4.14.1 +4.14.2 """ diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 3eeb0f14d..87b942812 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -82,6 +82,10 @@ struct Paywall: Codable { // MARK: - Added by client + /// A unique identifier for this paywall presentation, used to correlate all events + /// within a single presentation lifecycle. + var presentationId: String? + var responseLoadingInfo: LoadingInfo var webviewLoadingInfo: LoadingInfo var shimmerLoadingInfo: LoadingInfo @@ -424,6 +428,7 @@ struct Paywall: Codable { url: url, products: products, productIds: productIds, + presentationId: presentationId, fromPlacementData: fromPlacement, responseLoadStartTime: responseLoadingInfo.startAt, responseLoadCompleteTime: responseLoadingInfo.endAt, @@ -460,6 +465,7 @@ struct Paywall: Codable { presentationSourceType = paywall.presentationSourceType experiment = paywall.experiment featureGating = paywall.featureGating + presentationId = paywall.presentationId } } diff --git a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift index 0b5ec1ed6..dac01c42d 100644 --- a/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift +++ b/Sources/SuperwallKit/Paywall/Presentation/PaywallInfo.swift @@ -135,6 +135,10 @@ public final class PaywallInfo: NSObject { /// `.automatic`. public let introOfferEligibility: IntroOfferEligibility + /// A unique identifier for this paywall presentation, used to correlate all events + /// within a single presentation lifecycle. + public let presentationId: String? + init( databaseId: String, identifier: String, @@ -144,6 +148,7 @@ public final class PaywallInfo: NSObject { url: URL, products: [Product], productIds: [String], + presentationId: String?, fromPlacementData placementData: PlacementData?, responseLoadStartTime: Date?, responseLoadCompleteTime: Date?, @@ -182,6 +187,7 @@ public final class PaywallInfo: NSObject { self.presentationSourceType = presentationSourceType self.experiment = experiment self.paywalljsVersion = paywalljsVersion + self.presentationId = presentationId self.products = products self.productIds = productIds self.isFreeTrialAvailable = isFreeTrialAvailable @@ -271,7 +277,8 @@ public final class PaywallInfo: NSObject { "close_reason": closeReason.description, "is_scroll_enabled": isScrollEnabled as Any, "intro_offer_eligibility": introOfferEligibility.description, - "app_transaction_id": ReceiptManager.appTransactionId as Any + "app_transaction_id": ReceiptManager.appTransactionId as Any, + "presentation_id": presentationId as Any ] var loadingVars: [String: Any] = [:] @@ -356,6 +363,7 @@ extension PaywallInfo: Stubbable { url: URL(string: "https://superwall.com")!, products: [], productIds: [], + presentationId: nil, fromPlacementData: nil, responseLoadStartTime: nil, responseLoadCompleteTime: nil, @@ -398,6 +406,7 @@ extension PaywallInfo: Stubbable { url: URL(string: "https://superwall.com")!, products: [], productIds: [], + presentationId: nil, fromPlacementData: nil, responseLoadStartTime: nil, responseLoadCompleteTime: nil, diff --git a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift index b2d743b27..e4fce9a23 100644 --- a/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift +++ b/Sources/SuperwallKit/Paywall/Request/Operators/RawPaywallResponse.swift @@ -15,7 +15,8 @@ extension PaywallRequestManager { paywallId: request.responseIdentifiers.paywallId, placement: request.placementData ) - let paywall = try await getPaywallResponse(from: request) + var paywall = try await getPaywallResponse(from: request) + paywall.presentationId = UUID().uuidString let paywallInfo = paywall.getInfo(fromPlacement: request.placementData) await trackResponseLoaded( diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PageViewData.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PageViewData.swift new file mode 100644 index 000000000..44b747045 --- /dev/null +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PageViewData.swift @@ -0,0 +1,47 @@ +// +// PageViewData.swift +// +// +// Created by Yusuf Tör on 16/03/2026. +// + +import Foundation + +/// Contains page-specific details for a multi-page paywall page view. +public struct PageViewData: Decodable, Equatable { + /// The unique identifier for the page node. + public let pageNodeId: String + + /// The zero-based index of the page in the paywall flow. + public let flowPosition: Int + + /// The display name of the page. + public let pageName: String + + /// The unique identifier for the navigation node. + public let navigationNodeId: String + + /// The unique identifier for the previous page node, if any. + public let previousPageNodeId: String? + + /// The flow position of the previous page, if any. + public let previousFlowPosition: Int? + + /// How the user navigated to the page. Possible values: + /// `"entry"`, `"forward"`, `"back"`, `"auto_transition"`. + public let navigationType: String + + /// Time spent on the previous page in milliseconds, if any. + public let timeOnPreviousPageMs: Int? + + private enum CodingKeys: String, CodingKey { + case pageNodeId + case flowPosition + case pageName + case navigationNodeId + case previousPageNodeId + case previousFlowPosition + case navigationType = "type" + case timeOnPreviousPageMs + } +} diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift index cb1c3f5f5..f39f6c79e 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift @@ -74,6 +74,7 @@ enum PaywallMessage: Decodable, Equatable { variables: JSON? ) case hapticFeedback(hapticType: String) + case pageView(PageViewData) // All cases below here are sent from device to paywall case paywallClose @@ -121,6 +122,7 @@ enum PaywallMessage: Decodable, Equatable { case requestPermission = "request_permission" case requestCallback = "request_callback" case hapticFeedback = "haptic_feedback" + case pageView = "page_view" } // Everyone write to eventName, other may use the remaining keys @@ -310,6 +312,11 @@ enum PaywallMessage: Decodable, Equatable { self = .hapticFeedback(hapticType: hapticType) return } + case .pageView: + if let data = try? PageViewData(from: decoder) { + self = .pageView(data) + return + } } } diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift index 657bdff35..2b5ce4e72 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift @@ -255,6 +255,16 @@ final class PaywallMessageHandler: WebEventDelegate { ) case .hapticFeedback(let hapticType): triggerHapticFeedback(hapticType) + case .pageView(let data): + guard let delegate = delegate else { return } + let paywallInfo = delegate.info + Task { + let event = InternalSuperwallEvent.PaywallPageView( + paywallInfo: paywallInfo, + data: data + ) + await Superwall.shared.track(event) + } } } diff --git a/SuperwallKit.podspec b/SuperwallKit.podspec index 7e0aff9f4..824ebdc63 100644 --- a/SuperwallKit.podspec +++ b/SuperwallKit.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SuperwallKit" - s.version = "4.14.1" + s.version = "4.14.2" s.summary = "Superwall: In-App Paywalls Made Easy" s.description = "Paywall infrastructure for mobile apps :) we make things like editing your paywall and running price tests as easy as clicking a few buttons. superwall.com" diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 42a63d356..4bcf8d6e8 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -121,6 +121,7 @@ 342593FCA24FBEA77FE472C7 /* SK2ReceiptManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050BC76657949DBB5F3D551C /* SK2ReceiptManager.swift */; }; 3464196F9088F8A320FE24A4 /* PendingStripeCheckoutPollState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797EC0356AA1065ED11835BF /* PendingStripeCheckoutPollState.swift */; }; 35597883CB038DBEE63E162B /* EventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86D76FB5809C3B8122778A9 /* EventData.swift */; }; + 3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */; }; 369677E9A6E8754CFD20714D /* TrackingParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764012CF0C0972240A73E3CF /* TrackingParameters.swift */; }; 36B598D26A38D50CC713FD73 /* ProductsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641BC3C3F8AC2D6E1EF44D55 /* ProductsManager.swift */; }; 37264FFAF68B8349BD6F9BE8 /* WebEntitlementRedeemerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2D8F44D23EDD6316E1C4EE1 /* WebEntitlementRedeemerTests.swift */; }; @@ -264,12 +265,14 @@ 7F630637D79A1F24CC17A8A5 /* GetPaywallVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFB9AEAF72391341B4BDF6CD /* GetPaywallVC.swift */; }; 7FCDAF6C945FA04FC4C4E8E3 /* DeviceHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB242DC77FEC0BE10C0DDC9C /* DeviceHelper.swift */; }; 803BFA630F96B638E3BDE715 /* GameControllerEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21C52F36F0BFF59363EBB4C7 /* GameControllerEvent.swift */; }; + 80A96673A17176DD5EFE1FA5 /* PageViewMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB7C70BFD23FD038393FD6DC /* PageViewMessageTests.swift */; }; 81680E02D1693BF58E015C0C /* ASN1Decoder+UnkeyedDecodingContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D31BB6D0C57337C6E929D617 /* ASN1Decoder+UnkeyedDecodingContainer.swift */; }; 822B2898CDD9C6E50816F62B /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9298A79020030E9A1357A6 /* API.swift */; }; 84616856D40F775122FD9BF9 /* Dictionary+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571825E7515FCC1E877D4429 /* Dictionary+Cache.swift */; }; 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ECD75DF8F3EB6A68A21444D /* ProductsFetcherSK2Tests.swift */; }; 85728EABBC5C73193AC5F876 /* CustomURLSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3506FCC35155DF104A1DFCA /* CustomURLSessionMock.swift */; }; 8583971F8E9E51E9B7A4FCC6 /* PurchasingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB8384E2DB0A3627BE1CCB7D /* PurchasingCoordinator.swift */; }; + 87B66787F6EB43DA80667C36 /* PageViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E321E7EEC07CA9A8B9A5619 /* PageViewData.swift */; }; 880BBB2099D3112F256E6AE2 /* IntroOfferEligibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FACE677755EAA3EA4E67A8 /* IntroOfferEligibility.swift */; }; 88A5CA6515126BD3D09E0563 /* LimitedQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B921746BEC8F63DDB65C634 /* LimitedQueue.swift */; }; 88D22C84ACDED44E3952C786 /* SK2ObserverModePurchaseDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EC8705042D6AA74D40350A9 /* SK2ObserverModePurchaseDetector.swift */; }; @@ -870,6 +873,7 @@ 8D5F8BE7E93645C0FCA49E4A /* PopupTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupTransition.swift; sourceTree = ""; }; 8D9545633A97A2E63FEDF78A /* SWWebViewLoadingHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SWWebViewLoadingHandler.swift; sourceTree = ""; }; 8DE36D141F461F6E945823FA /* Future+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Future+Async.swift"; sourceTree = ""; }; + 8E321E7EEC07CA9A8B9A5619 /* PageViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageViewData.swift; sourceTree = ""; }; 8E3589BB187C8C6AA5C9DE71 /* FakeContactsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeContactsStore.swift; sourceTree = ""; }; 8E441343EAC43B2ECF35F929 /* SuperwallDelegateAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuperwallDelegateAdapter.swift; sourceTree = ""; }; 8E9E1A8F57B5DCA4CD16296F /* Dictionary+Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Keys.swift"; sourceTree = ""; }; @@ -922,6 +926,7 @@ A22E703895B07CF172665846 /* PaywallLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallLoadingState.swift; sourceTree = ""; }; A2D40088A465E104CF5C67CC /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; A3781CF21200CD2333F6779A /* GetPaywallManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetPaywallManager.swift; sourceTree = ""; }; + A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationIdTests.swift; sourceTree = ""; }; A3F4F74393061C17CEB18F90 /* ManagedEventData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedEventData.swift; sourceTree = ""; }; A40D9BA2449503F4B7F5B7A6 /* Array+Guarded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Guarded.swift"; sourceTree = ""; }; A4493EE88B00CADF85EF1196 /* PublicIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicIdentity.swift; sourceTree = ""; }; @@ -1029,6 +1034,7 @@ C937320625239F3E10FE8D8E /* PermissionsHandler+Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PermissionsHandler+Location.swift"; sourceTree = ""; }; C9B0C261DCC1ED74DD2BF3EA /* HiddenListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenListener.swift; sourceTree = ""; }; CA65A320EE640CDB878F43E9 /* UIWindow+Landscape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIWindow+Landscape.swift"; sourceTree = ""; }; + CB7C70BFD23FD038393FD6DC /* PageViewMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageViewMessageTests.swift; sourceTree = ""; }; CB8384E2DB0A3627BE1CCB7D /* PurchasingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasingCoordinator.swift; sourceTree = ""; }; CB96F8411D5C857311D7811C /* TransactionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionType.swift; sourceTree = ""; }; CB9C9132109020FA03D1D5C7 /* MockExternalPurchaseControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockExternalPurchaseControllerFactory.swift; sourceTree = ""; }; @@ -2061,6 +2067,7 @@ 75743C9AAFCAB9D91984F19C /* Presentation */ = { isa = PBXGroup; children = ( + A3F306D67A9F3A43D082DD83 /* PresentationIdTests.swift */, 43F99E26CAFD72F228189A4D /* Audience Logic */, B2C3E282003472D3326477C4 /* Internal Presentation */, ); @@ -2965,6 +2972,7 @@ F4AE7105F72063122615EFF1 /* Message Handling */ = { isa = PBXGroup; children = ( + 8E321E7EEC07CA9A8B9A5619 /* PageViewData.swift */, CC653A44D9B40812BDDD94E7 /* PaywallMessage.swift */, BF61DCA9CF170E0AFC507BAE /* PaywallMessageHandler.swift */, 260AE52B33B132D5A5C04911 /* PaywallWebEvent.swift */, @@ -2997,6 +3005,7 @@ FD8F67EFF69ECDBE85EB24F5 /* Message Handling */ = { isa = PBXGroup; children = ( + CB7C70BFD23FD038393FD6DC /* PageViewMessageTests.swift */, F798D9662212AD1CC75666F3 /* PaywallMessageHandlerDelegateMock.swift */, B45A6006E4299E75E91CC2ED /* PaywallMessageHandlerTests.swift */, EC91544C3B7D209A8D51397F /* RawWebMessageHandlerTests.swift */, @@ -3209,6 +3218,7 @@ 58185F7A0770111BDE259936 /* NetworkTests.swift in Sources */, F0013E500B7F2113857F8161 /* NotificationSchedulerTests.swift in Sources */, A191F045B8A9EE2D4A3B757D /* OccurrenceLogicTests.swift in Sources */, + 80A96673A17176DD5EFE1FA5 /* PageViewMessageTests.swift in Sources */, 27E396F717A62BA4E0D98086 /* PaywallCacheLogicTests.swift in Sources */, 2205A0CC8F059B3D6231C603 /* PaywallLogicTests.swift in Sources */, 23CD6038DD65F057C81A412D /* PaywallManagerLogicTests.swift in Sources */, @@ -3225,6 +3235,7 @@ 4A4E5413A8753AFB624D325D /* PermissionTypeTests.swift in Sources */, A3A0961A4A230C10B8896400 /* PopupTransitionTests.swift in Sources */, 3BE562844FD54486450CE6BB /* PresentPaywallOperatorTests.swift in Sources */, + 3652D5EE4C172D623BDEE7E4 /* PresentationIdTests.swift in Sources */, 68FF8D03BAD0F2BE33B9C976 /* ProductPurchaserSK1Tests.swift in Sources */, A44BAE75AAE4713FAE38F992 /* ProductsFetcherSK1.swift in Sources */, 847E0BD4BDA515E47608F6A1 /* ProductsFetcherSK2Tests.swift in Sources */, @@ -3452,6 +3463,7 @@ F75F5E1D503B9391ADD812EC /* PKCS7.swift in Sources */, 56408549E721E2ED524DEA35 /* PaddingListener.swift in Sources */, 2231B31B4B9A25778069B20A /* PaddleProduct.swift in Sources */, + 87B66787F6EB43DA80667C36 /* PageViewData.swift in Sources */, 2D4E15921C454AC9B9C13709 /* PassableValue.swift in Sources */, F605AA51AB24B564D3A21B07 /* Paywall.swift in Sources */, A9F9A35AEC72D17C7C15DAD4 /* PaywallArchiveManager.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift b/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift new file mode 100644 index 000000000..ae1f77b0f --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/Presentation/PresentationIdTests.swift @@ -0,0 +1,309 @@ +// +// PresentationIdTests.swift +// +// Created by Claude on 2026-03-06. +// +// swiftlint:disable all + +import Testing +import Foundation +@testable import SuperwallKit + +struct PresentationIdTests { + // MARK: - Paywall.presentationId + + @Test func paywall_presentationId_isNilByDefault() { + let paywall = Paywall.stub() + #expect(paywall.presentationId == nil) + } + + @Test func paywall_presentationId_canBeSet() { + var paywall = Paywall.stub() + paywall.presentationId = "test-uuid" + #expect(paywall.presentationId == "test-uuid") + } + + @Test func paywall_presentationId_survivesUpdate() { + var paywall = Paywall.stub() + paywall.presentationId = "original-id" + + var newPaywall = Paywall.stub() + newPaywall.presentationId = "new-id" + + paywall.update(from: newPaywall) + #expect(paywall.presentationId == "new-id") + } + + @Test func paywall_presentationId_passedThroughGetInfo() { + var paywall = Paywall.stub() + paywall.presentationId = "test-presentation-id" + + let info = paywall.getInfo(fromPlacement: nil) + #expect(info.presentationId == "test-presentation-id") + } + + @Test func paywall_presentationId_nilWhenNotSet() { + let paywall = Paywall.stub() + let info = paywall.getInfo(fromPlacement: nil) + #expect(info.presentationId == nil) + } + + // MARK: - PaywallInfo.presentationId in placementParams + + @Test func paywallInfo_placementParams_includesPresentationId() async { + var paywall = Paywall.stub() + paywall.presentationId = "my-presentation-id" + let info = paywall.getInfo(fromPlacement: nil) + + let params = await info.placementParams() + #expect(params["presentation_id"] as? String == "my-presentation-id") + } + + @Test func paywallInfo_placementParams_presentationIdIsNilWhenNotSet() async { + let paywall = Paywall.stub() + let info = paywall.getInfo(fromPlacement: nil) + + let params = await info.placementParams() + // presentationId is nil, cast as Any into the dict so the key exists + // but the underlying value is Optional.none + #expect(params["presentation_id"] as? String == nil) + } + + // MARK: - PresentationId consistency across info calls + + @Test func paywallInfo_presentationId_consistentAcrossMultipleInfoCalls() { + var paywall = Paywall.stub() + paywall.presentationId = "stable-id" + + let info1 = paywall.getInfo(fromPlacement: nil) + let info2 = paywall.getInfo(fromPlacement: nil) + let info3 = paywall.getInfo(fromPlacement: nil) + + #expect(info1.presentationId == "stable-id") + #expect(info2.presentationId == "stable-id") + #expect(info3.presentationId == "stable-id") + } + + @Test func paywallInfo_presentationId_doesNotChangeWhenPaywallMutated() { + var paywall = Paywall.stub() + paywall.presentationId = "stable-id" + + let info1 = paywall.getInfo(fromPlacement: nil) + + // Mutate paywall properties that change during lifecycle + paywall.paywalljsVersion = "1.0" + paywall.isFreeTrialAvailable = true + paywall.closeReason = .manualClose + + let info2 = paywall.getInfo(fromPlacement: nil) + + #expect(info1.presentationId == info2.presentationId) + } + + // MARK: - PresentationId uniqueness per presentation + + @Test func presentationId_uniquePerPresentation() { + // Simulates two separate presentations getting different IDs + var paywall1 = Paywall.stub() + paywall1.presentationId = UUID().uuidString + + var paywall2 = Paywall.stub() + paywall2.presentationId = UUID().uuidString + + #expect(paywall1.presentationId != paywall2.presentationId) + } + + // MARK: - Full presentation lifecycle simulation + // + // In the real app, PaywallViewController.info is a computed property: + // var info: PaywallInfo { paywall.getInfo(fromPlacement: ...) } + // It's called fresh at every event point. These tests simulate that. + + @Test func lifecycle_presentationId_stableFromOpenThroughClose() async { + // Simulate: getRawPaywall sets presentationId + var paywall = Paywall.stub() + paywall.presentationId = UUID().uuidString + let presentationId = paywall.presentationId! + + // Simulate: onReady fires, paywalljsVersion is set + paywall.paywalljsVersion = "4.2.0" + let infoAtReady = paywall.getInfo(fromPlacement: nil) + #expect(infoAtReady.presentationId == presentationId) + + // Simulate: webview load complete + paywall.webviewLoadingInfo.endAt = Date() + let infoAtWebviewLoad = paywall.getInfo(fromPlacement: nil) + #expect(infoAtWebviewLoad.presentationId == presentationId) + + // Simulate: paywall_open event + let openParams = await InternalSuperwallEvent.PaywallOpen( + paywallInfo: paywall.getInfo(fromPlacement: nil), + demandScore: nil, + demandTier: nil + ).getSuperwallParameters() + #expect(openParams["presentation_id"] as? String == presentationId) + + // Simulate: page_view events as user navigates + let pageView1Params = await InternalSuperwallEvent.PaywallPageView( + paywallInfo: paywall.getInfo(fromPlacement: nil), + data: PageViewData( + pageNodeId: "page1", flowPosition: 0, pageName: "Welcome", + navigationNodeId: "nav1", + previousPageNodeId: nil, previousFlowPosition: nil, + navigationType: "entry", timeOnPreviousPageMs: nil + ) + ).getSuperwallParameters() + #expect(pageView1Params["presentation_id"] as? String == presentationId) + + let pageView2Params = await InternalSuperwallEvent.PaywallPageView( + paywallInfo: paywall.getInfo(fromPlacement: nil), + data: PageViewData( + pageNodeId: "page2", flowPosition: 1, pageName: "Pricing", + navigationNodeId: "nav2", + previousPageNodeId: "page1", previousFlowPosition: 0, + navigationType: "forward", timeOnPreviousPageMs: 4500 + ) + ).getSuperwallParameters() + #expect(pageView2Params["presentation_id"] as? String == presentationId) + + // Simulate: user goes back + let pageView3Params = await InternalSuperwallEvent.PaywallPageView( + paywallInfo: paywall.getInfo(fromPlacement: nil), + data: PageViewData( + pageNodeId: "page1", flowPosition: 0, pageName: "Welcome", + navigationNodeId: "nav3", + previousPageNodeId: "page2", previousFlowPosition: 1, + navigationType: "back", timeOnPreviousPageMs: 2000 + ) + ).getSuperwallParameters() + #expect(pageView3Params["presentation_id"] as? String == presentationId) + + // Simulate: paywall_decline + let declineParams = await InternalSuperwallEvent.PaywallDecline( + paywallInfo: paywall.getInfo(fromPlacement: nil) + ).getSuperwallParameters() + #expect(declineParams["presentation_id"] as? String == presentationId) + + // Simulate: paywall_close + paywall.closeReason = .manualClose + let closeParams = await InternalSuperwallEvent.PaywallClose( + paywallInfo: paywall.getInfo(fromPlacement: nil), + surveyPresentationResult: .noShow + ).getSuperwallParameters() + #expect(closeParams["presentation_id"] as? String == presentationId) + } + + @Test func lifecycle_cachedVC_getsNewPresentationIdOnRePresentation() async { + // First presentation + var paywall = Paywall.stub() + paywall.presentationId = UUID().uuidString + let firstPresentationId = paywall.presentationId! + + let openParams1 = await InternalSuperwallEvent.PaywallOpen( + paywallInfo: paywall.getInfo(fromPlacement: nil), + demandScore: nil, + demandTier: nil + ).getSuperwallParameters() + #expect(openParams1["presentation_id"] as? String == firstPresentationId) + + // User closes paywall + paywall.closeReason = .manualClose + let closeParams1 = await InternalSuperwallEvent.PaywallClose( + paywallInfo: paywall.getInfo(fromPlacement: nil), + surveyPresentationResult: .noShow + ).getSuperwallParameters() + #expect(closeParams1["presentation_id"] as? String == firstPresentationId) + + // Second presentation: simulate update(from:) with new presentationId + // This is what happens when the cached VC is reused + var newPaywall = Paywall.stub() + newPaywall.presentationId = UUID().uuidString + let secondPresentationId = newPaywall.presentationId! + + paywall.update(from: newPaywall) + + // The paywall now has the NEW presentationId + #expect(paywall.presentationId == secondPresentationId) + #expect(secondPresentationId != firstPresentationId) + + // All events in the second presentation use the new ID + let openParams2 = await InternalSuperwallEvent.PaywallOpen( + paywallInfo: paywall.getInfo(fromPlacement: nil), + demandScore: nil, + demandTier: nil + ).getSuperwallParameters() + #expect(openParams2["presentation_id"] as? String == secondPresentationId) + + let pageViewParams2 = await InternalSuperwallEvent.PaywallPageView( + paywallInfo: paywall.getInfo(fromPlacement: nil), + data: PageViewData( + pageNodeId: "p1", flowPosition: 0, pageName: "Page", + navigationNodeId: "n1", + previousPageNodeId: nil, previousFlowPosition: nil, + navigationType: "entry", timeOnPreviousPageMs: nil + ) + ).getSuperwallParameters() + #expect(pageViewParams2["presentation_id"] as? String == secondPresentationId) + } + + @Test func lifecycle_presentationId_neverLeaksBetweenPresentations() async { + // Simulate 3 consecutive presentations of the same cached paywall + var paywall = Paywall.stub() + + var allPresentationIds: [String] = [] + + for i in 0..<3 { + // Each presentation gets a new ID (simulating getRawPaywall or update(from:)) + var incoming = Paywall.stub() + incoming.presentationId = UUID().uuidString + paywall.update(from: incoming) + + let currentId = paywall.presentationId! + allPresentationIds.append(currentId) + + // Simulate multiple events within this presentation + let openInfo = paywall.getInfo(fromPlacement: nil) + let openParams = await InternalSuperwallEvent.PaywallOpen( + paywallInfo: openInfo, demandScore: nil, demandTier: nil + ).getSuperwallParameters() + + let pageViewInfo = paywall.getInfo(fromPlacement: nil) + let pageViewParams = await InternalSuperwallEvent.PaywallPageView( + paywallInfo: pageViewInfo, + data: PageViewData( + pageNodeId: "p\(i)", flowPosition: 0, pageName: "Page \(i)", + navigationNodeId: "n\(i)", + previousPageNodeId: nil, previousFlowPosition: nil, + navigationType: "entry", timeOnPreviousPageMs: nil + ) + ).getSuperwallParameters() + + paywall.closeReason = .manualClose + let closeInfo = paywall.getInfo(fromPlacement: nil) + let closeParams = await InternalSuperwallEvent.PaywallClose( + paywallInfo: closeInfo, surveyPresentationResult: .noShow + ).getSuperwallParameters() + + // All events within this presentation share the same ID + #expect(openParams["presentation_id"] as? String == currentId) + #expect(pageViewParams["presentation_id"] as? String == currentId) + #expect(closeParams["presentation_id"] as? String == currentId) + } + + // All 3 presentations have different IDs + let uniqueIds = Set(allPresentationIds) + #expect(uniqueIds.count == 3, "Each presentation must have a unique presentationId") + } + + // MARK: - PaywallInfo stubs + + @Test func paywallInfo_stub_hasPresentationIdNil() { + let stub = PaywallInfo.stub() + #expect(stub.presentationId == nil) + } + + @Test func paywallInfo_empty_hasPresentationIdNil() { + let empty = PaywallInfo.empty() + #expect(empty.presentationId == nil) + } +} diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift new file mode 100644 index 000000000..004652644 --- /dev/null +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PageViewMessageTests.swift @@ -0,0 +1,318 @@ +// +// PageViewMessageTests.swift +// +// Created by Claude on 2026-03-06. +// +// swiftlint:disable all + +import Testing +import Foundation +@testable import SuperwallKit + +struct PageViewMessageTests { + private func decodeMessage(_ json: String) throws -> PaywallMessage { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let data = json.data(using: .utf8)! + return try decoder.decode(PaywallMessage.self, from: data) + } + + @Test func decodePageView_allFields() throws { + let json = """ + { + "event_name": "page_view", + "page_node_id": "node_123", + "flow_position": 2, + "page_name": "Pricing", + "navigation_node_id": "nav_456", + "previous_page_node_id": "node_789", + "previous_flow_position": 1, + "type": "forward", + "time_on_previous_page_ms": 5000 + } + """ + + let message = try decodeMessage(json) + + if case .pageView(let data) = message { + #expect(data.pageNodeId == "node_123") + #expect(data.flowPosition == 2) + #expect(data.pageName == "Pricing") + #expect(data.navigationNodeId == "nav_456") + #expect(data.previousPageNodeId == "node_789") + #expect(data.previousFlowPosition == 1) + #expect(data.navigationType == "forward") + #expect(data.timeOnPreviousPageMs == 5000) + } else { + Issue.record("Expected .pageView but got \(message)") + } + } + + @Test func decodePageView_optionalFieldsMissing() throws { + let json = """ + { + "event_name": "page_view", + "page_node_id": "node_first", + "flow_position": 0, + "page_name": "Welcome", + "navigation_node_id": "nav_entry", + "type": "entry" + } + """ + + let message = try decodeMessage(json) + + if case .pageView(let data) = message { + #expect(data.pageNodeId == "node_first") + #expect(data.flowPosition == 0) + #expect(data.pageName == "Welcome") + #expect(data.navigationNodeId == "nav_entry") + #expect(data.previousPageNodeId == nil) + #expect(data.previousFlowPosition == nil) + #expect(data.navigationType == "entry") + #expect(data.timeOnPreviousPageMs == nil) + } else { + Issue.record("Expected .pageView but got \(message)") + } + } + + @Test func decodePageView_backNavigation() throws { + let json = """ + { + "event_name": "page_view", + "page_node_id": "node_100", + "flow_position": 0, + "page_name": "Home", + "navigation_node_id": "nav_back", + "previous_page_node_id": "node_200", + "previous_flow_position": 1, + "type": "back", + "time_on_previous_page_ms": 1200 + } + """ + + let message = try decodeMessage(json) + + if case .pageView(let data) = message { + #expect(data.navigationType == "back") + } else { + Issue.record("Expected .pageView but got \(message)") + } + } + + @Test func decodePageView_autoTransition() throws { + let json = """ + { + "event_name": "page_view", + "page_node_id": "node_auto", + "flow_position": 3, + "page_name": "Auto Page", + "navigation_node_id": "nav_auto", + "type": "auto_transition" + } + """ + + let message = try decodeMessage(json) + + if case .pageView(let data) = message { + #expect(data.navigationType == "auto_transition") + } else { + Issue.record("Expected .pageView but got \(message)") + } + } + + // MARK: - PaywallPageView Event + + private func makePageViewData( + pageNodeId: String = "n", + flowPosition: Int = 0, + pageName: String = "P", + navigationNodeId: String = "nav", + previousPageNodeId: String? = nil, + previousFlowPosition: Int? = nil, + navigationType: String = "entry", + timeOnPreviousPageMs: Int? = nil + ) -> PageViewData { + return PageViewData( + pageNodeId: pageNodeId, + flowPosition: flowPosition, + pageName: pageName, + navigationNodeId: navigationNodeId, + previousPageNodeId: previousPageNodeId, + previousFlowPosition: previousFlowPosition, + navigationType: navigationType, + timeOnPreviousPageMs: timeOnPreviousPageMs + ) + } + + @Test func paywallPageViewEvent_superwallParameters() async { + var paywall = Paywall.stub() + paywall.presentationId = "test-pres-id" + let info = paywall.getInfo(fromPlacement: nil) + + let event = InternalSuperwallEvent.PaywallPageView( + paywallInfo: info, + data: makePageViewData( + pageNodeId: "node_abc", + flowPosition: 1, + pageName: "Pricing", + navigationNodeId: "nav_xyz", + previousPageNodeId: "node_prev", + previousFlowPosition: 0, + navigationType: "forward", + timeOnPreviousPageMs: 3000 + ) + ) + + let params = await event.getSuperwallParameters() + + // Page view specific params + #expect(params["page_node_id"] as? String == "node_abc") + #expect(params["flow_position"] as? Int == 1) + #expect(params["page_name"] as? String == "Pricing") + #expect(params["navigation_node_id"] as? String == "nav_xyz") + #expect(params["navigation_type"] as? String == "forward") + #expect(params["previous_page_node_id"] as? String == "node_prev") + #expect(params["previous_flow_position"] as? Int == 0) + #expect(params["time_on_previous_page_ms"] as? Int == 3000) + + // Inherited from placementParams (presentation_id) + #expect(params["presentation_id"] as? String == "test-pres-id") + + // Standard paywall params should be present too + #expect(params["paywall_identifier"] != nil) + } + + @Test func paywallPageViewEvent_optionalParamsOmitted() async { + let info = PaywallInfo.stub() + + let event = InternalSuperwallEvent.PaywallPageView( + paywallInfo: info, + data: makePageViewData(pageNodeId: "node_first", pageName: "Welcome") + ) + + let params = await event.getSuperwallParameters() + + #expect(params["page_node_id"] as? String == "node_first") + #expect(params["navigation_type"] as? String == "entry") + #expect(params["previous_page_node_id"] == nil) + #expect(params["previous_flow_position"] == nil) + #expect(params["time_on_previous_page_ms"] == nil) + } + + @Test func paywallPageViewEvent_audienceFilterParams() { + let info = PaywallInfo.stub() + + let event = InternalSuperwallEvent.PaywallPageView( + paywallInfo: info, + data: makePageViewData(pageNodeId: "node_abc", pageName: "Page") + ) + + let filterParams = event.audienceFilterParams + // Should contain standard paywall audience filter params + #expect(filterParams["paywall_id"] != nil) + #expect(filterParams["paywall_name"] != nil) + } + + @Test func paywallPageViewEvent_superwallEventCase() { + let info = PaywallInfo.stub() + + let event = InternalSuperwallEvent.PaywallPageView( + paywallInfo: info, + data: makePageViewData() + ) + + if case .paywallPageView(let eventInfo, let data) = event.superwallEvent { + #expect(eventInfo === info) + #expect(data.pageNodeId == "n") + #expect(data.flowPosition == 0) + #expect(data.pageName == "P") + #expect(data.navigationType == "entry") + } else { + Issue.record("Expected .paywallPageView") + } + } + + @Test func paywallPageViewEvent_description() { + let info = PaywallInfo.stub() + + let event = InternalSuperwallEvent.PaywallPageView( + paywallInfo: info, + data: makePageViewData() + ) + + #expect(event.superwallEvent.description == "paywall_page_view") + } + + // MARK: - PresentationId in PageView events + + @Test func paywallPageView_presentationId_matchesPaywallOpen() async { + var paywall = Paywall.stub() + paywall.presentationId = "shared-pres-id" + let info = paywall.getInfo(fromPlacement: nil) + + let openEvent = InternalSuperwallEvent.PaywallOpen( + paywallInfo: info, + demandScore: nil, + demandTier: nil + ) + let openParams = await openEvent.getSuperwallParameters() + + let pageViewEvent = InternalSuperwallEvent.PaywallPageView( + paywallInfo: info, + data: makePageViewData(pageNodeId: "node1", pageName: "Page") + ) + let pageViewParams = await pageViewEvent.getSuperwallParameters() + + let openPresentationId = openParams["presentation_id"] as? String + let pageViewPresentationId = pageViewParams["presentation_id"] as? String + + #expect(openPresentationId == "shared-pres-id") + #expect(pageViewPresentationId == "shared-pres-id") + #expect(openPresentationId == pageViewPresentationId) + } + + @Test func presentationId_consistentAcrossAllEventTypes() async { + var paywall = Paywall.stub() + paywall.presentationId = "lifecycle-id" + let info = paywall.getInfo(fromPlacement: nil) + + let openParams = await InternalSuperwallEvent.PaywallOpen( + paywallInfo: info, + demandScore: nil, + demandTier: nil + ).getSuperwallParameters() + + let pageViewParams = await InternalSuperwallEvent.PaywallPageView( + paywallInfo: info, + data: makePageViewData() + ).getSuperwallParameters() + + let closeParams = await InternalSuperwallEvent.PaywallClose( + paywallInfo: info, + surveyPresentationResult: .noShow + ).getSuperwallParameters() + + let declineParams = await InternalSuperwallEvent.PaywallDecline( + paywallInfo: info + ).getSuperwallParameters() + + let webviewLoadParams = await InternalSuperwallEvent.PaywallWebviewLoad( + state: .complete, + paywallInfo: info + ).getSuperwallParameters() + + let allPresentationIds = [ + openParams["presentation_id"] as? String, + pageViewParams["presentation_id"] as? String, + closeParams["presentation_id"] as? String, + declineParams["presentation_id"] as? String, + webviewLoadParams["presentation_id"] as? String, + ] + + // All should be the same non-nil value + for id in allPresentationIds { + #expect(id == "lifecycle-id", "All events in a presentation must share the same presentationId") + } + } +} diff --git a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift index 1a38968a4..0a9ffcf43 100644 --- a/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift +++ b/Tests/SuperwallKitTests/Web/WebEntitlementRedeemerTests.swift @@ -2497,13 +2497,13 @@ struct WebEntitlementRedeemerTests { superwall: superwall ) - // Let the init task settle, then reset counters - try? await Task.sleep(nanoseconds: 50_000_000) - mockNetwork.pollRedemptionResultCallCount = 0 + // Let the init task settle + try? await Task.sleep(nanoseconds: 200_000_000) + let countBeforeCall = mockNetwork.pollRedemptionResultCallCount let result = await redeemer.pollOrWaitForActiveStripePoll() #expect(result == false) - #expect(mockNetwork.pollRedemptionResultCallCount == 0) + #expect(mockNetwork.pollRedemptionResultCallCount == countBeforeCall) } @Test("pollOrWaitForActiveStripePoll starts own poll when no active poll")