From bac8c861dcc5bdbc6cd24977f785f7449138cac8 Mon Sep 17 00:00:00 2001 From: Pete Smith <5278441+aataraxiaa@users.noreply.github.com> Date: Fri, 10 Jan 2025 11:44:56 +0000 Subject: [PATCH] Privacy Pro Free Trials Core Implementation (#3760) Task/Issue URL: https://app.asana.com/0/0/1208767141940868/f Tech Design URL: https://app.asana.com/0/1206488453854252/1208916720468103/f **Description**: This PR adds the core implementation of Privacy Pro Free Trials. Specifically: 1. Adds a `FreeTrialsFeatureFlagExperimenting` type and associated concrete implementation 2. Adds a `Bucketer` type and associated concrete implementation 3. Updates `SubscriptionPagesUseSubscriptionFeature` to fetch Free Trial Subscriptions based on experiment enrollment --- Core/FeatureFlag.swift | 6 +- DuckDuckGo.xcodeproj/project.pbxproj | 54 ++- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../FreeTrialsFeatureFlagExperiment.swift | 305 +++++++++++++++++ .../PaywallViewBucketer.swift | 54 +++ ...scriptionPagesUseSubscriptionFeature.swift | 136 +++++++- .../SubscriptionDebugViewController.swift | 29 +- DuckDuckGoTests/MockFeatureFlagger.swift | 8 +- ...FreeTrialsFeatureFlagExperimentTests.swift | 311 ++++++++++++++++++ .../PaywallViewBucketerTests.swift | 57 ++++ ...seSubscriptionFeatureFreeTrialsTests.swift | 298 +++++++++++++++++ 11 files changed, 1229 insertions(+), 33 deletions(-) create mode 100644 DuckDuckGo/Subscription/FreeTrialsExperiment/FreeTrialsFeatureFlagExperiment.swift create mode 100644 DuckDuckGo/Subscription/FreeTrialsExperiment/PaywallViewBucketer.swift create mode 100644 DuckDuckGoTests/Subscription/FreeTrialsExperiment/FreeTrialsFeatureFlagExperimentTests.swift create mode 100644 DuckDuckGoTests/Subscription/FreeTrialsExperiment/PaywallViewBucketerTests.swift create mode 100644 DuckDuckGoTests/Subscription/FreeTrialsExperiment/SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index ea17309501..fbb919a464 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -60,7 +60,7 @@ public enum FeatureFlag: String { case crashReportOptInStatusResetting /// https://app.asana.com/0/0/1208767141940869/f - case freeTrials + case privacyProFreeTrialJan25 } extension FeatureFlag: FeatureFlagDescribing { @@ -136,8 +136,8 @@ extension FeatureFlag: FeatureFlagDescribing { return .remoteReleasable(.feature(.adAttributionReporting)) case .crashReportOptInStatusResetting: return .internalOnly() - case .freeTrials: - return .remoteDevelopment(.subfeature(PrivacyProSubfeature.freeTrials)) + case .privacyProFreeTrialJan25: + return .remoteDevelopment(.subfeature(PrivacyProSubfeature.privacyProFreeTrialJan25)) case .aiChat: return .remoteReleasable(.feature(.aiChat)) } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 65c0eb959d..03ddd3bd47 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -890,8 +890,10 @@ BDFF03232BA3D8E300F324C9 /* NetworkProtectionFeatureVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF03192BA39C5A00F324C9 /* NetworkProtectionFeatureVisibility.swift */; }; BDFF03262BA3DA4900F324C9 /* NetworkProtectionFeatureVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDFF03242BA3D92E00F324C9 /* NetworkProtectionFeatureVisibilityTests.swift */; }; C10CB5F32A1A5BDF0048E503 /* AutofillViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10CB5F22A1A5BDF0048E503 /* AutofillViews.swift */; }; + C1106F312D0EFD8B0054A221 /* FreeTrialsFeatureFlagExperimentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1106F302D0EFD8B0054A221 /* FreeTrialsFeatureFlagExperimentTests.swift */; }; C111B26927F579EF006558B1 /* BookmarkOrFolderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C111B26827F579EF006558B1 /* BookmarkOrFolderTests.swift */; }; C12324C32C4697C900FBB26B /* AutofillBreakageReportTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1836CE42C35A0EA0016D057 /* AutofillBreakageReportTableViewCell.swift */; }; + C12552972D0B06A100A0FDAA /* FreeTrialsFeatureFlagExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12552962D0B06A100A0FDAA /* FreeTrialsFeatureFlagExperiment.swift */; }; C12726EE2A5FF88C00215B02 /* EmailSignupPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12726ED2A5FF88C00215B02 /* EmailSignupPromptView.swift */; }; C12726F02A5FF89900215B02 /* EmailSignupPromptViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12726EF2A5FF89900215B02 /* EmailSignupPromptViewModel.swift */; }; C12726F22A5FF8CB00215B02 /* EmailSignupPromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12726F12A5FF8CB00215B02 /* EmailSignupPromptViewController.swift */; }; @@ -916,6 +918,8 @@ C1641EAF2BC2F5140012607A /* ImportPasswordsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1641EAE2BC2F5140012607A /* ImportPasswordsViewController.swift */; }; C1641EB12BC2F52B0012607A /* ImportPasswordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1641EB02BC2F52B0012607A /* ImportPasswordsView.swift */; }; C1641EB32BC2F53C0012607A /* ImportPasswordsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1641EB22BC2F53C0012607A /* ImportPasswordsViewModel.swift */; }; + C16C0E392D146A1B009A81CC /* PaywallViewBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16C0E382D146A1B009A81CC /* PaywallViewBucketer.swift */; }; + C16C0E3B2D146AF3009A81CC /* PaywallViewBucketerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16C0E3A2D146AF3009A81CC /* PaywallViewBucketerTests.swift */; }; C174CE602BD6A6CE00AED2EA /* MockDDGSyncing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C185ED652BD43A5500BAE9DC /* MockDDGSyncing.swift */; }; C177D9F62CFDDFEB0039CBF7 /* UIAlertControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C177D9F52CFDDFEB0039CBF7 /* UIAlertControllerExtension.swift */; }; C17B59592A03AAD30055F2D1 /* PasswordGenerationPromptViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17B59562A03AAD30055F2D1 /* PasswordGenerationPromptViewModel.swift */; }; @@ -985,6 +989,7 @@ C1EF5B232CC0457B002980E6 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1EF5B222CC0457B002980E6 /* AuthenticationServices.framework */; }; C1EF5B262CC0457B002980E6 /* CredentialProviderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EF5B252CC0457B002980E6 /* CredentialProviderViewController.swift */; }; C1EF5B2E2CC0457B002980E6 /* AutofillCredentialProvider.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = C1EF5B212CC0457B002980E6 /* AutofillCredentialProvider.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + C1F04B642D1068DC003DEF05 /* SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F04B632D1068DC003DEF05 /* SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift */; }; C1F341C52A6924000032057B /* EmailAddressPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F341C42A6924000032057B /* EmailAddressPromptView.swift */; }; C1F341C72A6924100032057B /* EmailAddressPromptViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F341C62A6924100032057B /* EmailAddressPromptViewModel.swift */; }; C1F341C92A6926920032057B /* EmailAddressPromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F341C82A6926920032057B /* EmailAddressPromptViewController.swift */; }; @@ -2761,11 +2766,13 @@ BDFF03202BA3D3CF00F324C9 /* NetworkProtectionVisibilityForTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionVisibilityForTunnelProvider.swift; sourceTree = ""; }; BDFF03242BA3D92E00F324C9 /* NetworkProtectionFeatureVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionFeatureVisibilityTests.swift; sourceTree = ""; }; C10CB5F22A1A5BDF0048E503 /* AutofillViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillViews.swift; sourceTree = ""; }; + C1106F302D0EFD8B0054A221 /* FreeTrialsFeatureFlagExperimentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeTrialsFeatureFlagExperimentTests.swift; sourceTree = ""; }; C111B26827F579EF006558B1 /* BookmarkOrFolderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOrFolderTests.swift; sourceTree = ""; }; C1193F602D08642900CB3239 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; C1193F612D08642900CB3239 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; C11C4D302D08648100288E85 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; C11C4D312D08648100288E85 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + C12552962D0B06A100A0FDAA /* FreeTrialsFeatureFlagExperiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeTrialsFeatureFlagExperiment.swift; sourceTree = ""; }; C12726ED2A5FF88C00215B02 /* EmailSignupPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailSignupPromptView.swift; sourceTree = ""; }; C12726EF2A5FF89900215B02 /* EmailSignupPromptViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailSignupPromptViewModel.swift; sourceTree = ""; }; C12726F12A5FF8CB00215B02 /* EmailSignupPromptViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailSignupPromptViewController.swift; sourceTree = ""; }; @@ -2807,6 +2814,8 @@ C1641EB22BC2F53C0012607A /* ImportPasswordsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportPasswordsViewModel.swift; sourceTree = ""; }; C164F9472D0861D600BAE88E /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/InfoPlist.strings; sourceTree = ""; }; C164F9482D0861D600BAE88E /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; + C16C0E382D146A1B009A81CC /* PaywallViewBucketer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewBucketer.swift; sourceTree = ""; }; + C16C0E3A2D146AF3009A81CC /* PaywallViewBucketerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallViewBucketerTests.swift; sourceTree = ""; }; C174E08E2D08625300ACE1AF /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/InfoPlist.strings; sourceTree = ""; }; C174E08F2D08625300ACE1AF /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; C177D9F52CFDDFEB0039CBF7 /* UIAlertControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertControllerExtension.swift; sourceTree = ""; }; @@ -2896,6 +2905,7 @@ C1EF5B252CC0457B002980E6 /* CredentialProviderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialProviderViewController.swift; sourceTree = ""; }; C1EF5B2A2CC0457B002980E6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; C1EF5B2B2CC0457B002980E6 /* AutofillCredentialProvider.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AutofillCredentialProvider.entitlements; sourceTree = ""; }; + C1F04B632D1068DC003DEF05 /* SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift; sourceTree = ""; }; C1F341C42A6924000032057B /* EmailAddressPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailAddressPromptView.swift; sourceTree = ""; }; C1F341C62A6924100032057B /* EmailAddressPromptViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailAddressPromptViewModel.swift; sourceTree = ""; }; C1F341C82A6926920032057B /* EmailAddressPromptViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailAddressPromptViewController.swift; sourceTree = ""; }; @@ -5349,6 +5359,25 @@ name = Autofill; sourceTree = ""; }; + C1106F2F2D0EFD6F0054A221 /* FreeTrialsExperiment */ = { + isa = PBXGroup; + children = ( + C1F04B632D1068DC003DEF05 /* SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift */, + C1106F302D0EFD8B0054A221 /* FreeTrialsFeatureFlagExperimentTests.swift */, + C16C0E3A2D146AF3009A81CC /* PaywallViewBucketerTests.swift */, + ); + path = FreeTrialsExperiment; + sourceTree = ""; + }; + C12552952D0B066B00A0FDAA /* FreeTrialsExperiment */ = { + isa = PBXGroup; + children = ( + C12552962D0B06A100A0FDAA /* FreeTrialsFeatureFlagExperiment.swift */, + C16C0E382D146A1B009A81CC /* PaywallViewBucketer.swift */, + ); + path = FreeTrialsExperiment; + sourceTree = ""; + }; C14882D627F2010700D59F0C /* ImportExport */ = { isa = PBXGroup; children = ( @@ -5753,18 +5782,19 @@ D664C7922B289AA000CBFA76 /* Subscription */ = { isa = PBXGroup; children = ( - BDE91CD42C6292BF0005CB74 /* Feedback */, - F1FDC92F2BF4E0B3006B1435 /* SubscriptionEnvironment+Default.swift */, - D60170BB2BA32DD6001911B5 /* Subscription.swift */, - 1E53508E2C7C9A1F00818DAA /* DefaultSubscriptionManager+AccountManagerKeychainAccessDelegate.swift */, D6D95CE42B6DA3F200960317 /* AsyncHeadlessWebview */, - D664C7932B289AA000CBFA76 /* ViewModel */, - D664C7AC2B289AA000CBFA76 /* Views */, - D664C7B02B289AA000CBFA76 /* UserScripts */, + 1E53508E2C7C9A1F00818DAA /* DefaultSubscriptionManager+AccountManagerKeychainAccessDelegate.swift */, D664C7962B289AA000CBFA76 /* Extensions */, - D65CEA6F2B6AC6C9008A759B /* Subscription.xcassets */, + BDE91CD42C6292BF0005CB74 /* Feedback */, + C12552952D0B066B00A0FDAA /* FreeTrialsExperiment */, BDE219E52C406D19005D5884 /* PrivacyProDataReporting.swift */, + D60170BB2BA32DD6001911B5 /* Subscription.swift */, + D65CEA6F2B6AC6C9008A759B /* Subscription.xcassets */, 1E39BEAF2CC9477200496FBA /* SubscriptionCookieManageEventPixelMapping.swift */, + F1FDC92F2BF4E0B3006B1435 /* SubscriptionEnvironment+Default.swift */, + D664C7B02B289AA000CBFA76 /* UserScripts */, + D664C7932B289AA000CBFA76 /* ViewModel */, + D664C7AC2B289AA000CBFA76 /* Views */, ); path = Subscription; sourceTree = ""; @@ -6554,6 +6584,7 @@ F1BDDBFC2C340D9C00459306 /* Subscription */ = { isa = PBXGroup; children = ( + C1106F2F2D0EFD6F0054A221 /* FreeTrialsExperiment */, 1E25D5302C921246004400F0 /* Mocks */, D664C7952B289AA000CBFA76 /* Subscription.storekit */, BDE219E92C457B46005D5884 /* PrivacyProDataReporterTests.swift */, @@ -8154,6 +8185,7 @@ 4B412ACC2BBB3D0900A39F5E /* LazyView.swift in Sources */, 7B10FF252D11A56300F36BF2 /* ControlWidgetVPNIntents.swift in Sources */, 85047C772A0D5D3D00D2FF3F /* SyncSettingsViewController+SyncDelegate.swift in Sources */, + C12552972D0B06A100A0FDAA /* FreeTrialsFeatureFlagExperiment.swift in Sources */, 85DDE0402AC6FF65006ABCA2 /* MainView.swift in Sources */, 980891A72237D5D800313A70 /* FeedbackPresenter.swift in Sources */, CBAD0F0C2CFF4EE1006267B8 /* AppDependencies.swift in Sources */, @@ -8423,6 +8455,7 @@ CBD4F13F279EBFAF00B20FD7 /* HomeMessageViewModel.swift in Sources */, 1E4DCF4A27B6A38000961E25 /* DownloadListRepresentable.swift in Sources */, 1DEAADFB2BA71E9A00E25A97 /* SettingsPrivacyProtectionDescriptionView.swift in Sources */, + C16C0E392D146A1B009A81CC /* PaywallViewBucketer.swift in Sources */, 6F3537A02C4AAFD2009F8717 /* NewTabPageSettingsSectionItemView.swift in Sources */, 2DC3FC65C6D9DA634426672D /* AutofillNoAuthAvailableView.swift in Sources */, 6F03CAFC2C32C6F6004179A8 /* NewTabPageMessagesModel.swift in Sources */, @@ -8610,6 +8643,7 @@ 3170048227A9504F00C03F35 /* DownloadMocks.swift in Sources */, 317045C02858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift in Sources */, 6FD3AEE32B8F4EEB0060FCCC /* AdAttributionPixelReporterTests.swift in Sources */, + C16C0E3B2D146AF3009A81CC /* PaywallViewBucketerTests.swift in Sources */, 987130C6294AAB9F00AB05E0 /* BookmarkListViewModelTests.swift in Sources */, 6F03CB022C32ED47004179A8 /* NewTabPageMessagesModelTests.swift in Sources */, F1134ED21F40EF3A00B73467 /* JsonTestDataLoader.swift in Sources */, @@ -8626,11 +8660,13 @@ 6AC98419288055C1005FA9CA /* BarsAnimatorTests.swift in Sources */, 85E065C12C73ADDD00D73E2A /* UsageSegmentationStorageTests.swift in Sources */, 8536A1CA209AF6490050739E /* HomeRowReminderTests.swift in Sources */, + C1106F312D0EFD8B0054A221 /* FreeTrialsFeatureFlagExperimentTests.swift in Sources */, 851DFD8A212C5EE800D95F20 /* TabSwitcherButtonTests.swift in Sources */, 98983096255B5019003339A2 /* BookmarksCachingSearchTests.swift in Sources */, D6B67A122C332B6E002122EB /* DuckPlayerMocks.swift in Sources */, 9FEA22352C327226006B03BF /* MockTimer.swift in Sources */, EE7917912A83DE93008DFF28 /* CombineTestUtilities.swift in Sources */, + C1F04B642D1068DC003DEF05 /* SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift in Sources */, 6FD0C41F2C5BF097000561C9 /* NewTabPageIntroMessageSetupTests.swift in Sources */, 85F200072217032E006BB258 /* AddressDisplayHelperTests.swift in Sources */, B6AD9E3728D4510A0019CDE9 /* ContentBlockingUpdatingTests.swift in Sources */, @@ -11865,7 +11901,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 224.4.0; + version = 224.5.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6fd938de84..98a84e0316 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "6379f0de589e656dd715a707600824e895bd951a", - "version" : "224.4.0" + "revision" : "1700c54067b6676974676ca9a81d654317a093f1", + "version" : "224.5.0" } }, { diff --git a/DuckDuckGo/Subscription/FreeTrialsExperiment/FreeTrialsFeatureFlagExperiment.swift b/DuckDuckGo/Subscription/FreeTrialsExperiment/FreeTrialsFeatureFlagExperiment.swift new file mode 100644 index 0000000000..5a875d9a33 --- /dev/null +++ b/DuckDuckGo/Subscription/FreeTrialsExperiment/FreeTrialsFeatureFlagExperiment.swift @@ -0,0 +1,305 @@ +// +// FreeTrialsFeatureFlagExperiment.swift +// DuckDuckGo +// +// 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 BrowserServicesKit +import PixelExperimentKit +import PixelKit +import Persistence + +/// A protocol that defines a method for firing experiment-related analytics pixels. +/// +/// Types conforming to this protocol can be used to send experiment data (e.g., metrics and user actions). +/// This protocol is particularly useful for injecting dependencies to enable testing. +protocol ExperimentPixelFiring { + /// Fires an experiment pixel with the specified parameters. + /// + /// - Parameters: + /// - subfeatureID: The unique identifier of the subfeature associated with the experiment. + /// - metric: The name of the metric being tracked (e.g., impressions, clicks, conversions). + /// - conversionWindowDays: The time range (in days) to associate the pixel with conversion events. + /// - value: A string representing the value associated with the metric, such as counts or statuses. + static func fireExperimentPixel(for subfeatureID: SubfeatureID, + metric: String, + conversionWindowDays: ConversionWindow, + value: String) +} + +/// Conforming `PixelKit` to the `ExperimentPixelFiring` protocol. +/// +/// `PixelKit` provides the concrete implementation for firing experiment pixels. By extending +/// `PixelKit` to conform to `ExperimentPixelFiring`, its functionality can be injected and mocked +/// for testing purposes. +extension PixelKit: ExperimentPixelFiring {} + +/// Protocol defining the functionality required for a feature flag experiment related to free trials. +protocol FreeTrialsFeatureFlagExperimenting: FeatureFlagExperimentDescribing { + + /// Retrieves the cohort assigned to the user for the experiment. + /// + /// This method determines the cohort for the experiment if it is enabled, allowing + /// for differentiation of behavior or configurations. + /// + /// - Returns: The user's cohort, or `nil` if the experiment is not enabled. + func getCohortIfEnabled() -> (any FlagCohort)? + + /// Provides experiment-specific parameters if applicable. + /// + /// This method returns parameters associated with the experiment and cohort, based on + /// certain criteria. Implementations can use this to provide experiment-specific data + /// to be used for analytics or feature configuration. + /// + /// - Parameter cohort: The cohort assigned to the user. + /// - Returns: A dictionary of experiment-specific parameters, or `nil` if parameters + /// are not applicable. + func freeTrialParametersIfNotPreviouslyReturned(for cohort: any FlagCohort) -> [String: String]? + + /// Increments the count of paywall views if the user's enrollment date is within the conversion window. + func incrementPaywallViewCountIfWithinConversionWindow() + + /// Fires a pixel tracking the impression of the paywall. + func firePaywallImpressionPixel() + + /// Fires a pixel when the monthly subscription offer is selected. + func fireOfferSelectionMonthlyPixel() + + /// Fires a pixel when the yearly subscription offer is selected. + func fireOfferSelectionYearlyPixel() + + /// Fires a pixel when a monthly subscription is started. + func fireSubscriptionStartedMonthlyPixel() + + /// Fires a pixel when a yearly subscription is started. + func fireSubscriptionStartedYearlyPixel() +} + +/// Implementation of a feature flag experiment for monitoring and optimizing the impact of free trial offers. +final class FreeTrialsFeatureFlagExperiment: FreeTrialsFeatureFlagExperimenting { + + /// Represents the cohorts in the experiment. + typealias CohortType = Cohort + enum Cohort: String, FlagCohort { + /// Control cohort with no changes applied. + case control + /// Treatment cohort where the experiment modifications are applied. + case treatment + } + + /// Constants used in the experiment. + enum Constants { + /// Unique identifier for the subfeature being tested. + static let subfeatureIdentifier = "privacyProFreeTrialJan25" + + /// Metric identifiers for various user actions during the experiment. + static let metricPaywallImpressions = "paywallImpressions" + static let metricStartClickedMonthly = "startClickedMonthly" + static let metricStartClickedYearly = "startClickedYearly" + static let metricSubscriptionStartedMonthly = "subscriptionStartedMonthly" + static let metricSubscriptionStartedYearly = "subscriptionStartedYearly" + + /// Conversion window in days for tracking user actions. + static let conversionWindowDays = 0...3 + + /// Key used to store the paywall view count in persistent storage. + static let paywallViewCountKey = "\(subfeatureIdentifier)_paywallViewCount" + static let hasReturnedFreeTrialParametersKey = "\(subfeatureIdentifier)_hasReturnedFreeTrialParameters" + + /// Experiment name key included in the free trial parameters. + static let freeTrialParameterExperimentName = "experimentName" + + /// Cohort value key included in the free trial parameters. + static let freeTrialParameterExperimentCohort = "experimentCohort" + + /// Key used to store the override status for the feature flag in persistent storage. + static let featureFlagOverrideKey = "\(subfeatureIdentifier)_featureFlagOverride" + } + + /// Identifier for the experiment. + let rawValue = Constants.subfeatureIdentifier + + /// Source of the feature flag, defining how it is retrieved and enabled. + let source: FeatureFlagSource = .remoteReleasable(.subfeature(PrivacyProSubfeature.privacyProFreeTrialJan25)) + + /// Persistent storage for experiment-related data. + private let storage: KeyValueStoring + + /// A type responsible for firing experiment-related analytics pixels. + private let experimentPixelFirer: ExperimentPixelFiring.Type + + /// A bucketer responsible for categorizing values into predefined ranges. + private let bucketer: any Bucketer + + /// A feature flagging service for managing feature flag experiments. + private let featureFlagger: FeatureFlagger + + /// Initializes the experiment with the specified storage. + /// - Parameter storage: The persistent storage to use. Defaults to `UserDefaults.standard`. + init(storage: KeyValueStoring = UserDefaults.standard, + experimentPixelFirer: ExperimentPixelFiring.Type = PixelKit.self, + bucketer: any Bucketer = PaywallViewBucketer(), + featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger) { + self.storage = storage + self.experimentPixelFirer = experimentPixelFirer + self.bucketer = bucketer + self.featureFlagger = featureFlagger + } + + /// Retrieves the cohort associated with the experiment if the feature flag is enabled or if the override is active. + /// + /// This method determines whether the feature flag for the experiment is enabled + /// or overridden. If the override flag stored in persistent storage is set to `true`, + /// it forces the method to return the `treatment` cohort regardless of the feature flag's actual state. + /// If no override is active, it checks the feature flag and returns the cohort assigned to the user + /// based on the feature flag configuration. + /// + /// - Returns: The `treatment` cohort if the override is enabled, the assigned cohort if the feature flag is enabled, or `nil` otherwise. + func getCohortIfEnabled() -> (any FlagCohort)? { + let isFlagOverrideEnabled = storage.object(forKey: Constants.featureFlagOverrideKey) as? Bool ?? false + if isFlagOverrideEnabled { + return FreeTrialsFeatureFlagExperiment.Cohort.treatment + } + + return featureFlagger.getCohortIfEnabled(for: self) + as? FreeTrialsFeatureFlagExperiment.Cohort + } + + /// Provides free trial experiment parameters if they haven't been returned before. + /// + /// This method checks whether the experiment parameters for the user's cohort have already been returned. + /// If not, it returns a dictionary of parameters containing the experiment name and cohort. + /// If the user is outside the conversion window, `_outside` is appended to the cohort name. + /// This ensures parameters are provided only once per user. + /// + /// - Parameter cohort: The cohort to which the user is assigned. + /// - Returns: A dictionary containing the experiment name and cohort, or `nil` if the parameters + /// have already been returned. + func freeTrialParametersIfNotPreviouslyReturned(for cohort: any FlagCohort) -> [String: String]? { + let hasReturnedParameters = storage.object(forKey: Constants.hasReturnedFreeTrialParametersKey) as? Bool ?? false + + // Return parameters only if they haven't been returned before + guard !hasReturnedParameters else { + return nil + } + + storage.set(true, forKey: Constants.hasReturnedFreeTrialParametersKey) + + let cohortValue: String + if userIsInConversionWindow { + cohortValue = cohort.rawValue + } else { + cohortValue = "\(cohort.rawValue)_outside" + } + + return [ + Constants.freeTrialParameterExperimentName: rawValue, + Constants.freeTrialParameterExperimentCohort: cohortValue + ] + } + + /// Increments the count of paywall views if the user's enrollment date is within the conversion window. + func incrementPaywallViewCountIfWithinConversionWindow() { + guard userIsInConversionWindow else { return } + paywallViewCount += 1 + } + + /// Fires a pixel tracking the impression of the paywall. + func firePaywallImpressionPixel() { + let bucket = bucketer.bucket(for: paywallViewCount) + experimentPixelFirer.fireExperimentPixel(for: Constants.subfeatureIdentifier, + metric: Constants.metricPaywallImpressions, + conversionWindowDays: Constants.conversionWindowDays, + value: bucket) + } + + /// Fires a pixel when the monthly subscription offer is selected. + func fireOfferSelectionMonthlyPixel() { + let bucket = bucketer.bucket(for: paywallViewCount) + experimentPixelFirer.fireExperimentPixel(for: Constants.subfeatureIdentifier, + metric: Constants.metricStartClickedMonthly, + conversionWindowDays: Constants.conversionWindowDays, + value: bucket) + } + + /// Fires a pixel when the yearly subscription offer is selected. + func fireOfferSelectionYearlyPixel() { + let bucket = bucketer.bucket(for: paywallViewCount) + experimentPixelFirer.fireExperimentPixel(for: Constants.subfeatureIdentifier, + metric: Constants.metricStartClickedYearly, + conversionWindowDays: Constants.conversionWindowDays, + value: bucket) + } + + /// Fires a pixel when a monthly subscription is started. + func fireSubscriptionStartedMonthlyPixel() { + let bucket = bucketer.bucket(for: paywallViewCount) + experimentPixelFirer.fireExperimentPixel(for: Constants.subfeatureIdentifier, + metric: Constants.metricSubscriptionStartedMonthly, + conversionWindowDays: Constants.conversionWindowDays, + value: bucket) + } + + /// Fires a pixel when a yearly subscription is started. + func fireSubscriptionStartedYearlyPixel() { + let bucket = bucketer.bucket(for: paywallViewCount) + experimentPixelFirer.fireExperimentPixel(for: Constants.subfeatureIdentifier, + metric: Constants.metricSubscriptionStartedYearly, + conversionWindowDays: Constants.conversionWindowDays, + value: bucket) + } +} + +private extension FreeTrialsFeatureFlagExperiment { + /// Computed property for managing the paywall view count in persistent storage. + var paywallViewCount: Int { + get { + storage.object(forKey: Constants.paywallViewCountKey) as? Int ?? 0 + } + set { + storage.set(newValue, forKey: Constants.paywallViewCountKey) + } + } + + /// Determines if the user is within the conversion window for the experiment. + var userIsInConversionWindow: Bool { + guard let enrollmentDate = featureFlagger.getAllActiveExperiments()[rawValue]?.enrollmentDate else { + return false + } + + let startOfWindow = enrollmentDate.addingDays(Constants.conversionWindowDays.lowerBound) + let endOfWindow = enrollmentDate.addingDays(Constants.conversionWindowDays.upperBound) + + let today = Date().startOfDay() + return today >= startOfWindow && today <= endOfWindow + } +} + +private extension Date { + /// Returns a new `Date` by adding the specified number of days to the current date. + /// - Parameter days: The number of days to add. Negative values subtract days. + /// - Returns: A new `Date` instance. + func addingDays(_ days: Int) -> Date { + Calendar.current.date(byAdding: .day, value: days, to: self) ?? self + } + + /// Returns the start of the day for the current date. + /// - Returns: A `Date` representing the beginning of the day. + func startOfDay() -> Date { + Calendar.current.startOfDay(for: self) + } +} diff --git a/DuckDuckGo/Subscription/FreeTrialsExperiment/PaywallViewBucketer.swift b/DuckDuckGo/Subscription/FreeTrialsExperiment/PaywallViewBucketer.swift new file mode 100644 index 0000000000..4ac078e314 --- /dev/null +++ b/DuckDuckGo/Subscription/FreeTrialsExperiment/PaywallViewBucketer.swift @@ -0,0 +1,54 @@ +// +// PaywallViewBucketer.swift +// DuckDuckGo +// +// 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 + +/// A protocol for defining a bucketing system. +/// - Note: The value to bucket is now explicitly an `Int`, removing the need for an associated type. +protocol Bucketer { + /// Determines the bucket for the given value. + /// - Parameter value: The integer value to bucket. + /// - Returns: A string label representing the bucket the value belongs to. + func bucket(for value: Int) -> String +} + +/// An implementation of the `Bucketer` protocol for bucketing paywall view counts. +struct PaywallViewBucketer: Bucketer { + /// A list of bucket ranges and their corresponding labels. + private let buckets: [(range: ClosedRange, label: String)] = [ + (1...1, "1"), + (2...2, "2"), + (3...3, "3"), + (4...4, "4"), + (5...5, "5"), + (6...10, "6-10"), + (11...50, "11-50"), + (51...Int.max, "51+") + ] + + /// Finds the bucket label for a given value. + /// - Parameter value: The number of paywall views. + /// - Returns: A string label representing the bucket, or "Unknown" if no bucket matches. + func bucket(for value: Int) -> String { + for bucket in buckets where bucket.range.contains(value) { + return bucket.label + } + return "Unknown" + } +} diff --git a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift index 03412bae5a..4bd355a5b5 100644 --- a/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift +++ b/DuckDuckGo/Subscription/UserScripts/SubscriptionPagesUseSubscriptionFeature.swift @@ -62,16 +62,6 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec static let getAccessToken = "getAccessToken" } - struct ProductIDs { - static let monthly = "1month" - static let yearly = "1year" - } - - struct RecurrenceOptions { - static let month = "monthly" - static let year = "yearly" - } - enum UseSubscriptionError: Error { case purchaseFailed, missingEntitlements, @@ -96,6 +86,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec private let appStoreRestoreFlow: AppStoreRestoreFlow private let appStoreAccountManagementFlow: AppStoreAccountManagementFlow private let privacyProDataReporter: PrivacyProDataReporting? + private let freeTrialsExperiment: any FreeTrialsFeatureFlagExperimenting init(subscriptionManager: SubscriptionManager, subscriptionFeatureAvailability: SubscriptionFeatureAvailability, @@ -103,7 +94,8 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec appStorePurchaseFlow: AppStorePurchaseFlow, appStoreRestoreFlow: AppStoreRestoreFlow, appStoreAccountManagementFlow: AppStoreAccountManagementFlow, - privacyProDataReporter: PrivacyProDataReporting? = nil) { + privacyProDataReporter: PrivacyProDataReporting? = nil, + freeTrialsExperiment: any FreeTrialsFeatureFlagExperimenting = FreeTrialsFeatureFlagExperiment()) { self.subscriptionManager = subscriptionManager self.subscriptionFeatureAvailability = subscriptionFeatureAvailability self.appStorePurchaseFlow = appStorePurchaseFlow @@ -111,6 +103,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec self.appStoreAccountManagementFlow = appStoreAccountManagementFlow self.subscriptionAttributionOrigin = subscriptionAttributionOrigin self.privacyProDataReporter = subscriptionAttributionOrigin != nil ? privacyProDataReporter : nil + self.freeTrialsExperiment = freeTrialsExperiment } // Transaction Status and errors are observed from ViewModels to handle errors in the UI @@ -203,10 +196,22 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec return [Constants.token: Constants.empty] } } - + func getSubscriptionOptions(params: Any, original: WKScriptMessage) async -> Encodable? { resetSubscriptionFlow() - if let subscriptionOptions = await subscriptionManager.storePurchaseManager().subscriptionOptions() { + + var subscriptionOptions: SubscriptionOptions? + + if let freeTrialsCohort = freeTrialCohortIfApplicable() { + freeTrialsExperiment.incrementPaywallViewCountIfWithinConversionWindow() + freeTrialsExperiment.firePaywallImpressionPixel() + + subscriptionOptions = await freeTrialSubscriptionOptions(for: freeTrialsCohort) + } else { + subscriptionOptions = await subscriptionManager.storePurchaseManager().subscriptionOptions() + } + + if let subscriptionOptions { if subscriptionFeatureAvailability.isSubscriptionPurchaseAllowed { return subscriptionOptions } else { @@ -277,7 +282,11 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec } setTransactionStatus(.polling) - switch await appStorePurchaseFlow.completeSubscriptionPurchase(with: purchaseTransactionJWS) { + + // Free Trials Experiment Pixels + fireFreeTrialSubscriptionPurchasePixelIfApplicable(for: subscriptionSelection.id) + + switch await appStorePurchaseFlow.completeSubscriptionPurchase(with: purchaseTransactionJWS, additionalParams: completeSubscriptionFreeTrialParameters) { case .success(let purchaseUpdate): Logger.subscription.debug("Subscription purchase completed successfully") DailyPixel.fireDailyAndCount(pixel: .privacyProPurchaseSuccess, @@ -376,12 +385,22 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec func subscriptionsMonthlyPriceClicked(params: Any, original: WKScriptMessage) async -> Encodable? { Logger.subscription.debug("Web function called: \(#function)") Pixel.fire(pixel: .privacyProOfferMonthlyPriceClick) + + if userIsEnrolledInFreeTrialsExperiment { + freeTrialsExperiment.fireOfferSelectionMonthlyPixel() + } + return nil } func subscriptionsYearlyPriceClicked(params: Any, original: WKScriptMessage) async -> Encodable? { Logger.subscription.debug("Web function called: \(#function)") Pixel.fire(pixel: .privacyProOfferYearlyPriceClick) + + if userIsEnrolledInFreeTrialsExperiment { + freeTrialsExperiment.fireOfferSelectionYearlyPixel() + } + return nil } @@ -411,7 +430,9 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec @MainActor func pushPurchaseUpdate(originalMessage: WKScriptMessage, purchaseUpdate: PurchaseUpdate) async { - pushAction(method: .onPurchaseUpdate, webView: originalMessage.webView!, params: purchaseUpdate) + guard let webView = originalMessage.webView else { return } + + pushAction(method: .onPurchaseUpdate, webView: webView, params: purchaseUpdate) } func pushAction(method: SubscribeActionName, webView: WKWebView, params: Encodable) { @@ -459,6 +480,91 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature, ObservableObjec } } +private extension SubscriptionPagesUseSubscriptionFeature { + + /// Retrieves the parameters for completing a subscription free trial if applicable. + /// + /// This property checks if the user is part of a valid free trial cohort. If the user is in a cohort, + /// it returns the associated free trial parameters, provided these parameters have not been returned previously. + /// If the user is not in a cohort or the parameters have already been returned, the property returns `nil`. + /// + /// - Returns: A dictionary of free trial parameters (`[String: String]`) if applicable, or `nil` otherwise. + var completeSubscriptionFreeTrialParameters: [String: String]? { + guard let cohort = freeTrialCohortIfApplicable() else { return nil } + return freeTrialsExperiment.freeTrialParametersIfNotPreviouslyReturned(for: cohort) + } + + /// Determines whether a user is enrolled in the Free Trials experiment + /// - Returns: `true` if the user is part of a free trial cohort, otherwise `false`. + var userIsEnrolledInFreeTrialsExperiment: Bool { + freeTrialCohortIfApplicable() != nil + } + + /// Fires a subscription purchase pixel for a free trial if applicable. + /// + /// This method checks if the user is part of a free trial cohort and fires + /// the appropriate pixel based on the subscription type (monthly or yearly). + /// + /// - Parameter id: The subscription identifier used to determine the type of subscription. + func fireFreeTrialSubscriptionPurchasePixelIfApplicable(for id: String) { + // Check if the pixel should be fired; exit early if not applicable + guard userIsEnrolledInFreeTrialsExperiment else { return } + + /* + Logic based on strings is obviously not ideal, but acceptable for this temporary + experiment. + */ + if id.contains("month") { + freeTrialsExperiment.fireSubscriptionStartedMonthlyPixel() + } else if id.contains("year") { + freeTrialsExperiment.fireSubscriptionStartedYearlyPixel() + } + } + + /// Retrieves the free trial cohort for the user, if applicable. + /// + /// Cohorts are determined based on the feature flag configuration, user authentication status, + /// and whether the user can make purchases. + /// + /// - Returns: A `FreeTrialsFeatureFlagExperiment.Cohort` if the user is part of a cohort, otherwise `nil`. + func freeTrialCohortIfApplicable() -> FreeTrialsFeatureFlagExperiment.Cohort? { + // Check if the user is authenticated; free trials are not applicable for authenticated users + guard !subscriptionManager.accountManager.isUserAuthenticated else { return nil } + // Ensure that the user can make purchases + guard subscriptionManager.canPurchase else { return nil } + + // Retrieve the cohort if the feature flag is enabled + guard let cohort = freeTrialsExperiment.getCohortIfEnabled() as? FreeTrialsFeatureFlagExperiment.Cohort else { return nil } + + return cohort + } + + /// Retrieves the appropriate subscription options based on the free trial cohort. + /// + /// - Parameter freeTrialsCohort: The cohort the user belongs to (`control` or `treatment`). + /// - Returns: A `SubscriptionOptions` object containing the relevant subscription options. + func freeTrialSubscriptionOptions(for freeTrialsCohort: FreeTrialsFeatureFlagExperiment.Cohort) async -> SubscriptionOptions? { + var subscriptionOptions: SubscriptionOptions? + + switch freeTrialsCohort { + case .control: + subscriptionOptions = await subscriptionManager.storePurchaseManager().subscriptionOptions() + case .treatment: + subscriptionOptions = await subscriptionManager.storePurchaseManager().freeTrialSubscriptionOptions() + + /* + Fallback to standard subscription options if nil. + This could occur if the Free Trial offer in AppStoreConnect had an end date in the past. + */ + if subscriptionOptions == nil { + subscriptionOptions = await subscriptionManager.storePurchaseManager().subscriptionOptions() + } + } + + return subscriptionOptions + } +} + private extension Pixel { enum AttributionParameters { diff --git a/DuckDuckGo/SubscriptionDebugViewController.swift b/DuckDuckGo/SubscriptionDebugViewController.swift index ada4f33f33..2609faaf88 100644 --- a/DuckDuckGo/SubscriptionDebugViewController.swift +++ b/DuckDuckGo/SubscriptionDebugViewController.swift @@ -54,6 +54,7 @@ final class SubscriptionDebugViewController: UITableViewController { case environment case pixels case metadata + case featureFlags } enum AuthorizationRows: Int, CaseIterable { @@ -86,8 +87,13 @@ final class SubscriptionDebugViewController: UITableViewController { case countryCode } + enum FeatureFlagRows: Int, CaseIterable { + case privacyProFreeTrialJan25 + } + private var storefrontID = "Loading" private var storefrontCountryCode = "Loading" + private let freeTrialKey = FreeTrialsFeatureFlagExperiment.Constants.featureFlagOverrideKey override func numberOfSections(in tableView: UITableView) -> Int { return Sections.allCases.count @@ -178,6 +184,15 @@ final class SubscriptionDebugViewController: UITableViewController { break } + case .featureFlags: + switch FeatureFlagRows(rawValue: indexPath.row) { + case .privacyProFreeTrialJan25: + cell.textLabel?.text = "privacyProFreeTrialJan25" + cell.accessoryType = UserDefaults.standard.bool(forKey: freeTrialKey) ? .checkmark : .none + case .none: + break + } + case .none: break } @@ -193,6 +208,7 @@ final class SubscriptionDebugViewController: UITableViewController { case .environment: return EnvironmentRows.allCases.count case .pixels: return PixelsRows.allCases.count case .metadata: return MetadataRows.allCases.count + case .featureFlags: return FeatureFlagRows.allCases.count case .none: return 0 } } @@ -228,6 +244,11 @@ final class SubscriptionDebugViewController: UITableViewController { } case .metadata: break + case .featureFlags: + switch FeatureFlagRows(rawValue: indexPath.row) { + case .privacyProFreeTrialJan25: togglePrivacyProFreeTrialJan25Flag() + default: break + } case .none: break } @@ -275,8 +296,6 @@ final class SubscriptionDebugViewController: UITableViewController { } } -// func showAlert(title: String, message: String, alternativeAction) - // MARK: Account Status Actions private func openSubscriptionRestoreFlow() { @@ -336,6 +355,12 @@ final class SubscriptionDebugViewController: UITableViewController { showAlert(title: "", message: message) } + private func togglePrivacyProFreeTrialJan25Flag() { + let currentValue = UserDefaults.standard.bool(forKey: freeTrialKey) + UserDefaults.standard.set(!currentValue, forKey: freeTrialKey) + tableView.reloadData() + } + private func syncAppleIDAccount() { Task { do { diff --git a/DuckDuckGoTests/MockFeatureFlagger.swift b/DuckDuckGoTests/MockFeatureFlagger.swift index 7f6d1a8866..060d187de3 100644 --- a/DuckDuckGoTests/MockFeatureFlagger.swift +++ b/DuckDuckGoTests/MockFeatureFlagger.swift @@ -24,8 +24,12 @@ final class MockFeatureFlagger: FeatureFlagger { var internalUserDecider: InternalUserDecider = DefaultInternalUserDecider(store: MockInternalUserStoring()) var localOverrides: FeatureFlagLocalOverriding? + var mockActiveExperiments: [String: ExperimentData] = [:] + var enabledFeatureFlags: [FeatureFlag] = [] + var cohortToReturn: (any FlagCohort)? + init(enabledFeatureFlags: [FeatureFlag] = []) { self.enabledFeatureFlags = enabledFeatureFlags } @@ -45,10 +49,10 @@ final class MockFeatureFlagger: FeatureFlagger { } func getCohortIfEnabled(for featureFlag: Flag) -> (any FlagCohort)? where Flag: FeatureFlagExperimentDescribing { - return nil + return cohortToReturn } func getAllActiveExperiments() -> Experiments { - return [:] + mockActiveExperiments } } diff --git a/DuckDuckGoTests/Subscription/FreeTrialsExperiment/FreeTrialsFeatureFlagExperimentTests.swift b/DuckDuckGoTests/Subscription/FreeTrialsExperiment/FreeTrialsFeatureFlagExperimentTests.swift new file mode 100644 index 0000000000..009c37e3ae --- /dev/null +++ b/DuckDuckGoTests/Subscription/FreeTrialsExperiment/FreeTrialsFeatureFlagExperimentTests.swift @@ -0,0 +1,311 @@ +// +// FreeTrialsFeatureFlagExperimentTests.swift +// DuckDuckGo +// +// 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 DuckDuckGo +import PixelExperimentKit +import PixelKit +import BrowserServicesKit + +final class FreeTrialsFeatureFlagExperimentTests: XCTestCase { + + private var mockUserDefaults: UserDefaults! + private var mockFeatureFlagger: MockFeatureFlagger! + private var mockBucketer: MockBucketer! + private var sut: FreeTrialsFeatureFlagExperiment! + + private var mockSuiteName: String { + String(describing: self) + } + + override func setUp() { + super.setUp() + mockUserDefaults = UserDefaults(suiteName: mockSuiteName) + mockUserDefaults.removePersistentDomain(forName: mockSuiteName) + + mockFeatureFlagger = MockFeatureFlagger() + mockBucketer = MockBucketer() + + sut = FreeTrialsFeatureFlagExperiment( + storage: mockUserDefaults, + experimentPixelFirer: MockExperimentPixelFirer.self, + bucketer: mockBucketer, + featureFlagger: mockFeatureFlagger + ) + + MockExperimentPixelFirer.reset() + } + + override func tearDown() { + mockUserDefaults.removePersistentDomain(forName: mockSuiteName) + mockUserDefaults = nil + mockFeatureFlagger = nil + mockBucketer = nil + sut = nil + super.tearDown() + } + + func testIncrementPaywallViewCount_incrementsWhenInConversionWindow() { + // Given + let cohort: FreeTrialsFeatureFlagExperiment.Cohort = .treatment + let enrollmentDate = Calendar.current.date(byAdding: .day, value: -1, to: Date())! + mockFeatureFlagger.mockActiveExperiments = [ + FreeTrialsFeatureFlagExperiment.Constants.subfeatureIdentifier: ExperimentData( + parentID: "testParentID", + cohortID: cohort.rawValue, + enrollmentDate: enrollmentDate + ) + ] + XCTAssertEqual(mockUserDefaults.integer(forKey: FreeTrialsFeatureFlagExperiment.Constants.paywallViewCountKey), 0) + + // When + sut.incrementPaywallViewCountIfWithinConversionWindow() + + // Then + XCTAssertEqual(mockUserDefaults.integer(forKey: FreeTrialsFeatureFlagExperiment.Constants.paywallViewCountKey), 1) + } + + func testIncrementPaywallViewCount_doesNotIncrementWhenNotInConversionWindow() { + // Given + let cohort: FreeTrialsFeatureFlagExperiment.Cohort = .treatment + let enrollmentDate = Calendar.current.date(byAdding: .day, value: -10, to: Date())! + mockFeatureFlagger.mockActiveExperiments = [ + FreeTrialsFeatureFlagExperiment.Constants.subfeatureIdentifier: ExperimentData( + parentID: "testParentID", + cohortID: cohort.rawValue, + enrollmentDate: enrollmentDate + ) + ] + XCTAssertEqual(mockUserDefaults.integer(forKey: FreeTrialsFeatureFlagExperiment.Constants.paywallViewCountKey), 0) + + // When + sut.incrementPaywallViewCountIfWithinConversionWindow() + + // Then + XCTAssertEqual(mockUserDefaults.integer(forKey: FreeTrialsFeatureFlagExperiment.Constants.paywallViewCountKey), 0) + } + + func testFirePaywallImpressionPixel_triggersPixelWithBucketedValue() { + // Given + mockBucketer.mockBucket = "6-10" + sut.incrementPaywallViewCountIfWithinConversionWindow() + + // When + sut.firePaywallImpressionPixel() + + // Then + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.count, 1) + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.first?.metric, FreeTrialsFeatureFlagExperiment.Constants.metricPaywallImpressions) + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.first?.value, "6-10") + } + + func testFireOfferSelectionMonthlyPixel_triggersPixelWithBucketedValue() { + // Given + mockBucketer.mockBucket = "6-10" + sut.incrementPaywallViewCountIfWithinConversionWindow() + + // When + sut.fireOfferSelectionMonthlyPixel() + + // Then + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.count, 1) + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.first?.metric, FreeTrialsFeatureFlagExperiment.Constants.metricStartClickedMonthly) + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.first?.value, "6-10") + } + + func testFireOfferSelectionYearlyPixel_triggersPixelWithBucketedValue() { + // Given + mockBucketer.mockBucket = "11-15" + sut.incrementPaywallViewCountIfWithinConversionWindow() + + // When + sut.fireOfferSelectionYearlyPixel() + + // Then + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.count, 1) + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.first?.metric, FreeTrialsFeatureFlagExperiment.Constants.metricStartClickedYearly) + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.first?.value, "11-15") + } + + func testFireSubscriptionStartedMonthlyPixel_triggersPixelWithBucketedValue() { + // Given + mockBucketer.mockBucket = "16-20" + sut.incrementPaywallViewCountIfWithinConversionWindow() + + // When + sut.fireSubscriptionStartedMonthlyPixel() + + // Then + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.count, 1) + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.first?.metric, FreeTrialsFeatureFlagExperiment.Constants.metricSubscriptionStartedMonthly) + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.first?.value, "16-20") + } + + func testFireSubscriptionStartedYearlyPixel_triggersPixelWithBucketedValue() { + // Given + mockBucketer.mockBucket = "21-25" + sut.incrementPaywallViewCountIfWithinConversionWindow() + + // When + sut.fireSubscriptionStartedYearlyPixel() + + // Then + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.count, 1) + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.first?.metric, FreeTrialsFeatureFlagExperiment.Constants.metricSubscriptionStartedYearly) + XCTAssertEqual(MockExperimentPixelFirer.firedMetrics.first?.value, "21-25") + } + + func testFreeTrialParametersIfApplicable_returnsParametersWithinConversionWindow() { + // Given + let cohort: FreeTrialsFeatureFlagExperiment.Cohort = .treatment + let enrollmentDate = Calendar.current.date(byAdding: .day, value: -1, to: Date())! + mockFeatureFlagger.mockActiveExperiments = [ + FreeTrialsFeatureFlagExperiment.Constants.subfeatureIdentifier: ExperimentData( + parentID: "testParentID", + cohortID: cohort.rawValue, + enrollmentDate: enrollmentDate + ) + ] + + // When + let parameters = sut.freeTrialParametersIfNotPreviouslyReturned(for: cohort) + + // Then + XCTAssertEqual(parameters?[FreeTrialsFeatureFlagExperiment.Constants.freeTrialParameterExperimentName], FreeTrialsFeatureFlagExperiment.Constants.subfeatureIdentifier) + XCTAssertEqual(parameters?[FreeTrialsFeatureFlagExperiment.Constants.freeTrialParameterExperimentCohort], cohort.rawValue) + } + + func testFreeTrialParametersIfApplicable_appendsOutsideWhenNotInConversionWindow() { + // Given + let cohort: FreeTrialsFeatureFlagExperiment.Cohort = .treatment + let enrollmentDate = Calendar.current.date(byAdding: .day, value: -10, to: Date())! + mockFeatureFlagger.mockActiveExperiments = [ + FreeTrialsFeatureFlagExperiment.Constants.subfeatureIdentifier: ExperimentData( + parentID: "testParentID", + cohortID: cohort.rawValue, + enrollmentDate: enrollmentDate + ) + ] + + // When + let parameters = sut.freeTrialParametersIfNotPreviouslyReturned(for: cohort) + + // Then + XCTAssertEqual(parameters?[FreeTrialsFeatureFlagExperiment.Constants.freeTrialParameterExperimentCohort], + "\(cohort.rawValue)_outside", + "Cohort value should include '_outside' when not in conversion window") + } + + func testFreeTrialParametersIfApplicable_doesNotReturnParametersIfAlreadyReturned() { + // Given + let cohort: FreeTrialsFeatureFlagExperiment.Cohort = .treatment + mockUserDefaults.set(true, forKey: FreeTrialsFeatureFlagExperiment.Constants.hasReturnedFreeTrialParametersKey) + + // When + let parameters = sut.freeTrialParametersIfNotPreviouslyReturned(for: cohort) + + // Then + XCTAssertNil(parameters, "Parameters should not be returned if they have already been returned") + } + + func testFreeTrialParametersIfApplicable_updatesUserDefaultsCorrectly() { + // Given + let cohort: FreeTrialsFeatureFlagExperiment.Cohort = .treatment + + // When + _ = sut.freeTrialParametersIfNotPreviouslyReturned(for: cohort) + + // Then + XCTAssertTrue(mockUserDefaults.bool(forKey: FreeTrialsFeatureFlagExperiment.Constants.hasReturnedFreeTrialParametersKey), + "UserDefaults should indicate that parameters have been returned") + } + + func testGetCohortIfEnabled_returnsTreatmentCohortWhenOverrideEnabled() { + // Given + mockUserDefaults.set(true, forKey: FreeTrialsFeatureFlagExperiment.Constants.featureFlagOverrideKey) + + // When + let cohort = sut.getCohortIfEnabled() as? FreeTrialsFeatureFlagExperiment.Cohort + + // Then + XCTAssertEqual(cohort, .treatment, "Should return the 'treatment' cohort when the override is enabled.") + } + + func testGetCohortIfEnabled_returnsNilWhenFeatureFlagDisabled() { + // Given + mockUserDefaults.set(false, forKey: FreeTrialsFeatureFlagExperiment.Constants.featureFlagOverrideKey) + mockFeatureFlagger.mockActiveExperiments = [:] + + // When + let cohort = sut.getCohortIfEnabled() + + // Then + XCTAssertNil(cohort, "Should return nil when the feature flag is disabled and no override is present.") + } + + func testGetCohortIfEnabled_returnsCohortFromFeatureFlaggerWhenEnabled() { + // Given + let expectedCohort: FreeTrialsFeatureFlagExperiment.Cohort = .control + let enrollmentDate = Date() + mockUserDefaults.set(false, forKey: FreeTrialsFeatureFlagExperiment.Constants.featureFlagOverrideKey) + mockFeatureFlagger.cohortToReturn = expectedCohort + + // When + let cohort = sut.getCohortIfEnabled() as? FreeTrialsFeatureFlagExperiment.Cohort + + // Then + XCTAssertEqual(cohort, expectedCohort, "Should return the cohort from the feature flagger when the feature flag is enabled.") + } +} + +private final class MockExperimentPixelFirer: ExperimentPixelFiring { + struct FiredPixel { + let subfeatureID: SubfeatureID + let metric: String + let conversionWindow: ConversionWindow + let value: String + } + + static private(set) var firedMetrics: [FiredPixel] = [] + + static func fireExperimentPixel(for subfeatureID: SubfeatureID, + metric: String, + conversionWindowDays: ConversionWindow, + value: String) { + let firedPixel = FiredPixel( + subfeatureID: subfeatureID, + metric: metric, + conversionWindow: conversionWindowDays, + value: value + ) + firedMetrics.append(firedPixel) + } + + static func reset() { + firedMetrics.removeAll() + } +} + +private final class MockBucketer: Bucketer { + var mockBucket: String = "Unknown" + + func bucket(for value: Int) -> String { + return mockBucket + } +} diff --git a/DuckDuckGoTests/Subscription/FreeTrialsExperiment/PaywallViewBucketerTests.swift b/DuckDuckGoTests/Subscription/FreeTrialsExperiment/PaywallViewBucketerTests.swift new file mode 100644 index 0000000000..b905de54d2 --- /dev/null +++ b/DuckDuckGoTests/Subscription/FreeTrialsExperiment/PaywallViewBucketerTests.swift @@ -0,0 +1,57 @@ +// +// PaywallViewBucketerTests.swift +// DuckDuckGo +// +// 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 DuckDuckGo + +class PaywallViewBucketerTests: XCTestCase { + + func testBucketForVariousValues() { + // Given + let bucketer = PaywallViewBucketer() + + // When & Then + XCTAssertEqual(bucketer.bucket(for: 1), "1", "Given 1 view, Then it should map to '1'") + XCTAssertEqual(bucketer.bucket(for: 5), "5", "Given 5 views, Then it should map to '5'") + XCTAssertEqual(bucketer.bucket(for: 7), "6-10", "Given 7 views, Then it should map to '6-10'") + XCTAssertEqual(bucketer.bucket(for: 15), "11-50", "Given 15 views, Then it should map to '11-50'") + XCTAssertEqual(bucketer.bucket(for: 51), "51+", "Given 51 views, Then it should map to '51+'") + XCTAssertEqual(bucketer.bucket(for: 100), "51+", "Given 100 views, Then it should map to '51+'") + } + + /// Tests that values outside the defined ranges return "Unknown". + func testBucketForOutOfRangeValues() { + // Given + let bucketer = PaywallViewBucketer() + + // When & Then + XCTAssertEqual(bucketer.bucket(for: 0), "Unknown", "Given 0 views (out of range), Then it should map to 'Unknown'") + XCTAssertEqual(bucketer.bucket(for: -10), "Unknown", "Given -10 views (negative value), Then it should map to 'Unknown'") + } + + /// Tests edge cases at the boundary of each bucket range. + func testBucketForEdgeCases() { + // Given + let bucketer = PaywallViewBucketer() + + // When & Then + XCTAssertEqual(bucketer.bucket(for: 10), "6-10", "Given 10 views (upper boundary of '6-10'), Then it should map to '6-10'") + XCTAssertEqual(bucketer.bucket(for: 50), "11-50", "Given 50 views (upper boundary of '11-50'), Then it should map to '11-50'") + } +} diff --git a/DuckDuckGoTests/Subscription/FreeTrialsExperiment/SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift b/DuckDuckGoTests/Subscription/FreeTrialsExperiment/SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift new file mode 100644 index 0000000000..eb85275588 --- /dev/null +++ b/DuckDuckGoTests/Subscription/FreeTrialsExperiment/SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift @@ -0,0 +1,298 @@ +// +// SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests.swift +// DuckDuckGo +// +// 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 +import BrowserServicesKit +import SubscriptionTestingUtilities +@testable import Subscription +@testable import DuckDuckGo + + +final class SubscriptionPagesUseSubscriptionFeatureFreeTrialsTests: XCTestCase { + + private var sut: SubscriptionPagesUseSubscriptionFeature! + + private var mockSubscriptionManager: SubscriptionManagerMock! + private var mockAccountManager: AccountManagerMock! + private var mockStorePurchaseManager: StorePurchaseManagerMock! + private var mockFreeTrialsFeatureFlagExperiment: MockFreeTrialsFeatureFlagExperiment! + private var mockAppStorePurchaseFlow: AppStorePurchaseFlowMock! + + override func setUpWithError() throws { + mockAccountManager = AccountManagerMock() + mockStorePurchaseManager = StorePurchaseManagerMock() + mockSubscriptionManager = SubscriptionManagerMock(accountManager: mockAccountManager, + subscriptionEndpointService: SubscriptionEndpointServiceMock(), + authEndpointService: AuthEndpointServiceMock(), + storePurchaseManager: mockStorePurchaseManager, + currentEnvironment: SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore), + canPurchase: true, + subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock()) + + mockAppStorePurchaseFlow = AppStorePurchaseFlowMock() + mockFreeTrialsFeatureFlagExperiment = MockFreeTrialsFeatureFlagExperiment() + + sut = SubscriptionPagesUseSubscriptionFeature(subscriptionManager: mockSubscriptionManager, + subscriptionFeatureAvailability: SubscriptionFeatureAvailabilityMock.enabled, + subscriptionAttributionOrigin: nil, + appStorePurchaseFlow: mockAppStorePurchaseFlow, + appStoreRestoreFlow: AppStoreRestoreFlowMock(), + appStoreAccountManagementFlow: AppStoreAccountManagementFlowMock(), + freeTrialsExperiment: mockFreeTrialsFeatureFlagExperiment) + } + + func testWhenFreeTrialsCohortIsControl_thenStandardSubscriptionOptionsAreReturned() async throws { + // Given + mockAccountManager.accessToken = nil + mockSubscriptionManager.canPurchase = true + mockFreeTrialsFeatureFlagExperiment.cohortToReturn = FreeTrialsFeatureFlagExperiment.Cohort.control + mockStorePurchaseManager.subscriptionOptionsResult = .mockStandard + + // When + let result = await sut.getSubscriptionOptions(params: "", original: MockWKScriptMessage(name: "", body: "")) + + // Then + XCTAssertEqual(result as? SubscriptionOptions, .mockStandard) + XCTAssertTrue(mockFreeTrialsFeatureFlagExperiment.incrementPaywallViewCountCalled) + XCTAssertTrue(mockFreeTrialsFeatureFlagExperiment.firePaywallImpressionPixelCalled) + } + + func testWhenFreeTrialsCohortIsTreatment_thenFreeTrialSubscriptionOptionsAreReturned() async throws { + // Given + mockAccountManager.accessToken = nil + mockSubscriptionManager.canPurchase = true + mockFreeTrialsFeatureFlagExperiment.cohortToReturn = FreeTrialsFeatureFlagExperiment.Cohort.treatment + mockStorePurchaseManager.freeTrialSubscriptionOptionsResult = .mockFreeTrial + + // When + let result = await sut.getSubscriptionOptions(params: "", original: MockWKScriptMessage(name: "", body: "")) + + // Then + XCTAssertEqual(result as? SubscriptionOptions, .mockFreeTrial) + XCTAssertTrue(mockFreeTrialsFeatureFlagExperiment.incrementPaywallViewCountCalled) + XCTAssertTrue(mockFreeTrialsFeatureFlagExperiment.firePaywallImpressionPixelCalled) + } + + func testWhenUserIsAuthenticated_thenStandardSubscriptionOptionsAreReturned() async throws { + // Given + mockAccountManager.accessToken = "token" + mockSubscriptionManager.canPurchase = true + mockStorePurchaseManager.subscriptionOptionsResult = .mockStandard + + // When + let result = await sut.getSubscriptionOptions(params: "", original: MockWKScriptMessage(name: "", body: "")) + + // Then + XCTAssertEqual(result as? SubscriptionOptions, .mockStandard) + XCTAssertFalse(mockFreeTrialsFeatureFlagExperiment.incrementPaywallViewCountCalled) + XCTAssertFalse(mockFreeTrialsFeatureFlagExperiment.firePaywallImpressionPixelCalled) + } + + func testWhenUserCannotPurchase_thenStandardSubscriptionOptionsAreReturned() async throws { + // Given + mockAccountManager.accessToken = nil + mockSubscriptionManager.canPurchase = false + mockStorePurchaseManager.subscriptionOptionsResult = .mockStandard + + // When + let result = await sut.getSubscriptionOptions(params: "", original: MockWKScriptMessage(name: "", body: "")) + + // Then + XCTAssertEqual(result as? SubscriptionOptions, .mockStandard) + XCTAssertFalse(mockFreeTrialsFeatureFlagExperiment.incrementPaywallViewCountCalled) + XCTAssertFalse(mockFreeTrialsFeatureFlagExperiment.firePaywallImpressionPixelCalled) + } + + func testWhenFailedToFetchSubscriptionOptions_thenEmptyOptionsAreReturned() async throws { + // Given + mockAccountManager.accessToken = nil + mockSubscriptionManager.canPurchase = true + mockFreeTrialsFeatureFlagExperiment.cohortToReturn = FreeTrialsFeatureFlagExperiment.Cohort.control + mockStorePurchaseManager.subscriptionOptionsResult = nil + + // When + let result = await sut.getSubscriptionOptions(params: "", original: MockWKScriptMessage(name: "", body: "")) + + // Then + XCTAssertEqual(result as? SubscriptionOptions, .empty) + XCTAssertEqual(sut.transactionError, .failedToGetSubscriptionOptions) + } + + func testWhenFreeTrialsCohortIsTreatmentAndFreeTrialOptionsAreNil_thenFallbackToStandardOptions() async throws { + // Given + mockAccountManager.accessToken = nil + mockSubscriptionManager.canPurchase = true + mockFreeTrialsFeatureFlagExperiment.cohortToReturn = FreeTrialsFeatureFlagExperiment.Cohort.treatment + mockStorePurchaseManager.freeTrialSubscriptionOptionsResult = nil + mockStorePurchaseManager.subscriptionOptionsResult = .mockStandard + + // When + let result = await sut.getSubscriptionOptions(params: "", original: MockWKScriptMessage(name: "", body: "")) + + // Then + XCTAssertEqual(result as? SubscriptionOptions, .mockStandard, "Should return standard subscription options as a fallback when free trial options are nil.") + XCTAssertTrue(mockFreeTrialsFeatureFlagExperiment.incrementPaywallViewCountCalled, "Paywall view count should be incremented.") + XCTAssertTrue(mockFreeTrialsFeatureFlagExperiment.firePaywallImpressionPixelCalled, "Paywall impression pixel should be fired.") + } + + func testWhenMonthlySubscribeSucceedsForTreatment_thenSubscriptionPurchasedMonthlyPixelFired() async throws { + // Given + mockAccountManager.accessToken = nil + mockSubscriptionManager.canPurchase = true + mockFreeTrialsFeatureFlagExperiment.cohortToReturn = FreeTrialsFeatureFlagExperiment.Cohort.treatment + mockAppStorePurchaseFlow.purchaseSubscriptionResult = .success("") + mockAppStorePurchaseFlow.completeSubscriptionPurchaseResult = .success(.completed) + + let params: [String: Any] = ["id": "monthly-free-trial"] + + // When + _ = await sut.subscriptionSelected(params: params, original: MockWKScriptMessage(name: "", body: "")) + + // Then + XCTAssertTrue(mockFreeTrialsFeatureFlagExperiment.fireSubscriptionStartedMonthlyPixelCalled) + XCTAssertFalse(mockFreeTrialsFeatureFlagExperiment.fireSubscriptionStartedYearlyPixelCalled) + } + + func testWhenMonthlySubscribeSucceedsForTreatment_thenSubscriptionPurchasedYearlyPixelFired() async throws { + // Given + mockAccountManager.accessToken = nil + mockSubscriptionManager.canPurchase = true + mockFreeTrialsFeatureFlagExperiment.cohortToReturn = FreeTrialsFeatureFlagExperiment.Cohort.treatment + mockAppStorePurchaseFlow.purchaseSubscriptionResult = .success("") + mockAppStorePurchaseFlow.completeSubscriptionPurchaseResult = .success(.completed) + + let params: [String: Any] = ["id": "yearly-free-trial"] + + // When + _ = await sut.subscriptionSelected(params: params, original: MockWKScriptMessage(name: "", body: "")) + + // Then + XCTAssertFalse(mockFreeTrialsFeatureFlagExperiment.fireSubscriptionStartedMonthlyPixelCalled) + XCTAssertTrue(mockFreeTrialsFeatureFlagExperiment.fireSubscriptionStartedYearlyPixelCalled) + } + + func testWhenMonthlySubscribeSucceedsForControl_thenSubscriptionPurchasedMonthlyPixelFired() async throws { + // Given + mockAccountManager.accessToken = nil + mockSubscriptionManager.canPurchase = true + mockFreeTrialsFeatureFlagExperiment.cohortToReturn = FreeTrialsFeatureFlagExperiment.Cohort.control + mockAppStorePurchaseFlow.purchaseSubscriptionResult = .success("") + mockAppStorePurchaseFlow.completeSubscriptionPurchaseResult = .success(.completed) + + let params: [String: Any] = ["id": "monthly-free-trial"] + + // When + _ = await sut.subscriptionSelected(params: params, original: MockWKScriptMessage(name: "", body: "")) + + // Then + XCTAssertTrue(mockFreeTrialsFeatureFlagExperiment.fireSubscriptionStartedMonthlyPixelCalled) + XCTAssertFalse(mockFreeTrialsFeatureFlagExperiment.fireSubscriptionStartedYearlyPixelCalled) + } + + func testWhenMonthlySubscribeSucceedsForControl_thenSubscriptionPurchasedYearlyPixelFired() async throws { + // Given + mockAccountManager.accessToken = nil + mockSubscriptionManager.canPurchase = true + mockFreeTrialsFeatureFlagExperiment.cohortToReturn = FreeTrialsFeatureFlagExperiment.Cohort.control + mockAppStorePurchaseFlow.purchaseSubscriptionResult = .success("") + mockAppStorePurchaseFlow.completeSubscriptionPurchaseResult = .success(.completed) + + let params: [String: Any] = ["id": "yearly-free-trial"] + + // When + _ = await sut.subscriptionSelected(params: params, original: MockWKScriptMessage(name: "", body: "")) + + // Then + XCTAssertFalse(mockFreeTrialsFeatureFlagExperiment.fireSubscriptionStartedMonthlyPixelCalled) + XCTAssertTrue(mockFreeTrialsFeatureFlagExperiment.fireSubscriptionStartedYearlyPixelCalled) + } +} + +private extension SubscriptionOptions { + static let mockStandard = SubscriptionOptions(platform: .ios, + options: [ + SubscriptionOption(id: "1", + cost: SubscriptionOptionCost(displayPrice: "9", recurrence: "monthly")), + SubscriptionOption(id: "2", + cost: SubscriptionOptionCost(displayPrice: "99", recurrence: "yearly")) + ], + features: [ + SubscriptionFeature(name: .networkProtection), + SubscriptionFeature(name: .dataBrokerProtection), + SubscriptionFeature(name: .identityTheftRestoration) + ]) + + static let mockFreeTrial = SubscriptionOptions(platform: .ios, + options: [ + SubscriptionOption(id: "3", + cost: SubscriptionOptionCost(displayPrice: "0", recurrence: "monthly-free-trial"), offer: .init(type: .freeTrial, id: "1", durationInDays: 4, isUserEligible: true)), + SubscriptionOption(id: "4", + cost: SubscriptionOptionCost(displayPrice: "0", recurrence: "yearly-free-trial"), offer: .init(type: .freeTrial, id: "1", durationInDays: 4, isUserEligible: true)) + ], + features: [ + SubscriptionFeature(name: .networkProtection) + ]) +} + +private final class MockFreeTrialsFeatureFlagExperiment: FreeTrialsFeatureFlagExperimenting { + + typealias CohortType = FreeTrialsFeatureFlagExperiment.Cohort + var rawValue: String = "" + var source: FeatureFlagSource = .remoteReleasable(.subfeature(PrivacyProSubfeature.privacyProFreeTrialJan25)) + + var incrementPaywallViewCountCalled = false + var firePaywallImpressionPixelCalled = false + var fireOfferSelectionMonthlyPixelCalled = false + var fireOfferSelectionYearlyPixelCalled = false + var fireSubscriptionStartedMonthlyPixelCalled = false + var fireSubscriptionStartedYearlyPixelCalled = false + var cohortToReturn = FreeTrialsFeatureFlagExperiment.Cohort.treatment + + func getCohortIfEnabled() -> (any FlagCohort)? { + cohortToReturn + } + + func freeTrialParametersIfNotPreviouslyReturned(for cohort: any FlagCohort) -> [String: String]? { + nil + } + + func incrementPaywallViewCountIfWithinConversionWindow() { + incrementPaywallViewCountCalled = true + } + + func firePaywallImpressionPixel() { + firePaywallImpressionPixelCalled = true + } + + func fireOfferSelectionMonthlyPixel() { + fireOfferSelectionMonthlyPixelCalled = true + } + + func fireOfferSelectionYearlyPixel() { + fireOfferSelectionYearlyPixelCalled = true + } + + func fireSubscriptionStartedMonthlyPixel() { + fireSubscriptionStartedMonthlyPixelCalled = true + } + + func fireSubscriptionStartedYearlyPixel() { + fireSubscriptionStartedYearlyPixelCalled = true + } +}