diff --git a/CHANGELOG.md b/CHANGELOG.md index 512d6946db..5ac69a530b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup - Fixes network requests that can never succeed, such as those with an invalid API key, taking up to a minute to fail instead of failing straight away. Timeouts and server errors still retry as before. - Fixes failed network requests being reported as a decoding error rather than the HTTP error that actually occurred. +- Fixes issue where the paywall debugger wouldn't work for accounts with many paywalls. ## 4.16.1 diff --git a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift index 6c1bf36d07..c39e7ab207 100644 --- a/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift +++ b/Sources/SuperwallKit/Config/Options/SuperwallOptions.swift @@ -216,6 +216,24 @@ public final class SuperwallOptions: NSObject, Encodable { } } + /// Host for the Superwall V2 API (the `apps/api` Cloudflare Worker), whose + /// routes live under a `/v2/` path. + /// + /// This is a DIFFERENT host from ``baseHost`` (the legacy v1 API on + /// `api.superwall.me`): the V2 API is served from the `superwall.com` + /// domain — `api.superwall.com` in production and `api.superwall.dev` in the + /// developer/staging environment. + var apiV2Host: String { + switch self { + case .developer: + return "api.superwall.dev" + case .local: + return "localhost:3001" + default: + return "api.superwall.com" + } + } + /// The base URL for the Superwall dashboard. var dashboardBaseUrl: String { switch self { diff --git a/Sources/SuperwallKit/Debug/DebugViewController.swift b/Sources/SuperwallKit/Debug/DebugViewController.swift index 37a7e31a62..e02936f7a0 100644 --- a/Sources/SuperwallKit/Debug/DebugViewController.swift +++ b/Sources/SuperwallKit/Debug/DebugViewController.swift @@ -118,7 +118,11 @@ final class DebugViewController: UIViewController { var paywallDatabaseId: String? var paywallIdentifier: String? var paywall: Paywall? - var paywalls: [Paywall] = [] + /// Backs the "Your Paywalls" picker. + /// + /// Populated from `GET /v2/paywalls/preview-list`. Empty when the request fails or the app + /// has a single paywall, in which case the picker declines to open. + var previewPaywalls: [PaywallSummary] = [] var previewViewContent: UIView? private var cancellable: AnyCancellable? private var initialLocaleIdentifier: String? @@ -156,6 +160,7 @@ final class DebugViewController: UIViewController { initialLocaleIdentifier = Superwall.shared.options.localeIdentifier addSubviews() Task { await loadPreview() } + Task { await loadPreviewPaywalls() } } private func addSubviews() { @@ -204,32 +209,30 @@ final class DebugViewController: UIViewController { func loadPreview() async { activityIndicator.startAnimating() previewViewContent?.removeFromSuperview() + await finishLoadingPreview() + } + + func finishLoadingPreview() async { + var paywallId: String? - if paywalls.isEmpty { + if let paywallIdentifier = paywallIdentifier { + paywallId = paywallIdentifier + } else if let paywallDatabaseId = paywallDatabaseId { + // Resolve the numeric database id from the deep link to the paywall's + // identifier (slug) with a single lookup. do { - paywalls = try await network.getPaywalls() - await finishLoadingPreview() + let resolution = try await network.resolvePaywallIdentifier(forDatabaseId: paywallDatabaseId) + paywallId = resolution.identifier + paywallIdentifier = resolution.identifier } catch { Logger.debug( logLevel: .error, scope: .debugViewController, - message: "Failed to Fetch Paywalls", + message: "Failed to Resolve Paywall", error: error ) + return } - } else { - await finishLoadingPreview() - } - } - - func finishLoadingPreview() async { - var paywallId: String? - - if let paywallIdentifier = paywallIdentifier { - paywallId = paywallIdentifier - } else if let paywallDatabaseId = paywallDatabaseId { - paywallId = paywalls.first { $0.databaseId == paywallDatabaseId }?.identifier - paywallIdentifier = paywallId } else { return } @@ -310,20 +313,49 @@ final class DebugViewController: UIViewController { } } - @objc func pressedPreview() { - guard let id = paywallDatabaseId else { return } + /// Populates the "Your Paywalls" picker from the application in the debugger's + /// preview token. + /// + /// Kicked off from `viewDidLoad` alongside — not after — the preview load, so + /// the picker is available even when the requested paywall fails to render. + /// Best-effort: a failure leaves `previewPaywalls` empty and `pressedPreview` + /// declines to open, which is the behaviour before this was restored. + private func loadPreviewPaywalls() async { + do { + let list = try await network.listPreviewPaywalls() + previewPaywalls = list.data + } catch { + Logger.debug( + logLevel: .warn, + scope: .debugViewController, + message: "Failed to Load Paywall Picker", + info: nil, + error: error + ) + } + } - let options: [AlertOption] = paywalls.map { paywall in + @objc func pressedPreview() { + // Open whenever there is something to switch *to*. That covers an empty list + // (the request failed) and a single-entry list whose one paywall is already + // on screen, without gating on `paywallDatabaseId` — which is nil when the + // deep link carried no `paywall_id` and nothing rendered. That is precisely + // when the picker is most useful, so it must not be inert then. + guard previewPaywalls.contains(where: { $0.id != paywallDatabaseId }) else { return } + + let options: [AlertOption] = previewPaywalls.map { paywall in var name = paywall.name - if id == paywall.databaseId { + // Optional comparison: with no paywall on screen nothing is marked, which + // is correct rather than a case to guard against. + if paywall.id == paywallDatabaseId { name = "\(name) ✓" } let alert = AlertOption( title: name, action: { [weak self] in - self?.paywallDatabaseId = paywall.databaseId + self?.paywallDatabaseId = paywall.id self?.paywallIdentifier = paywall.identifier Task { await self?.loadPreview() } }, diff --git a/Sources/SuperwallKit/Debug/SWBounceButton.swift b/Sources/SuperwallKit/Debug/SWBounceButton.swift index 217d14217f..658a8120dc 100644 --- a/Sources/SuperwallKit/Debug/SWBounceButton.swift +++ b/Sources/SuperwallKit/Debug/SWBounceButton.swift @@ -27,8 +27,17 @@ final class SWBounceButton: UIButton { self.activityIndicator.startAnimating() self.isEnabled = false } else { - self.setTitle(oldTitle, for: .normal) - self.oldTitle = "" + // `didSet` fires on every assignment, not just on a change, so + // `showLoading = false` can arrive when loading was never started (or + // was already ended). `oldTitle` is empty in that case, and restoring + // it unconditionally would blank a perfectly good title — e.g. the + // debugger's "Preview" button vanishes when a second terminal state + // (skipped, then presentationError) lands on an already-idle button. + // Only restore when there is a saved title to restore. + if !oldTitle.isEmpty { + self.setTitle(oldTitle, for: .normal) + self.oldTitle = "" + } self.activityIndicator.stopAnimating() self.isEnabled = true } diff --git a/Sources/SuperwallKit/Models/Paywall/Paywall.swift b/Sources/SuperwallKit/Models/Paywall/Paywall.swift index 4b943d5221..2082680480 100644 --- a/Sources/SuperwallKit/Models/Paywall/Paywall.swift +++ b/Sources/SuperwallKit/Models/Paywall/Paywall.swift @@ -8,10 +8,6 @@ import UIKit -struct Paywalls: Decodable { - var paywalls: [Paywall] -} - struct Paywall: Codable { /// The id of the paywall in the database. var databaseId: String diff --git a/Sources/SuperwallKit/Models/Paywall/PaywallPreviewList.swift b/Sources/SuperwallKit/Models/Paywall/PaywallPreviewList.swift new file mode 100644 index 0000000000..3eb9c12b4a --- /dev/null +++ b/Sources/SuperwallKit/Models/Paywall/PaywallPreviewList.swift @@ -0,0 +1,14 @@ +// +// PaywallPreviewList.swift +// SuperwallKit +// +// Created by Yusuf Tör on 30/07/2026. +// + +import Foundation + +/// The response from `GET /v2/paywalls/preview-list`. +struct PaywallPreviewList: Decodable { + /// The paywalls available to preview, capped server-side. + let data: [PaywallSummary] +} diff --git a/Sources/SuperwallKit/Models/Paywall/PaywallSummary.swift b/Sources/SuperwallKit/Models/Paywall/PaywallSummary.swift new file mode 100644 index 0000000000..75bf274386 --- /dev/null +++ b/Sources/SuperwallKit/Models/Paywall/PaywallSummary.swift @@ -0,0 +1,21 @@ +// +// PaywallSummary.swift +// SuperwallKit +// +// Created by Yusuf Tör on 30/07/2026. +// + +import Foundation + +/// The minimal paywall metadata the debug/preview flow needs: enough to fetch +/// and label a paywall. +struct PaywallSummary: Decodable { + /// The id of the paywall in the database. + let id: String + + /// The identifier (slug) of the paywall, used to fetch the full paywall. + let identifier: String + + /// The display name of the paywall. + let name: String +} diff --git a/Sources/SuperwallKit/Network/API.swift b/Sources/SuperwallKit/Network/API.swift index d84a8cfb99..d8a45fd318 100644 --- a/Sources/SuperwallKit/Network/API.swift +++ b/Sources/SuperwallKit/Network/API.swift @@ -13,6 +13,7 @@ enum EndpointHost { case enrichment case adServices case subscriptionsApi + case paywallsV2 case mmp } @@ -35,6 +36,7 @@ struct Api { let enrichment: Enrichment let adServices: AdServices let subscriptionsApi: SubscriptionsAPI + let paywallsV2: PaywallsV2 let mmp: MMP init(networkEnvironment: SuperwallOptions.NetworkEnvironment) { @@ -43,6 +45,7 @@ struct Api { enrichment = Enrichment(networkEnvironment: networkEnvironment) adServices = AdServices(networkEnvironment: networkEnvironment) subscriptionsApi = SubscriptionsAPI(networkEnvironment: networkEnvironment) + paywallsV2 = PaywallsV2(networkEnvironment: networkEnvironment) mmp = MMP(networkEnvironment: networkEnvironment) } @@ -58,6 +61,8 @@ struct Api { return adServices case .subscriptionsApi: return subscriptionsApi + case .paywallsV2: + return paywallsV2 case .mmp: return mmp } @@ -115,6 +120,19 @@ struct Api { } } + /// The Superwall V2 API, served under a `/v2/` path on `api.superwall.com` + /// (production) / `api.superwall.dev` (developer). See + /// `NetworkEnvironment.apiV2Host`. + struct PaywallsV2: ApiHostConfig { + let networkEnvironment: SuperwallOptions.NetworkEnvironment + var host: String { return networkEnvironment.apiV2Host } + var path: String { return "/v2/" } + + init(networkEnvironment: SuperwallOptions.NetworkEnvironment) { + self.networkEnvironment = networkEnvironment + } + } + struct MMP: ApiHostConfig { let networkEnvironment: SuperwallOptions.NetworkEnvironment var host: String { return networkEnvironment.mmpHost } diff --git a/Sources/SuperwallKit/Network/Endpoint.swift b/Sources/SuperwallKit/Network/Endpoint.swift index bf4c600c93..aa04da4a05 100644 --- a/Sources/SuperwallKit/Network/Endpoint.swift +++ b/Sources/SuperwallKit/Network/Endpoint.swift @@ -207,15 +207,45 @@ extension Endpoint where } } -// MARK: - PaywallsResponse +// MARK: - PaywallSummary extension Endpoint where Kind == EndpointKinds.Superwall, - Response == Paywalls { - static func paywalls() -> Self { + Response == PaywallSummary { + /// Resolves a numeric paywall database id to its identifier (slug). + /// + /// Used by the debug/preview flow so it no longer has to fetch every paywall + /// for the app just to translate a deep-link `paywall_id` into an identifier. + static func resolvePaywall( + byDatabaseId databaseId: String, + retryCount: Int + ) -> Self { return Endpoint( + retryCount: retryCount, components: Components( - host: .base, - path: "paywalls" + host: .paywallsV2, + path: "paywalls/resolve", + queryItems: [URLQueryItem(name: "id", value: databaseId)] + ), + method: .get + ) + } +} + +// MARK: - PaywallPreviewList +extension Endpoint where + Kind == EndpointKinds.Superwall, + Response == PaywallPreviewList { + /// Lists the paywalls available to preview for the application in the + /// debugger's signed preview token. + /// + /// Backs the debugger's "Your Paywalls" picker. Returns id/identifier/name + /// only. + static func listPreviewPaywalls(retryCount: Int) -> Self { + return Endpoint( + retryCount: retryCount, + components: Components( + host: .paywallsV2, + path: "paywalls/preview-list" ), method: .get ) diff --git a/Sources/SuperwallKit/Network/Network.swift b/Sources/SuperwallKit/Network/Network.swift index cc11ddd8f4..0e56cefe5d 100644 --- a/Sources/SuperwallKit/Network/Network.swift +++ b/Sources/SuperwallKit/Network/Network.swift @@ -122,21 +122,54 @@ class Network { } } - func getPaywalls() async throws -> [Paywall] { + /// Resolves a numeric paywall database id to its identifier (slug) so the + /// debug/preview flow can fetch a single paywall instead of all of them. + func resolvePaywallIdentifier( + forDatabaseId databaseId: String, + retryCount: Int = 6 + ) async throws -> PaywallSummary { do { - let response = try await urlSession.request( - .paywalls(), + return try await urlSession.request( + .resolvePaywall( + byDatabaseId: databaseId, + retryCount: retryCount + ), + data: SuperwallRequestData( + factory: factory, + isForDebugging: true + ) + ) + } catch { + Logger.debug( + logLevel: .error, + scope: .network, + message: "Request Failed: /v2/paywalls/resolve", + error: error + ) + throw error + } + } + + /// Lists the paywalls available to preview for the application in the + /// debugger's signed preview token, backing the debugger's paywall picker. + /// + /// Retries less than the resolver: this only populates an optional picker, so + /// a failure should degrade to "no alternatives to offer" quickly rather than + /// hold the debugger up. + func listPreviewPaywalls(retryCount: Int = 2) async throws -> PaywallPreviewList { + do { + return try await urlSession.request( + .listPreviewPaywalls(retryCount: retryCount), data: SuperwallRequestData( factory: factory, isForDebugging: true ) ) - return response.paywalls } catch { Logger.debug( logLevel: .error, scope: .network, - message: "Request Failed: /paywalls", + message: "Request Failed: /v2/paywalls/preview-list", error: error ) throw error diff --git a/SuperwallKit.xcodeproj/project.pbxproj b/SuperwallKit.xcodeproj/project.pbxproj index 04f8a8f561..d395f3ccee 100644 --- a/SuperwallKit.xcodeproj/project.pbxproj +++ b/SuperwallKit.xcodeproj/project.pbxproj @@ -491,6 +491,7 @@ DCE85B4A9DBD672B658F6EB3 /* MockSKPaymentTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B1A6ADFFB9FA982BF69C134 /* MockSKPaymentTransaction.swift */; }; DE2F41FF9D70AB13AD246E49 /* VariantOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 194B8214C0A66407CEDCC0F4 /* VariantOption.swift */; }; DE62F8E261EC7C60FBAAAE1D /* BundleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34468E3988E779132CE101A /* BundleHelper.swift */; }; + DEA127FF77AC2E0645E5D4A8 /* PaywallPreviewList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 570D9BC01DCF5BA4DB7F95AA /* PaywallPreviewList.swift */; }; DEB255607E961730B4A5761B /* UIDevice+ModelName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 750CC308DD48F4CE615DFC89 /* UIDevice+ModelName.swift */; }; DEBEE747AFE84B9F7D1ABB52 /* StoreProductAdapterObjc.swift in Sources */ = {isa = PBXBuildFile; fileRef = D054D402A5F820BB3D18D8AC /* StoreProductAdapterObjc.swift */; }; DEF83BEF1ED06921BD55F55A /* EvaluationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A07DC8EA2AB02423AEE5DF26 /* EvaluationContext.swift */; }; @@ -531,6 +532,7 @@ ED1C693657DA7FCBAE2DDDC6 /* FeatureGatingBehaviour.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B8D7F537204EAB13BB7F10 /* FeatureGatingBehaviour.swift */; }; ED246150DA2747AA42D6009C /* PermissionStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988E0E3F8D992744C9AC196F /* PermissionStatusTests.swift */; }; ED575DD46B84EE351972AC6B /* AdServicesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B4279B992779CAD5A0694A /* AdServicesResponse.swift */; }; + ED66539CDB2C991A812A6CC7 /* PaywallSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12EB4944354482783293010 /* PaywallSummary.swift */; }; EDAEC46845C1DB11CB4C99AE /* SWConsoleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDFBF0FA8B313E0D84A51DB /* SWConsoleViewController.swift */; }; EE5646D09161237C649731F4 /* SWWebViewLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2AC9214EA750436EF1FE11 /* SWWebViewLogicTests.swift */; }; F0013E500B7F2113857F8161 /* NotificationSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65F4CF06DE50031C329ED96F /* NotificationSchedulerTests.swift */; }; @@ -771,6 +773,7 @@ 544C032167E8198513B2A860 /* PresentationValueObjc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationValueObjc.swift; sourceTree = ""; }; 5664E123CF093BFF557741E1 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Localizable.strings; sourceTree = ""; }; 56B6A546564CE1CB37BDC6A0 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; + 570D9BC01DCF5BA4DB7F95AA /* PaywallPreviewList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallPreviewList.swift; sourceTree = ""; }; 571825E7515FCC1E877D4429 /* Dictionary+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+Cache.swift"; sourceTree = ""; }; 57478172574516BD5EDD254A /* LoadingInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingInfo.swift; sourceTree = ""; }; 577A16EE2161E2CDEDFA48C0 /* GameControllerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameControllerManager.swift; sourceTree = ""; }; @@ -957,6 +960,7 @@ A08CC3D275A02927073952EB /* ReceiptManagerTrialEligibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiptManagerTrialEligibilityTests.swift; sourceTree = ""; }; A0B4279B992779CAD5A0694A /* AdServicesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdServicesResponse.swift; sourceTree = ""; }; A116633D7246EB7BB7AF7229 /* ExpressionEvaluatorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionEvaluatorMock.swift; sourceTree = ""; }; + A12EB4944354482783293010 /* PaywallSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallSummary.swift; sourceTree = ""; }; A154A9E99D00B9BD8837B798 /* ExpressionLogic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpressionLogic.swift; sourceTree = ""; }; A21ED2D8CB4DDA70E228E8BC /* TaskExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskExecutor.swift; sourceTree = ""; }; A22E703895B07CF172665846 /* PaywallLoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallLoadingState.swift; sourceTree = ""; }; @@ -1225,8 +1229,10 @@ 82E6981E6A6574EE72B65A9E /* Paywall.swift */, C65CEB049E29538C699F6EF8 /* PaywallPresentationInfo.swift */, 153C660FB51D0D1DFE56D462 /* PaywallPresentationStyle.swift */, + 570D9BC01DCF5BA4DB7F95AA /* PaywallPreviewList.swift */, 0C95DABA23C6CBEF0AAA63C0 /* PaywallProducts.swift */, B6F71D7A7DC8FFB72CA13296 /* PaywallRequestBody.swift */, + A12EB4944354482783293010 /* PaywallSummary.swift */, 22439CFFFC5166F34D0DA524 /* WebViewURLConfig.swift */, 10B0008D7CD157DA0F7B6D54 /* Archive */, ); @@ -3582,12 +3588,14 @@ 953BB5825DA956E4BBE841B9 /* PaywallPresentationInfo.swift in Sources */, 1BA9EC022F0016E72A5EB01A /* PaywallPresentationRequestStatus.swift in Sources */, A2DC9FA3045DF056BC867D8B /* PaywallPresentationStyle.swift in Sources */, + DEA127FF77AC2E0645E5D4A8 /* PaywallPreviewList.swift in Sources */, CF3683E2AD703237EC0CE22E /* PaywallProducts.swift in Sources */, 8EC4001F5273FB1260618E84 /* PaywallRequest.swift in Sources */, D461F38D019122194A9CB38C /* PaywallRequestBody.swift in Sources */, BFBE808BCA151FAE95AE3276 /* PaywallRequestManager.swift in Sources */, 62F714EC324D4BFCB3DF1CA9 /* PaywallSkippedReason.swift in Sources */, 00BC6BCF58320BFAC95E7B5F /* PaywallState.swift in Sources */, + ED66539CDB2C991A812A6CC7 /* PaywallSummary.swift in Sources */, C8671043E6585DE19AAD1DAD /* PaywallView.swift in Sources */, 00A15C51FC3B450A7B0F8806 /* PaywallViewController.swift in Sources */, D56F32CB484F74EC7E49E581 /* PaywallViewControllerCache.swift in Sources */, diff --git a/Tests/SuperwallKitTests/Network/NetworkTests.swift b/Tests/SuperwallKitTests/Network/NetworkTests.swift index ac9f1fd86f..97c365b41a 100644 --- a/Tests/SuperwallKitTests/Network/NetworkTests.swift +++ b/Tests/SuperwallKitTests/Network/NetworkTests.swift @@ -179,4 +179,108 @@ struct NetworkTests { #expect(bodyJson["deviceId"] as? String == "device_123") #expect(bodyJson["appUserId"] as? String == "user_123") } + + @Test func resolvePaywall_endpointBuildsRequest() async throws { + let dependencyContainer = DependencyContainer() + dependencyContainer.storage.debugKey = "sat_test" + let endpoint = Endpoint.resolvePaywall( + byDatabaseId: "123", + retryCount: 6 + ) + + let urlRequest = await endpoint.makeRequest( + with: SuperwallRequestData(factory: dependencyContainer, isForDebugging: true), + factory: dependencyContainer + ) + + #expect(urlRequest?.httpMethod == "GET") + + let urlString = try #require(urlRequest?.url?.absoluteString) + // Host and path asserted together: matching the path alone passes whichever + // host the endpoint resolved to, which is how the V2 resolver shipped + // pointing at the v1 `baseHost` (fixed in b073dda). + #expect(urlString.contains("api.superwall.com/v2/paywalls/resolve")) + #expect(urlString.contains("id=123")) + + // The resolver authenticates with the debugger's signed preview token + // (isForDebugging: true), so the Authorization header carries the + // `sat_` debug key as a bearer token, not the app's public key. + #expect(urlRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer sat_test") + } + + @Test func listPreviewPaywalls_endpointBuildsRequest() async throws { + let dependencyContainer = DependencyContainer() + dependencyContainer.storage.debugKey = "sat_test" + let endpoint = Endpoint.listPreviewPaywalls( + retryCount: 2 + ) + + let urlRequest = await endpoint.makeRequest( + with: SuperwallRequestData(factory: dependencyContainer, isForDebugging: true), + factory: dependencyContainer + ) + + #expect(urlRequest?.httpMethod == "GET") + + let urlString = try #require(urlRequest?.url?.absoluteString) + // Host included for the same reason as the resolver's test: the path alone + // would pass against the v1 `baseHost`. + #expect(urlString.contains("api.superwall.com/v2/paywalls/preview-list")) + + // The application comes from the token's scope server-side, so the picker + // must not be sending an id or application_id of its own. + #expect(urlString.contains("?") == false) + + // Same auth as the resolver: the debugger's `sat_` preview token as a + // bearer, not the app's public key. + #expect(urlRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer sat_test") + } + + @Test func paywallPreviewList_decodesIgnoringUndeclaredFields() throws { + // `object`, `has_more` and `application_id` are returned by the endpoint but + // deliberately not declared on the model. Decoding must ignore them rather + // than throw, so a change to those fields can never empty the picker. + let json = """ + { + "object": "list", + "has_more": false, + "application_id": "2889", + "data": [ + { "id": "178725", "identifier": "some-slug", "name": "Some Paywall" } + ] + } + """.data(using: .utf8)! + + let list = try JSONDecoder.fromSnakeCase.decode(PaywallPreviewList.self, from: json) + + #expect(list.data.count == 1) + #expect(list.data.first?.id == "178725") + #expect(list.data.first?.identifier == "some-slug") + #expect(list.data.first?.name == "Some Paywall") + } + + @Test func paywallPreviewList_decodesWhenUndeclaredFieldsAbsent() throws { + // The inverse: `data` alone must decode, so the picker survives the endpoint + // dropping fields the SDK never reads. + let json = """ + { "data": [{ "id": "1", "identifier": "s", "name": "N" }] } + """.data(using: .utf8)! + + let list = try JSONDecoder.fromSnakeCase.decode(PaywallPreviewList.self, from: json) + + #expect(list.data.count == 1) + } + + @Test func paywallPreviewList_decodesEmptyList() throws { + // An app with no previewable paywalls returns an empty `data`. That must + // decode cleanly — `pressedPreview` then declines to open the picker rather + // than the request being treated as a failure. + let json = """ + { "object": "list", "has_more": false, "data": [] } + """.data(using: .utf8)! + + let list = try JSONDecoder.fromSnakeCase.decode(PaywallPreviewList.self, from: json) + + #expect(list.data.isEmpty) + } }