diff --git a/Modules/Sources/Experiments/DefaultFeatureFlagService.swift b/Modules/Sources/Experiments/DefaultFeatureFlagService.swift index 08d57cb8f29..0605b3077f8 100644 --- a/Modules/Sources/Experiments/DefaultFeatureFlagService.swift +++ b/Modules/Sources/Experiments/DefaultFeatureFlagService.swift @@ -104,6 +104,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService { return buildConfig == .localDeveloper || buildConfig == .alpha case .pointOfSaleLocalCatalogi1: return buildConfig == .localDeveloper || buildConfig == .alpha + case .ciabBookings: + return buildConfig == .localDeveloper || buildConfig == .alpha default: return true } diff --git a/Modules/Sources/Experiments/FeatureFlag.swift b/Modules/Sources/Experiments/FeatureFlag.swift index 797ad5e0495..4984f1e0339 100644 --- a/Modules/Sources/Experiments/FeatureFlag.swift +++ b/Modules/Sources/Experiments/FeatureFlag.swift @@ -215,4 +215,8 @@ public enum FeatureFlag: Int { /// It syncs products and variations to local storage and display them in POS for quick access. /// case pointOfSaleLocalCatalogi1 + + /// Enables a new Bookings tab for CIAB sites + /// + case ciabBookings } diff --git a/Modules/Sources/Networking/Remote/ProductsRemote.swift b/Modules/Sources/Networking/Remote/ProductsRemote.swift index dff388df17a..e812e66039e 100644 --- a/Modules/Sources/Networking/Remote/ProductsRemote.swift +++ b/Modules/Sources/Networking/Remote/ProductsRemote.swift @@ -48,6 +48,7 @@ public protocol ProductsRemoteProtocol { pageNumber: Int, pageSize: Int, productStatus: ProductStatus?, + productType: ProductType?, completion: @escaping (Result<[Int64], Error>) -> Void) func loadNumberOfProducts(siteID: Int64) async throws -> Int64 @@ -593,12 +594,14 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol { pageNumber: Int = Default.pageNumber, pageSize: Int = Default.pageSize, productStatus: ProductStatus? = nil, + productType: ProductType? = nil, completion: @escaping (Result<[Int64], Error>) -> Void) { let parameters = [ ParameterKey.page: String(pageNumber), ParameterKey.perPage: String(pageSize), ParameterKey.fields: ParameterKey.id, - ParameterKey.productStatus: productStatus?.rawValue ?? "" + ParameterKey.productStatus: productStatus?.rawValue ?? "", + ParameterKey.productType: productType?.rawValue ?? "" ].filter({ $0.value.isEmpty == false }) let path = Path.products diff --git a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift index 0b3a9c92145..903b2c4004c 100644 --- a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift +++ b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift @@ -271,6 +271,20 @@ public extension StorageType { return allObjects(ofType: Product.self, matching: predicate, sortedBy: [descriptor]) } + /// Has stored Products for the provided siteID and optional requirements. + /// + func hasProducts(siteID: Int64, status: String?, type: String?) -> Bool { + var predicates: [NSPredicate] = [\Product.siteID == siteID] + if let status { + predicates.append(\Product.statusKey == status) + } + if let type { + predicates.append(\Product.productTypeKey == type) + } + let combinedPredicate = NSCompoundPredicate(type: .and, subpredicates: predicates) + return firstObject(ofType: Product.self, matching: combinedPredicate) != nil + } + /// Retrieves all of the stored Products matching the provided array products ids from the provided SiteID /// func loadProducts(siteID: Int64, productsIDs: [Int64]) -> [Product] { diff --git a/Modules/Sources/Yosemite/Actions/ProductAction.swift b/Modules/Sources/Yosemite/Actions/ProductAction.swift index e6b1c9b276f..9eb9b522b39 100644 --- a/Modules/Sources/Yosemite/Actions/ProductAction.swift +++ b/Modules/Sources/Yosemite/Actions/ProductAction.swift @@ -124,6 +124,7 @@ public enum ProductAction: Action { /// case checkIfStoreHasProducts(siteID: Int64, status: ProductStatus? = nil, + type: ProductType? = nil, onCompletion: (Result) -> Void) /// Identifies the language from the given string diff --git a/Modules/Sources/Yosemite/Stores/ProductStore.swift b/Modules/Sources/Yosemite/Stores/ProductStore.swift index 1a73dba5a2e..5fe95981760 100644 --- a/Modules/Sources/Yosemite/Stores/ProductStore.swift +++ b/Modules/Sources/Yosemite/Stores/ProductStore.swift @@ -120,8 +120,8 @@ public class ProductStore: Store { validateProductSKU(sku, siteID: siteID, onCompletion: onCompletion) case let .replaceProductLocally(product, onCompletion): replaceProductLocally(product: product, onCompletion: onCompletion) - case let .checkIfStoreHasProducts(siteID, status, onCompletion): - checkIfStoreHasProducts(siteID: siteID, status: status, onCompletion: onCompletion) + case let .checkIfStoreHasProducts(siteID, status, productType, onCompletion): + checkIfStoreHasProducts(siteID: siteID, status: status, productType: productType, onCompletion: onCompletion) case let .identifyLanguage(siteID, string, feature, completion): identifyLanguage(siteID: siteID, string: string, feature: feature, @@ -562,19 +562,18 @@ private extension ProductStore { /// Checks if the store already has any products with the given status. /// Returns `false` if the store has no products. /// - func checkIfStoreHasProducts(siteID: Int64, status: ProductStatus?, onCompletion: @escaping (Result) -> Void) { + func checkIfStoreHasProducts(siteID: Int64, + status: ProductStatus?, + productType: ProductType?, + onCompletion: @escaping (Result) -> Void) { // Check for locally stored products first. let storage = storageManager.viewStorage - if let products = storage.loadProducts(siteID: siteID), !products.isEmpty { - if let status, (products.filter { $0.statusKey == status.rawValue }.isEmpty) == false { - return onCompletion(.success(true)) - } else if status == nil { - return onCompletion(.success(true)) - } + if storage.hasProducts(siteID: siteID, status: status?.rawValue, type: productType?.rawValue) { + return onCompletion(.success(true)) } // If there are no locally stored products, then check remote. - remote.loadProductIDs(for: siteID, pageNumber: 1, pageSize: 1, productStatus: status) { result in + remote.loadProductIDs(for: siteID, pageNumber: 1, pageSize: 1, productStatus: status, productType: productType) { result in switch result { case .success(let ids): onCompletion(.success(ids.isEmpty == false)) diff --git a/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift index a41c62769e3..f7e680ed280 100644 --- a/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift @@ -370,6 +370,7 @@ extension MockProductsRemote: ProductsRemoteProtocol { pageNumber: Int, pageSize: Int, productStatus: ProductStatus?, + productType: ProductType?, completion: @escaping (Result<[Int64], Error>) -> Void) { // no-op } diff --git a/Modules/Tests/YosemiteTests/Stores/ProductStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/ProductStoreTests.swift index f3d6c466dd0..0e055fecf26 100644 --- a/Modules/Tests/YosemiteTests/Stores/ProductStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/ProductStoreTests.swift @@ -1898,6 +1898,26 @@ final class ProductStoreTests: XCTestCase { XCTAssertFalse(hasPublishedProducts) } + func test_checkIfStoreHasProducts_returns_expected_result_when_local_storage_has_no_product_of_given_product_type_and_remote_returns_empty_array() throws { + // Given + storageManager.insertSampleProduct(readOnlyProduct: Product.fake().copy(siteID: sampleSiteID, productTypeKey: "simple")) + let productStore = ProductStore(dispatcher: dispatcher, storageManager: storageManager, network: network) + network.simulateResponse(requestUrlSuffix: "products", filename: "products-ids-only-empty") + + // When + let result: Result = waitFor { promise in + let action = ProductAction.checkIfStoreHasProducts(siteID: self.sampleSiteID, type: .booking) { result in + promise(result) + } + productStore.onAction(action) + } + + // Then + XCTAssertTrue(result.isSuccess) + let hasBookableProducts = try XCTUnwrap(result.get()) + XCTAssertFalse(hasBookableProducts) + } + // MARK: - ProductAction.generateProductDescription func test_generateProductDescription_returns_text_on_success() throws { diff --git a/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift b/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift new file mode 100644 index 00000000000..15d099e7393 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingsTabEligibilityChecker.swift @@ -0,0 +1,85 @@ +// periphery:ignore:all +import Foundation +import Yosemite +import Experiments + +protocol BookingsTabEligibilityCheckerProtocol { + /// Checks the initial visibility of the Bookings tab. + func checkInitialVisibility() -> Bool + /// Checks the final visibility of the Bookings tab. + func checkVisibility() async -> Bool +} + +final class BookingsTabEligibilityChecker: BookingsTabEligibilityCheckerProtocol { + private let site: Site + private let stores: StoresManager + private let featureFlagService: FeatureFlagService + private let ciabEligibilityChecker: CIABEligibilityCheckerProtocol + private let userDefaults: UserDefaults + + init(site: Site, + stores: StoresManager = ServiceLocator.stores, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + ciabEligibilityChecker: CIABEligibilityCheckerProtocol = CIABEligibilityChecker(), + userDefaults: UserDefaults = .standard) { + self.site = site + self.stores = stores + self.featureFlagService = featureFlagService + self.ciabEligibilityChecker = ciabEligibilityChecker + self.userDefaults = userDefaults + } + + /// Checks the initial visibility of the Bookings tab using cached result. + func checkInitialVisibility() -> Bool { + userDefaults.loadCachedBookingsTabVisibility(siteID: site.siteID) + } + + /// Checks the final visibility of the Bookings tab. + func checkVisibility() async -> Bool { + // Check feature flag + guard featureFlagService.isFeatureFlagEnabled(.ciabBookings) else { + return false + } + + // Check if current site is NOT CIAB (bookings only for non-CIAB sites) + guard ciabEligibilityChecker.isSiteCIAB(site) else { + return false + } + + // Cache the result + let isVisible = await checkIfStoreHasBookableProducts() + userDefaults.cacheBookingsTabVisibility(siteID: site.siteID, isVisible: isVisible) + + return isVisible + } +} + +// MARK: Private helpers +// +private extension BookingsTabEligibilityChecker { + @MainActor + func checkIfStoreHasBookableProducts() async -> Bool { + await withCheckedContinuation { continuation in + stores.dispatch(ProductAction.checkIfStoreHasProducts(siteID: site.siteID, type: .booking) { result in + let hasBookableProducts = (try? result.get()) ?? false + continuation.resume(returning: hasBookableProducts) + }) + } + } +} + +extension UserDefaults { + func loadCachedBookingsTabVisibility(siteID: Int64) -> Bool { + guard let cachedValue = self[.ciabBookingsTabAvailable] as? [String: Bool], + let availability = cachedValue[siteID.description] else { + return false + } + return availability + } + + func cacheBookingsTabVisibility(siteID: Int64, isVisible: Bool) { + var cache = (self[.ciabBookingsTabAvailable] as? [String: Bool]) ?? [:] + cache[siteID.description] = isVisible + self[.ciabBookingsTabAvailable] = cache + } +} diff --git a/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift b/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift index f0ad820d4ea..9406149e33e 100644 --- a/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift +++ b/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift @@ -73,6 +73,9 @@ extension UserDefaults { // Application passwords experiment remote FF cached value case applicationPasswordsExperimentRemoteFFValue + + // CIAB Bookings tab availability + case ciabBookingsTabAvailable } } diff --git a/WooCommerce/Classes/System/SessionManager.swift b/WooCommerce/Classes/System/SessionManager.swift index 51bd8dec01c..942b47385a7 100644 --- a/WooCommerce/Classes/System/SessionManager.swift +++ b/WooCommerce/Classes/System/SessionManager.swift @@ -227,6 +227,7 @@ final class SessionManager: SessionManagerProtocol { defaults[.tapToPayAwarenessMomentFirstLaunchCompleted] = nil defaults[.applicationPasswordUnsupportedList] = nil defaults[.applicationPasswordsExperimentRemoteFFValue] = nil + defaults[.ciabBookingsTabAvailable] = nil resetTimestampsValues() imageCache.clearCache() } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index c2437e8c1c1..e601a072244 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -2862,6 +2862,7 @@ DEDA8DBE2B19952B0076BF0F /* ThemeSettingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDA8DBD2B19952B0076BF0F /* ThemeSettingViewModel.swift */; }; DEDA8DC02B19CDC50076BF0F /* ThemeSettingViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDA8DBF2B19CDC50076BF0F /* ThemeSettingViewModelTests.swift */; }; DEDB2D262845D31900CE7D35 /* CouponAllowedEmailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDB2D252845D31900CE7D35 /* CouponAllowedEmailsViewModel.swift */; }; + DEDB5D392E7AC0670022E5A1 /* BookingsTabEligibilityCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDB5D372E7AC0670022E5A1 /* BookingsTabEligibilityCheckerTests.swift */; }; DEDB886B26E8531E00981595 /* ShippingLabelPackageAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDB886A26E8531E00981595 /* ShippingLabelPackageAttributes.swift */; }; DEDC5F5E2D9E7CD5005E38BD /* WooShippingShipmentDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDC5F5D2D9E7CD5005E38BD /* WooShippingShipmentDetailsView.swift */; }; DEDC5F602D9E7CEA005E38BD /* WooShippingShipmentDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDC5F5F2D9E7CDD005E38BD /* WooShippingShipmentDetailsViewModel.swift */; }; @@ -6099,6 +6100,7 @@ DEDA8DBD2B19952B0076BF0F /* ThemeSettingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSettingViewModel.swift; sourceTree = ""; }; DEDA8DBF2B19CDC50076BF0F /* ThemeSettingViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeSettingViewModelTests.swift; sourceTree = ""; }; DEDB2D252845D31900CE7D35 /* CouponAllowedEmailsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CouponAllowedEmailsViewModel.swift; sourceTree = ""; }; + DEDB5D372E7AC0670022E5A1 /* BookingsTabEligibilityCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookingsTabEligibilityCheckerTests.swift; sourceTree = ""; }; DEDB886A26E8531E00981595 /* ShippingLabelPackageAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPackageAttributes.swift; sourceTree = ""; }; DEDC5F5D2D9E7CD5005E38BD /* WooShippingShipmentDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingShipmentDetailsView.swift; sourceTree = ""; }; DEDC5F5F2D9E7CDD005E38BD /* WooShippingShipmentDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingShipmentDetailsViewModel.swift; sourceTree = ""; }; @@ -6406,6 +6408,7 @@ 3F0904022D26A40800D8ACCE /* WordPressAuthenticator */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3F09041C2D26A40800D8ACCE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WordPressAuthenticator; sourceTree = ""; }; 3F09040E2D26A40800D8ACCE /* WordPressAuthenticatorTests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3FD28D5E2D271391002EBB3D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WordPressAuthenticatorTests; sourceTree = ""; }; 3FD9BFBE2E0A2533004A8DC8 /* WooCommerceScreenshots */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (3FD9BFC42E0A2534004A8DC8 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = WooCommerceScreenshots; sourceTree = ""; }; + DEDB5D342E7A68950022E5A1 /* Bookings */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Bookings; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -10845,6 +10848,7 @@ B958A7CF28B527FB00823EEF /* Universal Links */, D8F01DD125DEDC0100CE70BE /* Stripe Integration Tests */, 5791FB4024EC833200117FD6 /* ViewModels */, + DEDB5D382E7AC0670022E5A1 /* Bookings */, 57C2F6E324C27B0C00131012 /* Authentication */, CCDC49F1240060F3003166BA /* UnitTests.xctestplan */, D816DDBA22265D8000903E59 /* ViewRelated */, @@ -10919,6 +10923,7 @@ B56DB3F12049C0B800D4AA8E /* Classes */ = { isa = PBXGroup; children = ( + DEDB5D342E7A68950022E5A1 /* Bookings */, 2DCB54F82E6AE8C900621F90 /* CIAB */, DEB387932C2E7A540025256E /* GoogleAds */, 20AE33C32B0510AD00527B60 /* Destinations */, @@ -13755,6 +13760,14 @@ path = Themes; sourceTree = ""; }; + DEDB5D382E7AC0670022E5A1 /* Bookings */ = { + isa = PBXGroup; + children = ( + DEDB5D372E7AC0670022E5A1 /* BookingsTabEligibilityCheckerTests.swift */, + ); + path = Bookings; + sourceTree = ""; + }; DEDC5F5C2D9E7CB8005E38BD /* ShipmentDetails */ = { isa = PBXGroup; children = ( @@ -14466,6 +14479,7 @@ ); fileSystemSynchronizedGroups = ( 2D33E6B02DD1453E000C7198 /* WooShippingPaymentMethod */, + DEDB5D342E7A68950022E5A1 /* Bookings */, ); name = WooCommerce; packageProductDependencies = ( @@ -17735,6 +17749,7 @@ EE57C121297E76E000BC31E7 /* TrackEventRequestNotificationHandlerTests.swift in Sources */, D8A8C4F32268288F001C72BF /* AddManualCustomTrackingViewModelTests.swift in Sources */, 269B46642A16D6ED00ADA872 /* UpdateAnalyticsSettingsUseCaseTests.swift in Sources */, + DEDB5D392E7AC0670022E5A1 /* BookingsTabEligibilityCheckerTests.swift in Sources */, AEFF77A829786A2900667F7A /* PriceInputViewControllerTests.swift in Sources */, 02C2756F24F5F5EE00286C04 /* ProductShippingSettingsViewModel+ProductVariationTests.swift in Sources */, EEBB9B402D8FE5B6008D6CE5 /* WooShippingSplitShipmentsViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Bookings/BookingsTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/Bookings/BookingsTabEligibilityCheckerTests.swift new file mode 100644 index 00000000000..fa956eabc2b --- /dev/null +++ b/WooCommerce/WooCommerceTests/Bookings/BookingsTabEligibilityCheckerTests.swift @@ -0,0 +1,279 @@ +import Combine +import Foundation +import Testing +import WooFoundation +import Yosemite +@testable import WooCommerce + +@MainActor +struct BookingsTabEligibilityCheckerTests { + private var stores: MockStoresManager! + private var site: Site! + private var featureFlagService: MockFeatureFlagService! + private var ciabEligibilityChecker: MockCIABEligibilityChecker! + private let siteID: Int64 = 123 + + init() { + stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true)) + stores.updateDefaultStore(storeID: siteID) + site = Site.fake().copy(siteID: siteID) + featureFlagService = MockFeatureFlagService() + ciabEligibilityChecker = MockCIABEligibilityChecker(mockedIsCurrentSiteCIAB: true, mockedCIABSites: [site]) + } + + // MARK: - `checkInitialVisibility` Tests + + @Test func checkInitialVisibility_returns_true_when_cached_tab_visibility_is_enabled() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + userDefaults[.ciabBookingsTabAvailable] = [siteID.description: true] + let checker = BookingsTabEligibilityChecker(site: site, + stores: stores, + featureFlagService: featureFlagService, + ciabEligibilityChecker: ciabEligibilityChecker, + userDefaults: userDefaults) + + // When + let result = checker.checkInitialVisibility() + + // Then + #expect(result == true) + } + + @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_disabled() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + userDefaults[.ciabBookingsTabAvailable] = [siteID.description: false] + let checker = BookingsTabEligibilityChecker(site: site, + stores: stores, + featureFlagService: featureFlagService, + ciabEligibilityChecker: ciabEligibilityChecker, + userDefaults: userDefaults) + + // When + let result = checker.checkInitialVisibility() + + // Then + #expect(result == false) + } + + @Test func checkInitialVisibility_returns_false_when_cached_tab_visibility_is_unavailable() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + let checker = BookingsTabEligibilityChecker(site: site, + stores: stores, + featureFlagService: featureFlagService, + ciabEligibilityChecker: ciabEligibilityChecker, + userDefaults: userDefaults) + + // When + let result = checker.checkInitialVisibility() + + // Then + #expect(result == false) + } + + // MARK: - `checkVisibility` Tests + + @Test func checkVisibility_returns_false_when_feature_flag_is_disabled() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + let featureFlagService = MockFeatureFlagService(isCIABBookingsEnabled: false) + let checker = BookingsTabEligibilityChecker(site: site, + stores: stores, + featureFlagService: featureFlagService, + ciabEligibilityChecker: ciabEligibilityChecker, + userDefaults: userDefaults) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test func checkVisibility_returns_false_when_site_is_not_CIAB() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + let featureFlagService = MockFeatureFlagService(isCIABBookingsEnabled: true) + let ciabEligibilityChecker = MockCIABEligibilityChecker(mockedIsCurrentSiteCIAB: false) + setupStoreHasBookableProducts(hasProducts: true) + let checker = BookingsTabEligibilityChecker(site: site, + stores: stores, + featureFlagService: featureFlagService, + ciabEligibilityChecker: ciabEligibilityChecker, + userDefaults: userDefaults) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test func checkVisibility_returns_false_when_store_has_no_bookable_products() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + let featureFlagService = MockFeatureFlagService(isCIABBookingsEnabled: true) + setupStoreHasBookableProducts(hasProducts: false) + let checker = BookingsTabEligibilityChecker(site: site, + stores: stores, + featureFlagService: featureFlagService, + ciabEligibilityChecker: ciabEligibilityChecker, + userDefaults: userDefaults) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + @Test func checkVisibility_returns_true_when_all_conditions_are_satisfied() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + let featureFlagService = MockFeatureFlagService(isCIABBookingsEnabled: true) + setupStoreHasBookableProducts(hasProducts: true) + let checker = BookingsTabEligibilityChecker(site: site, + stores: stores, + featureFlagService: featureFlagService, + ciabEligibilityChecker: ciabEligibilityChecker, + userDefaults: userDefaults) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + } + + @Test func checkVisibility_caches_result_when_visible() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + let featureFlagService = MockFeatureFlagService(isCIABBookingsEnabled: true) + setupStoreHasBookableProducts(hasProducts: true) + let checker = BookingsTabEligibilityChecker(site: site, + stores: stores, + featureFlagService: featureFlagService, + ciabEligibilityChecker: ciabEligibilityChecker, + userDefaults: userDefaults) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == true) + #expect(userDefaults.loadCachedBookingsTabVisibility(siteID: siteID) == true) + } + + @Test func checkVisibility_caches_result_when_not_visible() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + let featureFlagService = MockFeatureFlagService(isCIABBookingsEnabled: true) + setupStoreHasBookableProducts(hasProducts: false) + let checker = BookingsTabEligibilityChecker(site: site, + stores: stores, + featureFlagService: featureFlagService, + ciabEligibilityChecker: ciabEligibilityChecker, + userDefaults: userDefaults) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + #expect(userDefaults.loadCachedBookingsTabVisibility(siteID: siteID) == false) + } + + @Test func checkVisibility_handles_store_check_failure_gracefully() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + let featureFlagService = MockFeatureFlagService(isCIABBookingsEnabled: true) + setupStoreHasBookableProducts(hasProducts: false, shouldFail: true) + let checker = BookingsTabEligibilityChecker(site: site, + stores: stores, + featureFlagService: featureFlagService, + ciabEligibilityChecker: ciabEligibilityChecker, + userDefaults: userDefaults) + + // When + let result = await checker.checkVisibility() + + // Then + #expect(result == false) + } + + // MARK: - UserDefaults Extension Tests + + @Test func userDefaults_loadCachedBookingsTabVisibility_returns_false_when_no_cache() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + + // When + let result = userDefaults.loadCachedBookingsTabVisibility(siteID: siteID) + + // Then + #expect(result == false) + } + + @Test func userDefaults_loadCachedBookingsTabVisibility_returns_cached_value() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + userDefaults[.ciabBookingsTabAvailable] = [siteID.description: true] + + // When + let result = userDefaults.loadCachedBookingsTabVisibility(siteID: siteID) + + // Then + #expect(result == true) + } + + @Test func userDefaults_cacheBookingsTabVisibility_stores_value_correctly() async throws { + // Given + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + + // When + userDefaults.cacheBookingsTabVisibility(siteID: siteID, isVisible: true) + + // Then + #expect(userDefaults[.ciabBookingsTabAvailable] == [siteID.description: true]) + } + + @Test func userDefaults_handles_multiple_sites() async throws { + // Given + let siteID2: Int64 = 456 + let userDefaults = UserDefaults(suiteName: UUID().uuidString)! + + // When + userDefaults[.ciabBookingsTabAvailable] = [ + siteID.description: true, + siteID2.description: false + ] + + // Then + #expect(userDefaults.loadCachedBookingsTabVisibility(siteID: siteID) == true) + #expect(userDefaults.loadCachedBookingsTabVisibility(siteID: siteID2) == false) + } +} + +// MARK: - Private Helpers + +private extension BookingsTabEligibilityCheckerTests { + + func setupStoreHasBookableProducts(hasProducts: Bool, shouldFail: Bool = false) { + stores.whenReceivingAction(ofType: ProductAction.self) { action in + switch action { + case .checkIfStoreHasProducts(_, _, let type, let completion): + if type == .booking { + if shouldFail { + completion(.failure(NSError(domain: "test", code: 500))) + } else { + completion(.success(hasProducts)) + } + } + default: + break + } + } + } +} diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index 3e40308c598..fb6ada29531 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -25,6 +25,7 @@ final class MockFeatureFlagService: FeatureFlagService { var allowMerchantAIAPIKey: Bool var isProductImageOptimizedHandlingEnabled: Bool var isFeatureFlagEnabledReturnValue: [FeatureFlag: Bool] = [:] + var isCIABBookingsEnabled: Bool init(isInboxOn: Bool = false, isShowInboxCTAEnabled: Bool = false, @@ -47,7 +48,8 @@ final class MockFeatureFlagService: FeatureFlagService { backgroundProductImageUpload: Bool = false, notificationSettings: Bool = false, allowMerchantAIAPIKey: Bool = false, - isProductImageOptimizedHandlingEnabled: Bool = false) { + isProductImageOptimizedHandlingEnabled: Bool = false, + isCIABBookingsEnabled: Bool = false) { self.isInboxOn = isInboxOn self.isShowInboxCTAEnabled = isShowInboxCTAEnabled self.isUpdateOrderOptimisticallyOn = isUpdateOrderOptimisticallyOn @@ -70,6 +72,7 @@ final class MockFeatureFlagService: FeatureFlagService { self.notificationSettings = notificationSettings self.allowMerchantAIAPIKey = allowMerchantAIAPIKey self.isProductImageOptimizedHandlingEnabled = isProductImageOptimizedHandlingEnabled + self.isCIABBookingsEnabled = isCIABBookingsEnabled } func isFeatureFlagEnabled(_ featureFlag: FeatureFlag) -> Bool { @@ -124,6 +127,8 @@ final class MockFeatureFlagService: FeatureFlagService { return allowMerchantAIAPIKey case .productImageOptimizedHandling: return isProductImageOptimizedHandlingEnabled + case .ciabBookings: + return isCIABBookingsEnabled default: return false } diff --git a/WooCommerce/WooCommerceTests/System/SessionManagerTests.swift b/WooCommerce/WooCommerceTests/System/SessionManagerTests.swift index c3f40bad041..15b08cc3442 100644 --- a/WooCommerce/WooCommerceTests/System/SessionManagerTests.swift +++ b/WooCommerce/WooCommerceTests/System/SessionManagerTests.swift @@ -400,6 +400,28 @@ final class SessionManagerTests: XCTestCase { XCTAssertNil(defaults[.applicationPasswordUnsupportedList]) } + /// Verifies that image cache is cleared upon reset + /// + func test_cachedBookingsTabVisibility_is_cleared_upon_reset() throws { + // Given + let siteID: Int64 = 13 + let uuid = UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: uuid)) + let sut = SessionManager(defaults: defaults, keychainServiceName: Settings.keychainServiceName) + + // When + defaults[.ciabBookingsTabAvailable] = [siteID.description: true] + + // Then + XCTAssertEqual(try XCTUnwrap(defaults[.ciabBookingsTabAvailable] as? [String: Bool]), [siteID.description: true]) + + // When + sut.reset() + + // Then + XCTAssertNil(defaults[.ciabBookingsTabAvailable]) + } + /// Verifies that `removeDefaultCredentials` effectively nukes everything from the keychain /// func testDefaultCredentialsAreEffectivelyNuked() {