diff --git a/Modules/Sources/Codegen/Sourcery/Copiable/Models+Copiable.swifttemplate b/Modules/Sources/Codegen/Sourcery/Copiable/Models+Copiable.swifttemplate index b5be67d948c..4d3daa3467c 100644 --- a/Modules/Sources/Codegen/Sourcery/Copiable/Models+Copiable.swifttemplate +++ b/Modules/Sources/Codegen/Sourcery/Copiable/Models+Copiable.swifttemplate @@ -205,6 +205,7 @@ let specsToGenerate: [CopiableSpec] = matchingTypes.map { type in import <%= module %> <% } -%> +// swiftlint:disable line_length <% for copiableSpec in specsToGenerate { -%> extension <%= copiableSpec.name %> { @@ -231,3 +232,5 @@ extension <%= copiableSpec.name %> { } } <% } -%> + +// swiftlint:enable line_length diff --git a/Modules/Sources/Codegen/Sourcery/Fakes/Fakes.swifttemplate b/Modules/Sources/Codegen/Sourcery/Fakes/Fakes.swifttemplate index 5623e2395e4..10ed4e25bcc 100644 --- a/Modules/Sources/Codegen/Sourcery/Fakes/Fakes.swifttemplate +++ b/Modules/Sources/Codegen/Sourcery/Fakes/Fakes.swifttemplate @@ -97,6 +97,8 @@ import Networking import Hardware import WooFoundation +// swiftlint:disable line_length + <% for spec in specsToGenerate { -%> extension <%= spec.name -%> { /// Returns a "ready to use" type filled with fake values. @@ -115,3 +117,5 @@ extension <%= spec.name -%> { } <% } -%> <% } -%> + +// swiftlint:enable line_length diff --git a/Modules/Sources/Fakes/Hardware.generated.swift b/Modules/Sources/Fakes/Hardware.generated.swift index 8901425caf3..178d49fbf02 100644 --- a/Modules/Sources/Fakes/Hardware.generated.swift +++ b/Modules/Sources/Fakes/Hardware.generated.swift @@ -6,6 +6,8 @@ import Networking import Hardware import WooFoundation +// swiftlint:disable line_length + extension Hardware.CardBrand { /// Returns a "ready to use" type filled with fake values. /// @@ -97,3 +99,5 @@ extension Hardware.PaymentMethod { .card } } + +// swiftlint:enable line_length diff --git a/Modules/Sources/Fakes/Networking.generated.swift b/Modules/Sources/Fakes/Networking.generated.swift index 931e388a8d9..59d765ba9ad 100644 --- a/Modules/Sources/Fakes/Networking.generated.swift +++ b/Modules/Sources/Fakes/Networking.generated.swift @@ -6,6 +6,8 @@ import Networking import Hardware import WooFoundation +// swiftlint:disable line_length + extension Networking.AIProduct { /// Returns a "ready to use" type filled with fake values. /// @@ -2611,3 +2613,5 @@ extension Networking.WordPressTheme { ) } } + +// swiftlint:enable line_length diff --git a/Modules/Sources/Fakes/NetworkingCore.generated.swift b/Modules/Sources/Fakes/NetworkingCore.generated.swift index 871397491f6..2c818bc440d 100644 --- a/Modules/Sources/Fakes/NetworkingCore.generated.swift +++ b/Modules/Sources/Fakes/NetworkingCore.generated.swift @@ -6,6 +6,8 @@ import Networking import Hardware import WooFoundation +// swiftlint:disable line_length + extension NetworkingCore.Account { /// Returns a "ready to use" type filled with fake values. /// @@ -593,3 +595,5 @@ extension NetworkingCore.User { ) } } + +// swiftlint:enable line_length diff --git a/Modules/Sources/Fakes/WooFoundation.generated.swift b/Modules/Sources/Fakes/WooFoundation.generated.swift index 8103388b210..2c2fb4c78d0 100644 --- a/Modules/Sources/Fakes/WooFoundation.generated.swift +++ b/Modules/Sources/Fakes/WooFoundation.generated.swift @@ -2,3 +2,5 @@ // DO NOT EDIT // Currently empty because none of the given sources conforms to GeneratedFakeable + +// swiftlint:enable line_length diff --git a/Modules/Sources/Fakes/WooFoundationCore.generated.swift b/Modules/Sources/Fakes/WooFoundationCore.generated.swift index e4701849016..a22966476fe 100644 --- a/Modules/Sources/Fakes/WooFoundationCore.generated.swift +++ b/Modules/Sources/Fakes/WooFoundationCore.generated.swift @@ -6,6 +6,8 @@ import Networking import Hardware import WooFoundation +// swiftlint:disable line_length + extension WooFoundationCore.CurrencyCode { /// Returns a "ready to use" type filled with fake values. /// @@ -13,3 +15,5 @@ extension WooFoundationCore.CurrencyCode { .AED } } + +// swiftlint:enable line_length diff --git a/Modules/Sources/Fakes/Yosemite.generated.swift b/Modules/Sources/Fakes/Yosemite.generated.swift index 9f4ef148c6b..b58654291cd 100644 --- a/Modules/Sources/Fakes/Yosemite.generated.swift +++ b/Modules/Sources/Fakes/Yosemite.generated.swift @@ -6,6 +6,8 @@ import Networking import Hardware import WooFoundation +// swiftlint:disable line_length + extension Yosemite.JustInTimeMessage { /// Returns a "ready to use" type filled with fake values. /// @@ -99,3 +101,5 @@ extension Yosemite.WooPaymentsPayoutsOverviewByCurrency { ) } } + +// swiftlint:enable line_length diff --git a/Modules/Sources/Hardware/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Hardware/Model/Copiable/Models+Copiable.generated.swift index 28ab9ce1081..20b958909d2 100644 --- a/Modules/Sources/Hardware/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Hardware/Model/Copiable/Models+Copiable.generated.swift @@ -4,6 +4,7 @@ import Codegen import Foundation import UIKit +// swiftlint:disable line_length extension Hardware.CardPresentReceiptParameters { public func copy( @@ -94,3 +95,5 @@ extension Hardware.PaymentIntent { ) } } + +// swiftlint:enable line_length diff --git a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift index ff51dabe27f..f43a26bda9d 100644 --- a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -7,6 +7,7 @@ import WooFoundation import struct Alamofire.JSONEncoding import struct NetworkingCore.JetpackSite +// swiftlint:disable line_length extension Networking.AIProduct { public func copy( @@ -3970,3 +3971,5 @@ extension Networking.WordPressTheme { ) } } + +// swiftlint:enable line_length diff --git a/Modules/Sources/NetworkingCore/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/NetworkingCore/Model/Copiable/Models+Copiable.generated.swift index dd309d6316c..00226ef0476 100644 --- a/Modules/Sources/NetworkingCore/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/NetworkingCore/Model/Copiable/Models+Copiable.generated.swift @@ -3,6 +3,7 @@ import Codegen import Foundation +// swiftlint:disable line_length extension NetworkingCore.Address { public func copy( @@ -927,3 +928,5 @@ extension NetworkingCore.User { ) } } + +// swiftlint:enable line_length diff --git a/Modules/Sources/Storage/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Storage/Model/Copiable/Models+Copiable.generated.swift index 2baf941651b..4fbfac0e08a 100644 --- a/Modules/Sources/Storage/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Storage/Model/Copiable/Models+Copiable.generated.swift @@ -3,6 +3,7 @@ import Codegen import Foundation +// swiftlint:disable line_length extension Storage.AnalyticsCard { public func copy( @@ -80,10 +81,8 @@ extension Storage.GeneralAppSettings { let sitesWithAtLeastOneIPPTransactionFinished = sitesWithAtLeastOneIPPTransactionFinished ?? self.sitesWithAtLeastOneIPPTransactionFinished let isEUShippingNoticeDismissed = isEUShippingNoticeDismissed ?? self.isEUShippingNoticeDismissed let isCustomFieldsTopBannerDismissed = isCustomFieldsTopBannerDismissed ?? self.isCustomFieldsTopBannerDismissed - let isPOSSurveyPotentialMerchantNotificationScheduled = isPOSSurveyPotentialMerchantNotificationScheduled ?? - self.isPOSSurveyPotentialMerchantNotificationScheduled - let isPOSSurveyCurrentMerchantNotificationScheduled = isPOSSurveyCurrentMerchantNotificationScheduled ?? - self.isPOSSurveyCurrentMerchantNotificationScheduled + let isPOSSurveyPotentialMerchantNotificationScheduled = isPOSSurveyPotentialMerchantNotificationScheduled ?? self.isPOSSurveyPotentialMerchantNotificationScheduled + let isPOSSurveyCurrentMerchantNotificationScheduled = isPOSSurveyCurrentMerchantNotificationScheduled ?? self.isPOSSurveyCurrentMerchantNotificationScheduled let hasPOSBeenOpenedAtLeastOnce = hasPOSBeenOpenedAtLeastOnce ?? self.hasPOSBeenOpenedAtLeastOnce return Storage.GeneralAppSettings( @@ -126,7 +125,9 @@ extension Storage.GeneralStoreSettings { lastSelectedOrderStatus: NullableCopiableProp = .copy, favoriteProductIDs: CopiableProp<[Int64]> = .copy, searchTermsByKey: CopiableProp<[String: [String]]> = .copy, - isPOSTabVisible: NullableCopiableProp = .copy + isPOSTabVisible: NullableCopiableProp = .copy, + lastPOSOpenedDate: NullableCopiableProp = .copy, + firstPOSCatalogSyncDate: NullableCopiableProp = .copy ) -> Storage.GeneralStoreSettings { let storeID = storeID ?? self.storeID let isTelemetryAvailable = isTelemetryAvailable ?? self.isTelemetryAvailable @@ -148,6 +149,8 @@ extension Storage.GeneralStoreSettings { let favoriteProductIDs = favoriteProductIDs ?? self.favoriteProductIDs let searchTermsByKey = searchTermsByKey ?? self.searchTermsByKey let isPOSTabVisible = isPOSTabVisible ?? self.isPOSTabVisible + let lastPOSOpenedDate = lastPOSOpenedDate ?? self.lastPOSOpenedDate + let firstPOSCatalogSyncDate = firstPOSCatalogSyncDate ?? self.firstPOSCatalogSyncDate return Storage.GeneralStoreSettings( storeID: storeID, @@ -169,7 +172,11 @@ extension Storage.GeneralStoreSettings { lastSelectedOrderStatus: lastSelectedOrderStatus, favoriteProductIDs: favoriteProductIDs, searchTermsByKey: searchTermsByKey, - isPOSTabVisible: isPOSTabVisible + isPOSTabVisible: isPOSTabVisible, + lastPOSOpenedDate: lastPOSOpenedDate, + firstPOSCatalogSyncDate: firstPOSCatalogSyncDate ) } } + +// swiftlint:enable line_length diff --git a/Modules/Sources/Storage/Model/GeneralStoreSettings.swift b/Modules/Sources/Storage/Model/GeneralStoreSettings.swift index deefc73eb65..f18d8a1e041 100644 --- a/Modules/Sources/Storage/Model/GeneralStoreSettings.swift +++ b/Modules/Sources/Storage/Model/GeneralStoreSettings.swift @@ -86,6 +86,13 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable { /// public var isPOSTabVisible: Bool? + /// The last time POS was opened for this store. + /// + public var lastPOSOpenedDate: Date? + + /// The date of the first POS catalog sync for this store. + /// + public var firstPOSCatalogSyncDate: Date? public init(storeID: String? = nil, isTelemetryAvailable: Bool = false, @@ -106,7 +113,9 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable { lastSelectedOrderStatus: String? = nil, favoriteProductIDs: [Int64] = [], searchTermsByKey: [String: [String]] = [:], - isPOSTabVisible: Bool? = nil) { + isPOSTabVisible: Bool? = nil, + lastPOSOpenedDate: Date? = nil, + firstPOSCatalogSyncDate: Date? = nil) { self.storeID = storeID self.isTelemetryAvailable = isTelemetryAvailable self.telemetryLastReportedTime = telemetryLastReportedTime @@ -127,6 +136,8 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable { self.favoriteProductIDs = favoriteProductIDs self.searchTermsByKey = searchTermsByKey self.isPOSTabVisible = isPOSTabVisible + self.lastPOSOpenedDate = lastPOSOpenedDate + self.firstPOSCatalogSyncDate = firstPOSCatalogSyncDate } public func erasingSelectedTaxRateID() -> GeneralStoreSettings { @@ -148,7 +159,9 @@ public struct GeneralStoreSettings: Codable, Equatable, GeneratedCopiable { lastSelectedOrderStatus: lastSelectedOrderStatus, favoriteProductIDs: favoriteProductIDs, searchTermsByKey: searchTermsByKey, - isPOSTabVisible: isPOSTabVisible) + isPOSTabVisible: isPOSTabVisible, + lastPOSOpenedDate: lastPOSOpenedDate, + firstPOSCatalogSyncDate: firstPOSCatalogSyncDate) } } @@ -183,6 +196,8 @@ extension GeneralStoreSettings { self.searchTermsByKey = try container.decodeIfPresent([String: [String]].self, forKey: .searchTermsByKey) ?? [:] self.isPOSTabVisible = try container.decodeIfPresent(Bool.self, forKey: .isPOSTabVisible) + self.lastPOSOpenedDate = try container.decodeIfPresent(Date.self, forKey: .lastPOSOpenedDate) + self.firstPOSCatalogSyncDate = try container.decodeIfPresent(Date.self, forKey: .firstPOSCatalogSyncDate) // Decode new properties with `decodeIfPresent` and provide a default value if necessary. } diff --git a/Modules/Sources/WooFoundation/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/WooFoundation/Model/Copiable/Models+Copiable.generated.swift new file mode 100644 index 00000000000..db6b75fac4d --- /dev/null +++ b/Modules/Sources/WooFoundation/Model/Copiable/Models+Copiable.generated.swift @@ -0,0 +1,6 @@ +// Generated using Sourcery 2.2.6 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + +// swiftlint:disable line_length + +// swiftlint:enable line_length diff --git a/Modules/Sources/WooFoundationCore/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/WooFoundationCore/Model/Copiable/Models+Copiable.generated.swift new file mode 100644 index 00000000000..db6b75fac4d --- /dev/null +++ b/Modules/Sources/WooFoundationCore/Model/Copiable/Models+Copiable.generated.swift @@ -0,0 +1,6 @@ +// Generated using Sourcery 2.2.6 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + +// swiftlint:disable line_length + +// swiftlint:enable line_length diff --git a/Modules/Sources/Yosemite/Actions/AppSettingsAction.swift b/Modules/Sources/Yosemite/Actions/AppSettingsAction.swift index c81265d83c1..b6175bc97f0 100644 --- a/Modules/Sources/Yosemite/Actions/AppSettingsAction.swift +++ b/Modules/Sources/Yosemite/Actions/AppSettingsAction.swift @@ -396,4 +396,22 @@ public enum AppSettingsAction: Action { /// Resets all POS survey notification scheduled states /// At the moment this one is used for testing only. To remove in WOOMOB-1480 case resetPOSSurveyNotificationScheduled(onCompletion: (Result) -> Void) + + // MARK: - POS Sync Eligibility Tracking + + /// Sets the last time POS was opened for a specific site + /// + case setPOSLastOpenedDate(siteID: Int64, date: Date, onCompletion: () -> Void) + + /// Gets the last time POS was opened for a specific site + /// + case getPOSLastOpenedDate(siteID: Int64, onCompletion: (Date?) -> Void) + + /// Sets the date of the first POS catalog sync for a specific site + /// + case setFirstPOSCatalogSyncDate(siteID: Int64, date: Date, onCompletion: () -> Void) + + /// Gets the date of the first POS catalog sync for a specific site + /// + case getFirstPOSCatalogSyncDate(siteID: Int64, onCompletion: (Date?) -> Void) } diff --git a/Modules/Sources/Yosemite/Base/StoresManager.swift b/Modules/Sources/Yosemite/Base/StoresManager.swift index 2babb332e78..d869af05aec 100644 --- a/Modules/Sources/Yosemite/Base/StoresManager.swift +++ b/Modules/Sources/Yosemite/Base/StoresManager.swift @@ -74,6 +74,10 @@ public protocol StoresManager { /// var posCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol? { get } + /// Provides access to the session-scoped POS catalog eligibility service + /// + var posCatalogEligibilityChecker: POSLocalCatalogEligibilityServiceProtocol? { get set } + /// Indicates if we need a Default StoreID, or there's one already set. /// var needsDefaultStore: Bool { get } diff --git a/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift index a8b5fd70c75..1979de9a73e 100644 --- a/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift @@ -11,6 +11,7 @@ import struct NetworkingCore.Order import struct NetworkingCore.OrderItem import struct NetworkingCore.OrderRefundCondensed +// swiftlint:disable line_length extension Yosemite.JustInTimeMessage { public func copy( @@ -230,3 +231,5 @@ extension Yosemite.WooPaymentsPayoutsOverviewByCurrency { ) } } + +// swiftlint:enable line_length diff --git a/Modules/Sources/Yosemite/Model/Mocks/MockStoresManager.swift b/Modules/Sources/Yosemite/Model/Mocks/MockStoresManager.swift index 67edce1a37c..4d75b6719b4 100644 --- a/Modules/Sources/Yosemite/Model/Mocks/MockStoresManager.swift +++ b/Modules/Sources/Yosemite/Model/Mocks/MockStoresManager.swift @@ -262,6 +262,11 @@ public class MockStoresManager: StoresManager { public var posCatalogSyncCoordinator: (any POSCatalogSyncCoordinatorProtocol)? { nil } + + public var posCatalogEligibilityChecker: POSLocalCatalogEligibilityServiceProtocol? { + get { nil } + set { } + } } private extension MockStoresManager { diff --git a/Modules/Sources/PointOfSale/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift b/Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift similarity index 59% rename from Modules/Sources/PointOfSale/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift rename to Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift index f37457bd567..526c397c7a8 100644 --- a/Modules/Sources/PointOfSale/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift +++ b/Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift @@ -12,7 +12,7 @@ public enum POSLocalCatalogEligibilityState: Equatable { /// Reasons why local catalog is ineligible public enum POSLocalCatalogIneligibleReason: Equatable { - case posTabNotVisible + case posTabNotEligible case featureFlagDisabled case catalogSizeTooLarge(totalCount: Int, limit: Int) case catalogSizeCheckFailed(underlyingError: String) @@ -28,14 +28,19 @@ public enum POSLocalCatalogIneligibleReason: Equatable { /// NOTE: This service checks catalog-related eligibility (size limits) and feature flag state. /// The service performs an initial eligibility check during initialization. public protocol POSLocalCatalogEligibilityServiceProtocol { - /// Current eligibility state (synchronously accessible on main thread) - var eligibilityState: POSLocalCatalogEligibilityState { get } + /// Get catalog eligibility for a specific site + /// - Parameter siteID: The site ID to check eligibility for + /// - Returns: Cached eligibility state, or eligible if not yet checked + func catalogEligibility(for siteID: Int64) async -> POSLocalCatalogEligibilityState - /// Update the POS tab visibility state and refresh eligibility - /// - Parameter isPOSTabVisible: Whether the POS tab is visible - func updateVisibility(isPOSTabVisible: Bool) async + /// Update POS eligibility and refresh catalog eligibility for the specified site + /// - Parameters: + /// - isEligible: Whether POS is eligible for the site + /// - siteID: The site ID to refresh eligibility for + func updatePOSEligibility(isEligible: Bool, for siteID: Int64) async - /// Force refresh eligibility (bypasses cache and updates eligibilityState) + /// Refresh eligibility state for a specific site + /// - Parameter siteID: The site ID to check eligibility for /// - Returns: Fresh eligibility state with reason if ineligible - @discardableResult func refreshEligibilityState() async -> POSLocalCatalogEligibilityState + @discardableResult func refreshEligibilityState(for siteID: Int64) async -> POSLocalCatalogEligibilityState } diff --git a/Modules/Sources/Yosemite/Stores/AppSettingsStore.swift b/Modules/Sources/Yosemite/Stores/AppSettingsStore.swift index b2da190c3ea..ff1344b82a5 100644 --- a/Modules/Sources/Yosemite/Stores/AppSettingsStore.swift +++ b/Modules/Sources/Yosemite/Stores/AppSettingsStore.swift @@ -307,6 +307,14 @@ public class AppSettingsStore: Store { getHasPOSBeenOpenedAtLeastOnce(onCompletion: onCompletion) case .resetPOSSurveyNotificationScheduled(onCompletion: let onCompletion): resetPOSSurveyNotificationScheduled(onCompletion: onCompletion) + case .setPOSLastOpenedDate(siteID: let siteID, date: let date, onCompletion: let onCompletion): + setPOSLastOpenedDate(siteID: siteID, date: date, onCompletion: onCompletion) + case .getPOSLastOpenedDate(siteID: let siteID, onCompletion: let onCompletion): + getPOSLastOpenedDate(siteID: siteID, onCompletion: onCompletion) + case .setFirstPOSCatalogSyncDate(siteID: let siteID, date: let date, onCompletion: let onCompletion): + setFirstPOSCatalogSyncDate(siteID: siteID, date: date, onCompletion: onCompletion) + case .getFirstPOSCatalogSyncDate(siteID: let siteID, onCompletion: let onCompletion): + getFirstPOSCatalogSyncDate(siteID: siteID, onCompletion: onCompletion) } } } @@ -1357,6 +1365,26 @@ private extension AppSettingsStore { onCompletion(.failure(error)) } } + + func setPOSLastOpenedDate(siteID: Int64, date: Date, onCompletion: () -> Void) { + siteSpecificAppSettingsStoreMethods.setPOSLastOpenedDate(siteID: siteID, date: date) + onCompletion() + } + + func getPOSLastOpenedDate(siteID: Int64, onCompletion: (Date?) -> Void) { + let date = siteSpecificAppSettingsStoreMethods.getPOSLastOpenedDate(siteID: siteID) + onCompletion(date) + } + + func setFirstPOSCatalogSyncDate(siteID: Int64, date: Date, onCompletion: () -> Void) { + siteSpecificAppSettingsStoreMethods.setFirstPOSCatalogSyncDate(siteID: siteID, date: date) + onCompletion() + } + + func getFirstPOSCatalogSyncDate(siteID: Int64, onCompletion: (Date?) -> Void) { + let date = siteSpecificAppSettingsStoreMethods.getFirstPOSCatalogSyncDate(siteID: siteID) + onCompletion(date) + } } // MARK: - Errors diff --git a/Modules/Sources/Yosemite/Stores/Helpers/SiteSpecificAppSettingsStoreMethods.swift b/Modules/Sources/Yosemite/Stores/Helpers/SiteSpecificAppSettingsStoreMethods.swift index 7fbe31065d4..762d8649f2d 100644 --- a/Modules/Sources/Yosemite/Stores/Helpers/SiteSpecificAppSettingsStoreMethods.swift +++ b/Modules/Sources/Yosemite/Stores/Helpers/SiteSpecificAppSettingsStoreMethods.swift @@ -11,6 +11,12 @@ public protocol SiteSpecificAppSettingsStoreMethodsProtocol { // Search history methods func getSearchTerms(for itemType: POSItemType, siteID: Int64) -> [String] func setSearchTerms(_ terms: [String], for itemType: POSItemType, siteID: Int64) + + // POS sync eligibility tracking + func getPOSLastOpenedDate(siteID: Int64) -> Date? + func setPOSLastOpenedDate(siteID: Int64, date: Date) + func getFirstPOSCatalogSyncDate(siteID: Int64) -> Date? + func setFirstPOSCatalogSyncDate(siteID: Int64, date: Date) } /// Methods for managing site-specific app settings @@ -96,6 +102,26 @@ extension SiteSpecificAppSettingsStoreMethods { let updatedSettings = storeSettings.copy(searchTermsByKey: updatedSearchTermsByKey) setStoreSettings(settings: updatedSettings, for: siteID) } + + func getPOSLastOpenedDate(siteID: Int64) -> Date? { + getStoreSettings(for: siteID).lastPOSOpenedDate + } + + func setPOSLastOpenedDate(siteID: Int64, date: Date) { + let storeSettings = getStoreSettings(for: siteID) + let updatedSettings = storeSettings.copy(lastPOSOpenedDate: date) + setStoreSettings(settings: updatedSettings, for: siteID) + } + + func getFirstPOSCatalogSyncDate(siteID: Int64) -> Date? { + getStoreSettings(for: siteID).firstPOSCatalogSyncDate + } + + func setFirstPOSCatalogSyncDate(siteID: Int64, date: Date) { + let storeSettings = getStoreSettings(for: siteID) + let updatedSettings = storeSettings.copy(firstPOSCatalogSyncDate: date) + setStoreSettings(settings: updatedSettings, for: siteID) + } } // MARK: - Constants diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index 50cf47ddb93..32b5bbbdff3 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -53,8 +53,8 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { private let fullSyncService: POSCatalogFullSyncServiceProtocol private let incrementalSyncService: POSCatalogIncrementalSyncServiceProtocol private let grdbManager: GRDBManagerProtocol - private let catalogSizeLimit: Int - private let catalogSizeChecker: POSCatalogSizeCheckerProtocol + private let catalogEligibilityChecker: POSLocalCatalogEligibilityServiceProtocol + private let siteSettings: SiteSpecificAppSettingsStoreMethodsProtocol /// Tracks ongoing full syncs by site ID to prevent duplicates private var ongoingSyncs: Set = [] @@ -64,13 +64,13 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { public init(fullSyncService: POSCatalogFullSyncServiceProtocol, incrementalSyncService: POSCatalogIncrementalSyncServiceProtocol, grdbManager: GRDBManagerProtocol, - catalogSizeLimit: Int? = nil, - catalogSizeChecker: POSCatalogSizeCheckerProtocol) { + catalogEligibilityChecker: POSLocalCatalogEligibilityServiceProtocol, + siteSettings: SiteSpecificAppSettingsStoreMethodsProtocol? = nil) { self.fullSyncService = fullSyncService self.incrementalSyncService = incrementalSyncService self.grdbManager = grdbManager - self.catalogSizeLimit = catalogSizeLimit ?? Constants.defaultSizeLimitForPOSCatalog - self.catalogSizeChecker = catalogSizeChecker + self.catalogEligibilityChecker = catalogEligibilityChecker + self.siteSettings = siteSettings ?? SiteSpecificAppSettingsStoreMethods(fileStorage: PListFileStorage()) } public func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws { @@ -78,6 +78,12 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { throw POSCatalogSyncError.negativeMaxAge } + // Check sync eligibility before proceeding + guard await checkSyncEligibility(for: siteID) else { + DDLogInfo("📋 POSCatalogSyncCoordinator: Full sync skipped - site \(siteID) is not eligible") + return + } + guard await shouldPerformFullSync(for: siteID, maxAge: maxAge) else { return } @@ -100,9 +106,18 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { _ = try await fullSyncService.startFullSync(for: siteID) DDLogInfo("✅ POSCatalogSyncCoordinator completed full sync for site \(siteID)") + + // Record first sync date if this was the first successful sync + recordFirstSyncIfNeeded(for: siteID) } public func performSmartSync(for siteID: Int64, fullSyncMaxAge: TimeInterval) async throws { + // Check sync eligibility before proceeding + guard await checkSyncEligibility(for: siteID) else { + DDLogInfo("📋 POSCatalogSyncCoordinator: Sync skipped - site \(siteID) is not eligible") + return + } + let lastFullSync = await lastFullSyncDate(for: siteID) ?? Date(timeIntervalSince1970: 0) let lastFullSyncUTC = ISO8601DateFormatter().string(from: lastFullSync) @@ -113,6 +128,9 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { DDLogInfo("🔄 POSCatalogSyncCoordinator: Performing incremental sync for site \(siteID) (last full sync: \(lastFullSyncUTC) UTC)") try await performIncrementalSync(for: siteID) } + + // Record first sync date if this was the first successful sync + recordFirstSyncIfNeeded(for: siteID) } /// Determines if a full sync should be performed based on the age of the last sync @@ -121,14 +139,6 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { /// - maxAge: Maximum age before a sync is considered stale /// - Returns: True if a sync should be performed private func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) async -> Bool { - await shouldPerformFullSync(for: siteID, maxAge: maxAge, maxCatalogSize: catalogSizeLimit) - } - - private func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval, maxCatalogSize: Int) async -> Bool { - guard await isCatalogSizeWithinLimit(for: siteID, maxCatalogSize: maxCatalogSize) else { - return false - } - if !siteExistsInDatabase(siteID: siteID) { DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) not found in database, sync needed") return true @@ -159,15 +169,17 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { /// - maxAge: Maximum age before a sync is considered stale /// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site public func performIncrementalSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws { - try await performIncrementalSyncIfApplicable(for: siteID, maxAge: maxAge, maxCatalogSize: catalogSizeLimit) - } - - private func performIncrementalSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, maxCatalogSize: Int) async throws { guard maxAge >= 0 else { throw POSCatalogSyncError.negativeMaxAge } - guard await shouldPerformIncrementalSync(for: siteID, maxAge: maxAge, maxCatalogSize: maxCatalogSize) else { + // Check sync eligibility before proceeding + guard await checkSyncEligibility(for: siteID) else { + DDLogInfo("📋 POSCatalogSyncCoordinator: Incremental sync skipped - site \(siteID) is not eligible") + return + } + + guard await shouldPerformIncrementalSync(for: siteID, maxAge: maxAge) else { return } @@ -193,13 +205,12 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { lastIncrementalSyncDate: lastIncrementalSyncDate(for: siteID)) DDLogInfo("✅ POSCatalogSyncCoordinator completed incremental sync for site \(siteID)") - } - private func shouldPerformIncrementalSync(for siteID: Int64, maxAge: TimeInterval, maxCatalogSize: Int) async -> Bool { - guard await isCatalogSizeWithinLimit(for: siteID, maxCatalogSize: maxCatalogSize) else { - return false - } + // Record first sync date if this was the first successful sync + recordFirstSyncIfNeeded(for: siteID) + } + private func shouldPerformIncrementalSync(for siteID: Int64, maxAge: TimeInterval) async -> Bool { guard await lastFullSyncDate(for: siteID) != nil else { DDLogInfo("📋 POSCatalogSyncCoordinator: No full sync performed yet for site \(siteID), skipping incremental sync") return false @@ -219,28 +230,6 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { // MARK: - Private - /// Checks if the catalog size is within the specified sync limit - /// - Parameters: - /// - siteID: The site ID to check - /// - maxCatalogSize: Maximum allowed catalog size for syncing - /// - Returns: True if catalog size is within limit or if size cannot be determined - private func isCatalogSizeWithinLimit(for siteID: Int64, maxCatalogSize: Int) async -> Bool { - guard let catalogSize = try? await catalogSizeChecker.checkCatalogSize(for: siteID) else { - DDLogError("📋 POSCatalogSyncCoordinator: Could not get catalog size for site \(siteID)") - return false - } - - guard catalogSize.totalCount <= maxCatalogSize else { - DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) has catalog size \(catalogSize.totalCount), " + - "greater than \(maxCatalogSize), should not sync.") - return false - } - - DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) has catalog size \(catalogSize.totalCount), with " + - "\(catalogSize.productCount) products and \(catalogSize.variationCount) variations") - return true - } - private func lastFullSyncDate(for siteID: Int64) async -> Date? { do { return try await grdbManager.databaseConnection.read { db in @@ -279,5 +268,60 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { private extension POSCatalogSyncCoordinator { enum Constants { static let defaultSizeLimitForPOSCatalog = 1000 + static let maxDaysSinceLastOpened = 30 + } + + // MARK: - Sync Eligibility + + /// Checks if sync is eligible for the given site based on catalog eligibility and temporal criteria + func checkSyncEligibility(for siteID: Int64) async -> Bool { + guard await catalogEligibilityChecker.catalogEligibility(for: siteID) == .eligible else { + DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) - Catalog ineligible") + return false + } + + // Then check temporal eligibility (30-day criteria) + let firstSyncDate = siteSettings.getFirstPOSCatalogSyncDate(siteID: siteID) + let lastOpenedDate = siteSettings.getPOSLastOpenedDate(siteID: siteID) + + // Case 1: No first sync date yet - eligible (will be set on first sync) + guard let firstSync = firstSyncDate else { + DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) eligible (no first sync date recorded)") + return true + } + + // Case 2: Has synced before. Check if within 30-day window from first sync + let daysSinceFirstSync = Calendar.current.dateComponents([.day], from: firstSync, to: Date()).day ?? 0 + + if daysSinceFirstSync > Constants.maxDaysSinceLastOpened { + // More than 30 days since first sync - must have opened POS recently to remain eligible + guard let lastOpened = lastOpenedDate else { + DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) ineligible (past 30-day grace period, no recent POS open)") + return false + } + + let daysSinceLastOpened = Calendar.current.dateComponents([.day], from: lastOpened, to: Date()).day ?? 0 + + if daysSinceLastOpened <= Constants.maxDaysSinceLastOpened { + DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) eligible (last opened \(daysSinceLastOpened) days ago)") + return true + } else { + DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) ineligible (POS last opened \(daysSinceLastOpened) days ago)") + return false + } + } else { + // Within 30 days of first sync - always eligible (new user grace period) + DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) eligible (within grace period: \(daysSinceFirstSync) days since first sync)") + return true + } + } + + /// Records the first sync date if not already set + func recordFirstSyncIfNeeded(for siteID: Int64) { + // Only set if not already set (preserves original first sync date) + if siteSettings.getFirstPOSCatalogSyncDate(siteID: siteID) == nil { + siteSettings.setFirstPOSCatalogSyncDate(siteID: siteID, date: Date()) + DDLogInfo("📋 POSCatalogSyncCoordinator: Recorded first sync date for site \(siteID)") + } } } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift b/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift new file mode 100644 index 00000000000..367003eb2a3 --- /dev/null +++ b/Modules/Sources/Yosemite/Tools/POS/POSLocalCatalogEligibilityService.swift @@ -0,0 +1,114 @@ +import Foundation +import CocoaLumberjackSwift + +public actor POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol { + private let catalogSizeChecker: POSCatalogSizeCheckerProtocol + private let catalogSizeLimit: Int + private let isLocalCatalogFeatureFlagEnabled: Bool + + // Eligibility states cached per site + private var eligibilityStates: [Int64: POSLocalCatalogEligibilityState] = [:] + + // POS eligibility states cached per site + private var posEligibilityStates: [Int64: Bool] = [:] + + /// Initialize eligibility service + /// - Parameters: + /// - catalogSizeChecker: Service to check catalog size for sites + /// - isLocalCatalogFeatureFlagEnabled: Whether the local catalog feature flag is enabled + /// - catalogSizeLimit: Maximum allowed catalog size (products + variations) + public init( + catalogSizeChecker: POSCatalogSizeCheckerProtocol, + isLocalCatalogFeatureFlagEnabled: Bool, + catalogSizeLimit: Int? = nil + ) { + self.catalogSizeChecker = catalogSizeChecker + self.isLocalCatalogFeatureFlagEnabled = isLocalCatalogFeatureFlagEnabled + self.catalogSizeLimit = catalogSizeLimit ?? Constants.defaultCatalogSizeLimit + } + + /// Get catalog eligibility for a specific site + /// If not cached, refreshes eligibility and returns the result + public func catalogEligibility(for siteID: Int64) async -> POSLocalCatalogEligibilityState { + if let cached = eligibilityStates[siteID] { + return cached + } + // Not cached yet, refresh and return + return await refreshEligibilityState(for: siteID) + } + + /// Update POS eligibility and refresh catalog eligibility for the specified site + /// - Parameters: + /// - isEligible: Whether POS is eligible + /// - siteID: The site ID to refresh eligibility for + public func updatePOSEligibility(isEligible: Bool, for siteID: Int64) async { + // Store the POS eligibility state for this site + posEligibilityStates[siteID] = isEligible + // Refresh eligibility for the current site now that POS eligibility has changed + await refreshEligibilityState(for: siteID) + } + + /// Refresh eligibility state for a specific site + @discardableResult + public func refreshEligibilityState(for siteID: Int64) async -> POSLocalCatalogEligibilityState { + // Check POS tab eligibility FIRST - no point in checking catalog if POS isn't eligible + guard let isPOSEligible = posEligibilityStates[siteID] else { + // We don't have POS eligibility info yet - don't cache this state + // Return ineligible but allow it to be refreshed later when eligibility is known + let state = POSLocalCatalogEligibilityState.ineligible(reason: .posTabNotEligible) + DDLogInfo("📋 POSLocalCatalogEligibilityService: POS eligibility unknown for site \(siteID), assuming ineligible") + return state + } + + guard isPOSEligible else { + // We know POS is explicitly ineligible - cache this state + let state = POSLocalCatalogEligibilityState.ineligible(reason: .posTabNotEligible) + eligibilityStates[siteID] = state + DDLogInfo("📋 POSLocalCatalogEligibilityService: POS not eligible for site \(siteID)") + return state + } + + // Check feature flag - if disabled, no need to check catalog size + guard isLocalCatalogFeatureFlagEnabled else { + let state = POSLocalCatalogEligibilityState.ineligible(reason: .featureFlagDisabled) + eligibilityStates[siteID] = state + DDLogInfo("📋 POSLocalCatalogEligibilityService: Local catalog feature flag disabled for site \(siteID)") + return state + } + + // Fetch remote catalog size and check against limit + do { + let size = try await catalogSizeChecker.checkCatalogSize(for: siteID) + + if size.totalCount > catalogSizeLimit { + let state = POSLocalCatalogEligibilityState.ineligible( + reason: .catalogSizeTooLarge(totalCount: size.totalCount, limit: catalogSizeLimit) + ) + eligibilityStates[siteID] = state + DDLogInfo("📋 POSLocalCatalogEligibilityService: Site \(siteID) catalog size \(size.totalCount) exceeds limit \(catalogSizeLimit)") + return state + } + + DDLogInfo("📋 POSLocalCatalogEligibilityService: Site \(siteID) catalog size \(size.totalCount) is within limit \(catalogSizeLimit)") + eligibilityStates[siteID] = .eligible + return .eligible + + } catch { + let errorString = String(describing: error) + let state = POSLocalCatalogEligibilityState.ineligible( + reason: .catalogSizeCheckFailed(underlyingError: errorString) + ) + eligibilityStates[siteID] = state + DDLogError("📋 POSLocalCatalogEligibilityService: Failed to check catalog size for site \(siteID): \(error)") + return state + } + } +} + +// MARK: - Constants + +private extension POSLocalCatalogEligibilityService { + enum Constants { + static let defaultCatalogSizeLimit = 1000 + } +} diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSLocalCatalogEligibilityService.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSLocalCatalogEligibilityService.swift deleted file mode 100644 index 021ebbb3259..00000000000 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSLocalCatalogEligibilityService.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation -@testable import PointOfSale - -/// Mock implementation of POSLocalCatalogEligibilityServiceProtocol for testing -@MainActor -public final class MockPOSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol { - public var eligibilityState: POSLocalCatalogEligibilityState - public var refreshCallCount = 0 - - public init(eligibilityState: POSLocalCatalogEligibilityState = .eligible) { - self.eligibilityState = eligibilityState - } - - public func refreshEligibilityState() async -> POSLocalCatalogEligibilityState { - refreshCallCount += 1 - return eligibilityState - } - - public func updateVisibility(isPOSTabVisible: Bool) async { } -} diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSLocalCatalogEligibilityService.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSLocalCatalogEligibilityService.swift new file mode 100644 index 00000000000..f24e26fbfd1 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSLocalCatalogEligibilityService.swift @@ -0,0 +1,29 @@ +import Foundation +@testable import Yosemite + +/// Mock actor for testing +actor MockPOSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol { + private var eligibilityStates: [Int64: POSLocalCatalogEligibilityState] = [:] + + /// Set eligibility for a specific site + func setEligibility(_ state: POSLocalCatalogEligibilityState, for siteID: Int64) { + eligibilityStates[siteID] = state + } + + /// Set to ineligible for testing + func setIneligible(for siteID: Int64) { + eligibilityStates[siteID] = .ineligible(reason: .featureFlagDisabled) + } + + func catalogEligibility(for siteID: Int64) async -> POSLocalCatalogEligibilityState { + return eligibilityStates[siteID] ?? .eligible + } + + func updatePOSEligibility(isEligible: Bool, for siteID: Int64) async { + // No-op for tests + } + + func refreshEligibilityState(for siteID: Int64) async -> POSLocalCatalogEligibilityState { + return eligibilityStates[siteID] ?? .eligible + } +} diff --git a/Modules/Tests/YosemiteTests/Mocks/MockSiteSpecificAppSettingsStoreMethods.swift b/Modules/Tests/YosemiteTests/Mocks/MockSiteSpecificAppSettingsStoreMethods.swift index e311398ce28..45f6846238a 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockSiteSpecificAppSettingsStoreMethods.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockSiteSpecificAppSettingsStoreMethods.swift @@ -28,6 +28,14 @@ final class MockSiteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStor var spySetSearchTermsSiteID: Int64? var mockSearchTerms: [POSItemType: [String]] = [:] + // POS sync eligibility tracking properties + var getPOSLastOpenedDateCalled = false + var setPOSLastOpenedDateCalled = false + var getFirstPOSCatalogSyncDateCalled = false + var setFirstPOSCatalogSyncDateCalled = false + var mockPOSLastOpenedDate: Date? + var mockFirstPOSCatalogSyncDate: Date? + func getStoreSettings(for siteID: Int64) -> GeneralStoreSettings { getStoreSettingsCalled = true @@ -86,4 +94,24 @@ final class MockSiteSpecificAppSettingsStoreMethods: SiteSpecificAppSettingsStor mockSearchTerms[itemType] = terms } + // POS sync eligibility tracking methods + func getPOSLastOpenedDate(siteID: Int64) -> Date? { + getPOSLastOpenedDateCalled = true + return mockPOSLastOpenedDate + } + + func setPOSLastOpenedDate(siteID: Int64, date: Date) { + setPOSLastOpenedDateCalled = true + mockPOSLastOpenedDate = date + } + + func getFirstPOSCatalogSyncDate(siteID: Int64) -> Date? { + getFirstPOSCatalogSyncDateCalled = true + return mockFirstPOSCatalogSyncDate + } + + func setFirstPOSCatalogSyncDate(siteID: Int64, date: Date) { + setFirstPOSCatalogSyncDateCalled = true + mockFirstPOSCatalogSyncDate = date + } } diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift index 699c213be5b..7a8eaedeef7 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift @@ -7,7 +7,8 @@ struct POSCatalogSyncCoordinatorTests { private let mockSyncService: MockPOSCatalogFullSyncService private let mockIncrementalSyncService: MockPOSCatalogIncrementalSyncService private let grdbManager: GRDBManager - private let mockCatalogSizeChecker: MockPOSCatalogSizeChecker + private let mockSiteSettings: MockSiteSpecificAppSettingsStoreMethods + private let mockEligibilityChecker: MockPOSLocalCatalogEligibilityService private let sut: POSCatalogSyncCoordinator private let sampleSiteID: Int64 = 134 private let sampleMaxAge: TimeInterval = 60 * 60 @@ -16,12 +17,14 @@ struct POSCatalogSyncCoordinatorTests { self.mockSyncService = MockPOSCatalogFullSyncService() self.mockIncrementalSyncService = MockPOSCatalogIncrementalSyncService() self.grdbManager = try GRDBManager() - self.mockCatalogSizeChecker = MockPOSCatalogSizeChecker() + self.mockSiteSettings = MockSiteSpecificAppSettingsStoreMethods() + self.mockEligibilityChecker = MockPOSLocalCatalogEligibilityService() self.sut = POSCatalogSyncCoordinator( fullSyncService: mockSyncService, incrementalSyncService: mockIncrementalSyncService, grdbManager: grdbManager, - catalogSizeChecker: mockCatalogSizeChecker + catalogEligibilityChecker: mockEligibilityChecker, + siteSettings: mockSiteSettings ) } @@ -146,79 +149,6 @@ struct POSCatalogSyncCoordinatorTests { #expect(mockSyncService.startFullSyncCallCount == 1) } - // MARK: - Catalog Size Check Tests - - @Test func performFullSyncIfApplicable_skips_sync_when_catalog_size_exceeds_limit() async throws { - // Given - catalog size is above the 1000 item limit - mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total - try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) - - // When - let _ = try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) - - // Then - #expect(mockSyncService.startFullSyncCallCount == 0) - #expect(mockCatalogSizeChecker.checkCatalogSizeCallCount == 1) - #expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID) - } - - @Test func performFullSyncIfApplicable_starts_sync_when_catalog_size_is_at_limit() async throws { - // Given - catalog size is exactly at the 1000 item limit - mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 600, variationCount: 400)) // 1000 total - try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) - - // When - let _ = try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) - - // Then - #expect(mockSyncService.startFullSyncCallCount == 1) - #expect(mockCatalogSizeChecker.checkCatalogSizeCallCount == 1) - #expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID) - } - - @Test func performFullSyncIfApplicable_starts_sync_when_catalog_size_is_under_limit() async throws { - // Given - catalog size is below the 1000 item limit - mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 300, variationCount: 200)) // 500 total - try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) - - // When - let _ = try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) - - // Then - #expect(mockSyncService.startFullSyncCallCount == 1) - #expect(mockCatalogSizeChecker.checkCatalogSizeCallCount == 1) - #expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID) - } - - @Test func performFullSyncIfApplicable_skips_sync_when_catalog_size_check_fails() async throws { - // Given - catalog size check throws an error - let sizeCheckError = NSError(domain: "size_check", code: 500, userInfo: [NSLocalizedDescriptionKey: "Network error"]) - mockCatalogSizeChecker.sizeToReturn = .failure(sizeCheckError) - try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) - - // When - let _ = try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) - - // Then - #expect(mockSyncService.startFullSyncCallCount == 0) - #expect(mockCatalogSizeChecker.checkCatalogSizeCallCount == 1) - #expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID) - } - - @Test func performFullSyncIfApplicable_respects_time_only_when_catalog_size_is_acceptable() async throws { - // Given - catalog size is acceptable but sync is recent - mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 200, variationCount: 100)) // 300 total - let thirtyMinutesAgo = Date().addingTimeInterval(-30 * 60) - try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: thirtyMinutesAgo) - - // When - max age is 1 hour - let _ = try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) - - // Then - should not sync because time hasn't passed yet - #expect(mockSyncService.startFullSyncCallCount == 0) - #expect(mockCatalogSizeChecker.checkCatalogSizeCallCount == 1) - } - // MARK: - Database Check Tests @Test func performFullSyncIfApplicable_starts_sync_when_site_not_in_database() async throws { @@ -347,7 +277,7 @@ struct POSCatalogSyncCoordinatorTests { fullSyncService: mockSyncService, incrementalSyncService: mockIncrementalSyncService, grdbManager: grdbManager, - catalogSizeChecker: mockCatalogSizeChecker + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService() ) // When @@ -368,7 +298,7 @@ struct POSCatalogSyncCoordinatorTests { fullSyncService: mockSyncService, incrementalSyncService: mockIncrementalSyncService, grdbManager: grdbManager, - catalogSizeChecker: mockCatalogSizeChecker + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService() ) // When @@ -403,7 +333,7 @@ struct POSCatalogSyncCoordinatorTests { fullSyncService: mockSyncService, incrementalSyncService: mockIncrementalSyncService, grdbManager: grdbManager, - catalogSizeChecker: mockCatalogSizeChecker + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService() ) // When @@ -499,97 +429,6 @@ struct POSCatalogSyncCoordinatorTests { #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 2) } - // MARK: - Incremental Sync Catalog Size Tests - - @Test(arguments: [.zero, 60 * 60]) - func performIncrementalSyncIfApplicable_skips_sync_when_catalog_size_exceeds_limit(maxAge: TimeInterval) async throws { - // Given - catalog size is above the 1000 item limit - mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 700, variationCount: 400)) // 1100 total - let fullSyncDate = Date().addingTimeInterval(-3600) - try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate) - - // When - try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: maxAge) - - // Then - #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) - #expect(mockCatalogSizeChecker.checkCatalogSizeCallCount == 1) - #expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID) - } - - @Test(arguments: [.zero, 60 * 60]) - func performIncrementalSyncIfApplicable_performs_sync_when_catalog_size_is_at_limit(maxAge: TimeInterval) async throws { - // Given - catalog size is exactly at the 1000 item limit - mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 500, variationCount: 500)) // 1000 total - let fullSyncDate = Date().addingTimeInterval(-3600) - try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate) - - // When - try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: maxAge) - - // Then - #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1) - #expect(mockCatalogSizeChecker.checkCatalogSizeCallCount == 1) - #expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID) - } - - @Test(arguments: [.zero, 60 * 60]) - func performIncrementalSyncIfApplicable_performs_sync_when_catalog_size_is_under_limit(maxAge: TimeInterval) async throws { - // Given - catalog size is below the 1000 item limit - mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 200, variationCount: 150)) // 350 total - let fullSyncDate = Date().addingTimeInterval(-3600) - try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate) - - // When - try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: maxAge) - - // Then - #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1) - #expect(mockCatalogSizeChecker.checkCatalogSizeCallCount == 1) - #expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID) - } - - @Test(arguments: [.zero, 60 * 60]) - func performIncrementalSyncIfApplicable_skips_sync_when_catalog_size_check_fails(maxAge: TimeInterval) async throws { - // Given - catalog size check throws an error - let sizeCheckError = NSError(domain: "size_check", code: 500, userInfo: [NSLocalizedDescriptionKey: "Network error"]) - mockCatalogSizeChecker.sizeToReturn = .failure(sizeCheckError) - let fullSyncDate = Date().addingTimeInterval(-3600) - try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate) - - // When - try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: maxAge) - - // Then - should skip sync when size check fails - #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) - #expect(mockCatalogSizeChecker.checkCatalogSizeCallCount == 1) - #expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID) - } - - @Test func performIncrementalSyncIfApplicable_checks_size_before_age_check() async throws { - // Given - catalog is over limit but would otherwise sync due to age - mockCatalogSizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 800, variationCount: 300)) // 1100 total - let maxAge: TimeInterval = 2 - let staleIncrementalSyncDate = Date().addingTimeInterval(-(maxAge + 1)) // Older than max age - let fullSyncDate = Date().addingTimeInterval(-3600) - try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate, lastIncrementalSyncDate: staleIncrementalSyncDate) - - let sut = POSCatalogSyncCoordinator( - fullSyncService: mockSyncService, - incrementalSyncService: mockIncrementalSyncService, - grdbManager: grdbManager, - catalogSizeChecker: mockCatalogSizeChecker - ) - - // When - try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: maxAge) - - // Then - should skip sync due to size limit, regardless of age - #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) - #expect(mockCatalogSizeChecker.checkCatalogSizeCallCount == 1) - #expect(mockCatalogSizeChecker.lastCheckedSiteID == sampleSiteID) - } - // MARK: - Smart Sync Tests @Test func performSmartSync_performs_full_sync_when_last_full_sync_older_than_threshold() async throws { @@ -743,3 +582,234 @@ final class MockPOSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol { shouldBlockSync = false } } + +// MARK: - Sync Eligibility Tests + +extension POSCatalogSyncCoordinatorTests { + @Test func performSmartSync_skips_sync_when_catalog_is_ineligible() async throws { + // Given + let eligibilityChecker = MockPOSLocalCatalogEligibilityService() + await eligibilityChecker.setIneligible(for: sampleSiteID) // Catalog ineligible + let coordinator = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: eligibilityChecker, + siteSettings: mockSiteSettings + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) + + // When + try await coordinator.performSmartSync(for: sampleSiteID) + + // Then - sync should be skipped + #expect(mockSyncService.startFullSyncCallCount == 0) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) + } + + @Test func performSmartSync_proceeds_when_catalog_is_eligible_and_no_first_sync_date() async throws { + // Given - new user, catalog eligible + let eligibilityChecker = MockPOSLocalCatalogEligibilityService() + await eligibilityChecker.setEligibility(.eligible, for: sampleSiteID) + + let coordinator = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: eligibilityChecker, + siteSettings: mockSiteSettings + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) + mockSyncService.startFullSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now)) + + // When + try await coordinator.performSmartSync(for: sampleSiteID) + + // Then - sync should proceed + #expect(mockSyncService.startFullSyncCallCount == 1) + } + + @Test func performSmartSync_records_first_sync_date_after_successful_sync() async throws { + // Given - new user + let coordinator = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService(), + siteSettings: mockSiteSettings + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) + mockSyncService.startFullSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now)) + + // When + try await coordinator.performSmartSync(for: sampleSiteID) + + // Then - first sync date should be recorded + #expect(mockSiteSettings.setFirstPOSCatalogSyncDateCalled == true) + #expect(mockSiteSettings.mockFirstPOSCatalogSyncDate != nil) + } + + @Test func performSmartSync_does_not_overwrite_existing_first_sync_date() async throws { + // Given - existing user with first sync date + let originalDate = Date().addingTimeInterval(-10 * 24 * 60 * 60) // 10 days ago + mockSiteSettings.mockFirstPOSCatalogSyncDate = originalDate + + let coordinator = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService(), + siteSettings: mockSiteSettings + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: nil) + mockSyncService.startFullSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now)) + + // When + try await coordinator.performSmartSync(for: sampleSiteID) + + // Then - first sync date should remain unchanged + #expect(mockSiteSettings.mockFirstPOSCatalogSyncDate == originalDate) + } + + @Test func performSmartSync_proceeds_with_incremental_sync_for_new_user_within_30_day_grace_period() async throws { + // Given - user who first synced 15 days ago (within 30-day grace period) + let fifteenDaysAgo = Date().addingTimeInterval(-15 * 24 * 60 * 60) + mockSiteSettings.mockFirstPOSCatalogSyncDate = fifteenDaysAgo + // No lastPOSOpenedDate set + + let coordinator = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService(), + siteSettings: mockSiteSettings + ) + let twoHoursAgo = Date().addingTimeInterval(-2 * 60 * 60) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twoHoursAgo) + mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + + // When + try await coordinator.performSmartSync(for: sampleSiteID) + + // Then - sync should proceed (within grace period) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1) + } + + @Test func performSmartSync_skips_sync_for_existing_user_past_30_days_with_no_recent_open() async throws { + // Given - user who first synced 40 days ago, never opened POS recently + let fortyDaysAgo = Date().addingTimeInterval(-40 * 24 * 60 * 60) + mockSiteSettings.mockFirstPOSCatalogSyncDate = fortyDaysAgo + // No lastPOSOpenedDate set + + let coordinator = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService(), + siteSettings: mockSiteSettings + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-2 * 60 * 60)) + + // When + try await coordinator.performSmartSync(for: sampleSiteID) + + // Then - sync should be skipped (past grace period, no recent open) + #expect(mockSyncService.startFullSyncCallCount == 0) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) + } + + @Test func performSmartSync_proceeds_for_existing_user_past_30_days_with_recent_open() async throws { + // Given - user who first synced 40 days ago, but opened POS 5 days ago + let fortyDaysAgo = Date().addingTimeInterval(-40 * 24 * 60 * 60) + let fiveDaysAgo = Date().addingTimeInterval(-5 * 24 * 60 * 60) + mockSiteSettings.mockFirstPOSCatalogSyncDate = fortyDaysAgo + mockSiteSettings.mockPOSLastOpenedDate = fiveDaysAgo + + let coordinator = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService(), + siteSettings: mockSiteSettings + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-2 * 60 * 60)) + mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + + // When + try await coordinator.performSmartSync(for: sampleSiteID) + + // Then - sync should proceed (opened recently) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1) + } + + @Test func performSmartSync_skips_sync_for_existing_user_past_30_days_with_stale_open() async throws { + // Given - user who first synced 40 days ago, last opened POS 35 days ago (too long) + let fortyDaysAgo = Date().addingTimeInterval(-40 * 24 * 60 * 60) + let thirtyFiveDaysAgo = Date().addingTimeInterval(-35 * 24 * 60 * 60) + mockSiteSettings.mockFirstPOSCatalogSyncDate = fortyDaysAgo + mockSiteSettings.mockPOSLastOpenedDate = thirtyFiveDaysAgo + + let coordinator = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService(), + siteSettings: mockSiteSettings + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-2 * 60 * 60)) + + // When + try await coordinator.performSmartSync(for: sampleSiteID) + + // Then - sync should be skipped (last opened too long ago) + #expect(mockSyncService.startFullSyncCallCount == 0) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 0) + } + + @Test func performSmartSync_boundary_test_exactly_30_days_after_first_sync_with_recent_open() async throws { + // Given - user who first synced exactly 30 days ago, opened POS today + let exactlyThirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60) + mockSiteSettings.mockFirstPOSCatalogSyncDate = exactlyThirtyDaysAgo + mockSiteSettings.mockPOSLastOpenedDate = Date() + + let coordinator = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService(), + siteSettings: mockSiteSettings + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-2 * 60 * 60)) + mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + + // When + try await coordinator.performSmartSync(for: sampleSiteID) + + // Then - sync should proceed (opened recently, even though at 30-day boundary) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1) + } + + @Test func performSmartSync_boundary_test_exactly_30_days_since_last_open() async throws { + // Given - user who first synced 40 days ago, opened POS exactly 30 days ago + let fortyDaysAgo = Date().addingTimeInterval(-40 * 24 * 60 * 60) + let exactlyThirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60) + mockSiteSettings.mockFirstPOSCatalogSyncDate = fortyDaysAgo + mockSiteSettings.mockPOSLastOpenedDate = exactlyThirtyDaysAgo + + let coordinator = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: MockPOSLocalCatalogEligibilityService(), + siteSettings: mockSiteSettings + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-2 * 60 * 60)) + mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + + // When + try await coordinator.performSmartSync(for: sampleSiteID) + + // Then - sync should proceed (exactly at 30-day boundary is still eligible) + #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 1) + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Eligibility/POSLocalCatalogEligibilityServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSLocalCatalogEligibilityServiceTests.swift similarity index 62% rename from WooCommerce/WooCommerceTests/POS/Eligibility/POSLocalCatalogEligibilityServiceTests.swift rename to Modules/Tests/YosemiteTests/Tools/POS/POSLocalCatalogEligibilityServiceTests.swift index 4507f065b2f..8c497b830e5 100644 --- a/WooCommerce/WooCommerceTests/POS/Eligibility/POSLocalCatalogEligibilityServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSLocalCatalogEligibilityServiceTests.swift @@ -1,10 +1,6 @@ import Foundation import Testing -import PointOfSale -@testable import WooCommerce @testable import Yosemite -import protocol Experiments.FeatureFlagService -import enum Experiments.FeatureFlag @Suite("POSLocalCatalogEligibilityService Tests") @MainActor @@ -18,16 +14,14 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: true, + isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) + await service.updatePOSEligibility(isEligible: true, for: siteID) - #expect(service.eligibilityState == .eligible) + #expect(await service.catalogEligibility(for: siteID) == .eligible) } @Test("Exactly at size limit returns eligible") @@ -35,16 +29,14 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 600, variationCount: 400)) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: true, + isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) + await service.updatePOSEligibility(isEligible: true, for: siteID) - #expect(service.eligibilityState == .eligible) + #expect(await service.catalogEligibility(for: siteID) == .eligible) } // MARK: - Catalog Size Exceeds Limit @@ -54,16 +46,14 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 501, variationCount: 500)) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: true, + isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) + await service.updatePOSEligibility(isEligible: true, for: siteID) - let state = service.eligibilityState + let state = await service.catalogEligibility(for: siteID) guard case .ineligible(let reason) = state else { Issue.record("Expected ineligible state") @@ -87,16 +77,14 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .failure(expectedError) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: true, + isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) + await service.updatePOSEligibility(isEligible: true, for: siteID) - let state = service.eligibilityState + let state = await service.catalogEligibility(for: siteID) guard case .ineligible(let reason) = state else { Issue.record("Expected ineligible state") @@ -118,22 +106,20 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: true, + isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) + await service.updatePOSEligibility(isEligible: true, for: siteID) // First call - let firstState = service.eligibilityState + let firstState = await service.catalogEligibility(for: siteID) #expect(firstState == .eligible) #expect(sizeChecker.checkCatalogSizeCallCount == 1) // Second call should use cache - let secondState = service.eligibilityState + let secondState = await service.catalogEligibility(for: siteID) #expect(secondState == .eligible) #expect(sizeChecker.checkCatalogSizeCallCount == 1) // Should not increment } @@ -143,22 +129,20 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: true, + isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) + await service.updatePOSEligibility(isEligible: true, for: siteID) // First call - let firstState = service.eligibilityState + let firstState = await service.catalogEligibility(for: siteID) #expect(firstState == .eligible) #expect(sizeChecker.checkCatalogSizeCallCount == 1) // Refresh should fetch again - let refreshedState = await service.refreshEligibilityState() + let refreshedState = await service.refreshEligibilityState(for: siteID) #expect(refreshedState == .eligible) #expect(sizeChecker.checkCatalogSizeCallCount == 2) // Should increment } @@ -168,24 +152,22 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: true, + isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) + await service.updatePOSEligibility(isEligible: true, for: siteID) // First call caches eligible state - _ = service.eligibilityState + _ = await service.catalogEligibility(for: siteID) #expect(sizeChecker.checkCatalogSizeCallCount == 1) // Change the size checker to return ineligible sizeChecker.sizeToReturn = .success(POSCatalogSize(productCount: 1000, variationCount: 100)) // Refresh should update cache - let refreshedState = await service.refreshEligibilityState() + let refreshedState = await service.refreshEligibilityState(for: siteID) guard case .ineligible = refreshedState else { Issue.record("Expected ineligible after refresh") return @@ -193,7 +175,7 @@ struct POSLocalCatalogEligibilityServiceTests { #expect(sizeChecker.checkCatalogSizeCallCount == 2) // Next get should return cached ineligible - let cachedState = service.eligibilityState + let cachedState = await service.catalogEligibility(for: siteID) guard case .ineligible = cachedState else { Issue.record("Expected cached ineligible state") return @@ -208,16 +190,14 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: false) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: true, + isLocalCatalogFeatureFlagEnabled: false, catalogSizeLimit: 1000 ) + await service.updatePOSEligibility(isEligible: true, for: siteID) - let state = service.eligibilityState + let state = await service.catalogEligibility(for: siteID) guard case .ineligible(let reason) = state else { Issue.record("Expected ineligible state") @@ -240,16 +220,14 @@ struct POSLocalCatalogEligibilityServiceTests { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 100, variationCount: 50)) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: true, + isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 100 // Custom lower limit ) + await service.updatePOSEligibility(isEligible: true, for: siteID) - let state = service.eligibilityState + let state = await service.catalogEligibility(for: siteID) guard case .ineligible(let reason) = state else { Issue.record("Expected ineligible state") @@ -265,31 +243,31 @@ struct POSLocalCatalogEligibilityServiceTests { #expect(limit == 100) } - // MARK: - POS Tab Visibility + // MARK: - POS Eligibility - @Test("POS tab not visible returns ineligible") - func testPOSTabNotVisibleReturnsIneligible() async { + @Test("POS not eligible returns ineligible") + func testPOSNotEligibleReturnsIneligible() async { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: false, + isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) - let state = service.eligibilityState + // Set POS as not eligible + await service.updatePOSEligibility(isEligible: false, for: siteID) + + let state = await service.catalogEligibility(for: siteID) guard case .ineligible(let reason) = state else { Issue.record("Expected ineligible state") return } - guard case .posTabNotVisible = reason else { - Issue.record("Expected posTabNotVisible reason") + guard case .posTabNotEligible = reason else { + Issue.record("Expected posTabNotEligible reason") return } @@ -297,32 +275,53 @@ struct POSLocalCatalogEligibilityServiceTests { #expect(sizeChecker.checkCatalogSizeCallCount == 0) } - @Test("POS tab visibility checked before catalog size") - func testPOSTabVisibilityCheckedFirst() async { + @Test("POS eligibility checked before catalog size") + func testPOSEligibilityCheckedFirst() async { let sizeChecker = MockPOSCatalogSizeChecker( sizeToReturn: .success(POSCatalogSize(productCount: 2000, variationCount: 0)) ) - let featureFlagService = MockFeatureFlagService(isLocalCatalogEnabled: true) - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, + let service = POSLocalCatalogEligibilityService( catalogSizeChecker: sizeChecker, - featureFlagService: featureFlagService, - isPOSTabVisible: false, + isLocalCatalogFeatureFlagEnabled: true, catalogSizeLimit: 1000 ) - // Should be ineligible due to POS tab not visible, not catalog size - guard case .ineligible(let reason) = service.eligibilityState else { + // Set POS as not eligible + await service.updatePOSEligibility(isEligible: false, for: siteID) + + // Should be ineligible due to POS not eligible, not catalog size + guard case .ineligible(let reason) = await service.catalogEligibility(for: siteID) else { Issue.record("Expected ineligible state") return } - guard case .posTabNotVisible = reason else { - Issue.record("Expected posTabNotVisible reason, not catalogSizeTooLarge") + guard case .posTabNotEligible = reason else { + Issue.record("Expected posTabNotEligible reason, not catalogSizeTooLarge") return } - // Should not have checked catalog size since POS tab wasn't visible + // Should not have checked catalog size since POS wasn't eligible #expect(sizeChecker.checkCatalogSizeCallCount == 0) } + + @Test("POS eligible allows catalog size check") + func testPOSEligibleAllowsCatalogSizeCheck() async { + let sizeChecker = MockPOSCatalogSizeChecker( + sizeToReturn: .success(POSCatalogSize(productCount: 500, variationCount: 400)) + ) + let service = POSLocalCatalogEligibilityService( + catalogSizeChecker: sizeChecker, + isLocalCatalogFeatureFlagEnabled: true, + catalogSizeLimit: 1000 + ) + + // Set POS as eligible + await service.updatePOSEligibility(isEligible: true, for: siteID) + + let state = await service.catalogEligibility(for: siteID) + #expect(state == .eligible) + + // Should have checked catalog size since POS was eligible + #expect(sizeChecker.checkCatalogSizeCallCount == 1) + } } diff --git a/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift b/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift index 001ab321f83..caedd2d19eb 100644 --- a/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift +++ b/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift @@ -4,6 +4,7 @@ import Codegen import Foundation import Yosemite +// swiftlint:disable line_length extension WooCommerce.AggregateOrderItem { func copy( @@ -118,3 +119,5 @@ extension WooCommerce.ShippingLabelSelectedRate { ) } } + +// swiftlint:enable line_length diff --git a/WooCommerce/Classes/POS/Eligibility/POSLocalCatalogEligibilityService.swift b/WooCommerce/Classes/POS/Eligibility/POSLocalCatalogEligibilityService.swift deleted file mode 100644 index 186b0156e93..00000000000 --- a/WooCommerce/Classes/POS/Eligibility/POSLocalCatalogEligibilityService.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import CocoaLumberjackSwift -import Yosemite -import Experiments -import protocol PointOfSale.POSLocalCatalogEligibilityServiceProtocol -import enum PointOfSale.POSLocalCatalogEligibilityState -import enum PointOfSale.POSLocalCatalogIneligibleReason - -@MainActor -final class POSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol { - private let siteID: Int64 - private let catalogSizeChecker: POSCatalogSizeCheckerProtocol - private let catalogSizeLimit: Int - private let featureFlagService: FeatureFlagService - private var isPOSTabVisible: Bool - - // Current eligibility state - private(set) var eligibilityState: POSLocalCatalogEligibilityState - - /// Initialize eligibility service and perform initial eligibility check - init( - siteID: Int64, - catalogSizeChecker: POSCatalogSizeCheckerProtocol, - featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, - isPOSTabVisible: Bool, - catalogSizeLimit: Int? = nil - ) async { - self.siteID = siteID - self.catalogSizeChecker = catalogSizeChecker - self.featureFlagService = featureFlagService - self.isPOSTabVisible = isPOSTabVisible - self.catalogSizeLimit = catalogSizeLimit ?? Constants.defaultCatalogSizeLimit - - // Perform initial check - self.eligibilityState = .eligible // Temporary - await self.refreshEligibilityState() - } - - /// Update the visibility state and refresh eligibility - func updateVisibility(isPOSTabVisible: Bool) async { - self.isPOSTabVisible = isPOSTabVisible - await refreshEligibilityState() - } - - @discardableResult func refreshEligibilityState() async -> POSLocalCatalogEligibilityState { - // Check POS tab visibility FIRST - no point in checking catalog if POS tab isn't visible - guard isPOSTabVisible else { - eligibilityState = .ineligible(reason: .posTabNotVisible) - DDLogInfo("📋 POSLocalCatalogEligibilityService: POS tab not visible for site \(siteID)") - return eligibilityState - } - - // Check feature flag - if disabled, no need to check catalog size - let isFeatureFlagEnabled = featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) - guard isFeatureFlagEnabled else { - eligibilityState = .ineligible(reason: .featureFlagDisabled) - DDLogInfo("📋 POSLocalCatalogEligibilityService: Local catalog feature flag disabled for site \(siteID)") - return eligibilityState - } - - // Fetch remote catalog size and check against limit - do { - let size = try await catalogSizeChecker.checkCatalogSize(for: siteID) - - if size.totalCount > catalogSizeLimit { - eligibilityState = .ineligible( - reason: .catalogSizeTooLarge(totalCount: size.totalCount, limit: catalogSizeLimit) - ) - DDLogInfo("📋 POSLocalCatalogEligibilityService: Site \(siteID) catalog size \(size.totalCount) exceeds limit \(catalogSizeLimit)") - return eligibilityState - } - - DDLogInfo("📋 POSLocalCatalogEligibilityService: Site \(siteID) catalog size \(size.totalCount) is within limit \(catalogSizeLimit)") - eligibilityState = .eligible - return eligibilityState - - } catch { - let errorString = String(describing: error) - eligibilityState = .ineligible( - reason: .catalogSizeCheckFailed(underlyingError: errorString) - ) - DDLogError("📋 POSLocalCatalogEligibilityService: Failed to check catalog size for site \(siteID): \(error)") - return eligibilityState - } - } -} - -// MARK: - Constants - -private extension POSLocalCatalogEligibilityService { - enum Constants { - static let defaultCatalogSizeLimit = 1000 - } -} diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index ec0c85bbac4..2ba83832d91 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -108,7 +108,7 @@ final class POSTabCoordinator { currencySettings: CurrencySettings = ServiceLocator.currencySettings, pushNotesManager: PushNotesManager = ServiceLocator.pushNotesManager, eligibilityChecker: POSEntryPointEligibilityCheckerProtocol, - initialPOSTabVisibility: Bool) { + localCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol?) { self.siteID = siteID self.storesManager = storesManager self.defaultSitePublisher = storesManager.sessionManager.defaultSitePublisher @@ -125,33 +125,27 @@ final class POSTabCoordinator { self.currencySettings = currencySettings self.pushNotesManager = pushNotesManager self.eligibilityChecker = eligibilityChecker + self.localCatalogEligibilityService = localCatalogEligibilityService tabContainerController.wrappedController = POSTabViewController() - - // Create local catalog eligibility service asynchronously - // Use initial POS tab visibility from cached check - Task { @MainActor [weak self] in - guard let self else { return } - - let service = await POSLocalCatalogEligibilityService( - siteID: siteID, - catalogSizeChecker: POSCatalogSizeChecker( - credentials: credentials, - selectedSite: defaultSitePublisher, - appPasswordSupportState: isAppPasswordSupported - ), - isPOSTabVisible: initialPOSTabVisibility - ) - - self.localCatalogEligibilityService = service - } } - /// Update local catalog eligibility when POS tab visibility changes - func updatePOSTabVisibility(_ isPOSTabVisible: Bool) { + /// Check and update POS eligibility for local catalog + /// Only checks eligibility if the POS tab is visible + func updatePOSEligibility(isPOSTabVisible: Bool) { Task { @MainActor [weak self] in guard let self, let service = self.localCatalogEligibilityService else { return } - await service.updateVisibility(isPOSTabVisible: isPOSTabVisible) + + // If POS tab is not visible, mark as ineligible + guard isPOSTabVisible else { + await service.updatePOSEligibility(isEligible: false, for: siteID) + return + } + + // Check actual POS eligibility using the eligibility checker + let eligibilityState = await eligibilityChecker.checkEligibility() + let isPOSEligible = eligibilityState == .eligible + await service.updatePOSEligibility(isEligible: isPOSEligible, for: siteID) } } @@ -166,6 +160,10 @@ private extension POSTabCoordinator { Task { @MainActor in let action = AppSettingsAction.setHasPOSBeenOpenedAtLeastOnce { _ in } storesManager.dispatch(action) + + // Track last opened date for sync eligibility + let lastOpenedAction = AppSettingsAction.setPOSLastOpenedDate(siteID: siteID, date: Date()) {} + storesManager.dispatch(lastOpenedAction) } } @@ -174,14 +172,14 @@ private extension POSTabCoordinator { guard let self else { return } // Get local catalog eligibility as bool from service - // Service is created asynchronously in init, might not be ready yet let isLocalCatalogEligible: Bool if let service = localCatalogEligibilityService { // Retry transient failures before using the value - if case .ineligible(reason: .catalogSizeCheckFailed) = service.eligibilityState { - await service.refreshEligibilityState() + let state = await service.catalogEligibility(for: siteID) + if case .ineligible(reason: .catalogSizeCheckFailed) = state { + await service.refreshEligibilityState(for: siteID) } - isLocalCatalogEligible = service.eligibilityState == .eligible + isLocalCatalogEligible = await service.catalogEligibility(for: siteID) == .eligible } else { // Service not ready yet (rare race condition), assume ineligible isLocalCatalogEligible = false diff --git a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift index 4ad0802092d..db294a17f84 100644 --- a/WooCommerce/Classes/ViewRelated/MainTabBarController.swift +++ b/WooCommerce/Classes/ViewRelated/MainTabBarController.swift @@ -721,8 +721,8 @@ private extension MainTabBarController { updateTabViewControllers(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) viewModel.loadHubMenuTabBadge() - // Update POS tab coordinator with new visibility state for local catalog eligibility - posTabCoordinator?.updatePOSTabVisibility(isPOSTabVisible) + // Update POS eligibility - coordinator will check actual eligibility if tab is visible + posTabCoordinator?.updatePOSEligibility(isPOSTabVisible: isPOSTabVisible) // Begin foreground synchronization if POS tab becomes visible await isPOSTabVisible ? posSyncDispatcher.start() : posSyncDispatcher.stop() @@ -829,16 +829,16 @@ private extension MainTabBarController { selectedIndex = WooTab.myStore.visibleIndex(isPOSTabVisible: isPOSTabVisible, isBookingsTabVisible: isBookingsTabVisible) - // Create POS tab coordinator with initial visibility from cache - let initialPOSTabVisibility = posTabVisibilityChecker?.checkInitialVisibility() ?? false - posTabCoordinator = POSTabCoordinator( + // Create POS tab coordinator with eligibility service from stores + let coordinator = POSTabCoordinator( siteID: siteID, tabContainerController: posContainerController, viewControllerToPresent: self, storesManager: stores, eligibilityChecker: POSTabEligibilityChecker(siteID: siteID), - initialPOSTabVisibility: initialPOSTabVisibility + localCatalogEligibilityService: stores.posCatalogEligibilityChecker ) + posTabCoordinator = coordinator // Updates site ID for the bookings tab to display correct bookings (bookingsContainerController.wrappedController as? BookingsTabViewHostingController)?.didSwitchStore(id: siteID) diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index ec79983faf1..612dfa85653 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -37,13 +37,20 @@ class AuthenticatedState: StoresManagerState { /// private(set) var posCatalogSyncCoordinator: POSCatalogSyncCoordinator? + /// POS Catalog Eligibility Service (session-scoped) + /// Created during initialization alongside the sync coordinator + /// + var posCatalogEligibilityChecker: POSLocalCatalogEligibilityServiceProtocol? + // periphery:ignore - keep strong reference to keep the state publisher alive private var appPasswordSupportStateHandler: ApplicationPasswordsExperimentState? private var appPasswordSupportState: PassthroughSubject /// Designated Initializer /// - init(credentials: Credentials, sessionManager: SessionManagerProtocol) { + init(credentials: Credentials, + sessionManager: SessionManagerProtocol, + isLocalCatalogFeatureFlagEnabled: Bool) { let storageManager = ServiceLocator.storageManager let site = sessionManager.defaultSitePublisher @@ -161,8 +168,8 @@ class AuthenticatedState: StoresManagerState { self.services = services - // Initialize POS catalog sync coordinator if feature flag is enabled - if ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1), + // Initialize POS catalog sync coordinator and eligibility service if feature flag is enabled + if isLocalCatalogFeatureFlagEnabled, let fullSyncService = POSCatalogFullSyncService(credentials: credentials, selectedSite: site, appPasswordSupportState: appPasswordSupportState.eraseToAnyPublisher(), @@ -173,20 +180,34 @@ class AuthenticatedState: StoresManagerState { appPasswordSupportState: appPasswordSupportState.eraseToAnyPublisher(), grdbManager: ServiceLocator.grdbManager ) { - let syncRemote = POSCatalogSyncRemote(network: network) - let catalogSizeChecker = POSCatalogSizeChecker(syncRemote: syncRemote) + + // Create eligibility service + let eligibilityService = POSLocalCatalogEligibilityService( + catalogSizeChecker: POSCatalogSizeChecker( + credentials: credentials, + selectedSite: site, + appPasswordSupportState: appPasswordSupportState.eraseToAnyPublisher() + ), + isLocalCatalogFeatureFlagEnabled: isLocalCatalogFeatureFlagEnabled + ) + posCatalogEligibilityChecker = eligibilityService + + // Create sync coordinator with eligibility service posCatalogSyncCoordinator = POSCatalogSyncCoordinator( fullSyncService: fullSyncService, incrementalSyncService: incrementalSyncService, grdbManager: ServiceLocator.grdbManager, - catalogSizeChecker: catalogSizeChecker + catalogEligibilityChecker: eligibilityService ) + + // Note: POS eligibility will be set later by POSTabCoordinator.updatePOSEligibility + // when the POS tab visibility check completes in MainTabBarController } else { posCatalogSyncCoordinator = nil + posCatalogEligibilityChecker = nil } trackEventRequestNotificationHandler = TrackEventRequestNotificationHandler() - startListeningToNotifications() observeAppPasswordSupportState() } @@ -197,7 +218,10 @@ class AuthenticatedState: StoresManagerState { guard let credentials = sessionManager.defaultCredentials else { return nil } - self.init(credentials: credentials, sessionManager: sessionManager) + let isLocalCatalogFeatureFlagEnabled = ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) + self.init(credentials: credentials, + sessionManager: sessionManager, + isLocalCatalogFeatureFlagEnabled: isLocalCatalogFeatureFlagEnabled) } /// Executed before the current state is deactivated. diff --git a/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift b/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift index e47afd3314a..138429ac5c0 100644 --- a/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift +++ b/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift @@ -143,6 +143,17 @@ class DefaultStoresManager: StoresManager { (state as? AuthenticatedState)?.posCatalogSyncCoordinator } + /// Provides access to the session-scoped POS catalog eligibility checker + /// + var posCatalogEligibilityChecker: POSLocalCatalogEligibilityServiceProtocol? { + get { + (state as? AuthenticatedState)?.posCatalogEligibilityChecker + } + set { + (state as? AuthenticatedState)?.posCatalogEligibilityChecker = newValue + } + } + /// Designated Initializer /// init(sessionManager: SessionManagerProtocol, @@ -187,7 +198,10 @@ class DefaultStoresManager: StoresManager { /// @discardableResult func authenticate(credentials: Credentials) -> StoresManager { - state = AuthenticatedState(credentials: credentials, sessionManager: sessionManager) + let isLocalCatalogFeatureFlagEnabled = ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) + state = AuthenticatedState(credentials: credentials, + sessionManager: sessionManager, + isLocalCatalogFeatureFlagEnabled: isLocalCatalogFeatureFlagEnabled) sessionManager.defaultCredentials = credentials if case .wpcom = credentials { diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 1a7595915b4..d6f5a021927 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -706,10 +706,6 @@ 20134CE62D4D1BDF00076A80 /* LearnMoreViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20134CE52D4D1BDF00076A80 /* LearnMoreViewModelTests.swift */; }; 20134CE82D4D38E000076A80 /* CardPresentPaymentPlugin+SetUpTapToPay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20134CE72D4D38E000076A80 /* CardPresentPaymentPlugin+SetUpTapToPay.swift */; }; 20203AB22B31EEF1009D0C11 /* ExpandableBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20203AB12B31EEF1009D0C11 /* ExpandableBottomSheet.swift */; }; - 2020B8732EAA405500422B86 /* POSLocalCatalogEligibilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2020B8712EAA405500422B86 /* POSLocalCatalogEligibilityService.swift */; }; - 2020B8762EAA41C200422B86 /* POSLocalCatalogEligibilityServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2020B8742EAA41C200422B86 /* POSLocalCatalogEligibilityServiceTests.swift */; }; - 2020B8782EAA425700422B86 /* MockPOSLocalCatalogEligibilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2020B8772EAA425700422B86 /* MockPOSLocalCatalogEligibilityService.swift */; }; - 2020B87A2EAA439200422B86 /* MockPOSCatalogSizeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2020B8792EAA439200422B86 /* MockPOSCatalogSizeChecker.swift */; }; 2024966A2B0CC97100EE527D /* MockWooPaymentsDepositService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202496692B0CC97100EE527D /* MockWooPaymentsDepositService.swift */; }; 202D2A5A2AC5933100E4ABC0 /* TopTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 202D2A592AC5933100E4ABC0 /* TopTabView.swift */; }; 203A5C312AC5ADD700BF29A1 /* WooPaymentsPayoutsOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 203A5C302AC5ADD700BF29A1 /* WooPaymentsPayoutsOverviewView.swift */; }; @@ -3623,10 +3619,6 @@ 20134CE52D4D1BDF00076A80 /* LearnMoreViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LearnMoreViewModelTests.swift; sourceTree = ""; }; 20134CE72D4D38E000076A80 /* CardPresentPaymentPlugin+SetUpTapToPay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CardPresentPaymentPlugin+SetUpTapToPay.swift"; sourceTree = ""; }; 20203AB12B31EEF1009D0C11 /* ExpandableBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpandableBottomSheet.swift; sourceTree = ""; }; - 2020B8712EAA405500422B86 /* POSLocalCatalogEligibilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSLocalCatalogEligibilityService.swift; sourceTree = ""; }; - 2020B8742EAA41C200422B86 /* POSLocalCatalogEligibilityServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSLocalCatalogEligibilityServiceTests.swift; sourceTree = ""; }; - 2020B8772EAA425700422B86 /* MockPOSLocalCatalogEligibilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSLocalCatalogEligibilityService.swift; sourceTree = ""; }; - 2020B8792EAA439200422B86 /* MockPOSCatalogSizeChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSCatalogSizeChecker.swift; sourceTree = ""; }; 202496692B0CC97100EE527D /* MockWooPaymentsDepositService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWooPaymentsDepositService.swift; sourceTree = ""; }; 202D2A592AC5933100E4ABC0 /* TopTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopTabView.swift; sourceTree = ""; }; 203A5C302AC5ADD700BF29A1 /* WooPaymentsPayoutsOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsPayoutsOverviewView.swift; sourceTree = ""; }; @@ -6769,7 +6761,6 @@ 029327662BF59D2D00D703E7 /* POS */ = { isa = PBXGroup; children = ( - 2020B8722EAA405500422B86 /* Eligibility */, 683F18652E9CC838007BC608 /* POSNotificationScheduler.swift */, 01654EB02E786223001DBB6F /* Adaptors */, 02ABF9B92DF7F8E200348186 /* TabBar */, @@ -6966,11 +6957,9 @@ 02CD3BFC2C35D01600E575C4 /* Mocks */ = { isa = PBXGroup; children = ( - 2020B8772EAA425700422B86 /* MockPOSLocalCatalogEligibilityService.swift */, 02DE61812E86437E0060DCEF /* MockPOSAnalytics.swift */, 02F5E5DE2E857A90002DEE24 /* MockSelectedSiteSettings.swift */, 02F5E5DC2E857A50002DEE24 /* MockPOSSiteSettingService.swift */, - 2020B8792EAA439200422B86 /* MockPOSCatalogSizeChecker.swift */, ); path = Mocks; sourceTree = ""; @@ -7318,22 +7307,6 @@ path = "Bulk Update"; sourceTree = ""; }; - 2020B8722EAA405500422B86 /* Eligibility */ = { - isa = PBXGroup; - children = ( - 2020B8712EAA405500422B86 /* POSLocalCatalogEligibilityService.swift */, - ); - path = Eligibility; - sourceTree = ""; - }; - 2020B8752EAA41C200422B86 /* Eligibility */ = { - isa = PBXGroup; - children = ( - 2020B8742EAA41C200422B86 /* POSLocalCatalogEligibilityServiceTests.swift */, - ); - path = Eligibility; - sourceTree = ""; - }; 202496682B0BC07E00EE527D /* Deposits Overview */ = { isa = PBXGroup; children = ( @@ -12030,7 +12003,6 @@ DABF35242C11B40C006AF826 /* POS */ = { isa = PBXGroup; children = ( - 2020B8752EAA41C200422B86 /* Eligibility */, 68051E1D2E9DFE5100228196 /* POSNotificationSchedulerTests.swift */, 687C006C2D63469F00F832FC /* Analytics */, 02CD3BFC2C35D01600E575C4 /* Mocks */, @@ -15138,7 +15110,6 @@ DE3877E0283B68CF0075D87E /* DiscountTypeBottomSheetListSelectorCommand.swift in Sources */, B9FBEF9A2A7BCCF100AC609B /* UnderlineableTitleAndSubtitleAndDetailTableViewCell.swift in Sources */, 020DD48D2322A617005822B1 /* ProductsTabProductViewModel.swift in Sources */, - 2020B8732EAA405500422B86 /* POSLocalCatalogEligibilityService.swift in Sources */, B5A56BF5219F5AB20065A902 /* NSNotificationName+Woo.swift in Sources */, D449C51B26DE6B5000D75B02 /* ReportList.swift in Sources */, 68E952D0287587BF0095A23D /* CardReaderManualRowView.swift in Sources */, @@ -15782,7 +15753,6 @@ 03FBDAFD263EE4E800ACE257 /* CouponListViewModelTests.swift in Sources */, 036F6EA6281847D5006D84F8 /* PaymentCaptureOrchestratorTests.swift in Sources */, B555531321B57E8800449E71 /* MockUserNotificationsCenterAdapter.swift in Sources */, - 2020B8762EAA41C200422B86 /* POSLocalCatalogEligibilityServiceTests.swift in Sources */, 682210ED2909666600814E14 /* CustomerSearchUICommandTests.swift in Sources */, 4590B652261C8D1E00A6FCE0 /* WeightFormatterTests.swift in Sources */, DE78DE442B2846AF002E58DE /* ThemesCarouselViewModelTests.swift in Sources */, @@ -15823,7 +15793,6 @@ 02DE61822E86437E0060DCEF /* MockPOSAnalytics.swift in Sources */, 02FADAA52A607CEE00FE8683 /* MockImageTextScanner.swift in Sources */, 68674D312B6C895D00E93FBD /* ReceiptEligibilityUseCaseTests.swift in Sources */, - 2020B8782EAA425700422B86 /* MockPOSLocalCatalogEligibilityService.swift in Sources */, CE4DA5C821DD759400074607 /* CurrencyFormatterTests.swift in Sources */, DE61979528A25842005E4362 /* StorePickerViewModelTests.swift in Sources */, EEB4E2D329B2047700371C3C /* StoreOnboardingViewHostingControllerTests.swift in Sources */, @@ -16099,7 +16068,6 @@ 02BAB02124D0235F00F8B06E /* ProductPriceSettingsViewModel+ProductVariationTests.swift in Sources */, CC2E72F727B6BFB800A62872 /* ProductVariationFormatterTests.swift in Sources */, 02B21C5529C84E4A00C5623B /* WPAdminWebViewModelTests.swift in Sources */, - 2020B87A2EAA439200422B86 /* MockPOSCatalogSizeChecker.swift in Sources */, D802547826551DB8001B2CC1 /* CardPresentModalDisplayMessageTests.swift in Sources */, 02503C632538301400FD235D /* ProductVariationFormActionsFactory+ReadonlyVariationTests.swift in Sources */, B9A5317F2D2FCC5600208304 /* WooShippingCustomsItemViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index 18eff831786..93707695a87 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -26,7 +26,6 @@ final class MockFeatureFlagService: FeatureFlagService, POSFeatureFlagProviding var isProductImageOptimizedHandlingEnabled: Bool var isFeatureFlagEnabledReturnValue: [FeatureFlag: Bool] = [:] var isCIABBookingsEnabled: Bool - var isLocalCatalogEnabled: Bool init(isInboxOn: Bool = false, isShowInboxCTAEnabled: Bool = false, @@ -49,8 +48,7 @@ final class MockFeatureFlagService: FeatureFlagService, POSFeatureFlagProviding notificationSettings: Bool = false, allowMerchantAIAPIKey: Bool = false, isProductImageOptimizedHandlingEnabled: Bool = false, - isCIABBookingsEnabled: Bool = false, - isLocalCatalogEnabled: Bool = false) { + isCIABBookingsEnabled: Bool = false) { self.isInboxOn = isInboxOn self.isShowInboxCTAEnabled = isShowInboxCTAEnabled self.isUpdateOrderOptimisticallyOn = isUpdateOrderOptimisticallyOn @@ -73,7 +71,6 @@ final class MockFeatureFlagService: FeatureFlagService, POSFeatureFlagProviding self.allowMerchantAIAPIKey = allowMerchantAIAPIKey self.isProductImageOptimizedHandlingEnabled = isProductImageOptimizedHandlingEnabled self.isCIABBookingsEnabled = isCIABBookingsEnabled - self.isLocalCatalogEnabled = isLocalCatalogEnabled } func isFeatureFlagEnabled(_ featureFlag: FeatureFlag) -> Bool { @@ -128,8 +125,6 @@ final class MockFeatureFlagService: FeatureFlagService, POSFeatureFlagProviding return isProductImageOptimizedHandlingEnabled case .ciabBookings: return isCIABBookingsEnabled - case .pointOfSaleLocalCatalogi1: - return isLocalCatalogEnabled default: return false } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSizeChecker.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSizeChecker.swift deleted file mode 100644 index cd90fe1e4d7..00000000000 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSizeChecker.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -@testable import Yosemite - -final class MockPOSCatalogSizeChecker: POSCatalogSizeCheckerProtocol { - var sizeToReturn: Result - var checkCatalogSizeCallCount = 0 - var lastCheckedSiteID: Int64? - - init(sizeToReturn: Result = .success(POSCatalogSize(productCount: 100, variationCount: 50))) { - self.sizeToReturn = sizeToReturn - } - - func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize { - checkCatalogSizeCallCount += 1 - lastCheckedSiteID = siteID - switch sizeToReturn { - case .success(let size): - return size - case .failure(let error): - throw error - } - } -} diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSLocalCatalogEligibilityService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSLocalCatalogEligibilityService.swift deleted file mode 100644 index 021ebbb3259..00000000000 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSLocalCatalogEligibilityService.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation -@testable import PointOfSale - -/// Mock implementation of POSLocalCatalogEligibilityServiceProtocol for testing -@MainActor -public final class MockPOSLocalCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol { - public var eligibilityState: POSLocalCatalogEligibilityState - public var refreshCallCount = 0 - - public init(eligibilityState: POSLocalCatalogEligibilityState = .eligible) { - self.eligibilityState = eligibilityState - } - - public func refreshEligibilityState() async -> POSLocalCatalogEligibilityState { - refreshCallCount += 1 - return eligibilityState - } - - public func updateVisibility(isPOSTabVisible: Bool) async { } -}