From e8654e1a51bd20fa8fa234a4155a9aec37411ed1 Mon Sep 17 00:00:00 2001 From: Pete Smith <5278441+aataraxiaa@users.noreply.github.com> Date: Wed, 11 Dec 2024 15:32:40 +0000 Subject: [PATCH 1/6] Privacy Pro Free Trials - Models and API (#1120) Task/Issue URL: https://app.asana.com/0/1208114992212396/1208796999534221/f iOS PR: https://github.com/duckduckgo/iOS/pull/3691 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3641 What kind of version bump will this require?: Minor **Optional**: Tech Design URL: https://app.asana.com/0/481882893211075/1208773437150501/f **Description**: This PR includes the following: * Adds a new model type for Subscription Offers * Adds a new API to `StorePurchaseManager` to retrieve Free Trial Subscriptions * Updates existing `subscriptionOptions` API to NOT return Free Trial Subscriptions * Adds abstractions on StoreKit types to enable testing * Adds tests (**Note: Make sure to expand the `StorePurchaseManagerTests` file when reviewing**) --- .../Flows/Models/SubscriptionOptions.swift | 21 + .../ProductFetching.swift | 45 ++ .../StorePurchaseManager.swift | 112 ++-- .../SubscriptionProduct.swift | 101 ++++ ...SubscriptionProductIntroductoryOffer.swift | 67 +++ .../StoreSubscriptionConfiguration.swift | 4 +- .../Managers/StorePurchaseManagerMock.swift | 6 + .../SubscriptionFeatureMappingCacheMock.swift | 5 + .../Models/SubscriptionOptionsTests.swift | 24 +- .../Managers/StorePurchaseManagerTests.swift | 542 ++++++++++++++++++ 10 files changed, 885 insertions(+), 42 deletions(-) create mode 100644 Sources/Subscription/Managers/StorePurchaseManager/ProductFetching.swift rename Sources/Subscription/Managers/{ => StorePurchaseManager}/StorePurchaseManager.swift (84%) create mode 100644 Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProduct.swift create mode 100644 Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProductIntroductoryOffer.swift create mode 100644 Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift diff --git a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift index 5544cf680..2c69e31f6 100644 --- a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift +++ b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift @@ -50,6 +50,13 @@ public enum SubscriptionPlatformName: String, Encodable { public struct SubscriptionOption: Encodable, Equatable { let id: String let cost: SubscriptionOptionCost + let offer: SubscriptionOptionOffer? + + init(id: String, cost: SubscriptionOptionCost, offer: SubscriptionOptionOffer? = nil) { + self.id = id + self.cost = cost + self.offer = offer + } } struct SubscriptionOptionCost: Encodable, Equatable { @@ -60,3 +67,17 @@ struct SubscriptionOptionCost: Encodable, Equatable { public struct SubscriptionFeature: Encodable, Equatable { let name: Entitlement.ProductName } + +/// A `SubscriptionOptionOffer` represents an offer (e.g Free Trials) associated with a Subscription +public struct SubscriptionOptionOffer: Encodable, Equatable { + + public enum OfferType: String, Codable, CaseIterable { + case freeTrial + } + + let type: OfferType + let id: String + let displayPrice: String + let durationInDays: Int + let isUserEligible: Bool +} diff --git a/Sources/Subscription/Managers/StorePurchaseManager/ProductFetching.swift b/Sources/Subscription/Managers/StorePurchaseManager/ProductFetching.swift new file mode 100644 index 000000000..485bd8a6a --- /dev/null +++ b/Sources/Subscription/Managers/StorePurchaseManager/ProductFetching.swift @@ -0,0 +1,45 @@ +// +// ProductFetching.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import StoreKit + +/// A protocol for types that can fetch subscription products. +@available(macOS 12.0, iOS 15.0, *) +public protocol ProductFetching { + /// Fetches products for the specified identifiers. + /// - Parameter identifiers: An array of product identifiers to fetch. + /// - Returns: An array of subscription products. + /// - Throws: An error if the fetch operation fails. + func products(for identifiers: [String]) async throws -> [any SubscriptionProduct] +} + +/// A default implementation of ProductFetching that uses StoreKit's standard product fetching. +@available(macOS 12.0, iOS 15.0, *) +public final class DefaultProductFetcher: ProductFetching { + /// Initializes a new DefaultProductFetcher instance. + public init() {} + + /// Fetches products using StoreKit's Product.products API. + /// - Parameter identifiers: An array of product identifiers to fetch. + /// - Returns: An array of subscription products. + /// - Throws: An error if the fetch operation fails. + public func products(for identifiers: [String]) async throws -> [any SubscriptionProduct] { + return try await Product.products(for: identifiers) + } +} diff --git a/Sources/Subscription/Managers/StorePurchaseManager.swift b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift similarity index 84% rename from Sources/Subscription/Managers/StorePurchaseManager.swift rename to Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift index 47791eb22..b23f24548 100644 --- a/Sources/Subscription/Managers/StorePurchaseManager.swift +++ b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift @@ -37,7 +37,16 @@ public enum StorePurchaseManagerError: Error { public protocol StorePurchaseManager { typealias TransactionJWS = String + /// Returns the available subscription options that DON'T include Free Trial periods. + /// - Returns: A `SubscriptionOptions` object containing the available subscription plans and pricing, + /// or `nil` if no options are available or cannot be fetched. func subscriptionOptions() async -> SubscriptionOptions? + + /// Returns the subscription options that include Free Trial periods. + /// - Returns: A `SubscriptionOptions` object containing subscription plans with free trial offers, + /// or `nil` if no free trial options are available or the user is not eligible. + func freeTrialSubscriptionOptions() async -> SubscriptionOptions? + var purchasedProductIDs: [String] { get } var purchaseQueue: [String] { get } var areProductsAvailable: Bool { get } @@ -61,7 +70,7 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM private let subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache private let subscriptionFeatureFlagger: FeatureFlaggerMapping? - @Published public private(set) var availableProducts: [Product] = [] + @Published public private(set) var availableProducts: [any SubscriptionProduct] = [] @Published public private(set) var purchasedProductIDs: [String] = [] @Published public private(set) var purchaseQueue: [String] = [] @@ -70,11 +79,15 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM private var transactionUpdates: Task? private var storefrontChanges: Task? + private var productFetcher: ProductFetching + public init(subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache, - subscriptionFeatureFlagger: FeatureFlaggerMapping? = nil) { + subscriptionFeatureFlagger: FeatureFlaggerMapping? = nil, + productFetcher: ProductFetching = DefaultProductFetcher()) { self.storeSubscriptionConfiguration = DefaultStoreSubscriptionConfiguration() self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache self.subscriptionFeatureFlagger = subscriptionFeatureFlagger + self.productFetcher = productFetcher transactionUpdates = observeTransactionUpdates() storefrontChanges = observeStorefrontChanges() } @@ -104,40 +117,13 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM } public func subscriptionOptions() async -> SubscriptionOptions? { - Logger.subscription.info("[AppStorePurchaseFlow] subscriptionOptions") - let products = availableProducts - let monthly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .month && $0.subscription?.subscriptionPeriod.value == 1 }) - let yearly = products.first(where: { $0.subscription?.subscriptionPeriod.unit == .year && $0.subscription?.subscriptionPeriod.value == 1 }) - guard let monthly, let yearly else { - Logger.subscription.error("[AppStorePurchaseFlow] No products found") - return nil - } - - let platform: SubscriptionPlatformName = { -#if os(iOS) - .ios -#else - .macos -#endif - }() - - let options = [SubscriptionOption(id: monthly.id, - cost: .init(displayPrice: monthly.displayPrice, recurrence: "monthly")), - SubscriptionOption(id: yearly.id, - cost: .init(displayPrice: yearly.displayPrice, recurrence: "yearly"))] - - let features: [SubscriptionFeature] - - if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) { - features = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(name: $0) } - } else { - let allFeatures: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration] - features = allFeatures.compactMap { SubscriptionFeature(name: $0) } - } + let nonFreeTrialProducts = availableProducts.filter { !$0.hasFreeTrialOffer } + return await subscriptionOptions(for: nonFreeTrialProducts) + } - return SubscriptionOptions(platform: platform, - options: options, - features: features) + public func freeTrialSubscriptionOptions() async -> SubscriptionOptions? { + let freeTrialProducts = availableProducts.filter { $0.hasFreeTrialOffer } + return await subscriptionOptions(for: freeTrialProducts) } @MainActor @@ -165,10 +151,10 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM self.currentStorefrontRegion = storefrontRegion let applicableProductIdentifiers = storeSubscriptionConfiguration.subscriptionIdentifiers(for: storefrontRegion) - let availableProducts = try await Product.products(for: applicableProductIdentifiers) + let availableProducts = try await productFetcher.products(for: applicableProductIdentifiers) Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products for \(storefrontCountryCode ?? "", privacy: .public)") - if self.availableProducts != availableProducts { + if Set(availableProducts.map { $0.id }) != Set(self.availableProducts.map { $0.id }) { self.availableProducts = availableProducts // Update cached subscription features mapping @@ -298,6 +284,40 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM } } + private func subscriptionOptions(for products: [any SubscriptionProduct]) async -> SubscriptionOptions? { + Logger.subscription.info("[AppStorePurchaseFlow] subscriptionOptions") + let monthly = products.first(where: { $0.isMonthly }) + let yearly = products.first(where: { $0.isYearly }) + guard let monthly, let yearly else { + Logger.subscription.error("[AppStorePurchaseFlow] No products found") + return nil + } + + let platform: SubscriptionPlatformName = { +#if os(iOS) + .ios +#else + .macos +#endif + }() + + let options: [SubscriptionOption] = await [.init(from: monthly, withRecurrence: "monthly"), + .init(from: yearly, withRecurrence: "yearly")] + + let features: [SubscriptionFeature] + + if let featureFlagger = subscriptionFeatureFlagger, featureFlagger.isFeatureOn(.isLaunchedROW) || featureFlagger.isFeatureOn(.isLaunchedROWOverride) { + features = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(name: $0) } + } else { + let allFeatures: [Entitlement.ProductName] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration] + features = allFeatures.compactMap { SubscriptionFeature(name: $0) } + } + + return SubscriptionOptions(platform: platform, + options: options, + features: features) + } + private func checkVerified(_ result: VerificationResult) throws -> T { // Check whether the JWS passes StoreKit verification. switch result { @@ -337,6 +357,24 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM } } +@available(macOS 12.0, iOS 15.0, *) +private extension SubscriptionOption { + + init(from product: any SubscriptionProduct, withRecurrence recurrence: String) async { + var offer: SubscriptionOptionOffer? + + if let introOffer = product.introductoryOffer, introOffer.isFreeTrial { + + let durationInDays = introOffer.periodInDays + let isUserEligible = await product.isEligibleForIntroOffer + + offer = .init(type: .freeTrial, id: introOffer.id ?? "", displayPrice: introOffer.displayPrice, durationInDays: durationInDays, isUserEligible: isUserEligible) + } + + self.init(id: product.id, cost: .init(displayPrice: product.displayPrice, recurrence: recurrence), offer: offer) + } +} + public extension UserDefaults { enum Constants { diff --git a/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProduct.swift b/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProduct.swift new file mode 100644 index 000000000..4c0e64faa --- /dev/null +++ b/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProduct.swift @@ -0,0 +1,101 @@ +// +// SubscriptionProduct.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import StoreKit + +/// A protocol that defines the core functionality and properties of a subscription product. +/// Conforming types must provide information about pricing, description, and subscription terms. +@available(macOS 12.0, iOS 15.0, *) +public protocol SubscriptionProduct: Equatable { + /// The unique identifier of the product. + var id: String { get } + + /// The user-facing name of the product. + var displayName: String { get } + + /// The formatted price that should be displayed to users. + var displayPrice: String { get } + + /// A detailed description of the product. + var description: String { get } + + /// Indicates whether this is a monthly subscription. + var isMonthly: Bool { get } + + /// Indicates whether this is a yearly subscription. + var isYearly: Bool { get } + + /// The introductory offer associated with this subscription, if any. + var introductoryOffer: SubscriptionProductIntroductoryOffer? { get } + + /// Indicates whether this subscription has a Free Trial offer available. + var hasFreeTrialOffer: Bool { get } + + /// Asynchronously determines whether the user is eligible for an introductory offer. + var isEligibleForIntroOffer: Bool { get async } + + /// Initiates a purchase of the subscription with the specified options. + /// - Parameter options: A set of options to configure the purchase. + /// - Returns: The result of the purchase attempt. + /// - Throws: An error if the purchase fails. + func purchase(options: Set) async throws -> Product.PurchaseResult +} + +/// Extends StoreKit's Product to conform to SubscriptionProduct. +@available(macOS 12.0, iOS 15.0, *) +extension Product: SubscriptionProduct { + /// Determines if this is a monthly subscription by checking if the subscription period + /// is exactly one month. + public var isMonthly: Bool { + guard let subscription else { return false } + return subscription.subscriptionPeriod.unit == .month && + subscription.subscriptionPeriod.value == 1 + } + + /// Determines if this is a yearly subscription by checking if the subscription period + /// is exactly one year. + public var isYearly: Bool { + guard let subscription else { return false } + return subscription.subscriptionPeriod.unit == .year && + subscription.subscriptionPeriod.value == 1 + } + + /// Returns the introductory offer for this subscription if available. + public var introductoryOffer: (any SubscriptionProductIntroductoryOffer)? { + subscription?.introductoryOffer + } + + /// Indicates whether this subscription has a Free Trial offer. + public var hasFreeTrialOffer: Bool { + return subscription?.introductoryOffer?.isFreeTrial ?? false + } + + /// Asynchronously checks if the user is eligible for an introductory offer. + public var isEligibleForIntroOffer: Bool { + get async { + guard let subscription else { return false } + return await subscription.isEligibleForIntroOffer + } + } + + /// Implements Equatable by comparing product IDs. + public static func == (lhs: Product, rhs: Product) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProductIntroductoryOffer.swift b/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProductIntroductoryOffer.swift new file mode 100644 index 000000000..63bbd7a92 --- /dev/null +++ b/Sources/Subscription/Managers/StorePurchaseManager/SubscriptionProductIntroductoryOffer.swift @@ -0,0 +1,67 @@ +// +// SubscriptionProductIntroductoryOffer.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import StoreKit + +/// A protocol that defines the properties of an introductory offer for a subscription product. +/// Use this protocol to represent trial periods, introductory prices, or other special offers. +@available(macOS 12.0, iOS 15.0, *) +public protocol SubscriptionProductIntroductoryOffer { + /// The unique identifier of the introductory offer. + var id: String? { get } + + /// The formatted price of the offer that should be displayed to users. + var displayPrice: String { get } + + /// The duration of the offer in days. + var periodInDays: Int { get } + + /// Indicates whether this offer represents a free trial period. + var isFreeTrial: Bool { get } +} + +/// Extends StoreKit's Product.SubscriptionOffer to conform to SubscriptionProductIntroductoryOffer. +@available(macOS 12.0, iOS 15.0, *) +extension Product.SubscriptionOffer: SubscriptionProductIntroductoryOffer { + /// Calculates the total number of days in the offer period by multiplying + /// the base period length by the period count. + public var periodInDays: Int { + period.periodInDays * periodCount + } + + /// Determines if this offer represents a free trial based on the payment mode. + public var isFreeTrial: Bool { + paymentMode == .freeTrial + } +} + +@available(macOS 12.0, iOS 15.0, *) +private extension Product.SubscriptionPeriod { + + var periodInDays: Int { + switch unit { + case .day: return value + case .week: return value * 7 + case .month: return value * 30 + case .year: return value * 365 + @unknown default: + return value + } + } +} diff --git a/Sources/Subscription/StoreSubscriptionConfiguration.swift b/Sources/Subscription/StoreSubscriptionConfiguration.swift index 8dacf7db4..be3347c62 100644 --- a/Sources/Subscription/StoreSubscriptionConfiguration.swift +++ b/Sources/Subscription/StoreSubscriptionConfiguration.swift @@ -44,7 +44,9 @@ final class DefaultStoreSubscriptionConfiguration: StoreSubscriptionConfiguratio appIdentifier: "com.duckduckgo.mobile.ios.alpha", environment: .staging, identifiersByRegion: [.usa: ["ios.subscription.1month", - "ios.subscription.1year"], + "ios.subscription.1year", + "ios.subscription.1month.freetrial.dev", + "ios.subscription.1year.freetrial.dev"], .restOfWorld: ["ios.subscription.1month.row", "ios.subscription.1year.row"]]), // macOS debug build diff --git a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift index e326fd720..3d5c34f63 100644 --- a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift @@ -20,12 +20,14 @@ import Foundation import Subscription public final class StorePurchaseManagerMock: StorePurchaseManager { + public var purchasedProductIDs: [String] = [] public var purchaseQueue: [String] = [] public var areProductsAvailable: Bool = false public var currentStorefrontRegion: SubscriptionRegion = .usa public var subscriptionOptionsResult: SubscriptionOptions? + public var freeTrialSubscriptionOptionsResult: SubscriptionOptions? public var syncAppleIDAccountResultError: Error? public var mostRecentTransactionResult: String? @@ -44,6 +46,10 @@ public final class StorePurchaseManagerMock: StorePurchaseManager { subscriptionOptionsResult } + public func freeTrialSubscriptionOptions() async -> SubscriptionOptions? { + freeTrialSubscriptionOptionsResult + } + public func syncAppleIDAccount() async throws { if let syncAppleIDAccountResultError { throw syncAppleIDAccountResultError diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift index c4612a9bd..ef39c4d04 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift @@ -21,11 +21,16 @@ import Subscription public final class SubscriptionFeatureMappingCacheMock: SubscriptionFeatureMappingCache { + public var didCallSubscriptionFeatures = false + public var lastCalledSubscriptionId: String? + public var mapping: [String: [Entitlement.ProductName]] = [:] public init() { } public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [Entitlement.ProductName] { + didCallSubscriptionFeatures = true + lastCalledSubscriptionId = subscriptionIdentifier return mapping[subscriptionIdentifier] ?? [] } } diff --git a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift index 3fb695733..8ea41fa66 100644 --- a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift +++ b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift @@ -23,12 +23,14 @@ import SubscriptionTestingUtilities final class SubscriptionOptionsTests: XCTestCase { func testEncoding() throws { + let monthlySubscriptionOffer = SubscriptionOptionOffer(type: .freeTrial, id: "1", displayPrice: "$0.00", durationInDays: 7, isUserEligible: true) + let yearlySubscriptionOffer = SubscriptionOptionOffer(type: .freeTrial, id: "2", displayPrice: "$0.00", durationInDays: 7, isUserEligible: true) let subscriptionOptions = SubscriptionOptions(platform: .macos, options: [ SubscriptionOption(id: "1", - cost: SubscriptionOptionCost(displayPrice: "9 USD", recurrence: "monthly")), + cost: SubscriptionOptionCost(displayPrice: "9 USD", recurrence: "monthly"), offer: monthlySubscriptionOffer), SubscriptionOption(id: "2", - cost: SubscriptionOptionCost(displayPrice: "99 USD", recurrence: "yearly")) + cost: SubscriptionOptionCost(displayPrice: "99 USD", recurrence: "yearly"), offer: yearlySubscriptionOffer) ], features: [ SubscriptionFeature(name: .networkProtection), @@ -60,14 +62,28 @@ final class SubscriptionOptionsTests: XCTestCase { "displayPrice" : "9 USD", "recurrence" : "monthly" }, - "id" : "1" + "id" : "1", + "offer" : { + "displayPrice" : "$0.00", + "durationInDays" : 7, + "id" : "1", + "isUserEligible" : true, + "type" : "freeTrial" + } }, { "cost" : { "displayPrice" : "99 USD", "recurrence" : "yearly" }, - "id" : "2" + "id" : "2", + "offer" : { + "displayPrice" : "$0.00", + "durationInDays" : 7, + "id" : "2", + "isUserEligible" : true, + "type" : "freeTrial" + } } ], "platform" : "macos" diff --git a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift new file mode 100644 index 000000000..730134ec3 --- /dev/null +++ b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift @@ -0,0 +1,542 @@ +// +// StorePurchaseManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities +import StoreKit + +final class StorePurchaseManagerTests: XCTestCase { + + private var sut: StorePurchaseManager! + private var mockCache: SubscriptionFeatureMappingCacheMock! + private var mockProductFetcher: MockProductFetcher! + private var mockFeatureFlagger: MockFeatureFlagger! + + override func setUpWithError() throws { + mockCache = SubscriptionFeatureMappingCacheMock() + mockProductFetcher = MockProductFetcher() + mockFeatureFlagger = MockFeatureFlagger() + sut = DefaultStorePurchaseManager(subscriptionFeatureMappingCache: mockCache, + subscriptionFeatureFlagger: mockFeatureFlagger, + productFetcher: mockProductFetcher) + } + + func testSubscriptionOptionsReturnsOnlyNonTrialProducts() async { + // Given + let monthlyProduct = MockSubscriptionProduct( + id: "com.test.monthly", + displayName: "Monthly Plan", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: false + ) + + let yearlyProduct = MockSubscriptionProduct( + id: "com.test.yearly", + displayName: "Yearly Plan", + displayPrice: "$99.99", + isYearly: true, + hasFreeTrialOffer: false + ) + + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ) + ) + + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct, monthlyTrialProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.subscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + XCTAssertEqual(subscriptionOptions?.options.count, 2) + + let productIds = subscriptionOptions?.options.map { $0.id } ?? [] + XCTAssertTrue(productIds.contains("com.test.monthly")) + XCTAssertTrue(productIds.contains("com.test.yearly")) + XCTAssertFalse(productIds.contains("com.test.monthly.trial")) + } + + func testFreeTrialSubscriptionOptionsReturnsOnlyTrialProducts() async { + // Given + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + let yearlyTrialProduct = MockSubscriptionProduct( + id: "com.test.yearly.trial", + displayName: "Yearly Plan with Trial", + displayPrice: "$99.99", + isYearly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial2", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + let regularProduct = MockSubscriptionProduct( + id: "com.test.regular", + displayName: "Regular Plan", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: false + ) + + mockProductFetcher.mockProducts = [monthlyTrialProduct, yearlyTrialProduct, regularProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.freeTrialSubscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + XCTAssertEqual(subscriptionOptions?.options.count, 2) + + let productIds = subscriptionOptions?.options.map { $0.id } ?? [] + XCTAssertTrue(productIds.contains("com.test.monthly.trial")) + XCTAssertTrue(productIds.contains("com.test.yearly.trial")) + XCTAssertFalse(productIds.contains("com.test.regular")) + } + + func testSubscriptionOptionsReturnsNilWhenNoValidProductPairExists() async { + // Given + let monthlyProduct = MockSubscriptionProduct( + id: "com.test.monthly", + displayName: "Monthly Plan", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: false + ) + + mockProductFetcher.mockProducts = [monthlyProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.subscriptionOptions() + + // Then + XCTAssertNil(subscriptionOptions) + } + + func testFreeTrialSubscriptionOptionsReturnsNilWhenNoValidProductPairExists() async { + // Given + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ) + ) + + mockProductFetcher.mockProducts = [monthlyTrialProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.freeTrialSubscriptionOptions() + + // Then + XCTAssertNil(subscriptionOptions) + } + + func testSubscriptionOptionsIncludesCorrectDetails() async { + // Given + let monthlyProduct = MockSubscriptionProduct( + id: "com.test.monthly", + displayName: "Monthly Plan", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: false + ) + + let yearlyProduct = MockSubscriptionProduct( + id: "com.test.yearly", + displayName: "Yearly Plan", + displayPrice: "$99.99", + isYearly: true, + hasFreeTrialOffer: false + ) + + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.subscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + XCTAssertEqual(subscriptionOptions?.options.count, 2) + + let monthlyOption = subscriptionOptions?.options.first { $0.id == "com.test.monthly" } + XCTAssertNotNil(monthlyOption) + XCTAssertEqual(monthlyOption?.cost.displayPrice, "$9.99") + XCTAssertEqual(monthlyOption?.cost.recurrence, "monthly") + XCTAssertNil(monthlyOption?.offer) + + let yearlyOption = subscriptionOptions?.options.first { $0.id == "com.test.yearly" } + XCTAssertNotNil(yearlyOption) + XCTAssertEqual(yearlyOption?.cost.displayPrice, "$99.99") + XCTAssertEqual(yearlyOption?.cost.recurrence, "yearly") + XCTAssertNil(yearlyOption?.offer) + } + + func testFreeTrialSubscriptionOptionsIncludesCorrectTrialDetails() async { + // Given + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "$0.00", + periodInDays: 7, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + let yearlyTrialProduct = MockSubscriptionProduct( + id: "com.test.yearly.trial", + displayName: "Yearly Plan with Trial", + displayPrice: "$99.99", + isYearly: true, + hasFreeTrialOffer: true, + introOffer: MockIntroductoryOffer( + id: "trial2", + displayPrice: "$0.00", + periodInDays: 14, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + mockProductFetcher.mockProducts = [monthlyTrialProduct, yearlyTrialProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.freeTrialSubscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + + let monthlyOption = subscriptionOptions?.options.first { $0.id == "com.test.monthly.trial" } + XCTAssertNotNil(monthlyOption) + XCTAssertNotNil(monthlyOption?.offer) + XCTAssertEqual(monthlyOption?.offer?.type, .freeTrial) + XCTAssertEqual(monthlyOption?.offer?.displayPrice, "$0.00") + XCTAssertEqual(monthlyOption?.offer?.durationInDays, 7) + XCTAssertTrue(monthlyOption?.offer?.isUserEligible ?? false) + + let yearlyOption = subscriptionOptions?.options.first { $0.id == "com.test.yearly.trial" } + XCTAssertNotNil(yearlyOption) + XCTAssertNotNil(yearlyOption?.offer) + XCTAssertEqual(yearlyOption?.offer?.type, .freeTrial) + XCTAssertEqual(yearlyOption?.offer?.displayPrice, "$0.00") + XCTAssertEqual(yearlyOption?.offer?.durationInDays, 14) + XCTAssertTrue(yearlyOption?.offer?.isUserEligible ?? false) + } + + func testUpdateAvailableProductsSuccessfully() async { + // Given + let monthlyProduct = createMonthlyProduct() + let yearlyProduct = createYearlyProduct() + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct] + + // When + await sut.updateAvailableProducts() + + // Then + let products = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertEqual(products.count, 2) + XCTAssertTrue(products.contains(where: { $0.id == monthlyProduct.id })) + XCTAssertTrue(products.contains(where: { $0.id == yearlyProduct.id })) + } + + func testUpdateAvailableProductsWithError() async { + // Given + mockProductFetcher.fetchError = MockProductError.fetchFailed + + // When + await sut.updateAvailableProducts() + + // Then + let products = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertTrue(products.isEmpty) + } + + func testUpdateAvailableProductsWithDifferentRegions() async { + // Given + let usaMonthlyProduct = MockSubscriptionProduct( + id: "com.test.usa.monthly", + displayName: "USA Monthly Plan", + displayPrice: "$9.99", + isMonthly: true + ) + let usaYearlyProduct = MockSubscriptionProduct( + id: "com.test.usa.yearly", + displayName: "USA Yearly Plan", + displayPrice: "$99.99", + isYearly: true + ) + + let rowMonthlyProduct = MockSubscriptionProduct( + id: "com.test.row.monthly", + displayName: "ROW Monthly Plan", + displayPrice: "€8.99", + isMonthly: true + ) + let rowYearlyProduct = MockSubscriptionProduct( + id: "com.test.row.yearly", + displayName: "ROW Yearly Plan", + displayPrice: "€89.99", + isYearly: true + ) + + // Set USA products initially + mockProductFetcher.mockProducts = [usaMonthlyProduct, usaYearlyProduct] + mockFeatureFlagger.enabledFeatures = [] // No ROW features enabled - defaults to USA + + // When - Update for USA region + await sut.updateAvailableProducts() + + // Then - Verify USA products + let usaProducts = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertEqual(usaProducts.count, 2) + XCTAssertEqual((sut as? DefaultStorePurchaseManager)?.currentStorefrontRegion, .usa) + XCTAssertTrue(usaProducts.contains(where: { $0.id == "com.test.usa.monthly" })) + XCTAssertTrue(usaProducts.contains(where: { $0.id == "com.test.usa.yearly" })) + + // When - Switch to ROW region + mockProductFetcher.mockProducts = [rowMonthlyProduct, rowYearlyProduct] + mockFeatureFlagger.enabledFeatures = [.isLaunchedROW, .usePrivacyProROWRegionOverride] + await sut.updateAvailableProducts() + + // Then - Verify ROW products + let rowProducts = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertEqual(rowProducts.count, 2) + XCTAssertEqual((sut as? DefaultStorePurchaseManager)?.currentStorefrontRegion, .restOfWorld) + XCTAssertTrue(rowProducts.contains(where: { $0.id == "com.test.row.monthly" })) + XCTAssertTrue(rowProducts.contains(where: { $0.id == "com.test.row.yearly" })) + + // Verify pricing differences + let usaMonthlyPrice = usaProducts.first(where: { $0.isMonthly })?.displayPrice + let rowMonthlyPrice = rowProducts.first(where: { $0.isMonthly })?.displayPrice + XCTAssertEqual(usaMonthlyPrice, "$9.99") + XCTAssertEqual(rowMonthlyPrice, "€8.99") + } + + func testUpdateAvailableProductsUpdatesFeatureMapping() async { + // Given + let monthlyProduct = createMonthlyProduct() + let yearlyProduct = createYearlyProduct() + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct] + + // When + await sut.updateAvailableProducts() + + // Then + XCTAssertTrue(mockCache.didCallSubscriptionFeatures) + XCTAssertEqual(mockCache.lastCalledSubscriptionId, yearlyProduct.id) + } +} + +private final class MockProductFetcher: ProductFetching { + var mockProducts: [any SubscriptionProduct] = [] + var fetchError: Error? + var fetchCount: Int = 0 + + public func products(for identifiers: [String]) async throws -> [any SubscriptionProduct] { + fetchCount += 1 + if let error = fetchError { + throw error + } + return mockProducts + } +} + +private enum MockProductError: Error { + case fetchFailed +} + +private extension StorePurchaseManagerTests { + func createMonthlyProduct(withTrial: Bool = false) -> MockSubscriptionProduct { + MockSubscriptionProduct( + id: "com.test.monthly\(withTrial ? ".trial" : "")", + displayName: "Monthly Plan\(withTrial ? " with Trial" : "")", + displayPrice: "$9.99", + isMonthly: true, + hasFreeTrialOffer: withTrial, + introOffer: withTrial ? MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ) : nil, + isEligibleForIntroOffer: withTrial + ) + } + + func createYearlyProduct(withTrial: Bool = false) -> MockSubscriptionProduct { + MockSubscriptionProduct( + id: "com.test.yearly\(withTrial ? ".trial" : "")", + displayName: "Yearly Plan\(withTrial ? " with Trial" : "")", + displayPrice: "$99.99", + isYearly: true, + hasFreeTrialOffer: withTrial, + introOffer: withTrial ? MockIntroductoryOffer( + id: "trial2", + displayPrice: "Free", + periodInDays: 14, + isFreeTrial: true + ) : nil, + isEligibleForIntroOffer: withTrial + ) + } +} + +private class MockSubscriptionProduct: SubscriptionProduct { + let id: String + let displayName: String + let displayPrice: String + let description: String + let isMonthly: Bool + let isYearly: Bool + let hasFreeTrialOffer: Bool + private let mockIntroOffer: MockIntroductoryOffer? + private let mockIsEligibleForIntroOffer: Bool + + init(id: String, + displayName: String = "Mock Product", + displayPrice: String = "$4.99", + description: String = "Mock Description", + isMonthly: Bool = false, + isYearly: Bool = false, + hasFreeTrialOffer: Bool = false, + introOffer: MockIntroductoryOffer? = nil, + isEligibleForIntroOffer: Bool = false) { + self.id = id + self.displayName = displayName + self.displayPrice = displayPrice + self.description = description + self.isMonthly = isMonthly + self.isYearly = isYearly + self.hasFreeTrialOffer = hasFreeTrialOffer + self.mockIntroOffer = introOffer + self.mockIsEligibleForIntroOffer = isEligibleForIntroOffer + } + + var introductoryOffer: SubscriptionProductIntroductoryOffer? { + return mockIntroOffer + } + + var isEligibleForIntroOffer: Bool { + get async { + return mockIsEligibleForIntroOffer + } + } + + func purchase(options: Set) async throws -> Product.PurchaseResult { + fatalError("Not implemented for tests") + } + + static func == (lhs: MockSubscriptionProduct, rhs: MockSubscriptionProduct) -> Bool { + return lhs.id == rhs.id + } +} + +private struct MockIntroductoryOffer: SubscriptionProductIntroductoryOffer { + var id: String? + var displayPrice: String + var periodInDays: Int + var isFreeTrial: Bool +} + +private class MockFeatureFlagger: FeatureFlaggerMapping { + var enabledFeatures: Set = [] + + init(enabledFeatures: Set = []) { + self.enabledFeatures = enabledFeatures + super.init(mapping: {_ in true}) + } + + override func isFeatureOn(_ feature: SubscriptionFeatureFlags) -> Bool { + return enabledFeatures.contains(feature) + } +} + +private class MockStoreSubscriptionConfiguration: StoreSubscriptionConfiguration { + let usaIdentifiers = ["com.test.usa.monthly", "com.test.usa.yearly"] + let rowIdentifiers = ["com.test.row.monthly", "com.test.row.yearly"] + + var allSubscriptionIdentifiers: [String] { + usaIdentifiers + rowIdentifiers + } + + func subscriptionIdentifiers(for region: SubscriptionRegion) -> [String] { + switch region { + case .usa: + return usaIdentifiers + case .restOfWorld: + return rowIdentifiers + } + } + + func subscriptionIdentifiers(for country: String) -> [String] { + switch country.uppercased() { + case "USA": + return usaIdentifiers + default: + return rowIdentifiers + } + } +} From fb809edc28e809aba8a17aaf3e1c4b37086d9cd4 Mon Sep 17 00:00:00 2001 From: Michal Smaga Date: Thu, 12 Dec 2024 13:10:13 +0100 Subject: [PATCH 2/6] Ensure authToken is present before calling refreshAuthTokenIfNeeded --- .../Flows/AppStore/AppStoreAccountManagementFlow.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift index bb955ccde..ff75ecf4b 100644 --- a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift @@ -23,6 +23,7 @@ import os.log public enum AppStoreAccountManagementFlowError: Swift.Error { case noPastTransaction case authenticatingWithTransactionFailed + case missingAuthTokenOnRefresh } @available(macOS 12.0, iOS 15.0, *) @@ -46,7 +47,8 @@ public final class DefaultAppStoreAccountManagementFlow: AppStoreAccountManageme @discardableResult public func refreshAuthTokenIfNeeded() async -> Result { Logger.subscription.info("[AppStoreAccountManagementFlow] refreshAuthTokenIfNeeded") - var authToken = accountManager.authToken ?? "" + + guard let authToken = accountManager.authToken else { return .failure(.missingAuthTokenOnRefresh) } // Check if auth token if still valid if case let .failure(validateTokenError) = await authEndpointService.validateToken(accessToken: authToken) { @@ -58,8 +60,9 @@ public final class DefaultAppStoreAccountManagementFlow: AppStoreAccountManageme switch await authEndpointService.storeLogin(signature: lastTransactionJWSRepresentation) { case .success(let response): if response.externalID == accountManager.externalID { - authToken = response.authToken - accountManager.storeAuthToken(token: authToken) + let refreshedAuthToken = response.authToken + accountManager.storeAuthToken(token: refreshedAuthToken) + return .success(refreshedAuthToken) } case .failure(let storeLoginError): Logger.subscription.error("[AppStoreAccountManagementFlow] storeLogin error: \(String(reflecting: storeLoginError), privacy: .public)") From 276754fc1efab85c39a77da64e68439e7f105de3 Mon Sep 17 00:00:00 2001 From: Michal Smaga Date: Thu, 12 Dec 2024 13:41:31 +0100 Subject: [PATCH 3/6] Add 'locale' to report broken site params --- Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift index effe229fa..3fc35c46d 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift @@ -363,6 +363,7 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { {"id": "jsPerformance"}, {"id": "openerContext"}, {"id": "userRefreshCount"}, + {"id": "locale"}, ] } window.onGetToggleReportOptionsResponse(json); From a9ef99071cf73be2ddad449dbacb5208f62ee79d Mon Sep 17 00:00:00 2001 From: Michal Smaga Date: Thu, 12 Dec 2024 13:41:31 +0100 Subject: [PATCH 4/6] Add 'locale' to report broken site params --- Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift index 801cdd81c..fbc5a4b55 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift @@ -363,6 +363,7 @@ final class PrivacyDashboardUserScript: NSObject, StaticUserScript { {"id": "jsPerformance"}, {"id": "openerContext"}, {"id": "userRefreshCount"}, + {"id": "locale"}, ] } window.onGetToggleReportOptionsResponse(json); From 155b47e287dfc9d5edb25b10da0bac8e638d08b7 Mon Sep 17 00:00:00 2001 From: Michal Smaga Date: Thu, 12 Dec 2024 13:10:13 +0100 Subject: [PATCH 5/6] Ensure authToken is present before calling refreshAuthTokenIfNeeded --- .../Flows/AppStore/AppStoreAccountManagementFlow.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift index bb955ccde..ff75ecf4b 100644 --- a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift @@ -23,6 +23,7 @@ import os.log public enum AppStoreAccountManagementFlowError: Swift.Error { case noPastTransaction case authenticatingWithTransactionFailed + case missingAuthTokenOnRefresh } @available(macOS 12.0, iOS 15.0, *) @@ -46,7 +47,8 @@ public final class DefaultAppStoreAccountManagementFlow: AppStoreAccountManageme @discardableResult public func refreshAuthTokenIfNeeded() async -> Result { Logger.subscription.info("[AppStoreAccountManagementFlow] refreshAuthTokenIfNeeded") - var authToken = accountManager.authToken ?? "" + + guard let authToken = accountManager.authToken else { return .failure(.missingAuthTokenOnRefresh) } // Check if auth token if still valid if case let .failure(validateTokenError) = await authEndpointService.validateToken(accessToken: authToken) { @@ -58,8 +60,9 @@ public final class DefaultAppStoreAccountManagementFlow: AppStoreAccountManageme switch await authEndpointService.storeLogin(signature: lastTransactionJWSRepresentation) { case .success(let response): if response.externalID == accountManager.externalID { - authToken = response.authToken - accountManager.storeAuthToken(token: authToken) + let refreshedAuthToken = response.authToken + accountManager.storeAuthToken(token: refreshedAuthToken) + return .success(refreshedAuthToken) } case .failure(let storeLoginError): Logger.subscription.error("[AppStoreAccountManagementFlow] storeLogin error: \(String(reflecting: storeLoginError), privacy: .public)") From b71ed70ce9b0ef3ce51d4f96da0193ab70493944 Mon Sep 17 00:00:00 2001 From: Sabrina Tardio <44158575+SabrinaTardio@users.noreply.github.com> Date: Tue, 17 Dec 2024 12:47:17 +0100 Subject: [PATCH 6/6] change api (#1133) Task/Issue URL: https://app.asana.com/0/1204186595873227/1208964427775425/f iOS PR: https://github.com/duckduckgo/iOS/pull/3732 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3670 What kind of version bump will this require?: Minor --- .../PixelExperimentKit.swift | 96 ++++++++----- .../PixelExperimentKitTests.swift | 130 ++++++++++++++---- 2 files changed, 171 insertions(+), 55 deletions(-) diff --git a/Sources/PixelExperimentKit/PixelExperimentKit.swift b/Sources/PixelExperimentKit/PixelExperimentKit.swift index d0962f791..ecfb35482 100644 --- a/Sources/PixelExperimentKit/PixelExperimentKit.swift +++ b/Sources/PixelExperimentKit/PixelExperimentKit.swift @@ -21,6 +21,7 @@ import BrowserServicesKit import Foundation public typealias ConversionWindow = ClosedRange +public typealias NumberOfCalls = Int struct ExperimentEvent: PixelKitEvent { var name: String @@ -72,7 +73,7 @@ extension PixelKit { ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false) } - /// Fires a pixel for a specific action in an experiment, based on conversion window and value thresholds (if value is a number). + /// Fires a pixel for a specific action in an experiment, based on conversion window and value. /// - Parameters: /// - subfeatureID: Identifier for the subfeature associated with the experiment. /// - metric: The name of the metric being tracked (e.g., "searches"). @@ -82,7 +83,7 @@ extension PixelKit { /// This function: /// 1. Validates if the experiment is active. /// 2. Ensures the user is within the specified conversion window. - /// 3. Tracks actions performed and sends the pixel once the target value is reached (if applicable). + /// 3. Sends the pixel if not sent before (unique by name and parameter) public static func fireExperimentPixel(for subfeatureID: SubfeatureID, metric: String, conversionWindowDays: ConversionWindow, @@ -94,11 +95,41 @@ extension PixelKit { } guard let experimentData = featureFlagger.getAllActiveExperiments()[subfeatureID] else { return } + // Check if within conversion window + guard isUserInConversionWindow(conversionWindowDays, enrollmentDate: experimentData.enrollmentDate) else { return } + + // Define event + let event = event(for: subfeatureID, experimentData: experimentData, conversionWindowDays: conversionWindowDays, metric: metric, value: value) + ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false) + } + + /// Fires a pixel for a specific action in an experiment, based on conversion window and value thresholds. + /// - Parameters: + /// - subfeatureID: Identifier for the subfeature associated with the experiment. + /// - metric: The name of the metric being tracked (e.g., "searches"). + /// - conversionWindowDays: The range of days after enrollment during which the action is valid. + /// - numberOfCalls: target number of actions required to fire the pixel. + /// + /// This function: + /// 1. Validates if the experiment is active. + /// 2. Ensures the user is within the specified conversion window. + /// 3. Tracks actions performed and sends the pixel once the target value is reached (if applicable). + public static func fireExperimentPixelIfThresholdReached(for subfeatureID: SubfeatureID, + metric: String, + conversionWindowDays: ConversionWindow, + threshold: NumberOfCalls) { + // Check is active experiment for user + guard let featureFlagger = ExperimentConfig.featureFlagger else { + assertionFailure("PixelKit is not configured for experiments") + return + } + guard let experimentData = featureFlagger.getAllActiveExperiments()[subfeatureID] else { return } + fireExperimentPixelForActiveExperiment(subfeatureID, experimentData: experimentData, metric: metric, conversionWindowDays: conversionWindowDays, - value: value) + numberOfCalls: threshold) } /// Fires search-related experiment pixels for all active experiments. @@ -172,7 +203,7 @@ extension PixelKit { experimentData: experimentData, metric: metric, conversionWindowDays: range, - value: "\(value)" + numberOfCalls: value ) } } @@ -182,39 +213,24 @@ extension PixelKit { experimentData: ExperimentData, metric: String, conversionWindowDays: ConversionWindow, - value: String) { + numberOfCalls: Int) { // Set parameters, event name, store key - let eventName = "\(Constants.metricsEventPrefix)_\(subfeatureID)_\(experimentData.cohortID)" - let conversionWindowValue = (conversionWindowDays.lowerBound != conversionWindowDays.upperBound) ? - "\(conversionWindowDays.lowerBound)-\(conversionWindowDays.upperBound)" : - "\(conversionWindowDays.lowerBound)" - let parameters: [String: String] = [ - Constants.metricKey: metric, - Constants.conversionWindowDaysKey: conversionWindowValue, - Constants.valueKey: value, - Constants.enrollmentDateKey: experimentData.enrollmentDate.toYYYYMMDDInET() - ] - let event = ExperimentEvent(name: eventName, parameters: parameters) - let eventStoreKey = "\(eventName)_\(parameters.toString())" + let event = event(for: subfeatureID, experimentData: experimentData, conversionWindowDays: conversionWindowDays, metric: metric, value: String(numberOfCalls)) + let parameters = parameters(metric: metric, conversionWindowDays: conversionWindowDays, value: String(numberOfCalls), experimentData: experimentData) + let eventStoreKey = "\(event.name)_\(parameters.toString())" // Determine if the user is within the conversion window let isInWindow = isUserInConversionWindow(conversionWindowDays, enrollmentDate: experimentData.enrollmentDate) - // Check if value is a number - if let numberOfAction = NumberOfActions(value), numberOfAction > 1 { - // Increment or remove based on conversion window status - let shouldSendPixel = ExperimentConfig.eventTracker.incrementAndCheckThreshold( - forKey: eventStoreKey, - threshold: numberOfAction, - isInWindow: isInWindow - ) + // Increment or remove based on conversion window status + let shouldSendPixel = ExperimentConfig.eventTracker.incrementAndCheckThreshold( + forKey: eventStoreKey, + threshold: numberOfCalls, + isInWindow: isInWindow + ) - // Send the pixel only if conditions are met - if shouldSendPixel { - ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false) - } - } else if isInWindow { - // If value is not a number, send the pixel only if within the window + // Send the pixel only if conditions are met + if shouldSendPixel { ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false) } } @@ -233,6 +249,24 @@ extension PixelKit { return currentDate >= calendar.startOfDay(for: startOfWindow) && currentDate <= calendar.startOfDay(for: endOfWindow) } + + private static func event(for subfeatureID: SubfeatureID, experimentData: ExperimentData, conversionWindowDays: ConversionWindow, metric: String, value: String) -> ExperimentEvent{ + let eventName = "\(Constants.metricsEventPrefix)_\(subfeatureID)_\(experimentData.cohortID)" + let parameters = parameters(metric: metric, conversionWindowDays: conversionWindowDays, value: value, experimentData: experimentData) + return ExperimentEvent(name: eventName, parameters: parameters) + } + + private static func parameters(metric: String, conversionWindowDays: ConversionWindow, value: String, experimentData: ExperimentData) -> [String: String] { + let conversionWindowValue = (conversionWindowDays.lowerBound != conversionWindowDays.upperBound) ? + "\(conversionWindowDays.lowerBound)-\(conversionWindowDays.upperBound)" : + "\(conversionWindowDays.lowerBound)" + return [ + Constants.metricKey: metric, + Constants.conversionWindowDaysKey: conversionWindowValue, + Constants.valueKey: value, + Constants.enrollmentDateKey: experimentData.enrollmentDate.toYYYYMMDDInET() + ] + } } extension Date { diff --git a/Tests/PixelExperimentKitTests/PixelExperimentKitTests.swift b/Tests/PixelExperimentKitTests/PixelExperimentKitTests.swift index 30334f6e6..545bc68ce 100644 --- a/Tests/PixelExperimentKitTests/PixelExperimentKitTests.swift +++ b/Tests/PixelExperimentKitTests/PixelExperimentKitTests.swift @@ -51,7 +51,7 @@ final class PixelExperimentKitTests: XCTestCase { firedIncludeAppVersion = [] } - func testFireExperimentEnrollmentPixelSendsExpectedData() { + func testfireExperimentEnrollmentPixelPixelSendsExpectedData() { // GIVEN let subfeatureID = "testSubfeature" let cohort = "A" @@ -70,7 +70,7 @@ final class PixelExperimentKitTests: XCTestCase { XCTAssertFalse(firedIncludeAppVersion[0]) } - func testFireExperimentPixel_WithValidExperimentAndConversionWindowAndValueNotNumber() { + func testFireExperimentPixel_WithValidExperimentAndConversionWindow() { // GIVEN let subfeatureID = "credentialsSaving" @@ -99,25 +99,82 @@ final class PixelExperimentKitTests: XCTestCase { XCTAssertEqual(mockPixelStore.store.count, 0) } + func testFireExperimentPixel_WithInvalidExperimentAndValidConversionWindow() { + // GIVEN + let subfeatureID = "credentialsSaving" + let conversionWindow = 3...7 + let value = String(Int.random(in: 1...100)) + mockFeatureFlagger.experiments = [:] + + // WHEN + PixelKit.fireExperimentPixel(for: subfeatureID, metric: "someMetric", conversionWindowDays: conversionWindow, value: value) + + // THEN + XCTAssertTrue(firedEvent.isEmpty) + XCTAssertTrue(firedFrequency.isEmpty) + XCTAssertTrue(firedIncludeAppVersion.isEmpty) + XCTAssertEqual(mockPixelStore.store.count, 0) + } + + func testFireExperimentPixel_WithValidExperimentAndBeforeConversionWindow() { + // GIVEN + let subfeatureID = "credentialsSaving" + let cohort = "control" + let enrollmentDate = Date().addingTimeInterval(-7 * 24 * 60 * 60) // 7 days ago + let conversionWindow = 8...11 + let value = "someValue" + let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate) + mockFeatureFlagger.experiments = [subfeatureID: experimentData] + + // WHEN + PixelKit.fireExperimentPixel(for: subfeatureID, metric: "someMetric", conversionWindowDays: conversionWindow, value: value) + + // THEN + XCTAssertTrue(firedEvent.isEmpty) + XCTAssertTrue(firedFrequency.isEmpty) + XCTAssertTrue(firedIncludeAppVersion.isEmpty) + XCTAssertEqual(mockPixelStore.store.count, 0) + } + + func testFireExperimentPixel_WithValidExperimentAndAfterConversionWindow() { + // GIVEN + let subfeatureID = "credentialsSaving" + let cohort = "control" + let enrollmentDate = Date().addingTimeInterval(-12 * 24 * 60 * 60) // 12 days ago + let conversionWindow = 8...11 + let value = "someValue" + let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate) + mockFeatureFlagger.experiments = [subfeatureID: experimentData] + + // WHEN + PixelKit.fireExperimentPixel(for: subfeatureID, metric: "someMetric", conversionWindowDays: conversionWindow, value: value) + + // THEN + XCTAssertTrue(firedEvent.isEmpty) + XCTAssertTrue(firedFrequency.isEmpty) + XCTAssertTrue(firedIncludeAppVersion.isEmpty) + XCTAssertEqual(mockPixelStore.store.count, 0) + } + func testFireExperimentPixel_WithValidExperimentAndConversionWindowAndValue1() { // GIVEN let subfeatureID = "credentialsSaving" let cohort = "control" let enrollmentDate = Date().addingTimeInterval(-3 * 24 * 60 * 60) // 5 days ago let conversionWindow = 3...7 - let value = "1" + let value = 1 let expectedEventName = "experiment_metrics_\(subfeatureID)_\(cohort)" let expectedParameters = [ "metric": "someMetric", "conversionWindowDays": "3-7", - "value": value, + "value": String(value), "enrollmentDate": enrollmentDate.toYYYYMMDDInET() ] let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate) mockFeatureFlagger.experiments = [subfeatureID: experimentData] // WHEN - PixelKit.fireExperimentPixel(for: subfeatureID, metric: "someMetric", conversionWindowDays: conversionWindow, value: value) + PixelKit.fireExperimentPixelIfThresholdReached(for: subfeatureID, metric: "someMetric", conversionWindowDays: conversionWindow, threshold: value) // THEN XCTAssertEqual(firedEvent[0].name, expectedEventName) @@ -127,27 +184,54 @@ final class PixelExperimentKitTests: XCTestCase { XCTAssertEqual(mockPixelStore.store.count, 0) } - func testFireExperimentPixel_WithValidExperimentAndConversionWindowAndValueN() { + func testFireExperimentPixelWhenReachingNumberOfCalls_WithValidExperimentAndConversionWindowAndValue1() { + // GIVEN + let subfeatureID = "credentialsSaving" + let cohort = "control" + let enrollmentDate = Date().addingTimeInterval(-3 * 24 * 60 * 60) // 5 days ago + let conversionWindow = 3...7 + let value = 1 + let expectedEventName = "experiment_metrics_\(subfeatureID)_\(cohort)" + let expectedParameters = [ + "metric": "someMetric", + "conversionWindowDays": "3-7", + "value": String(value), + "enrollmentDate": enrollmentDate.toYYYYMMDDInET() + ] + let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate) + mockFeatureFlagger.experiments = [subfeatureID: experimentData] + + // WHEN + PixelKit.fireExperimentPixelIfThresholdReached(for: subfeatureID, metric: "someMetric", conversionWindowDays: conversionWindow, threshold: value) + + // THEN + XCTAssertEqual(firedEvent[0].name, expectedEventName) + XCTAssertEqual(firedEvent[0].parameters, expectedParameters) + XCTAssertEqual(firedFrequency[0], .uniqueByNameAndParameters) + XCTAssertFalse(firedIncludeAppVersion[0]) + XCTAssertEqual(mockPixelStore.store.count, 0) + } + + func testFireExperimentPixelWhenReachingNumberOfCalls_WithValidExperimentAndConversionWindowAndValueN() { // GIVEN let subfeatureID = "credentialsSaving" let cohort = "control" let enrollmentDate = Date().addingTimeInterval(-7 * 24 * 60 * 60) // 5 days ago let conversionWindow = 3...7 - let randomNumber = Int.random(in: 1...100) - let value = "\(randomNumber)" + let value = Int.random(in: 1...100) let expectedEventName = "experiment_metrics_\(subfeatureID)_\(cohort)" let expectedParameters = [ "metric": "someMetric", "conversionWindowDays": "3-7", - "value": value, + "value": String(value), "enrollmentDate": enrollmentDate.toYYYYMMDDInET() ] let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate) mockFeatureFlagger.experiments = [subfeatureID: experimentData] // WHEN calling fire before expected number of calls - for n in 1..