diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSettingsService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSettingsService.swift index 0f3a0963832..6ad7cc6ec89 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSettingsService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSettingsService.swift @@ -1,4 +1,3 @@ -// periphery:ignore:all import Foundation import GRDB import protocol Storage.GRDBManagerProtocol diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleSettingsController.swift b/WooCommerce/Classes/POS/Models/PointOfSaleSettingsController.swift index b5a1165cc07..e071c0257ef 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleSettingsController.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleSettingsController.swift @@ -7,10 +7,14 @@ import protocol Yosemite.PluginsServiceProtocol import protocol Yosemite.PointOfSaleSettingsServiceProtocol import struct Yosemite.POSReceiptInformation import Observation +import protocol Storage.GRDBManagerProtocol +import protocol Yosemite.POSCatalogSyncCoordinatorProtocol +import class Yosemite.POSCatalogSettingsService protocol PointOfSaleSettingsControllerProtocol { var connectedCardReader: CardPresentPaymentCardReader? { get } var storeViewModel: POSSettingsStoreViewModel { get } + var localCatalogViewModel: POSSettingsLocalCatalogViewModel? { get } } @Observable final class PointOfSaleSettingsController: PointOfSaleSettingsControllerProtocol { @@ -18,18 +22,30 @@ protocol PointOfSaleSettingsControllerProtocol { private var cancellables: AnyCancellable? let storeViewModel: POSSettingsStoreViewModel + let localCatalogViewModel: POSSettingsLocalCatalogViewModel? init(siteID: Int64, settingsService: PointOfSaleSettingsServiceProtocol, cardPresentPaymentService: CardPresentPaymentFacade, pluginsService: PluginsServiceProtocol, defaultSiteName: String?, - siteSettings: [SiteSetting]) { + siteSettings: [SiteSetting], + grdbManager: GRDBManagerProtocol?, + catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol?) { self.storeViewModel = POSSettingsStoreViewModel(siteID: siteID, settingsService: settingsService, pluginsService: pluginsService, defaultSiteName: defaultSiteName, siteSettings: siteSettings) + if let catalogSyncCoordinator, let grdbManager { + self.localCatalogViewModel = POSSettingsLocalCatalogViewModel( + siteID: siteID, + catalogSettingsService: POSCatalogSettingsService(grdbManager: grdbManager), + catalogSyncCoordinator: catalogSyncCoordinator + ) + } else { + self.localCatalogViewModel = nil + } observeCardReader(from: cardPresentPaymentService) } @@ -62,6 +78,8 @@ final class PointOfSaleSettingsPreviewController: PointOfSaleSettingsControllerP pluginsService: PluginsServicePreview(), defaultSiteName: "Sample Store", siteSettings: []) + + var localCatalogViewModel: POSSettingsLocalCatalogViewModel? } final class MockPointOfSaleSettingsService: PointOfSaleSettingsServiceProtocol { diff --git a/WooCommerce/Classes/POS/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift b/WooCommerce/Classes/POS/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift index 11a9356c49d..8176e20da2d 100644 --- a/WooCommerce/Classes/POS/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift +++ b/WooCommerce/Classes/POS/Presentation/Settings/POSSettingsLocalCatalogDetailView.swift @@ -3,6 +3,11 @@ import SwiftUI struct POSSettingsLocalCatalogDetailView: View { // TODO: WOOMOB-1335 - implement full sync cellular data setting functionality @State private var allowFullSyncOnCellular: Bool = true + private let viewModel: POSSettingsLocalCatalogViewModel + + init(viewModel: POSSettingsLocalCatalogViewModel) { + self.viewModel = viewModel + } var body: some View { NavigationStack { @@ -21,6 +26,9 @@ struct POSSettingsLocalCatalogDetailView: View { .background(Style.backgroundColor) } } + .task { + await viewModel.loadCatalogData() + } } } @@ -31,12 +39,13 @@ private extension POSSettingsLocalCatalogDetailView { sectionHeaderView(title: Localization.catalogStatus) VStack(spacing: POSSpacing.medium) { - // TODO: WOOMOB-1100 - replace with catalog data - fieldRowView(label: Localization.catalogSize, value: "1,250 products, 3,420 variations") - fieldRowView(label: Localization.lastIncrementalUpdate, value: "5 minutes ago") - fieldRowView(label: Localization.lastFullSync, value: "Today at 2:34 PM") + fieldRowView(label: Localization.catalogSize, value: viewModel.catalogSize) + fieldRowView(label: Localization.lastIncrementalUpdate, value: viewModel.lastIncrementalSyncDate) + fieldRowView(label: Localization.lastFullSync, value: viewModel.lastFullSyncDate) } .padding(.bottom, POSPadding.medium) + .redacted(reason: viewModel.isLoading ? .placeholder : []) + .shimmering(active: viewModel.isLoading) } } @@ -64,11 +73,13 @@ private extension POSSettingsLocalCatalogDetailView { .frame(maxWidth: .infinity, alignment: .leading) Button(action: { - // Handle refresh catalog action + Task { + await viewModel.refreshCatalog() + } }) { Text(Localization.refreshCatalog) } - .buttonStyle(POSFilledButtonStyle(size: .normal)) + .buttonStyle(POSFilledButtonStyle(size: .normal, isLoading: viewModel.isRefreshingCatalog)) } .padding(.horizontal, POSPadding.medium) .padding(.bottom, POSPadding.medium) @@ -134,20 +145,20 @@ private extension POSSettingsLocalCatalogDetailView { ) static let managingDataUsage = NSLocalizedString( - "posSettingsLocalCatalogDetailView.managingDataUsage", - value: "Managing data usage", + "posSettingsLocalCatalogDetailView.managingDataUsage.1", + value: "Managing Data Usage", comment: "Section title for managing data usage in Point of Sale settings." ) static let lastIncrementalUpdate = NSLocalizedString( - "posSettingsLocalCatalogDetailView.lastIncrementalUpdate", - value: "Last incremental update", + "posSettingsLocalCatalogDetailView.lastIncrementalSync", + value: "Last update", comment: "Label for last incremental update field in Point of Sale settings." ) static let lastFullSync = NSLocalizedString( - "posSettingsLocalCatalogDetailView.lastFullSync", - value: "Last full sync", + "posSettingsLocalCatalogDetailView.lastFullSync.1", + value: "Last full update", comment: "Label for last full sync field in Point of Sale settings." ) @@ -159,8 +170,8 @@ private extension POSSettingsLocalCatalogDetailView { static let allowFullSyncOnCellular = NSLocalizedString( - "posSettingsLocalCatalogDetailView.allowFullSyncOnCellular", - value: "Allow full sync on cellular data", + "posSettingsLocalCatalogDetailView.allowFullSyncOnCellular.1", + value: "Allow full update on cellular data", comment: "Label for allow full sync on cellular data toggle in Point of Sale settings." ) @@ -187,6 +198,11 @@ private extension POSSettingsLocalCatalogDetailView { #if DEBUG #Preview { - POSSettingsLocalCatalogDetailView() + let viewModel = POSSettingsLocalCatalogViewModel( + siteID: 123, + catalogSettingsService: POSPreviewCatalogSettingsService(), + catalogSyncCoordinator: POSPreviewCatalogSyncCoordinator() + ) + POSSettingsLocalCatalogDetailView(viewModel: viewModel) } #endif diff --git a/WooCommerce/Classes/POS/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift b/WooCommerce/Classes/POS/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift new file mode 100644 index 00000000000..c81a6cac525 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift @@ -0,0 +1,97 @@ +import Yosemite +import Foundation + +@Observable +final class POSSettingsLocalCatalogViewModel { + private(set) var catalogSize: String = "" + private(set) var lastFullSyncDate: String = "" + private(set) var lastIncrementalSyncDate: String = "" + + private(set) var isLoading: Bool = false + private(set) var isRefreshingCatalog: Bool = false + + private let siteID: Int64 + private let catalogSettingsService: POSCatalogSettingsServiceProtocol + private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol + private let dateFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.dateTimeStyle = .named + formatter.unitsStyle = .full + return formatter + }() + + init(siteID: Int64, + catalogSettingsService: POSCatalogSettingsServiceProtocol, + catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol) { + self.siteID = siteID + self.catalogSettingsService = catalogSettingsService + self.catalogSyncCoordinator = catalogSyncCoordinator + } + + @MainActor + func loadCatalogData() async { + isLoading = true + defer { isLoading = false } + + do { + let catalogInfo = try await catalogSettingsService.loadCatalogInfo(for: siteID) + catalogSize = String(format: Localization.catalogSizeFormat, catalogInfo.productCount, catalogInfo.variationCount) + lastFullSyncDate = formatSyncDate(catalogInfo.lastFullSyncDate) + lastIncrementalSyncDate = formatSyncDate(catalogInfo.lastIncrementalSyncDate) + } catch { + DDLogError("⛔️ POSSettingsLocalCatalog: Error loading catalog data: \(error)") + catalogSize = Localization.catalogSizeUnavailable + lastFullSyncDate = Localization.syncDateUnavailable + lastIncrementalSyncDate = Localization.syncDateUnavailable + } + } + + @MainActor + func refreshCatalog() async { + isRefreshingCatalog = true + defer { isRefreshingCatalog = false } + + do { + try await catalogSyncCoordinator.performFullSync(for: siteID) + await loadCatalogData() + } catch { + DDLogError("⛔️ POSSettingsLocalCatalog: Failed to refresh catalog: \(error)") + } + } +} + +private extension POSSettingsLocalCatalogViewModel { + func formatSyncDate(_ date: Date?) -> String { + guard let date else { return Localization.neverSynced } + return dateFormatter.localizedString(for: date, relativeTo: Date()) + } +} + +private extension POSSettingsLocalCatalogViewModel { + enum Localization { + static let catalogSizeFormat = NSLocalizedString( + "posSettingsLocalCatalogViewModel.catalogSizeFormat", + value: "%1$d products, %2$ld variations", + comment: "Format string for catalog size showing product count and variation count. " + + "%1$d will be replaced by the product count, and %2$ld will be replaced by the variation count." + ) + + static let catalogSizeUnavailable = NSLocalizedString( + "posSettingsLocalCatalogViewModel.catalogSizeUnavailable", + value: "Catalog size unavailable", + comment: "Text shown when catalog size cannot be determined." + ) + + static let neverSynced = NSLocalizedString( + "posSettingsLocalCatalogViewModel.neverSynced", + value: "Not updated", + comment: "Text shown when no update has been performed yet." + ) + + static let syncDateUnavailable = NSLocalizedString( + "posSettingsLocalCatalogViewModel.syncDateUnavailable", + value: "Update date unavailable", + comment: "Text shown when update date cannot be determined." + ) + } +} diff --git a/WooCommerce/Classes/POS/Presentation/Settings/PointOfSaleSettingsView.swift b/WooCommerce/Classes/POS/Presentation/Settings/PointOfSaleSettingsView.swift index e6c907b1fa3..c647430165e 100644 --- a/WooCommerce/Classes/POS/Presentation/Settings/PointOfSaleSettingsView.swift +++ b/WooCommerce/Classes/POS/Presentation/Settings/PointOfSaleSettingsView.swift @@ -55,7 +55,7 @@ extension PointOfSaleSettingsView { ) // TODO: WOOMOB-1287 - integrate with local catalog feature eligibility - if ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) { + if ServiceLocator.featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) && settingsController.localCatalogViewModel != nil { PointOfSaleSettingsCard( item: .localCatalog, isSelected: selection == .localCatalog, @@ -89,7 +89,11 @@ extension PointOfSaleSettingsView { case .hardware: PointOfSaleSettingsHardwareDetailView(settingsController: settingsController) case .localCatalog: - POSSettingsLocalCatalogDetailView() + if let viewModel = settingsController.localCatalogViewModel { + POSSettingsLocalCatalogDetailView(viewModel: viewModel) + } else { + EmptyView() + } case .help: PointOfSaleSettingsHelpDetailView() default: diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 5878826b9f2..bfaf88267da 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -3,6 +3,7 @@ import UIKit import SwiftUI import Yosemite import class WooFoundation.CurrencySettings +import protocol Storage.GRDBManagerProtocol import protocol Storage.StorageManagerType import class WooFoundationCore.CurrencyFormatter import struct NetworkingCore.JetpackSite @@ -171,6 +172,8 @@ private extension POSTabCoordinator { let pluginsService = PluginsService(storageManager: storageManager) let siteTimezone = storesManager.sessionManager.defaultSite?.siteTimezone ?? .current + let grdbManager: GRDBManagerProtocol? = serviceAdaptor.featureFlags.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) ? ServiceLocator.grdbManager : nil + let catalogSyncCoordinator = ServiceLocator.posCatalogSyncCoordinator if let receiptService = POSReceiptService(siteID: siteID, credentials: credentials, @@ -230,7 +233,9 @@ private extension POSTabCoordinator { cardPresentPaymentService: cardPresentPaymentService, pluginsService: pluginsService, defaultSiteName: storesManager.sessionManager.defaultSite?.name, - siteSettings: ServiceLocator.selectedSiteSettings.siteSettings), + siteSettings: ServiceLocator.selectedSiteSettings.siteSettings, + grdbManager: grdbManager, + catalogSyncCoordinator: catalogSyncCoordinator), collectOrderPaymentAnalyticsTracker: collectPaymentAnalyticsAdaptor, searchHistoryService: POSSearchHistoryService(siteID: siteID), popularPurchasableItemsController: PointOfSaleItemsController( diff --git a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift index 485dd987346..db00dfda94a 100644 --- a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift +++ b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift @@ -28,6 +28,9 @@ import struct Yosemite.POSOrderRefund import typealias Yosemite.OrderItemAttribute import class Yosemite.POSOrderListService import class Yosemite.POSOrderListFetchStrategyFactory +import protocol Yosemite.POSCatalogSyncCoordinatorProtocol +import protocol Yosemite.POSCatalogSettingsServiceProtocol +import struct Yosemite.POSCatalogInfo import struct Yosemite.Site // MARK: - PreviewProvider helpers @@ -453,4 +456,36 @@ final class POSPreviewServices: POSDependencyProviding { var externalViews: POSExternalViewProviding = EmptyPOSExternalView() } +// MARK: - Preview Catalog Services + +final class POSPreviewCatalogSettingsService: POSCatalogSettingsServiceProtocol { + func loadCatalogInfo(for siteID: Int64) async throws -> POSCatalogInfo { + let now = Date() + let lastFullSync = now.addingTimeInterval(-2 * 60 * 60) // 2 hours ago + let lastIncrementalSync = now.addingTimeInterval(-15 * 60) // 15 minutes ago + return POSCatalogInfo( + productCount: 247, + variationCount: 89, + lastFullSyncDate: lastFullSync, + lastIncrementalSyncDate: lastIncrementalSync + ) + } +} + +final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { + func performFullSync(for siteID: Int64) async throws { + // Simulates a full sync operation with a 1 second delay. + try await Task.sleep(nanoseconds: 1_000_000_000) + } + + func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) async -> Bool { + true + } + + func performIncrementalSyncIfApplicable(for siteID: Int64, forceSync: Bool) async throws { + // Simulates an incremental sync operation with a 0.5 second delay. + try await Task.sleep(nanoseconds: 500_000_000) + } +} + #endif diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index cc2dc21359d..06ac4580e32 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -294,6 +294,7 @@ 023D69BC2589BF5900F7DA72 /* PrintShippingLabelCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D69BB2589BF5900F7DA72 /* PrintShippingLabelCoordinator.swift */; }; 023D877925EC8BCB00625963 /* UIScrollView+LargeTitleWorkaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023D877825EC8BCB00625963 /* UIScrollView+LargeTitleWorkaround.swift */; }; 023DE6262E73FE4600FF6562 /* POSSettingsLocalCatalogDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023DE6252E73FE4600FF6562 /* POSSettingsLocalCatalogDetailView.swift */; }; + 023DE6282E740E1E00FF6562 /* POSSettingsLocalCatalogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023DE6272E740E0B00FF6562 /* POSSettingsLocalCatalogViewModel.swift */; }; 023EC2E024DA87460021DA91 /* ProductInventorySettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023EC2DF24DA87460021DA91 /* ProductInventorySettingsViewModelTests.swift */; }; 023EC2E224DA8BAB0021DA91 /* MockProductSKUValidationStoresManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023EC2E124DA8BAB0021DA91 /* MockProductSKUValidationStoresManager.swift */; }; 023EC2E424DA95DB0021DA91 /* ProductInventorySettingsViewModel+VariationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023EC2E324DA95DB0021DA91 /* ProductInventorySettingsViewModel+VariationTests.swift */; }; @@ -610,6 +611,9 @@ 02C34C752D50F4C3004BFB50 /* AddressMapPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C34C742D50F4C3004BFB50 /* AddressMapPickerViewModel.swift */; }; 02C3FACE282A93020095440A /* WooAnalyticsEvent+Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C3FACD282A93020095440A /* WooAnalyticsEvent+Dashboard.swift */; }; 02C3FDEA251091CE009569EE /* ProductFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C3FDE9251091CE009569EE /* ProductFactoryTests.swift */; }; + 02C470B62E7BDB7700F5F716 /* POSSettingsLocalCatalogViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C470B52E7BDB7700F5F716 /* POSSettingsLocalCatalogViewModelTests.swift */; }; + 02C470B82E7BDD9600F5F716 /* MockPOSCatalogSyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C470B72E7BDD9400F5F716 /* MockPOSCatalogSyncCoordinator.swift */; }; + 02C470BA2E7BDDD100F5F716 /* MockPOSCatalogSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C470B92E7BDDCF00F5F716 /* MockPOSCatalogSettingsService.swift */; }; 02C7EE8A2B21B951008B7DF8 /* ProductWithQuantityStepperViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7EE892B21B951008B7DF8 /* ProductWithQuantityStepperViewModel.swift */; }; 02C7EE8C2B22B21D008B7DF8 /* CollapsibleProductRowCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7EE8B2B22B21D008B7DF8 /* CollapsibleProductRowCardViewModel.swift */; }; 02C7EE902B22EFB9008B7DF8 /* CollapsibleProductRowCardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7EE8F2B22EFB9008B7DF8 /* CollapsibleProductRowCardViewModelTests.swift */; }; @@ -3513,6 +3517,7 @@ 023D69BB2589BF5900F7DA72 /* PrintShippingLabelCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrintShippingLabelCoordinator.swift; sourceTree = ""; }; 023D877825EC8BCB00625963 /* UIScrollView+LargeTitleWorkaround.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+LargeTitleWorkaround.swift"; sourceTree = ""; }; 023DE6252E73FE4600FF6562 /* POSSettingsLocalCatalogDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSSettingsLocalCatalogDetailView.swift; sourceTree = ""; }; + 023DE6272E740E0B00FF6562 /* POSSettingsLocalCatalogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSSettingsLocalCatalogViewModel.swift; sourceTree = ""; }; 023EC2DF24DA87460021DA91 /* ProductInventorySettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductInventorySettingsViewModelTests.swift; sourceTree = ""; }; 023EC2E124DA8BAB0021DA91 /* MockProductSKUValidationStoresManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProductSKUValidationStoresManager.swift; sourceTree = ""; }; 023EC2E324DA95DB0021DA91 /* ProductInventorySettingsViewModel+VariationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductInventorySettingsViewModel+VariationTests.swift"; sourceTree = ""; }; @@ -3831,6 +3836,9 @@ 02C34C742D50F4C3004BFB50 /* AddressMapPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressMapPickerViewModel.swift; sourceTree = ""; }; 02C3FACD282A93020095440A /* WooAnalyticsEvent+Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+Dashboard.swift"; sourceTree = ""; }; 02C3FDE9251091CE009569EE /* ProductFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductFactoryTests.swift; sourceTree = ""; }; + 02C470B52E7BDB7700F5F716 /* POSSettingsLocalCatalogViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSSettingsLocalCatalogViewModelTests.swift; sourceTree = ""; }; + 02C470B72E7BDD9400F5F716 /* MockPOSCatalogSyncCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSCatalogSyncCoordinator.swift; sourceTree = ""; }; + 02C470B92E7BDDCF00F5F716 /* MockPOSCatalogSettingsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSCatalogSettingsService.swift; sourceTree = ""; }; 02C7EE892B21B951008B7DF8 /* ProductWithQuantityStepperViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductWithQuantityStepperViewModel.swift; sourceTree = ""; }; 02C7EE8B2B22B21D008B7DF8 /* CollapsibleProductRowCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleProductRowCardViewModel.swift; sourceTree = ""; }; 02C7EE8F2B22EFB9008B7DF8 /* CollapsibleProductRowCardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleProductRowCardViewModelTests.swift; sourceTree = ""; }; @@ -7419,6 +7427,7 @@ 01AB2D112DDC7AD100AA67FD /* PointOfSaleItemListAnalyticsTrackerTests.swift */, 208628722D48E476003F45DC /* Payments Onboarding */, 026A502E2D2F8099002C42C2 /* Infinite Scroll */, + 02C470B42E7BDB2D00F5F716 /* Settings */, 686A71B52DC9E5C10006E835 /* POSItemActionHandlerTests.swift */, 689F291B2DE45604004DF52B /* POSStockFormatterTests.swift */, ); @@ -7816,6 +7825,14 @@ path = "Edit Shipping"; sourceTree = ""; }; + 02C470B42E7BDB2D00F5F716 /* Settings */ = { + isa = PBXGroup; + children = ( + 02C470B52E7BDB7700F5F716 /* POSSettingsLocalCatalogViewModelTests.swift */, + ); + path = Settings; + sourceTree = ""; + }; 02C5CBD724A5D16A00860C64 /* Linked Products List Selector */ = { isa = PBXGroup; children = ( @@ -7862,6 +7879,8 @@ 02CD3BFC2C35D01600E575C4 /* Mocks */ = { isa = PBXGroup; children = ( + 02C470B92E7BDDCF00F5F716 /* MockPOSCatalogSettingsService.swift */, + 02C470B72E7BDD9400F5F716 /* MockPOSCatalogSyncCoordinator.swift */, 016582E12E787187001DBB6F /* MockOnboardingViewFactoryConfiguration.swift */, 01B7AFBF2E7080180004BE9D /* MockPOSOrderListController.swift */, 019460E12E70121A00FCB9AB /* MockPOSReceiptController.swift */, @@ -10077,6 +10096,7 @@ 68707A152E570E7D00500CD8 /* Settings */ = { isa = PBXGroup; children = ( + 023DE6272E740E0B00FF6562 /* POSSettingsLocalCatalogViewModel.swift */, 023DE6252E73FE4600FF6562 /* POSSettingsLocalCatalogDetailView.swift */, 683D41172E4D9B570024CFE4 /* PointOfSaleSettingsView.swift */, 68707A1A2E570F2200500CD8 /* PointOfSaleSettingsStoreDetailView.swift */, @@ -15352,6 +15372,7 @@ 028FA466257E021100F88A48 /* RefundShippingLabelViewModel.swift in Sources */, DE2FE5882925DD950018040A /* JetpackInstallHeaderView.swift in Sources */, 263C4CC02963784900CA7E05 /* ProductVariationGenerator.swift in Sources */, + 023DE6282E740E1E00FF6562 /* POSSettingsLocalCatalogViewModel.swift in Sources */, EE3E9E932B06379000985B2C /* SubscriptionPeriod+Limit.swift in Sources */, 019130192CF49A77008C0C88 /* TapToPayEducationView.swift in Sources */, 02D681A929C358BA00348510 /* StoreOnboardingPaymentsSetupView.swift in Sources */, @@ -17359,6 +17380,7 @@ EE2EDFE12987A189004E702B /* MockABTestVariationProvider.swift in Sources */, 0273707E24C0047800167204 /* SequenceHelpersTests.swift in Sources */, 012ACB762E5C83EC00A49458 /* POSOrderListControllerTests.swift in Sources */, + 02C470B62E7BDB7700F5F716 /* POSSettingsLocalCatalogViewModelTests.swift in Sources */, DE9A02A32A44441200193ABF /* RequirementsCheckerTests.swift in Sources */, D802547326551D0F001B2CC1 /* CardPresentModalTapCardTests.swift in Sources */, B55BC1F321A8790F0011A0C0 /* StringHTMLTests.swift in Sources */, @@ -17688,6 +17710,7 @@ DE36E09C2A89EEA400B98496 /* StoreNameSetupViewModelTests.swift in Sources */, 57C2F6E624C27B3100131012 /* SwitchStoreNoticePresenterTests.swift in Sources */, 020BE77123B4A4C6007FE54C /* AztecHorizontalRulerFormatBarCommandTests.swift in Sources */, + 02C470BA2E7BDDD100F5F716 /* MockPOSCatalogSettingsService.swift in Sources */, B5C6CE612190D28E00515926 /* NSAttributedStringHelperTests.swift in Sources */, CC07860526736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift in Sources */, 02490D1E284F3226002096EF /* ProductImagesSaverTests.swift in Sources */, @@ -17866,6 +17889,7 @@ 2D09E0D52E65C9B9005C26F3 /* ApplicationPasswordsExperimentStateTests.swift in Sources */, D816DDBC22265DA300903E59 /* OrderTrackingTableViewCellTests.swift in Sources */, 579CDF01274D811D00E8903D /* StoreStatsUsageTracksEventEmitterTests.swift in Sources */, + 02C470B82E7BDD9600F5F716 /* MockPOSCatalogSyncCoordinator.swift in Sources */, CE4AFE482CD239B90013C52B /* WooShippingPostPurchaseViewModelTests.swift in Sources */, EE3272A429A88F750015F8D0 /* StoreOnboardingViewModelTests.swift in Sources */, 262A2C2B2537A3330086C1BE /* MockRefunds.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleSettingsControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleSettingsControllerTests.swift index 033e1ab76ae..3503173d117 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleSettingsControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleSettingsControllerTests.swift @@ -18,7 +18,9 @@ struct PointOfSaleSettingsControllerTests { cardPresentPaymentService: mockCardPresentPaymentService, pluginsService: mockPluginService, defaultSiteName: "Test Store", - siteSettings: []) + siteSettings: [], + grdbManager: nil, + catalogSyncCoordinator: nil) // When let cardReader = sut.connectedCardReader @@ -35,7 +37,9 @@ struct PointOfSaleSettingsControllerTests { cardPresentPaymentService: mockService, pluginsService: mockPluginService, defaultSiteName: "Test Store", - siteSettings: []) + siteSettings: [], + grdbManager: nil, + catalogSyncCoordinator: nil) // Initially nil #expect(sut.connectedCardReader == nil) @@ -76,4 +80,5 @@ final class MockPointOfSaleSettingsController: PointOfSaleSettingsControllerProt pluginsService: MockPluginsService(), defaultSiteName: "Sample Store", siteSettings: []) + var localCatalogViewModel: POSSettingsLocalCatalogViewModel? } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSettingsService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSettingsService.swift new file mode 100644 index 00000000000..12f7342217a --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSettingsService.swift @@ -0,0 +1,21 @@ +@testable import Yosemite + +final class MockPOSCatalogSettingsService: POSCatalogSettingsServiceProtocol { + var catalogInfoResult: Result = .success( + .init(productCount: 0, variationCount: 0, lastFullSyncDate: nil, lastIncrementalSyncDate: nil) + ) + var shouldDelayResponse = false + + func loadCatalogInfo(for siteID: Int64) async throws -> POSCatalogInfo { + if shouldDelayResponse { + try await Task.sleep(for: .milliseconds(100)) + } + + switch catalogInfoResult { + case .success(let info): + return info + case .failure(let error): + throw error + } + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSyncCoordinator.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSyncCoordinator.swift new file mode 100644 index 00000000000..37bc800cc9a --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSCatalogSyncCoordinator.swift @@ -0,0 +1,31 @@ +import Foundation +@testable import Yosemite + +final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { + var performFullSyncInvocationCount = 0 + var performFullSyncSiteID: Int64? + var performFullSyncResult: Result = .success(()) + var shouldDelayResponse = false + + func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) async -> Bool { + true + } + + func performFullSync(for siteID: Int64) async throws { + if shouldDelayResponse { + try await Task.sleep(for: .milliseconds(100)) + } + + performFullSyncInvocationCount += 1 + performFullSyncSiteID = siteID + + switch performFullSyncResult { + case .success: + return + case .failure(let error): + throw error + } + } + + func performIncrementalSyncIfApplicable(for siteID: Int64, forceSync: Bool) async throws {} +} diff --git a/WooCommerce/WooCommerceTests/POS/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift b/WooCommerce/WooCommerceTests/POS/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift new file mode 100644 index 00000000000..786ae340b61 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Presentation/Settings/POSSettingsLocalCatalogViewModelTests.swift @@ -0,0 +1,209 @@ +import Testing +import Foundation +@testable import WooCommerce +@testable import Yosemite + +struct POSSettingsLocalCatalogViewModelTests { + private let sampleSiteID: Int64 = 12345 + private let sut: POSSettingsLocalCatalogViewModel + private let catalogSettingsService: MockPOSCatalogSettingsService + private let catalogSyncCoordinator: MockPOSCatalogSyncCoordinator + + init() { + self.catalogSettingsService = MockPOSCatalogSettingsService() + self.catalogSyncCoordinator = MockPOSCatalogSyncCoordinator() + self.sut = POSSettingsLocalCatalogViewModel( + siteID: sampleSiteID, + catalogSettingsService: catalogSettingsService, + catalogSyncCoordinator: catalogSyncCoordinator + ) + } + + // MARK: - Initialization + + @Test func view_model_initializes_with_correct_properties() async throws { + // Then + #expect(sut.catalogSize == "") + #expect(sut.lastFullSyncDate == "") + #expect(sut.lastIncrementalSyncDate == "") + #expect(sut.isLoading == false) + #expect(sut.isRefreshingCatalog == false) + } + + // MARK: - `loadCatalogData` Tests + + @Test func loadCatalogData_sets_catalog_size_and_sync_dates_on_success() async throws { + // Given + catalogSettingsService.catalogInfoResult = .success(POSCatalogInfo( + productCount: 150, + variationCount: 75, + lastFullSyncDate: Date(timeIntervalSinceNow: -3600), // 1 hour ago + lastIncrementalSyncDate: Date(timeIntervalSinceNow: -1800) // 30 minutes ago + )) + + // When + await sut.loadCatalogData() + + // Then + #expect(sut.catalogSize == "150 products, 75 variations") + #expect(sut.lastFullSyncDate.contains("ago")) + #expect(sut.lastIncrementalSyncDate.contains("ago")) + #expect(sut.isLoading == false) + } + + @Test func loadCatalogData_sets_all_fields_to_unavailable_when_loadCatalogInfo_returns_error() async throws { + // Given + catalogSettingsService.catalogInfoResult = .failure(MockError.loadError) + + // When + await sut.loadCatalogData() + + // Then + #expect(sut.catalogSize == "Catalog size unavailable") + #expect(sut.lastFullSyncDate == "Update date unavailable") + #expect(sut.lastIncrementalSyncDate == "Update date unavailable") + } + + @Test func loadCatalogData_sets_sync_dates_to_placeholder_when_sync_dates_are_nil() async throws { + // Given + catalogSettingsService.catalogInfoResult = .success(POSCatalogInfo( + productCount: 100, + variationCount: 50, + lastFullSyncDate: nil, + lastIncrementalSyncDate: nil + )) + + // When + await sut.loadCatalogData() + + // Then + #expect(sut.catalogSize == "100 products, 50 variations") + #expect(sut.lastFullSyncDate == "Not updated") + #expect(sut.lastIncrementalSyncDate == "Not updated") + } + + + @Test func loadCatalogData_sets_loading_state_correctly() async throws { + // Given + catalogSettingsService.catalogInfoResult = .success(POSCatalogInfo( + productCount: 0, + variationCount: 0, + lastFullSyncDate: nil, + lastIncrementalSyncDate: nil + )) + catalogSettingsService.shouldDelayResponse = true + + // When + let loadTask = Task { + await sut.loadCatalogData() + } + + // Then + try await Task.sleep(for: .milliseconds(10)) + #expect(sut.isLoading == true) + + catalogSettingsService.shouldDelayResponse = false + await loadTask.value + + #expect(sut.isLoading == false) + } + + // MARK: - `refreshCatalog` Tests + + @Test func refreshCatalog_performs_full_sync_and_reloads_data_when_sync_succeeds() async throws { + // Given + catalogSettingsService.catalogInfoResult = .success(POSCatalogInfo( + productCount: 200, + variationCount: 100, + lastFullSyncDate: Date(), + lastIncrementalSyncDate: Date() + )) + + // When + await sut.refreshCatalog() + + // Then + #expect(catalogSyncCoordinator.performFullSyncInvocationCount == 1) + #expect(catalogSyncCoordinator.performFullSyncSiteID == sampleSiteID) + #expect(sut.catalogSize == "200 products, 100 variations") + #expect(sut.isRefreshingCatalog == false) + } + + @Test func refreshCatalog_sets_refreshing_state_correctly() async throws { + // Given + catalogSettingsService.catalogInfoResult = .success(POSCatalogInfo( + productCount: 0, + variationCount: 0, + lastFullSyncDate: nil, + lastIncrementalSyncDate: nil + )) + catalogSyncCoordinator.shouldDelayResponse = true + + // When + let refreshTask = Task { + await sut.refreshCatalog() + } + + // Then + try await Task.sleep(for: .milliseconds(10)) + #expect(sut.isRefreshingCatalog == true) + + catalogSyncCoordinator.shouldDelayResponse = false + await refreshTask.value + + #expect(sut.isRefreshingCatalog == false) + } + + @Test func refreshCatalog_does_not_update_catalog_size_when_sync_fails() async throws { + // Given + catalogSettingsService.catalogInfoResult = .success(POSCatalogInfo( + productCount: 50, + variationCount: 25, + lastFullSyncDate: nil, + lastIncrementalSyncDate: nil + )) + catalogSyncCoordinator.performFullSyncResult = .failure(MockError.syncError) + + // When + await sut.refreshCatalog() + + // Then + #expect(catalogSyncCoordinator.performFullSyncInvocationCount == 1) + #expect(sut.catalogSize != "50 products, 25 variations") // `loadCatalogInfo` should not be invoked + #expect(sut.isRefreshingCatalog == false) + } + + // MARK: - Test Concurrent Operations + + @Test func concurrent_loadCatalogData_and_refreshCatalog_operations_work_correctly() async throws { + // Given + catalogSettingsService.catalogInfoResult = .success(POSCatalogInfo( + productCount: 100, + variationCount: 50, + lastFullSyncDate: Date(), + lastIncrementalSyncDate: Date() + )) + + // When + let loadTask = Task { + await sut.loadCatalogData() + } + let refreshTask = Task { + await sut.refreshCatalog() + } + + await loadTask.value + await refreshTask.value + + // Then + #expect(sut.catalogSize == "100 products, 50 variations") + #expect(sut.isLoading == false) + #expect(sut.isRefreshingCatalog == false) + #expect(catalogSyncCoordinator.performFullSyncInvocationCount == 1) + } +} + +private enum MockError: Error { + case loadError + case syncError +}