diff --git a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift index babfe9d6f10..cf8cd20b5b7 100644 --- a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift +++ b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift @@ -832,13 +832,6 @@ public extension StorageType { return allObjects(ofType: SystemPlugin.self, matching: predicate, sortedBy: [descriptor]) } - /// Returns a system plugin with a specified `siteID` and `name` - /// - func loadSystemPlugin(siteID: Int64, name: String) -> SystemPlugin? { - let predicate = \SystemPlugin.siteID == siteID && \SystemPlugin.name == name - return firstObject(ofType: SystemPlugin.self, matching: predicate) - } - /// Returns a system plugin with a specified `siteID` and `path`. /// /// - Parameters: diff --git a/Modules/Sources/Yosemite/Actions/SystemStatusAction.swift b/Modules/Sources/Yosemite/Actions/SystemStatusAction.swift index 7b75ccc6e96..e41906342cc 100644 --- a/Modules/Sources/Yosemite/Actions/SystemStatusAction.swift +++ b/Modules/Sources/Yosemite/Actions/SystemStatusAction.swift @@ -12,10 +12,6 @@ public enum SystemStatusAction: Action { /// case fetchSystemPlugin(siteID: Int64, systemPluginName: String, onCompletion: (SystemPlugin?) -> Void) - /// Fetch an specific systemPlugin by siteID and name list. - /// - case fetchSystemPluginListWithNameList(siteID: Int64, systemPluginNameList: [String], onCompletion: (SystemPlugin?) -> Void) - /// Fetch a specific systemPlugin by path. /// case fetchSystemPluginWithPath(siteID: Int64, pluginPath: String, onCompletion: (SystemPlugin?) -> Void) diff --git a/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockSystemStatusActionHandler.swift b/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockSystemStatusActionHandler.swift index 42dfaa70dc0..ea167eb97b0 100644 --- a/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockSystemStatusActionHandler.swift +++ b/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockSystemStatusActionHandler.swift @@ -19,10 +19,6 @@ struct MockSystemStatusActionHandler: MockActionHandler { let filteredSystemPlugin = systemPlugins.first { $0.name == systemPluginName } let matchingPlugin = systemPlugins.first { $0.name == systemPluginName && $0.active } ?? filteredSystemPlugin onCompletion(matchingPlugin) - case .fetchSystemPluginListWithNameList(let siteID, let systemPluginNameList, let onCompletion): - let systemPlugins = objectGraph.systemPlugins(for: siteID) - let filteredSystemPlugins = systemPlugins.first { systemPluginNameList.contains($0.name) } - onCompletion(filteredSystemPlugins) case .fetchSystemPluginWithPath(let siteID, let pluginPath, let onCompletion): let systemPlugins = objectGraph.systemPlugins(for: siteID) let matchingPlugin = systemPlugins.first { $0.plugin == pluginPath } diff --git a/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift b/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift index a6af2a7f594..e94bb60cbcd 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Eligibility/POSSystemStatusService.swift @@ -25,17 +25,20 @@ public struct POSPluginAndFeatureInfo { public final class POSSystemStatusService: POSSystemStatusServiceProtocol { private let remote: SystemStatusRemote private let storageManager: StorageManagerType + private let pluginsService: PluginsServiceProtocol public init(credentials: Credentials?, storageManager: StorageManagerType) { let network = AlamofireNetwork(credentials: credentials) self.remote = SystemStatusRemote(network: network) self.storageManager = storageManager + self.pluginsService = PluginsService(storageManager: storageManager) } /// Test-friendly initializer that accepts a network implementation. init(network: Network, storageManager: StorageManagerType) { self.remote = SystemStatusRemote(network: network) self.storageManager = storageManager + self.pluginsService = PluginsService(storageManager: storageManager) } @MainActor @@ -54,9 +57,7 @@ public final class POSSystemStatusService: POSSystemStatusServiceProtocol { }) // Loads WooCommerce plugin from storage. - guard let wcPlugin = storageManager.viewStorage.loadSystemPlugin(siteID: siteID, - fileNameWithoutExtension: Constants.wcPluginFileNameWithoutExtension, - active: true)?.toReadOnly() else { + guard let wcPlugin = pluginsService.loadPluginInStorage(siteID: siteID, plugin: .wooCommerce, isActive: true) else { return POSPluginAndFeatureInfo(wcPlugin: nil, featureValue: nil) } @@ -66,12 +67,6 @@ public final class POSSystemStatusService: POSSystemStatusServiceProtocol { } } -private extension POSSystemStatusService { - enum Constants { - static let wcPluginFileNameWithoutExtension = "woocommerce" - } -} - // MARK: - Network Response Structs private struct POSPluginEligibilitySystemStatus: Decodable { diff --git a/Modules/Sources/Yosemite/Stores/SystemStatusStore.swift b/Modules/Sources/Yosemite/Stores/SystemStatusStore.swift index 50487050b65..21b555e3bb9 100644 --- a/Modules/Sources/Yosemite/Stores/SystemStatusStore.swift +++ b/Modules/Sources/Yosemite/Stores/SystemStatusStore.swift @@ -31,8 +31,6 @@ public final class SystemStatusStore: Store { synchronizeSystemInformation(siteID: siteID, completionHandler: onCompletion) case .fetchSystemPlugin(let siteID, let systemPluginName, let onCompletion): fetchSystemPlugin(siteID: siteID, systemPluginNameList: [systemPluginName], completionHandler: onCompletion) - case .fetchSystemPluginListWithNameList(let siteID, let systemPluginNameList, let onCompletion): - fetchSystemPlugin(siteID: siteID, systemPluginNameList: systemPluginNameList, completionHandler: onCompletion) case .fetchSystemPluginWithPath(let siteID, let pluginPath, let onCompletion): fetchSystemPluginWithPath(siteID: siteID, pluginPath: pluginPath, diff --git a/Modules/Sources/Yosemite/Tools/Plugin.swift b/Modules/Sources/Yosemite/Tools/Plugin.swift new file mode 100644 index 00000000000..dfae2a8175f --- /dev/null +++ b/Modules/Sources/Yosemite/Tools/Plugin.swift @@ -0,0 +1,23 @@ +import Foundation + +public enum Plugin: Equatable, CaseIterable { + case wooCommerce + case wooSubscriptions + case wooShipmentTracking + case wooSquare + + /// File name without extension in the plugin path. + /// Full plugin path is like `woocommerce/woocommerce.php`. + var fileNameWithoutExtension: String { + switch self { + case .wooCommerce: + return "woocommerce" + case .wooSubscriptions: + return "woocommerce-subscriptions" + case .wooShipmentTracking: + return "woocommerce-shipment-tracking" + case .wooSquare: + return "woocommerce-square" + } + } +} diff --git a/Modules/Sources/Yosemite/Tools/Plugins/PluginsService.swift b/Modules/Sources/Yosemite/Tools/Plugins/PluginsService.swift index 8c8f0f74236..3b733c66298 100644 --- a/Modules/Sources/Yosemite/Tools/Plugins/PluginsService.swift +++ b/Modules/Sources/Yosemite/Tools/Plugins/PluginsService.swift @@ -11,6 +11,21 @@ public protocol PluginsServiceProtocol { /// - isActive: Whether the plugin is active or not. /// - Returns: The SystemPlugin when found in storage. func waitForPluginInStorage(siteID: Int64, pluginPath: String, isActive: Bool) async -> SystemPlugin + + /// Loads a specific plugin from storage synchronously. + /// - Parameters: + /// - siteID: The site ID to search for the plugin. + /// - plugin: The plugin to load. + /// - isActive: Whether the plugin is active, inactive, or nil for any state. + /// - Returns: The SystemPlugin if found in storage, nil otherwise. + func loadPluginInStorage(siteID: Int64, plugin: Plugin, isActive: Bool?) -> SystemPlugin? +} + +public extension PluginsServiceProtocol { + func isPluginActiveInStorage(siteID: Int64, plugin: Plugin) -> Bool { + let plugin = loadPluginInStorage(siteID: siteID, plugin: plugin, isActive: true) + return plugin != nil && plugin?.active == true + } } public class PluginsService: PluginsServiceProtocol { @@ -49,4 +64,10 @@ public class PluginsService: PluginsServiceProtocol { } } } + + public func loadPluginInStorage(siteID: Int64, plugin: Plugin, isActive: Bool?) -> SystemPlugin? { + storageManager.viewStorage.loadSystemPlugin(siteID: siteID, + fileNameWithoutExtension: plugin.fileNameWithoutExtension, + active: isActive)?.toReadOnly() + } } diff --git a/Modules/Tests/StorageTests/Tools/StorageTypeExtensionsTests.swift b/Modules/Tests/StorageTests/Tools/StorageTypeExtensionsTests.swift index c78fcfdeccb..d8c660043b3 100644 --- a/Modules/Tests/StorageTests/Tools/StorageTypeExtensionsTests.swift +++ b/Modules/Tests/StorageTests/Tools/StorageTypeExtensionsTests.swift @@ -1432,23 +1432,6 @@ final class StorageTypeExtensionsTests: XCTestCase { XCTAssertEqual(storedSystemPlugins, [systemPlugin1, systemPlugin4]) } - func test_loadSystemPlugin_by_siteID_and_name() throws { - // Given - let systemPlugin1 = storage.insertNewObject(ofType: SystemPlugin.self) - systemPlugin1.name = "Plugin 1" - systemPlugin1.siteID = sampleSiteID - - let systemPlugin2 = storage.insertNewObject(ofType: SystemPlugin.self) - systemPlugin2.name = "Plugin 2" - systemPlugin2.siteID = sampleSiteID - - // When - let foundSystemPlugin = try XCTUnwrap(storage.loadSystemPlugin(siteID: sampleSiteID, name: "Plugin 2")) - - // Then - XCTAssertEqual(foundSystemPlugin, systemPlugin2) - } - func test_loadSystemPlugin_by_siteID_and_path() throws { // Given let systemPlugin1 = storage.insertNewObject(ofType: SystemPlugin.self) diff --git a/Modules/Tests/YosemiteTests/Stores/SystemStatusStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/SystemStatusStoreTests.swift index 915bd94721c..195db80d25b 100644 --- a/Modules/Tests/YosemiteTests/Stores/SystemStatusStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/SystemStatusStoreTests.swift @@ -79,8 +79,8 @@ final class SystemStatusStoreTests: XCTestCase { func test_synchronizeSystemInformation_removes_stale_systemPlugins_correctly() { // Given - let staleSystemPluginName = "Stale System Plugin" - let staleSystemPlugin = SystemPlugin.fake().copy(siteID: sampleSiteID, name: staleSystemPluginName) + let staleSystemPluginPath = "folder/stale-plugin.php" + let staleSystemPlugin = SystemPlugin.fake().copy(siteID: sampleSiteID, plugin: staleSystemPluginPath) let storedStaleSystemPlugin = viewStorage.insertNewObject(ofType: StorageSystemPlugin.self) storedStaleSystemPlugin.update(with: staleSystemPlugin) XCTAssertEqual(viewStorage.countObjects(ofType: StorageSystemPlugin.self), 1) @@ -98,7 +98,7 @@ final class SystemStatusStoreTests: XCTestCase { // Then XCTAssertTrue(result.isSuccess) XCTAssertEqual(viewStorage.countObjects(ofType: StorageSystemPlugin.self), 6) // number of systemPlugins in json file - XCTAssertNil(viewStorage.loadSystemPlugin(siteID: sampleSiteID, name: staleSystemPluginName)) + XCTAssertNil(viewStorage.loadSystemPlugin(siteID: sampleSiteID, fileNameWithoutExtension: "stale-plugin")) } func test_fetchSystemPlugins_return_systemPlugins_correctly() { @@ -194,31 +194,6 @@ final class SystemStatusStoreTests: XCTestCase { XCTAssertNil(fetchedPlugin) } - func test_fetchSystemPluginsList_return_systemPlugins_correctly() { - // Given - let systemPlugin1 = viewStorage.insertNewObject(ofType: SystemPlugin.self) - systemPlugin1.name = "Plugin 1" - systemPlugin1.siteID = sampleSiteID - - let systemPlugin3 = viewStorage.insertNewObject(ofType: SystemPlugin.self) - systemPlugin3.name = "Plugin 3" - systemPlugin3.siteID = sampleSiteID - - let store = SystemStatusStore(dispatcher: dispatcher, storageManager: storageManager, network: network) - - // When - let systemPluginResult: Yosemite.SystemPlugin? = waitFor { promise in - let action = SystemStatusAction.fetchSystemPluginListWithNameList(siteID: self.sampleSiteID, - systemPluginNameList: ["Plugin 2", "Plugin 3"]) { result in - promise(result) - } - store.onAction(action) - } - - // Then - XCTAssertEqual(systemPluginResult?.name, "Plugin 3") - } - func test_fetchSystemPluginWithPath_returns_plugin_when_matching_plugin_is_in_storage() { // Given let systemPlugin1 = viewStorage.insertNewObject(ofType: SystemPlugin.self) diff --git a/Modules/Tests/YosemiteTests/Tools/Plugins/PluginsServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/Plugins/PluginsServiceTests.swift index 4f277eba69b..411ee427991 100644 --- a/Modules/Tests/YosemiteTests/Tools/Plugins/PluginsServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/Plugins/PluginsServiceTests.swift @@ -15,14 +15,14 @@ struct PluginsServiceTests { @Test func waitForPluginInStorage_returns_plugin_when_already_in_storage() async { // Given await storageManager.reset() - storageManager.insertWCPlugin(siteID: siteID, isActive: true, version: "1.0.0") + storageManager.insertPlugin(siteID: siteID, plugin: .wooCommerce, isActive: true, version: "1.0.0") // When - let result = await sut.waitForPluginInStorage(siteID: siteID, pluginPath: PluginConstants.plugin, isActive: true) + let result = await sut.waitForPluginInStorage(siteID: siteID, pluginPath: "woocommerce/woocommerce.php", isActive: true) // Then #expect(result.siteID == siteID) - #expect(result.plugin == PluginConstants.plugin) + #expect(result.plugin == "woocommerce/woocommerce.php") #expect(result.active == true) #expect(result.version == "1.0.0") } @@ -33,31 +33,103 @@ struct PluginsServiceTests { await storageManager.reset() // When - async let plugin = sut.waitForPluginInStorage(siteID: siteID, pluginPath: PluginConstants.plugin, isActive: true) + async let plugin = sut.waitForPluginInStorage(siteID: siteID, pluginPath: "woocommerce/woocommerce.php", isActive: true) #expect(storageManager.viewStorage.loadSystemPlugins(siteID: siteID).count == 0) - storageManager.insertWCPlugin(siteID: siteID, isActive: true, version: "2.0.0") + storageManager.insertPlugin(siteID: siteID, plugin: .wooCommerce, isActive: true, version: "2.0.0") #expect(storageManager.viewStorage.loadSystemPlugins(siteID: siteID).count == 1) // Then let result = await plugin #expect(result.siteID == siteID) - #expect(result.plugin == PluginConstants.plugin) - #expect(result.name == PluginConstants.pluginName) + #expect(result.plugin == "woocommerce/woocommerce.php") #expect(result.active == true) #expect(result.version == "2.0.0") } + + // MARK: - `loadPluginInStorage` + + @Test(arguments: [(Plugin.wooCommerce, true, "1.5.0"), + (Plugin.wooCommerce, false, "2.1.0"), + (Plugin.wooSubscriptions, true, "3.0.0"), + (Plugin.wooShipmentTracking, false, "3.0.0")]) + func loadPluginInStorage_returns_plugin_when_exists_in_storage(plugin: Plugin, isActive: Bool, version: String) async throws { + // Given + await storageManager.reset() + storageManager.insertPlugin(siteID: siteID, plugin: plugin, isActive: isActive, version: version) + + // When + let result = sut.loadPluginInStorage(siteID: siteID, plugin: plugin, isActive: isActive) + + // Then + let unwrappedResult = try #require(result) + #expect(unwrappedResult.siteID == siteID) + #expect(unwrappedResult.plugin == plugin.pluginPath) + #expect(unwrappedResult.active == isActive) + #expect(unwrappedResult.version == version) + } + + @Test func loadPluginInStorage_returns_plugin_when_exists_in_storage_with_any_active_state() async throws { + // Given + await storageManager.reset() + storageManager.insertPlugin(siteID: siteID, plugin: .wooCommerce, isActive: true, version: "3.0.0") + + // When + let result = sut.loadPluginInStorage(siteID: siteID, plugin: .wooCommerce, isActive: nil) + + // Then + let unwrappedResult = try #require(result) + #expect(unwrappedResult.siteID == siteID) + #expect(unwrappedResult.plugin == "woocommerce/woocommerce.php") + #expect(unwrappedResult.active == true) + #expect(unwrappedResult.version == "3.0.0") + } + + @Test func loadPluginInStorage_returns_nil_when_plugin_does_not_exist() async { + // Given + await storageManager.reset() + + // When + let result = sut.loadPluginInStorage(siteID: siteID, plugin: .wooCommerce, isActive: true) + + // Then + #expect(result == nil) + } + + @Test func loadPluginInStorage_returns_nil_when_plugin_exists_but_active_state_does_not_match() async { + // Given + await storageManager.reset() + storageManager.insertPlugin(siteID: siteID, plugin: .wooCommerce, isActive: true, version: "1.0.0") + + // When + let result = sut.loadPluginInStorage(siteID: siteID, plugin: .wooCommerce, isActive: false) + + // Then + #expect(result == nil) + } + + @Test func loadPluginInStorage_returns_nil_when_plugin_exists_for_different_site() async { + // Given + await storageManager.reset() + let differentSiteID: Int64 = 999 + storageManager.insertPlugin(siteID: differentSiteID, plugin: .wooCommerce, isActive: true, version: "1.0.0") + + // When + let result = sut.loadPluginInStorage(siteID: siteID, plugin: .wooCommerce, isActive: true) + + // Then + #expect(result == nil) + } } private extension MockStorageManager { - func insertWCPlugin(siteID: Int64, isActive: Bool, version: String? = nil) { + func insertPlugin(siteID: Int64, plugin: Plugin, isActive: Bool, version: String? = nil) { performAndSave({ storage in - let plugin = SystemPlugin.fake().copy(siteID: siteID, - plugin: PluginConstants.plugin, - name: PluginConstants.pluginName, - version: version, - active: isActive) + let systemPlugin = SystemPlugin.fake().copy(siteID: siteID, + plugin: plugin.pluginPath, + version: version, + active: isActive) let newPlugin = storage.insertNewObject(ofType: StorageSystemPlugin.self) - newPlugin.update(with: plugin) + newPlugin.update(with: systemPlugin) }, completion: nil, on: .main) } @@ -70,8 +142,8 @@ private extension MockStorageManager { } } -// MARK: - Constants -private enum PluginConstants { - static let plugin = "example-plugin/example-plugin.php" - static let pluginName = "Example Plugin" +extension Plugin { + var pluginPath: String { + "\(fileNameWithoutExtension)/\(fileNameWithoutExtension).php" + } } diff --git a/WooCommerce/Classes/Extensions/SitePlugin+Woo.swift b/WooCommerce/Classes/Extensions/SitePlugin+Woo.swift index a8ff7280c96..4d1ee0a7fce 100644 --- a/WooCommerce/Classes/Extensions/SitePlugin+Woo.swift +++ b/WooCommerce/Classes/Extensions/SitePlugin+Woo.swift @@ -4,11 +4,9 @@ import Yosemite /// extension SitePlugin { enum SupportedPlugin { - public static let WCTracking = "WooCommerce Shipment Tracking" public static let WCSubscriptions = ["WooCommerce Subscriptions", "Woo Subscriptions"] public static let WCProductBundles = ["WooCommerce Product Bundles", "Woo Product Bundles"] public static let WCCompositeProducts = "WooCommerce Composite Products" - public static let square = "WooCommerce Square" public static let WCGiftCards = ["WooCommerce Gift Cards", "Woo Gift Cards"] public static let GoogleForWooCommerce = ["Google Listings and Ads", "Google for WooCommerce"] } diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift index ee247f3ec9d..19eba8e33e0 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift @@ -15,6 +15,7 @@ final class OrderDetailsViewModel { private let stores: StoresManager private let storageManager: StorageManagerType private let currencyFormatter: CurrencyFormatter + private let pluginsService: PluginsServiceProtocol let featureFlagService: FeatureFlagService private(set) var order: Order @@ -35,7 +36,8 @@ final class OrderDetailsViewModel { currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings), featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, syncStateController: OrderDetailsSyncStateControlling = OrderDetailsSyncStateController(syncState: .notSynced), - receiptEligibilityUseCase: ReceiptEligibilityUseCaseProtocol = ReceiptEligibilityUseCase()) { + receiptEligibilityUseCase: ReceiptEligibilityUseCaseProtocol = ReceiptEligibilityUseCase(), + pluginsService: PluginsServiceProtocol? = nil) { self.order = order self.stores = stores self.storageManager = storageManager @@ -46,6 +48,7 @@ final class OrderDetailsViewModel { self.dataSource = OrderDetailsDataSource(order: order, cardPresentPaymentsConfiguration: configurationLoader.configuration) self.receiptEligibilityUseCase = receiptEligibilityUseCase + self.pluginsService = pluginsService ?? PluginsService(storageManager: storageManager) } func update(order newOrder: Order) { @@ -228,6 +231,7 @@ final class OrderDetailsViewModel { extension OrderDetailsViewModel { /// Syncs all data related to the current order. /// + @MainActor func syncEverything(onReloadSections: (() -> ())? = nil, onCompletion: (() -> ())? = nil) { let group = DispatchGroup() @@ -309,7 +313,7 @@ extension OrderDetailsViewModel { defer { group.leave() } - trackingIsReachable = await isShipmentTrackingEnabled() + trackingIsReachable = isShipmentTrackingEnabled() guard trackingIsReachable else { return } @@ -371,9 +375,9 @@ extension OrderDetailsViewModel { /// Checks if shipment tracking is enabled for the order. /// - Returns: Whether shipment tracking is enabled for the user by checking the products and if the Shipment Tracking plugin is active. @MainActor - func isShipmentTrackingEnabled() async -> Bool { + func isShipmentTrackingEnabled() -> Bool { guard orderContainsOnlyVirtualProducts == false, - await isPluginActive(SitePlugin.SupportedPlugin.WCTracking) else { + isPluginActive(.wooShipmentTracking) else { return false } return true @@ -749,9 +753,10 @@ extension OrderDetailsViewModel { stores.dispatch(action) } + @MainActor func syncSubscriptions(onCompletion: ((Error?) -> ())? = nil) { // If the plugin is not active, there is no point in continuing with a request that will fail. - isPluginActive(SitePlugin.SupportedPlugin.WCSubscriptions) { [weak self] isActive in + isPluginActive(.wooSubscriptions) { [weak self] isActive in guard let self, isActive else { onCompletion?(nil) @@ -892,40 +897,33 @@ extension OrderDetailsViewModel { stores.dispatch(action) } - /// Helper function that returns `true` in its callback if the provided plugin name is active on the order's store. + /// Helper function that returns `true` in its callback if the provided plugin is active on the order's store. /// Additionally it logs to tracks if the plugin store is accessed without it being in sync so we can handle that edge-case if it happens recurrently. /// - private func isPluginActive(_ plugin: String, completion: @escaping (Bool) -> (Void)) { - isPluginActive([plugin], completion: completion) + @MainActor + private func isPluginActive(_ plugin: Plugin) -> Bool { + let plugin = fetchPlugin(plugin, isActive: true) + return plugin != nil && plugin?.active == true } - /// Helper function that returns `true` in its callback if any of the the provided plugin names are active on the order's store. - /// Additionally it logs to tracks if the plugin store is accessed without it being in sync so we can handle that edge-case if it happens recurrently. - /// Useful for when a plugin has had many names. - /// - private func isPluginActive(_ pluginNames: [String], completion: @escaping (Bool) -> (Void)) { - Task { @MainActor in - let plugin = await fetchPluginByNames(pluginNames) - completion(plugin?.active == true) - } + /// Legacy helper function that returns plugin active value in a completion closure. + @MainActor + private func isPluginActive(_ plugin: Plugin, completion: @escaping (Bool) -> (Void)) { + completion(isPluginActive(plugin)) } /// Fetches a plugin from storage, based on the provided list of plugin names. /// Additionally it logs to tracks if the plugin store is accessed without it being in sync so we can handle that edge-case if it happens recurrently. /// @MainActor - private func fetchPluginByNames(_ pluginNames: [String]) async -> SystemPlugin? { + private func fetchPlugin(_ plugin: Plugin, isActive: Bool? = nil) -> SystemPlugin? { guard arePluginsSynced() else { DDLogError("⚠️ SystemPlugins accessed without being in sync.") ServiceLocator.analytics.track(event: WooAnalyticsEvent.Orders.pluginsNotSyncedYet()) return nil } - return await withCheckedContinuation { continuation in - stores.dispatch(SystemStatusAction.fetchSystemPluginListWithNameList(siteID: order.siteID, systemPluginNameList: pluginNames, onCompletion: { plugin in - continuation.resume(returning: plugin) - })) - } + return pluginsService.loadPluginInStorage(siteID: order.siteID, plugin: plugin, isActive: isActive) } /// Fetches a plugin from storage, based on the provided plugin path. @@ -1039,20 +1037,6 @@ private extension OrderDetailsViewModel { } } - @MainActor - func isPluginActive(_ plugin: String) async -> Bool { - return await isPluginActive([plugin]) - } - - @MainActor - func isPluginActive(_ pluginNames: [String]) async -> Bool { - await withCheckedContinuation { continuation in - isPluginActive(pluginNames) { isActive in - continuation.resume(returning: isActive) - } - } - } - @MainActor func isPluginActive(pluginPath: String) async -> Bool { let plugin = await fetchPluginByPath(pluginPath) diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift index 77aa403b2e5..fb49490969f 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsListViewModel.swift @@ -4,7 +4,7 @@ import Yosemite import Experiments protocol ProductsListViewModelProtocol { - func scanToUpdateInventoryButtonShouldBeVisible(completion: @escaping (Bool) -> (Void)) + func scanToUpdateInventoryButtonShouldBeVisible(isCameraAvailable: Bool, completion: @escaping (Bool) -> (Void)) } /// View model for `ProductsViewController`. Has stores logic related to Bulk Editing and Woo Subscriptions. @@ -17,6 +17,7 @@ final class ProductListViewModel: ProductsListViewModelProtocol { let siteID: Int64 private let stores: StoresManager + private let pluginsService: PluginsServiceProtocol private(set) var selectedProducts: Set = .init() @@ -32,13 +33,15 @@ final class ProductListViewModel: ProductsListViewModelProtocol { stores: StoresManager = ServiceLocator.stores, favoriteProductsUseCase: FavoriteProductsUseCase? = nil, barcodeScannerItemFinder: BarcodeScannerItemFinder = BarcodeScannerItemFinder(), - featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + pluginsService: PluginsServiceProtocol = PluginsService(storageManager: ServiceLocator.storageManager)) { self.siteID = siteID self.stores = stores self.featureFlagService = featureFlagService self.wooSubscriptionProductsEligibilityChecker = WooSubscriptionProductsEligibilityChecker(siteID: siteID) self.barcodeScannerItemFinder = barcodeScannerItemFinder self.favoriteProductsUseCase = favoriteProductsUseCase ?? DefaultFavoriteProductsUseCase(siteID: siteID) + self.pluginsService = pluginsService Task { @MainActor [weak self] in await self?.loadFavoriteProductIDs() @@ -198,24 +201,22 @@ final class ProductListViewModel: ProductsListViewModelProtocol { // The feature breaks if the Square plugin is active, since modifies inventory management logic // If the plugin is active, we'll hide the inventory scanner button // More details: https://wp.me/pdfdoF-2Nq - func scanToUpdateInventoryButtonShouldBeVisible(completion: @escaping (Bool) -> (Void)) { - isPluginActive(SitePlugin.SupportedPlugin.square, completion: { [weak self] isPluginActive in - guard let self else { return } - switch isPluginActive { - case true: - completion(false) - case false: - guard self.featureFlagService.isFeatureFlagEnabled(.scanToUpdateInventory), - UIImagePickerController.isSourceTypeAvailable(.camera) else { - return completion(false) - } - // If all conditions are met, scan to update inventory should be visible: - // 1. No Square plugin - // 2. Feature flag - // 3. Camera is available - completion(true) + func scanToUpdateInventoryButtonShouldBeVisible(isCameraAvailable: Bool = UIImagePickerController.isSourceTypeAvailable(.camera), + completion: @escaping (Bool) -> (Void)) { + let isPluginActive = isPluginActive(.wooSquare) + switch isPluginActive { + case true: + completion(false) + case false: + guard featureFlagService.isFeatureFlagEnabled(.scanToUpdateInventory), isCameraAvailable else { + return completion(false) } - }) + // If all conditions are met, scan to update inventory should be visible: + // 1. No Square plugin + // 2. Feature flag + // 3. Camera is available + completion(true) + } } /// Loads favorite product IDs @@ -225,10 +226,7 @@ final class ProductListViewModel: ProductsListViewModelProtocol { favoriteProductIDs = await favoriteProductsUseCase.favoriteProductIDs() } - private func isPluginActive(_ plugin: String, completion: @escaping (Bool) -> (Void)) { - let action = SystemStatusAction.fetchSystemPluginListWithNameList(siteID: siteID, systemPluginNameList: [plugin]) { plugin in - completion(plugin?.active == true) - } - stores.dispatch(action) + private func isPluginActive(_ plugin: Plugin) -> Bool { + pluginsService.isPluginActiveInStorage(siteID: siteID, plugin: plugin) } } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 0e884841456..d4b5aeeee32 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -202,6 +202,7 @@ 0227958D237A51F300787C63 /* OptionsTableViewController+Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0227958C237A51F300787C63 /* OptionsTableViewController+Styles.swift */; }; 02279590237A5DC900787C63 /* AztecUnorderedListFormatBarCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0227958F237A5DC900787C63 /* AztecUnorderedListFormatBarCommandTests.swift */; }; 02279594237A60FD00787C63 /* AztecHeaderFormatBarCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02279593237A60FD00787C63 /* AztecHeaderFormatBarCommandTests.swift */; }; + 0229008A2E3019040028F6D7 /* MockPluginsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022900892E3019020028F6D7 /* MockPluginsService.swift */; }; 02291737270BEFF200449FA0 /* ProcessConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02291736270BEFF200449FA0 /* ProcessConfiguration.swift */; }; 0229ED00258767BC00C336F8 /* ShippingLabelPrintingStepContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0229ECFF258767BC00C336F8 /* ShippingLabelPrintingStepContentView.swift */; }; 022A45EE237BADA6001417F0 /* Product+ProductFormTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022A45ED237BADA6001417F0 /* Product+ProductFormTests.swift */; }; @@ -3380,6 +3381,7 @@ 0227958C237A51F300787C63 /* OptionsTableViewController+Styles.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "OptionsTableViewController+Styles.swift"; sourceTree = ""; }; 0227958F237A5DC900787C63 /* AztecUnorderedListFormatBarCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AztecUnorderedListFormatBarCommandTests.swift; sourceTree = ""; }; 02279593237A60FD00787C63 /* AztecHeaderFormatBarCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AztecHeaderFormatBarCommandTests.swift; sourceTree = ""; }; + 022900892E3019020028F6D7 /* MockPluginsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPluginsService.swift; sourceTree = ""; }; 02291736270BEFF200449FA0 /* ProcessConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessConfiguration.swift; sourceTree = ""; }; 0229ECFF258767BC00C336F8 /* ShippingLabelPrintingStepContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPrintingStepContentView.swift; sourceTree = ""; }; 022A45ED237BADA6001417F0 /* Product+ProductFormTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+ProductFormTests.swift"; sourceTree = ""; }; @@ -9976,6 +9978,7 @@ 746791642108D853007CF1DC /* Mocks */ = { isa = PBXGroup; children = ( + 022900892E3019020028F6D7 /* MockPluginsService.swift */, 02F36C3F2E0130E900DD8CB6 /* MockPOSEligibilityService.swift */, 02B8E41A2DFBC33C001D01FD /* MockPOSEligibilityChecker.swift */, 01F067EC2D0C5D56001C5805 /* MockLocationService.swift */, @@ -17016,6 +17019,7 @@ 450C2CB324D0803000D570DD /* ProductSettingsRowsTests.swift in Sources */, EEB221A529B97F8400662A12 /* CouponInputTransformerTests.swift in Sources */, 45AF9DAF265CFAB4001EB794 /* MockShippingLabelCarrierRate.swift in Sources */, + 0229008A2E3019040028F6D7 /* MockPluginsService.swift in Sources */, CC593A6726EA116300EF0E04 /* ShippingLabelAddNewPackageViewModelTests.swift in Sources */, DE68B84326FAF17A00C86CFB /* DefaultConnectivityObserver.swift in Sources */, 455A2FDB246B1349000CA72C /* ProductVisibilityTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Mocks/MockPluginsService.swift b/WooCommerce/WooCommerceTests/Mocks/MockPluginsService.swift new file mode 100644 index 00000000000..d28e56f6495 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Mocks/MockPluginsService.swift @@ -0,0 +1,14 @@ +import Yosemite + +final class MockPluginsService: PluginsServiceProtocol { + var pluginToReturn: SystemPlugin = .fake() + var pluginToReturnForLoadPluginInStorage: SystemPlugin? + + func waitForPluginInStorage(siteID: Int64, pluginPath: String, isActive: Bool) async -> SystemPlugin { + pluginToReturn + } + + func loadPluginInStorage(siteID: Int64, plugin: Yosemite.Plugin, isActive: Bool?) -> SystemPlugin? { + pluginToReturnForLoadPluginInStorage + } +} diff --git a/WooCommerce/WooCommerceTests/Mocks/MockProductListViewModel.swift b/WooCommerce/WooCommerceTests/Mocks/MockProductListViewModel.swift index eb0a122c64e..024252dee5c 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockProductListViewModel.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockProductListViewModel.swift @@ -11,7 +11,7 @@ final class MockProductListViewModel: ProductsListViewModelProtocol { self.featureFlagService = featureFlagService } - func scanToUpdateInventoryButtonShouldBeVisible(completion: @escaping (Bool) -> (Void)) { + func scanToUpdateInventoryButtonShouldBeVisible(isCameraAvailable: Bool = true, completion: @escaping (Bool) -> (Void)) { guard self.featureFlagService.isFeatureFlagEnabled(.scanToUpdateInventory) else { scanToUpdateInventoryShouldBeVisible = false return completion(false) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/OrderDetailsViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/OrderDetailsViewModelTests.swift index 7e1342dc715..c64c3fc05d1 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/OrderDetailsViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/OrderDetailsViewModelTests.swift @@ -520,41 +520,11 @@ final class OrderDetailsViewModelTests: XCTestCase { XCTAssertTrue(title.contains("\u{20AC}10.0")) } - func test_syncSubscriptions_loads_subscription_into_dataSource_with_legacy_plugin_name() throws { - // Given - let plugin = SystemPlugin.fake().copy(siteID: order.siteID, name: "WooCommerce Subscriptions", active: true) - storageManager.insertSampleSystemPlugin(readOnlySystemPlugin: plugin) - - storesManager.reset() - XCTAssertEqual(storesManager.receivedActions.count, 0) - - // When - let subscriptionsCount: Int = waitFor { promise in - - // Return the active WCExtensions plugin. - self.whenFetchingSystemPlugin(thenReturn: plugin) - - // Return the synced subscription. - self.storesManager.whenReceivingAction(ofType: SubscriptionAction.self) { action in - switch action { - case .loadSubscriptions(_, let onCompletion): - onCompletion(.success([Subscription.fake()])) - promise(self.viewModel.dataSource.orderSubscriptions.count) - } - } - - self.viewModel.syncSubscriptions() - } - - // Then - XCTAssertEqual(subscriptionsCount, 1) - } - - func test_syncSubscriptions_loads_subscription_into_dataSource_with_current_plugin_name() throws { + func test_syncSubscriptions_loads_subscription_into_dataSource() throws { // Given // Make sure the are plugins synced - let plugin = SystemPlugin.fake().copy(siteID: order.siteID, name: "Woo Subscriptions", active: true) + let plugin = SystemPlugin.fake().copy(siteID: order.siteID, plugin: "woocommerce-subscriptions/woocommerce-subscriptions.php", active: true) storageManager.insertSampleSystemPlugin(readOnlySystemPlugin: plugin) storesManager.reset() @@ -626,7 +596,7 @@ final class OrderDetailsViewModelTests: XCTestCase { XCTAssertEqual(storesManager.receivedActions.count, 0) // When - let isEnabled = await viewModel.isShipmentTrackingEnabled() + let isEnabled = viewModel.isShipmentTrackingEnabled() // Then XCTAssertFalse(isEnabled) @@ -640,11 +610,11 @@ final class OrderDetailsViewModelTests: XCTestCase { storesManager.reset() XCTAssertEqual(storesManager.receivedActions.count, 0) - let plugin = insertSystemPlugin(name: SitePlugin.SupportedPlugin.WCTracking, siteID: order.siteID, isActive: true) + let plugin = insertSystemPlugin(path: "woocommerce-shipment-tracking/woocommerce-shipment-tracking.php", siteID: order.siteID, isActive: true) whenFetchingSystemPlugin(thenReturn: plugin) // When - let isEnabled = await viewModel.isShipmentTrackingEnabled() + let isEnabled = viewModel.isShipmentTrackingEnabled() // Then XCTAssertTrue(isEnabled) @@ -735,13 +705,6 @@ final class OrderDetailsViewModelTests: XCTestCase { } private extension OrderDetailsViewModelTests { - @discardableResult - func insertSystemPlugin(name: String, siteID: Int64, isActive: Bool, version: String? = nil) -> SystemPlugin { - let plugin = SystemPlugin.fake().copy(siteID: siteID, name: name, version: version, active: isActive) - storageManager.insertSampleSystemPlugin(readOnlySystemPlugin: plugin) - return plugin - } - @discardableResult func insertSystemPlugin(path: String, siteID: Int64, isActive: Bool, version: String? = nil) -> SystemPlugin { let plugin = SystemPlugin.fake().copy(siteID: siteID, plugin: path, version: version, active: isActive) @@ -764,8 +727,6 @@ private extension OrderDetailsViewModelTests { switch action { case let .fetchSystemPlugin(_, _, onCompletion): onCompletion(plugin) - case let .fetchSystemPluginListWithNameList(_, _, onCompletion): - onCompletion(plugin) case let .fetchSystemPluginWithPath(_, pluginPath, onCompletion): if let path, path != pluginPath { onCompletion(nil) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/OrderDetailsViewControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/OrderDetailsViewControllerTests.swift index 266c1d8611e..20a373ca709 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/OrderDetailsViewControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/OrderDetailsViewControllerTests.swift @@ -190,16 +190,6 @@ private struct OrderDetailStoreManagerFactory { } } - // Need to sync plugins first - storesManager.whenReceivingAction(ofType: SystemStatusAction.self) { action in - switch action { - case let .fetchSystemPluginListWithNameList(_, _, onCompletion): - onCompletion(nil) - default: - break - } - } - storesManager.whenReceivingAction(ofType: SubscriptionAction.self) { action in switch action { case let .loadSubscriptions(_, onCompletion): diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductListViewModelTests.swift index d00a5ec72d4..2cb7d1c556a 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/ProductListViewModelTests.swift @@ -341,6 +341,86 @@ final class ProductListViewModelTests: XCTestCase { XCTAssertFalse(result) } + func test_scanToUpdateInventoryButton_when_square_plugin_is_active_then_should_not_be_visible() { + // Given + let featureFlagService = MockFeatureFlagService(isScanToUpdateInventoryEnabled: true) + let pluginsService = MockPluginsService() + pluginsService.pluginToReturnForLoadPluginInStorage = .fake().copy( + siteID: sampleSiteID, + plugin: "woocommerce-square/woocommerce-square.php", + active: true + ) + + let viewModel = ProductListViewModel( + siteID: sampleSiteID, + stores: storesManager, + featureFlagService: featureFlagService, + pluginsService: pluginsService + ) + + // When + let result = waitFor { promise in + viewModel.scanToUpdateInventoryButtonShouldBeVisible(isCameraAvailable: true) { result in + promise(result) + } + } + + // Then + XCTAssertFalse(result) + } + + func test_scanToUpdateInventoryButton_when_square_plugin_is_inactive_then_should_be_visible() { + // Given + let featureFlagService = MockFeatureFlagService(isScanToUpdateInventoryEnabled: true) + let pluginsService = MockPluginsService() + pluginsService.pluginToReturnForLoadPluginInStorage = .fake().copy( + siteID: sampleSiteID, + plugin: "woocommerce-square/woocommerce-square.php", + active: false + ) + + let viewModel = ProductListViewModel( + siteID: sampleSiteID, + stores: storesManager, + featureFlagService: featureFlagService, + pluginsService: pluginsService + ) + + // When + let result = waitFor { promise in + viewModel.scanToUpdateInventoryButtonShouldBeVisible(isCameraAvailable: true) { result in + promise(result) + } + } + + // Then + XCTAssertTrue(result) + } + + func test_scanToUpdateInventoryButton_when_square_plugin_is_not_installed_then_should_be_visible() { + // Given + let featureFlagService = MockFeatureFlagService(isScanToUpdateInventoryEnabled: true) + let pluginsService = MockPluginsService() + pluginsService.pluginToReturnForLoadPluginInStorage = nil // Plugin not found + + let viewModel = ProductListViewModel( + siteID: sampleSiteID, + stores: storesManager, + featureFlagService: featureFlagService, + pluginsService: pluginsService + ) + + // When + let result = waitFor { promise in + viewModel.scanToUpdateInventoryButtonShouldBeVisible(isCameraAvailable: true) { result in + promise(result) + } + } + + // Then + XCTAssertTrue(result) + } + // MARK: Favorite products // func test_it_loads_favorite_products_on_init() { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift index 5968b4f7639..4f5ecc23812 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/LegacyPOSTabEligibilityCheckerTests.swift @@ -423,14 +423,6 @@ private extension LegacyPOSTabEligibilityCheckerTests { } } -private final class MockPluginsService: PluginsServiceProtocol { - var pluginToReturn: SystemPlugin = .fake() - - func waitForPluginInStorage(siteID: Int64, pluginPath: String, isActive: Bool) async -> SystemPlugin { - pluginToReturn - } -} - private final class MockSelectedSiteSettings: SelectedSiteSettingsProtocol { var mockSettingsStream: AnyPublisher<(siteID: Int64, settings: [SiteSetting], source: SettingsUpdateSource), Never>? var siteSettings: [SiteSetting] = []