diff --git a/Modules/Sources/PointOfSale/Analytics/POSItemFetchAnalytics.swift b/Modules/Sources/PointOfSale/Analytics/POSItemFetchAnalytics.swift index 727014568cd..a4bb7e287b3 100644 --- a/Modules/Sources/PointOfSale/Analytics/POSItemFetchAnalytics.swift +++ b/Modules/Sources/PointOfSale/Analytics/POSItemFetchAnalytics.swift @@ -43,4 +43,18 @@ struct POSItemFetchAnalytics: POSItemFetchAnalyticsTracking { ) ) } + + /// Tracks when a local search results fetch completes + /// - Parameters: + /// - millisecondsSinceRequestSent: The time taken to fetch results in milliseconds + /// - totalItems: The total number of items found in the search + func trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) { + analytics.track( + event: .PointOfSale.pointOfSaleSearchResultsFetched( + itemType: itemType, + resultsCount: totalItems, + millisecondsSinceRequestSent: millisecondsSinceRequestSent + ) + ) + } } diff --git a/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift b/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift index b1f6cec3cf3..17eedb6c15d 100644 --- a/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift +++ b/Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift @@ -305,6 +305,17 @@ extension WooAnalyticsEvent { ]) } + static func pointOfSaleSearchResultsFetched(itemType: POSItemType, + resultsCount: Int, + millisecondsSinceRequestSent: Int) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleSearchResultsFetched, + properties: [ + Key.sourceView: SourceView(itemType: itemType).rawValue, + Key.resultsCount: "\(resultsCount)", + Key.millisecondsSinceRequestSent: "\(millisecondsSinceRequestSent)" + ]) + } + static func pointOfSaleItemsFetched(itemType: POSItemType, totalItems: Int) -> WooAnalyticsEvent { WooAnalyticsEvent(statName: .pointOfSaleItemsFetched, diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift index 388878c3427..024f1e7847c 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift @@ -7,6 +7,7 @@ import protocol Yosemite.PointOfSaleCouponServiceProtocol import struct Yosemite.PointOfSaleCouponFetchStrategyFactory import protocol Yosemite.PointOfSaleCouponFetchStrategy import class Yosemite.AsyncPaginationTracker +import enum Yosemite.SearchDebounceStrategy protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControllerProtocol { /// Enables coupons in store settings @@ -57,6 +58,18 @@ protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControll setSearchingState() } + var currentDebounceStrategy: SearchDebounceStrategy { + fetchStrategy.debounceStrategy + } + + var searchDebounceStrategy: SearchDebounceStrategy { + // Return the debounce strategy that would be used for a search + let searchStrategy = fetchStrategyFactory.searchStrategy(searchTerm: "", + analytics: POSItemFetchAnalytics(itemType: .coupon, + analytics: analyticsProvider)) + return searchStrategy.debounceStrategy + } + @MainActor func loadNextItems(base: ItemListBaseItem) async { guard paginationTracker.hasNextPage else { diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift index b67da2bce67..b14221eb7c8 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift @@ -11,6 +11,7 @@ import struct Yosemite.POSVariableParentProduct import class Yosemite.Store import enum Yosemite.POSItemType import class Yosemite.AsyncPaginationTracker +import enum Yosemite.SearchDebounceStrategy protocol PointOfSaleItemsControllerProtocol { /// @@ -27,6 +28,10 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController /// Searches for items func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async func clearSearchItems(baseItem: ItemListBaseItem) + /// The debouncing strategy from the current fetch strategy + var currentDebounceStrategy: SearchDebounceStrategy { get } + /// The debouncing strategy that will be used when performing a search + var searchDebounceStrategy: SearchDebounceStrategy { get } } @@ -70,7 +75,6 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController fetchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: searchTerm, analytics: POSItemFetchAnalytics(itemType: .product, analytics: analyticsProvider)) - setSearchingState(base: baseItem) await loadFirstPage(base: baseItem) } @@ -78,6 +82,19 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController setSearchingState(base: baseItem) } + var currentDebounceStrategy: SearchDebounceStrategy { + fetchStrategy.debounceStrategy + } + + var searchDebounceStrategy: SearchDebounceStrategy { + // Return the debounce strategy that would be used for a search + // We create a temporary search strategy to get its debounce settings + let searchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: "", + analytics: POSItemFetchAnalytics(itemType: .product, + analytics: analyticsProvider)) + return searchStrategy.debounceStrategy + } + @MainActor private func loadFirstPage(base: ItemListBaseItem) async { switch base { diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift index a74053265dd..1869e7849de 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift @@ -2,6 +2,7 @@ import Foundation import enum Yosemite.POSItemType import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy final class POSProductSearchable: POSSearchable { private let itemListType: ItemListType @@ -24,6 +25,14 @@ final class POSProductSearchable: POSSearchable { itemListType.itemType.searchFieldLabel } + var currentDebounceStrategy: SearchDebounceStrategy { + itemsController.currentDebounceStrategy + } + + var searchDebounceStrategy: SearchDebounceStrategy { + itemsController.searchDebounceStrategy + } + func performSearch(term: String) async { await itemsController.searchItems(searchTerm: term, baseItem: .root) } diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift index 974b7d09868..396ca103e93 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift @@ -1,12 +1,17 @@ import SwiftUI import enum Yosemite.POSItemType import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy /// Protocol defining search capabilities for POS items protocol POSSearchable { var searchFieldPlaceholder: String { get } /// Recent search history for the current item type var searchHistory: [String] { get } + /// The debouncing strategy currently active based on the controller's current state + var currentDebounceStrategy: SearchDebounceStrategy { get } + /// The debouncing strategy that will be used when performing a search (may differ from current strategy) + var searchDebounceStrategy: SearchDebounceStrategy { get } /// Called when a search should be performed /// - Parameter term: The search term to use @@ -54,40 +59,7 @@ struct POSSearchField: View { .textInputAutocapitalization(.never) .focused($isSearchFieldFocused) .onChange(of: searchTerm) { oldValue, newValue in - // The debouncing logic is a little tricky, because the loading state is held in the controller. - // Arguably, we should use view state `isSearching` for this, so the UI is independent of the request timing. - - // As the user types, we don't want to send every keystroke to the remote, so we debounce the requests. - // However, we don't want to debounce the first keystroke of a new search, so that the loading - // state shows immediately and the UI feels responsive. - - // So, if the last search was finished, we don't debounce the first character. If it didn't - // finish i.e. it is still ongoing, we debounce the next keystrokes by 300ms. In either case, - // the ongoing search is redundant now there's a new search term, so we cancel it. - let shouldDebounceNextSearchRequest = !didFinishSearch - searchTask?.cancel() - - searchTask = Task { - if shouldDebounceNextSearchRequest { - try? await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) - } else { - searchable.clearSearchResults() - } - - guard !Task.isCancelled else { return } - - guard newValue.isNotEmpty else { - didFinishSearch = true - return - } - - didFinishSearch = false - await searchable.performSearch(term: newValue) - - if !Task.isCancelled { - didFinishSearch = true - } - } + handleSearchTermChange(newValue) } } .onChange(of: keyboardObserver.isKeyboardVisible) { _, isVisible in @@ -100,6 +72,142 @@ struct POSSearchField: View { } } +// MARK: - Search Handling +private extension POSSearchField { + func handleSearchTermChange(_ newValue: String) { + searchTask?.cancel() + + let debounceStrategy = selectDebounceStrategy(for: newValue) + + searchTask = Task { + await executeSearchWithStrategy(debounceStrategy, searchTerm: newValue) + } + } + + func selectDebounceStrategy(for searchTerm: String) -> SearchDebounceStrategy { + // Use searchDebounceStrategy for non-empty search terms (actual searches), + // and currentDebounceStrategy for empty terms (returning to popular products). + searchTerm.isNotEmpty ? searchable.searchDebounceStrategy : searchable.currentDebounceStrategy + } + + func executeSearchWithStrategy(_ strategy: SearchDebounceStrategy, searchTerm: String) async { + switch strategy { + case .smart(let duration, let loadingDelayThreshold): + await executeSmartDebouncedSearch(duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm) + case .simple(let duration, let loadingDelayThreshold): + await executeSimpleDebouncedSearch(duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm) + case .immediate: + await executeImmediateSearch(searchTerm: searchTerm) + } + } + + func executeSmartDebouncedSearch(duration: UInt64, loadingDelayThreshold: UInt64?, searchTerm: String) async { + // Smart debouncing: Don't debounce first keystroke, but debounce subsequent keystrokes + // The loading indicator behavior depends on whether there's a threshold: + // - With threshold: Show loading after threshold if search hasn't completed (prevents flicker) + // - Without threshold: Show loading immediately (responsive feel) + + let isFirstKeystroke = didFinishSearch + + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + // Handle loading indicators for first keystroke + let loadingTask = isFirstKeystroke ? startLoadingIndicatorTask(threshold: loadingDelayThreshold) : nil + + // Debounce subsequent keystrokes + if !isFirstKeystroke { + try? await Task.sleep(nanoseconds: duration) + } + + guard !Task.isCancelled else { + loadingTask?.cancel() + return + } + + await performSearchAndTrackCompletion(searchTerm: searchTerm) + loadingTask?.cancel() + } + + func executeSimpleDebouncedSearch(duration: UInt64, + loadingDelayThreshold: UInt64?, + searchTerm: String) async { + // Simple debouncing: Always debounce every keystroke + try? await Task.sleep(nanoseconds: duration) + + guard !Task.isCancelled else { return } + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + didFinishSearch = false + + if let threshold = loadingDelayThreshold { + await performSearchWithDelayedLoading(searchTerm: searchTerm, threshold: threshold) + } else { + searchable.clearSearchResults() + await searchable.performSearch(term: searchTerm) + } + + if !Task.isCancelled { + didFinishSearch = true + } + } + + func executeImmediateSearch(searchTerm: String) async { + guard !Task.isCancelled else { return } + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + await performSearchAndTrackCompletion(searchTerm: searchTerm) + } + + func startLoadingIndicatorTask(threshold: UInt64?) -> Task? { + if let threshold { + // With threshold: delay showing loading to prevent flicker for fast searches + return Task { @MainActor in + try? await Task.sleep(nanoseconds: threshold) + if !Task.isCancelled { + searchable.clearSearchResults() + } + } + } else { + // No threshold - show loading immediately for responsive feel + searchable.clearSearchResults() + return nil + } + } + + func performSearchWithDelayedLoading(searchTerm: String, threshold: UInt64) async { + // Create a loading task that shows indicators after threshold + let loadingTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: threshold) + if !Task.isCancelled { + searchable.clearSearchResults() + } + } + + await searchable.performSearch(term: searchTerm) + + // Cancel loading task if search completed before threshold + loadingTask.cancel() + } + + private func performSearchAndTrackCompletion(searchTerm: String) async { + didFinishSearch = false + await searchable.performSearch(term: searchTerm) + + if !Task.isCancelled { + didFinishSearch = true + } + } +} + /// A reusable search content view for POS items struct POSSearchContentView: View { @Environment(\.posAnalytics) private var analytics diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift index 8d2f488db1f..8c56105571e 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift @@ -2,6 +2,7 @@ import SwiftUI import struct WooFoundation.WooAnalyticsEvent import struct Yosemite.POSOrder import enum Yosemite.OrderPaymentMethod +import enum Yosemite.SearchDebounceStrategy struct POSOrderListView: View { @Binding var isSearching: Bool @@ -364,6 +365,18 @@ final class POSOrderSearchable: POSSearchable { [] } + var currentDebounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for order search to match original behavior: + // don't debounce first keystroke to show loading immediately, + // then debounce subsequent keystrokes while search is ongoing + .smart() + } + + var searchDebounceStrategy: SearchDebounceStrategy { + // Orders use the same strategy for both modes + currentDebounceStrategy + } + func performSearch(term: String) async { await ordersController.searchOrders(searchTerm: term) } diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index ead13993bec..131c0485644 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -18,6 +18,7 @@ import struct Yosemite.POSProduct import struct Yosemite.POSProductVariation import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItemType +import enum Yosemite.SearchDebounceStrategy import protocol Yosemite.PointOfSaleBarcodeScanServiceProtocol import enum Yosemite.PointOfSaleBarcodeScanError import Combine @@ -105,6 +106,8 @@ final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerPro @Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading(), itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy { .immediate } + var searchDebounceStrategy: SearchDebounceStrategy { .smart() } func enableCoupons() async { } func loadItems(base: ItemListBaseItem) async { } func refreshItems(base: ItemListBaseItem) async { } @@ -118,6 +121,9 @@ final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControll itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy { .immediate } + var searchDebounceStrategy: SearchDebounceStrategy { .smart() } + func loadItems(base: ItemListBaseItem) async { switch base { case .root: diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index d2d38cedf3d..a0f86f86046 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -129,6 +129,37 @@ public extension PersistedProduct { .filter(Columns.siteID == siteID) .filter(Columns.globalUniqueID == globalUniqueID) } + + /// Searches for POS-supported products by search term using LIKE query + /// - Parameters: + /// - siteID: The site ID + /// - searchTerm: The search term to match against product name, SKU, and global unique ID + /// - Returns: A query request that matches products containing the search term, ordered by name + static func posProductSearch(siteID: Int64, searchTerm: String) -> QueryInterfaceRequest { + let escapedTerm = escapeSQLLikePattern(searchTerm) + let likePattern = "%\(escapedTerm)%" + + return PersistedProduct + .filter(Columns.siteID == siteID) + .filter([ProductType.simple.rawValue, ProductType.variable.rawValue].contains(Columns.productTypeKey)) + .filter(Columns.downloadable == false) + .filter( + Columns.name.like(likePattern, escape: "\\") || + Columns.sku.like(likePattern, escape: "\\") || + Columns.globalUniqueID.like(likePattern, escape: "\\") + ) + .order(Columns.name.collating(.localizedCaseInsensitiveCompare)) + } + + /// Escapes special SQL LIKE pattern characters (% and _) in a search term + /// - Parameter pattern: The user-provided search term + /// - Returns: An escaped pattern safe for use in LIKE queries + private static func escapeSQLLikePattern(_ pattern: String) -> String { + pattern + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "%", with: "\\%") + .replacingOccurrences(of: "_", with: "\\_") + } } // periphery:ignore - TODO: remove ignore when populating database diff --git a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift index abfae67cbbe..4fef3cd08da 100644 --- a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift +++ b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift @@ -1274,6 +1274,7 @@ public enum WooAnalyticsStat: String { case pointOfSaleKeyboardDismissedInSearch = "keyboard_dismissed_in_search" case pointOfSaleItemsNextPageLoaded = "items_next_page_loaded" case pointOfSaleSearchRemoteResultsFetched = "search_remote_results_fetched" + case pointOfSaleSearchResultsFetched = "search_results_fetched" case pointOfSaleBarcodeScanningMenuItemTapped = "barcode_scanning_menu_item_tapped" case pointOfSaleBarcodeScanningExplanationDialogShown = "barcode_scanning_explanation_dialog_shown" case pointOfSaleBarcodeScannerSetupFlowShown = "barcode_scanner_setup_flow_shown" diff --git a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift index 138b9b68702..38e5efa8a8a 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift @@ -6,6 +6,16 @@ import protocol Storage.StorageManagerType public protocol PointOfSaleCouponFetchStrategy { func fetchCoupons(pageNumber: Int) async throws -> PagedItems func fetchLocalCoupons() async throws -> [POSItem] + /// The debouncing strategy to use for search input. + /// Default is `.immediate` (no debouncing) for non-search strategies. + var debounceStrategy: SearchDebounceStrategy { get } +} + +public extension PointOfSaleCouponFetchStrategy { + /// Default implementation returns `.immediate` for strategies that don't need debouncing + var debounceStrategy: SearchDebounceStrategy { + .immediate + } } struct PointOfSaleDefaultCouponFetchStrategy: PointOfSaleCouponFetchStrategy { @@ -89,6 +99,12 @@ struct PointOfSaleSearchCouponFetchStrategy: PointOfSaleCouponFetchStrategy { self.analytics = analytics } + var debounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for remote coupon search + // No loading delay threshold - show loading immediately for responsive feel + .smart() + } + func fetchCoupons(pageNumber: Int) async throws -> PagedItems { let startTime = Date() try await couponStoreMethods.searchCoupons(siteID: siteID, diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift index 6166cf02c9a..b8119125d95 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemFetchStrategyFactory.swift @@ -4,6 +4,7 @@ import class Networking.ProductVariationsRemote import class Networking.AlamofireNetwork import struct Combine.AnyPublisher import struct NetworkingCore.JetpackSite +import protocol Storage.GRDBManagerProtocol public protocol PointOfSaleItemFetchStrategyFactoryProtocol { func defaultStrategy(analytics: POSItemFetchAnalyticsTracking) -> PointOfSalePurchasableItemFetchStrategy @@ -16,17 +17,23 @@ public final class PointOfSaleItemFetchStrategyFactory: PointOfSaleItemFetchStra private let siteID: Int64 private let productsRemote: ProductsRemote private let variationsRemote: ProductVariationsRemote + private let grdbManager: GRDBManagerProtocol? + private let isLocalCatalogEnabled: Bool public init(siteID: Int64, credentials: Credentials?, selectedSite: AnyPublisher? = nil, - appPasswordSupportState: AnyPublisher? = nil) { + appPasswordSupportState: AnyPublisher? = nil, + grdbManager: GRDBManagerProtocol? = nil, + isLocalCatalogEnabled: Bool = false) { self.siteID = siteID let network = AlamofireNetwork(credentials: credentials, selectedSite: selectedSite, appPasswordSupportState: appPasswordSupportState) self.productsRemote = ProductsRemote(network: network) self.variationsRemote = ProductVariationsRemote(network: network) + self.grdbManager = grdbManager + self.isLocalCatalogEnabled = isLocalCatalogEnabled } public func defaultStrategy(analytics: POSItemFetchAnalyticsTracking) -> PointOfSalePurchasableItemFetchStrategy { @@ -37,11 +44,19 @@ public final class PointOfSaleItemFetchStrategyFactory: PointOfSaleItemFetchStra } public func searchStrategy(searchTerm: String, analytics: POSItemFetchAnalyticsTracking) -> PointOfSalePurchasableItemFetchStrategy { - PointOfSaleSearchPurchasableItemFetchStrategy(siteID: siteID, - searchTerm: searchTerm, - productsRemote: productsRemote, - variationsRemote: variationsRemote, - analytics: analytics) + // Use local search if explicitly enabled and GRDB manager is available + if isLocalCatalogEnabled, let grdbManager = grdbManager { + return PointOfSaleLocalSearchPurchasableItemFetchStrategy(siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: analytics) + } + return PointOfSaleSearchPurchasableItemFetchStrategy(siteID: siteID, + searchTerm: searchTerm, + productsRemote: productsRemote, + variationsRemote: variationsRemote, + analytics: analytics) } public func popularStrategy(pageSize: Int = 10) -> PointOfSalePurchasableItemFetchStrategy { diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift index 06a7a55aecd..186db3c6202 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift @@ -36,7 +36,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { return .init(items: itemMapper.mapProductsToPOSItems(products: products), hasMorePages: pagedProducts.hasMorePages, totalItems: pagedProducts.totalItems) - } catch AFError.explicitlyCancelled { + } catch AFError.explicitlyCancelled, is CancellationError { throw PointOfSaleItemServiceError.requestCancelled } } @@ -55,7 +55,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { hasMorePages: pagedVariations.hasMorePages, totalItems: pagedVariations.totalItems ) - } catch AFError.explicitlyCancelled { + } catch AFError.explicitlyCancelled, is CancellationError { throw PointOfSaleItemServiceError.requestCancelled } } diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift new file mode 100644 index 00000000000..58b32470436 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift @@ -0,0 +1,100 @@ +import Foundation +import protocol Storage.GRDBManagerProtocol +import protocol Networking.ProductVariationsRemoteProtocol + +/// Fetch strategy for searching products in the local GRDB catalog using SQL LIKE queries +struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { + private let siteID: Int64 + private let searchTerm: String + private let grdbManager: GRDBManagerProtocol + // periphery:ignore - Reserved for future variation fetching from remote when not in local catalog + private let variationsRemote: ProductVariationsRemoteProtocol + private let analytics: POSItemFetchAnalyticsTracking + private let pageSize: Int + + init(siteID: Int64, + searchTerm: String, + grdbManager: GRDBManagerProtocol, + variationsRemote: ProductVariationsRemoteProtocol, + analytics: POSItemFetchAnalyticsTracking, + pageSize: Int = 25) { + self.siteID = siteID + self.searchTerm = searchTerm + self.grdbManager = grdbManager + self.variationsRemote = variationsRemote + self.analytics = analytics + self.pageSize = pageSize + } + + var debounceStrategy: SearchDebounceStrategy { + // Use simple debouncing for local search: always debounce to prevent excessive queries + // even though local searches are fast. 100ms provides responsive feel while preventing + // queries on every keystroke. Delay loading indicators by 150ms to avoid flicker for fast queries. + .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + } + + func fetchProducts(pageNumber: Int) async throws -> PagedItems { + let startTime = Date() + + // Get total count and persisted products in one transaction + let (persistedProducts, totalCount) = try await grdbManager.databaseConnection.read { db in + let totalCount = try PersistedProduct + .posProductSearch(siteID: siteID, searchTerm: searchTerm) + .fetchCount(db) + + let offset = (pageNumber - 1) * pageSize + let persistedProducts = try PersistedProduct + .posProductSearch(siteID: siteID, searchTerm: searchTerm) + .limit(pageSize, offset: offset) + .fetchAll(db) + + return (persistedProducts, totalCount) + } + + // Convert to POSProduct outside the read transaction + // toPOSProduct(db:) starts its own transaction, so we can't call it inside another transaction + let products = try persistedProducts.map { persistedProduct in + try persistedProduct.toPOSProduct(db: grdbManager.databaseConnection) + } + + let hasMorePages = (pageNumber * pageSize) < totalCount + + if pageNumber == 1 { + let milliseconds = Int(Date().timeIntervalSince(startTime) * Double(MSEC_PER_SEC)) + analytics.trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: milliseconds, + totalItems: totalCount) + } + + return PagedItems(items: products, + hasMorePages: hasMorePages, + totalItems: totalCount) + } + + func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems { + // Get total count and persisted variations in one transaction + let (persistedVariations, totalCount) = try await grdbManager.databaseConnection.read { db in + let totalCount = try PersistedProductVariation + .posVariationsRequest(siteID: siteID, parentProductID: parentProductID) + .fetchCount(db) + + let offset = (pageNumber - 1) * pageSize + let persistedVariations = try PersistedProductVariation + .posVariationsRequest(siteID: siteID, parentProductID: parentProductID) + .limit(pageSize, offset: offset) + .fetchAll(db) + + return (persistedVariations, totalCount) + } + + // Convert to POSProductVariation outside the read transaction + let variations = try persistedVariations.map { persistedVariation in + try persistedVariation.toPOSProductVariation(db: grdbManager.databaseConnection) + } + + let hasMorePages = (pageNumber * pageSize) < totalCount + + return PagedItems(items: variations, + hasMorePages: hasMorePages, + totalItems: totalCount) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift index f1178dbe7af..90ab4d1a4c4 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift @@ -5,6 +5,17 @@ import protocol Networking.ProductVariationsRemoteProtocol public protocol PointOfSalePurchasableItemFetchStrategy { func fetchProducts(pageNumber: Int) async throws -> PagedItems func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems + + /// The debouncing strategy to use for search input. + /// Default is `.immediate` (no debouncing) for non-search strategies. + var debounceStrategy: SearchDebounceStrategy { get } +} + +public extension PointOfSalePurchasableItemFetchStrategy { + /// Default implementation returns `.immediate` for strategies that don't need debouncing + var debounceStrategy: SearchDebounceStrategy { + .immediate + } } public struct PointOfSaleDefaultPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { @@ -69,6 +80,14 @@ public struct PointOfSaleSearchPurchasableItemFetchStrategy: PointOfSalePurchasa self.analytics = analytics } + // periphery:ignore - Protocol requirement, used via protocol + public var debounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for remote search: don't debounce first keystroke to show loading immediately, + // then debounce subsequent keystrokes while search is ongoing. + // No loading delay threshold - show loading immediately for responsive feel. + .smart() + } + public func fetchProducts(pageNumber: Int) async throws -> PagedItems { let startTime = Date() let pagedProducts = try await productsRemote.searchProductsForPointOfSale( diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift new file mode 100644 index 00000000000..7faa67bd7d3 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Defines the debouncing behavior for search input +public enum SearchDebounceStrategy: Equatable { + /// Smart debouncing: Skip debounce on first keystroke after a search completes, then debounce subsequent keystrokes. + /// Optionally delays showing loading indicators until a threshold is exceeded. + /// Optimized for slow network searches where the first keystroke should show loading immediately. + /// - Parameters: + /// - duration: The debounce duration in nanoseconds for subsequent keystrokes + /// - loadingDelayThreshold: Optional threshold in nanoseconds before showing loading indicators. If nil, shows loading immediately. + case smart(duration: UInt64 = 500 * NSEC_PER_MSEC, loadingDelayThreshold: UInt64? = nil) + + /// Simple debouncing: Always debounce every keystroke by the specified duration. + /// Optionally delays showing loading indicators until a threshold is exceeded. + /// Optimized for fast local searches to prevent excessive queries and loading flicker. + /// - Parameters: + /// - duration: The debounce duration in nanoseconds + /// - loadingDelayThreshold: Optional threshold in nanoseconds before showing loading indicators. If nil, shows loading immediately. + case simple(duration: UInt64, loadingDelayThreshold: UInt64? = nil) + + /// Immediate: No debouncing, execute search on every keystroke. + /// Use when debouncing is not needed (e.g., non-search operations). + case immediate +} diff --git a/Modules/Sources/Yosemite/PointOfSale/POSItemFetchAnalyticsTracking.swift b/Modules/Sources/Yosemite/PointOfSale/POSItemFetchAnalyticsTracking.swift index 2bb1bb96eaf..d4d86354c21 100644 --- a/Modules/Sources/Yosemite/PointOfSale/POSItemFetchAnalyticsTracking.swift +++ b/Modules/Sources/Yosemite/PointOfSale/POSItemFetchAnalyticsTracking.swift @@ -12,4 +12,10 @@ public protocol POSItemFetchAnalyticsTracking { /// - millisecondsSinceRequestSent: The time taken to fetch results in milliseconds /// - totalItems: The total number of items found in the search func trackSearchRemoteResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) + + /// Tracks when a local search results fetch completes + /// - Parameters: + /// - millisecondsSinceRequestSent: The time taken to fetch results in milliseconds + /// - totalItems: The total number of items found in the search + func trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) } diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift index 0484ea0d91c..5da65e0170e 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift @@ -1,4 +1,6 @@ @testable import PointOfSale +import Yosemite +import Foundation final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtocol { var loadItemsCalled = false @@ -7,6 +9,9 @@ final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtoc var itemsViewState: ItemsViewState = .init(containerState: .content, itemsStack: .init(root: .empty, itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy = .immediate + var searchDebounceStrategy: SearchDebounceStrategy = .smart() + func loadItems(base: ItemListBaseItem) async { loadItemsCalled = true loadItemsBase = base diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift index 976a85379a5..4576e0606fb 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift @@ -2,11 +2,15 @@ import Foundation import Combine @testable import PointOfSale import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol { var itemsViewState: ItemsViewState = .init(containerState: .content, itemsStack: .init(root: .empty, itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy = .immediate + var searchDebounceStrategy: SearchDebounceStrategy = .smart() + func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async {} func loadItems(base: ItemListBaseItem) async { } @@ -15,5 +19,5 @@ final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchin func loadNextItems(base: ItemListBaseItem) async { } - func clearSearchItems(baseItem: PointOfSale.ItemListBaseItem) { } + func clearSearchItems(baseItem: ItemListBaseItem) { } } diff --git a/Modules/Tests/StorageTests/GRDB/PersistedProductSearchQueryTests.swift b/Modules/Tests/StorageTests/GRDB/PersistedProductSearchQueryTests.swift new file mode 100644 index 00000000000..a054f4dda3f --- /dev/null +++ b/Modules/Tests/StorageTests/GRDB/PersistedProductSearchQueryTests.swift @@ -0,0 +1,507 @@ +import Foundation +import Testing +@testable import Storage + +@Suite("PersistedProduct Search Query Tests") +struct PersistedProductSearchQueryTests { + private let siteID: Int64 = 123 + private var grdbManager: GRDBManager! + + init() async throws { + grdbManager = try GRDBManager() + + // Initialize site + let siteID = siteID + try await grdbManager.databaseConnection.write { db in + try PersistedSite(id: siteID).insert(db) + } + } + + // MARK: - Basic Search Tests + + @Test("posProductSearch finds product by name") + func test_finds_product_by_name() async throws { + // Given + let product = PersistedProduct( + id: 1, + siteID: siteID, + name: "Coffee Mug", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: "MUG-001", + globalUniqueID: nil, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try await insertProduct(product) + + // When + let results = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "Coffee").fetchAll(db) + } + + // Then + #expect(results.count == 1) + #expect(results.first?.id == 1) + #expect(results.first?.name == "Coffee Mug") + } + + @Test("posProductSearch finds product by SKU") + func test_finds_product_by_sku() async throws { + // Given + let product = PersistedProduct( + id: 2, + siteID: siteID, + name: "Test Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: "ABC-123", + globalUniqueID: nil, + price: "20.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try await insertProduct(product) + + // When + let results = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "ABC").fetchAll(db) + } + + // Then + #expect(results.count == 1) + #expect(results.first?.sku == "ABC-123") + } + + @Test("posProductSearch finds product by global unique ID") + func test_finds_product_by_global_unique_id() async throws { + // Given + let product = PersistedProduct( + id: 3, + siteID: siteID, + name: "Barcode Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: "1234567890", + price: "30.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try await insertProduct(product) + + // When + let results = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "12345").fetchAll(db) + } + + // Then + #expect(results.count == 1) + #expect(results.first?.globalUniqueID == "1234567890") + } + + // MARK: - Case Insensitive Search Tests + + @Test("posProductSearch is case insensitive") + func test_search_is_case_insensitive() async throws { + // Given + let product = PersistedProduct( + id: 4, + siteID: siteID, + name: "Blue Shirt", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: "SHIRT-BLUE", + globalUniqueID: nil, + price: "25.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try await insertProduct(product) + + // When - search with different cases + let lowerCaseResults = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "blue").fetchAll(db) + } + let upperCaseResults = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "BLUE").fetchAll(db) + } + let mixedCaseResults = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "BLuE").fetchAll(db) + } + + // Then + #expect(lowerCaseResults.count == 1) + #expect(upperCaseResults.count == 1) + #expect(mixedCaseResults.count == 1) + } + + // MARK: - Partial Match Tests + + @Test("posProductSearch matches partial terms") + func test_search_matches_partial_terms() async throws { + // Given + let product = PersistedProduct( + id: 5, + siteID: siteID, + name: "Ergonomic Keyboard", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: "KB-ERG-001", + globalUniqueID: nil, + price: "75.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try await insertProduct(product) + + // When + let results = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "nomic").fetchAll(db) + } + + // Then + #expect(results.count == 1) + #expect(results.first?.name == "Ergonomic Keyboard") + } + + // MARK: - Multiple Results Tests + + @Test("posProductSearch returns multiple matching products") + func test_search_returns_multiple_matches() async throws { + // Given + let products = [ + PersistedProduct(id: 6, siteID: siteID, name: "Coffee Beans", productTypeKey: "simple", + fullDescription: nil, shortDescription: nil, sku: nil, globalUniqueID: nil, + price: "15.00", downloadable: false, parentID: 0, manageStock: false, + stockQuantity: nil, stockStatusKey: "instock", statusKey: "publish"), + PersistedProduct(id: 7, siteID: siteID, name: "Coffee Grinder", productTypeKey: "simple", + fullDescription: nil, shortDescription: nil, sku: nil, globalUniqueID: nil, + price: "45.00", downloadable: false, parentID: 0, manageStock: false, + stockQuantity: nil, stockStatusKey: "instock", statusKey: "publish"), + PersistedProduct(id: 8, siteID: siteID, name: "Coffee Maker", productTypeKey: "variable", + fullDescription: nil, shortDescription: nil, sku: nil, globalUniqueID: nil, + price: "100.00", downloadable: false, parentID: 0, manageStock: false, + stockQuantity: nil, stockStatusKey: "instock", statusKey: "publish"), + PersistedProduct(id: 9, siteID: siteID, name: "Tea Strainer", productTypeKey: "variable", + fullDescription: nil, shortDescription: nil, sku: nil, globalUniqueID: nil, + price: "5.00", downloadable: false, parentID: 0, manageStock: false, + stockQuantity: nil, stockStatusKey: "instock", statusKey: "publish") + ] + for product in products { + try await insertProduct(product) + } + + // When + let results = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "Coffee").fetchAll(db) + } + + // Then + #expect(results.count == 3) + } + + // MARK: - Filtering Tests + + @Test("posProductSearch filters out downloadable products") + func test_search_filters_out_downloadable_products() async throws { + // Given + let downloadableProduct = PersistedProduct( + id: 9, + siteID: siteID, + name: "Digital Download", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "10.00", + downloadable: true, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try await insertProduct(downloadableProduct) + + // When + let results = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "Digital").fetchAll(db) + } + + // Then + #expect(results.isEmpty) + } + + @Test("posProductSearch only returns simple and variable product types") + func test_search_only_returns_pos_supported_product_types() async throws { + // Given + let simpleProduct = PersistedProduct( + id: 10, + siteID: siteID, + name: "Search Test Simple", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + let variableProduct = PersistedProduct( + id: 11, + siteID: siteID, + name: "Search Test Variable", + productTypeKey: "variable", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "20.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + let groupedProduct = PersistedProduct( + id: 12, + siteID: siteID, + name: "Search Test Grouped", + productTypeKey: "grouped", + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "0.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try await insertProduct(simpleProduct) + try await insertProduct(variableProduct) + try await insertProduct(groupedProduct) + + // When + let results = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "Search Test").fetchAll(db) + } + + // Then + #expect(results.count == 2) + #expect(results.contains(where: { $0.productTypeKey == "simple" })) + #expect(results.contains(where: { $0.productTypeKey == "variable" })) + #expect(!results.contains(where: { $0.productTypeKey == "grouped" })) + } + + // MARK: - Site Isolation Tests + + @Test("posProductSearch only returns products from specified site") + func test_search_respects_site_isolation() async throws { + // Given + let otherSiteID: Int64 = 456 + + // Insert other site + try await grdbManager.databaseConnection.write { db in + try PersistedSite(id: otherSiteID).insert(db) + } + + let ourProduct = PersistedProduct( + id: 13, + siteID: siteID, + name: "Our Site Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: "SITE-123", + globalUniqueID: nil, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + let otherProduct = PersistedProduct( + id: 14, + siteID: otherSiteID, + name: "Other Site Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: "SITE-456", + globalUniqueID: nil, + price: "20.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try await insertProduct(ourProduct) + try await insertProduct(otherProduct) + + // When + let results = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "Site Product").fetchAll(db) + } + + // Then + #expect(results.count == 1) + #expect(results.first?.siteID == siteID) + #expect(results.first?.id == 13) + } + + // MARK: - Empty Results Tests + + @Test("posProductSearch returns empty array when no matches") + func test_search_returns_empty_when_no_matches() async throws { + // Given + let product = PersistedProduct( + id: 15, + siteID: siteID, + name: "Example Product", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: "EX-123", + globalUniqueID: nil, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try await insertProduct(product) + + // When + let results = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "Nonexistent").fetchAll(db) + } + + // Then + #expect(results.isEmpty) + } + + // MARK: - SQL Escaping Tests + + @Test("posProductSearch handles SQL special characters safely") + func test_search_handles_sql_special_characters() async throws { + // Given + let product = PersistedProduct( + id: 16, + siteID: siteID, + name: "Product 100%", + productTypeKey: "simple", + fullDescription: nil, + shortDescription: nil, + sku: "100%_OFF", + globalUniqueID: nil, + price: "5.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + try await insertProduct(product) + + // When - search with % and _ (SQL wildcards that should be escaped) + let percentResults = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "100%").fetchAll(db) + } + let underscoreResults = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "100%_").fetchAll(db) + } + + // Then + #expect(percentResults.count == 1) + #expect(percentResults.first?.name == "Product 100%") + #expect(underscoreResults.count == 1) + #expect(underscoreResults.first?.sku == "100%_OFF") + } + + // MARK: - Sorting Tests + + @Test("posProductSearch returns results sorted by name") + func test_search_returns_results_sorted_by_name() async throws { + // Given - insert in non-alphabetical order + let products = [ + PersistedProduct(id: 17, siteID: siteID, name: "Zebra Product", productTypeKey: "simple", + fullDescription: nil, shortDescription: nil, sku: "ITEM-Z", globalUniqueID: nil, + price: "10.00", downloadable: false, parentID: 0, manageStock: false, + stockQuantity: nil, stockStatusKey: "instock", statusKey: "publish"), + PersistedProduct(id: 18, siteID: siteID, name: "Alpha Product", productTypeKey: "simple", + fullDescription: nil, shortDescription: nil, sku: "ITEM-A", globalUniqueID: nil, + price: "10.00", downloadable: false, parentID: 0, manageStock: false, + stockQuantity: nil, stockStatusKey: "instock", statusKey: "publish"), + PersistedProduct(id: 19, siteID: siteID, name: "Beta Product", productTypeKey: "simple", + fullDescription: nil, shortDescription: nil, sku: "ITEM-B", globalUniqueID: nil, + price: "10.00", downloadable: false, parentID: 0, manageStock: false, + stockQuantity: nil, stockStatusKey: "instock", statusKey: "publish") + ] + for product in products { + try await insertProduct(product) + } + + // When + let results = try await grdbManager.databaseConnection.read { db in + try PersistedProduct.posProductSearch(siteID: siteID, searchTerm: "Product").fetchAll(db) + } + + // Then + #expect(results.count == 3) + #expect(results[0].name == "Alpha Product") + #expect(results[1].name == "Beta Product") + #expect(results[2].name == "Zebra Product") + } + + // MARK: - Helper Methods + + private func insertProduct(_ product: PersistedProduct) async throws { + try await grdbManager.databaseConnection.write { db in + try product.insert(db) + } + } +} diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSItemFetchAnalyticsTracking.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSItemFetchAnalyticsTracking.swift index 342768f606b..5a41f8c4093 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSItemFetchAnalyticsTracking.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSItemFetchAnalyticsTracking.swift @@ -5,6 +5,8 @@ final class MockPOSItemFetchAnalyticsTracking: POSItemFetchAnalyticsTracking { private(set) var spyTotalItems: Int? private(set) var spyMillisecondsSinceRequestSent: Int? private(set) var spySearchTotalItems: Int? + private(set) var spyLocalSearchMilliseconds: Int? + private(set) var spyLocalSearchTotalItems: Int? func trackItemsFetchComplete(totalItems: Int) { spyTotalItems = totalItems @@ -14,4 +16,9 @@ final class MockPOSItemFetchAnalyticsTracking: POSItemFetchAnalyticsTracking { spyMillisecondsSinceRequestSent = millisecondsSinceRequestSent spySearchTotalItems = totalItems } + + func trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) { + spyLocalSearchMilliseconds = millisecondsSinceRequestSent + spyLocalSearchTotalItems = totalItems + } } diff --git a/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleLocalSearchPurchasableItemFetchStrategyTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleLocalSearchPurchasableItemFetchStrategyTests.swift new file mode 100644 index 00000000000..aa544799fa2 --- /dev/null +++ b/Modules/Tests/YosemiteTests/PointOfSale/PointOfSaleLocalSearchPurchasableItemFetchStrategyTests.swift @@ -0,0 +1,532 @@ +import Foundation +import Testing +import Networking +@testable import Storage +@testable import Yosemite + +@Suite("PointOfSaleLocalSearchPurchasableItemFetchStrategy Tests") +struct PointOfSaleLocalSearchPurchasableItemFetchStrategyTests { + private let siteID: Int64 = 123 + private let searchTerm = "test" + private var grdbManager: GRDBManager! + private let variationsRemote = MockProductVariationsRemote() + private let mockAnalytics = MockPOSItemFetchAnalyticsTracking() + + init() async throws { + grdbManager = try GRDBManager() + + // Initialize site + let siteIDLocalCopy = self.siteID + try await grdbManager.databaseConnection.write { db in + try PersistedSite(id: siteIDLocalCopy).insert(db) + } + } + + // MARK: - Search Functionality Tests + + @Test("fetchProducts returns matching products") + func test_fetchProducts_returns_matching_products() async throws { + // Given + try await insertProduct(makeProduct(id: 1, name: "Test Product")) + try await insertProduct(makeProduct(id: 2, name: "Another Product")) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchProducts(pageNumber: 1) + + // Then + #expect(result.items.count == 1) + #expect(result.items.first?.name == "Test Product") + #expect(result.totalItems == 1) + } + + @Test("fetchProducts searches by SKU") + func test_fetchProducts_searches_by_sku() async throws { + // Given + try await insertProduct(makeProduct(id: 1, name: "Product A", sku: "TEST-SKU-123")) + try await insertProduct(makeProduct(id: 2, name: "Product B", sku: "OTHER-SKU-456")) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "TEST-SKU", + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchProducts(pageNumber: 1) + + // Then + #expect(result.items.count == 1) + #expect(result.items.first?.sku == "TEST-SKU-123") + } + + @Test("fetchProducts searches by global unique ID") + func test_fetchProducts_searches_by_global_unique_id() async throws { + // Given + try await insertProduct(makeProduct(id: 1, name: "Product A", globalUniqueID: "1234567890")) + try await insertProduct(makeProduct(id: 2, name: "Product B", globalUniqueID: "0987654321")) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "12345", + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchProducts(pageNumber: 1) + + // Then + #expect(result.items.count == 1) + #expect(result.items.first?.globalUniqueID == "1234567890") + } + + @Test("fetchProducts returns empty result when no matches") + func test_fetchProducts_returns_empty_when_no_matches() async throws { + // Given + try await insertProduct(makeProduct(id: 1, name: "Coffee")) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "nonexistent", + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchProducts(pageNumber: 1) + + // Then + #expect(result.items.isEmpty) + #expect(result.totalItems == 0) + #expect(result.hasMorePages == false) + } + + // MARK: - Pagination Tests + + @Test("fetchProducts handles pagination correctly") + func test_fetchProducts_handles_pagination_correctly() async throws { + // Given - insert 30 products + for i in 1...30 { + try await insertProduct(makeProduct(id: Int64(i), name: "Test Product \(i)")) + } + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics, + pageSize: 10 + ) + + // When + let page1 = try await strategy.fetchProducts(pageNumber: 1) + let page2 = try await strategy.fetchProducts(pageNumber: 2) + let page3 = try await strategy.fetchProducts(pageNumber: 3) + let page4 = try await strategy.fetchProducts(pageNumber: 4) + + // Then + #expect(page1.items.count == 10) + #expect(page1.hasMorePages == true) + #expect(page1.totalItems == 30) + + #expect(page2.items.count == 10) + #expect(page2.hasMorePages == true) + #expect(page2.totalItems == 30) + + #expect(page3.items.count == 10) + #expect(page3.hasMorePages == false) + #expect(page3.totalItems == 30) + + #expect(page4.items.isEmpty) + #expect(page4.hasMorePages == false) + #expect(page4.totalItems == 30) + } + + @Test("fetchProducts hasMorePages is false when exactly one page") + func test_fetchProducts_hasMorePages_false_when_exactly_one_page() async throws { + // Given - insert exactly 25 products (default page size) + for i in 1...25 { + try await insertProduct(makeProduct(id: Int64(i), name: "Test Product \(i)")) + } + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics, + pageSize: 25 + ) + + // When + let result = try await strategy.fetchProducts(pageNumber: 1) + + // Then + #expect(result.items.count == 25) + #expect(result.hasMorePages == false) + #expect(result.totalItems == 25) + } + + @Test("fetchProducts hasMorePages is true when more than one page") + func test_fetchProducts_hasMorePages_true_when_more_than_one_page() async throws { + // Given - insert 26 products (one more than page size) + for i in 1...26 { + try await insertProduct(makeProduct(id: Int64(i), name: "Test Product \(i)")) + } + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics, + pageSize: 25 + ) + + // When + let page1 = try await strategy.fetchProducts(pageNumber: 1) + let page2 = try await strategy.fetchProducts(pageNumber: 2) + + // Then + #expect(page1.items.count == 25) + #expect(page1.hasMorePages == true) + + #expect(page2.items.count == 1) + #expect(page2.hasMorePages == false) + } + + // MARK: - Filtering Tests + + @Test("fetchProducts filters out downloadable products") + func test_fetchProducts_filters_out_downloadable_products() async throws { + // Given + try await insertProduct(makeProduct(id: 1, name: "Test Physical", downloadable: false)) + try await insertProduct(makeProduct(id: 2, name: "Test Digital", downloadable: true)) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchProducts(pageNumber: 1) + + // Then + #expect(result.items.count == 1) + #expect(result.items.first?.name == "Test Physical") + #expect(result.items.first?.downloadable == false) + } + + @Test("fetchProducts only returns simple and variable products") + func test_fetchProducts_only_returns_pos_supported_types() async throws { + // Given + try await insertProduct(makeProduct(id: 1, name: "Test Simple", productTypeKey: "simple")) + try await insertProduct(makeProduct(id: 2, name: "Test Variable", productTypeKey: "variable")) + try await insertProduct(makeProduct(id: 3, name: "Test Grouped", productTypeKey: "grouped")) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchProducts(pageNumber: 1) + + // Then + #expect(result.items.count == 2) + #expect(result.items.contains(where: { $0.productTypeKey == "simple" })) + #expect(result.items.contains(where: { $0.productTypeKey == "variable" })) + #expect(!result.items.contains(where: { $0.productTypeKey == "grouped" })) + } + + // MARK: - Site Isolation Tests + + @Test("fetchProducts only returns products from specified site") + func test_fetchProducts_respects_site_isolation() async throws { + // Given + let otherSiteID: Int64 = 456 + + // Insert other site + try await grdbManager.databaseConnection.write { db in + try PersistedSite(id: otherSiteID).insert(db) + } + + try await insertProduct(makeProduct(id: 1, name: "Test Our Site", siteID: siteID)) + try await insertProduct(makeProduct(id: 2, name: "Test Other Site", siteID: otherSiteID)) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchProducts(pageNumber: 1) + + // Then + #expect(result.items.count == 1) + #expect(result.items.first?.siteID == siteID) + #expect(result.items.first?.name == "Test Our Site") + } + + // MARK: - Sorting Tests + + @Test("fetchProducts returns results sorted by name") + func test_fetchProducts_returns_results_sorted_by_name() async throws { + // Given - insert in non-alphabetical order + try await insertProduct(makeProduct(id: 1, name: "Test Zebra")) + try await insertProduct(makeProduct(id: 2, name: "Test Alpha")) + try await insertProduct(makeProduct(id: 3, name: "Test Beta")) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchProducts(pageNumber: 1) + + // Then + #expect(result.items.count == 3) + #expect(result.items[0].name == "Test Alpha") + #expect(result.items[1].name == "Test Beta") + #expect(result.items[2].name == "Test Zebra") + } + + // MARK: - Variations Tests + + @Test("fetchVariations returns variations for parent product from local catalog") + func test_fetchVariations_returns_variations_from_local_catalog() async throws { + // Given + let parentProductID: Int64 = 100 + try await insertProduct(makeProduct(id: parentProductID, name: "Variable Product", productTypeKey: "variable")) + try await insertVariation(makeVariation(id: 1, productID: parentProductID, price: "10.00")) + try await insertVariation(makeVariation(id: 2, productID: parentProductID, price: "15.00")) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchVariations(parentProductID: parentProductID, pageNumber: 1) + + // Then + #expect(result.items.count == 2) + #expect(result.items[0].productVariationID == 1) + #expect(result.items[1].productVariationID == 2) + #expect(result.totalItems == 2) + #expect(result.hasMorePages == false) + } + + @Test("fetchVariations filters out downloadable variations") + func test_fetchVariations_filters_out_downloadable_variations() async throws { + // Given + let parentProductID: Int64 = 100 + try await insertProduct(makeProduct(id: parentProductID, name: "Variable Product", productTypeKey: "variable")) + try await insertVariation(makeVariation(id: 1, productID: parentProductID, downloadable: false)) + try await insertVariation(makeVariation(id: 2, productID: parentProductID, downloadable: true)) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchVariations(parentProductID: parentProductID, pageNumber: 1) + + // Then + #expect(result.items.count == 1) + #expect(result.items.first?.productVariationID == 1) + #expect(result.items.first?.downloadable == false) + } + + @Test("fetchVariations returns empty result when no variations") + func test_fetchVariations_returns_empty_when_no_variations() async throws { + // Given + let parentProductID: Int64 = 100 + try await insertProduct(makeProduct(id: parentProductID, name: "Simple Product", productTypeKey: "simple")) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchVariations(parentProductID: parentProductID, pageNumber: 1) + + // Then + #expect(result.items.isEmpty) + #expect(result.totalItems == 0) + #expect(result.hasMorePages == false) + } + + @Test("fetchVariations handles pagination correctly") + func test_fetchVariations_handles_pagination_correctly() async throws { + // Given + let parentProductID: Int64 = 100 + try await insertProduct(makeProduct(id: parentProductID, name: "Variable Product", productTypeKey: "variable")) + + // Insert 30 variations + for i in 1...30 { + try await insertVariation(makeVariation(id: Int64(i), productID: parentProductID, price: "\(i).00")) + } + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics, + pageSize: 10 + ) + + // When + let page1 = try await strategy.fetchVariations(parentProductID: parentProductID, pageNumber: 1) + let page2 = try await strategy.fetchVariations(parentProductID: parentProductID, pageNumber: 2) + let page3 = try await strategy.fetchVariations(parentProductID: parentProductID, pageNumber: 3) + let page4 = try await strategy.fetchVariations(parentProductID: parentProductID, pageNumber: 4) + + // Then + #expect(page1.items.count == 10) + #expect(page1.hasMorePages == true) + #expect(page1.totalItems == 30) + + #expect(page2.items.count == 10) + #expect(page2.hasMorePages == true) + #expect(page2.totalItems == 30) + + #expect(page3.items.count == 10) + #expect(page3.hasMorePages == false) + #expect(page3.totalItems == 30) + + #expect(page4.items.isEmpty) + #expect(page4.hasMorePages == false) + #expect(page4.totalItems == 30) + } + + @Test("fetchVariations only returns variations for specified parent") + func test_fetchVariations_respects_parent_product_isolation() async throws { + // Given + let parentProduct1ID: Int64 = 100 + let parentProduct2ID: Int64 = 200 + try await insertProduct(makeProduct(id: parentProduct1ID, name: "Variable Product 1", productTypeKey: "variable")) + try await insertProduct(makeProduct(id: parentProduct2ID, name: "Variable Product 2", productTypeKey: "variable")) + try await insertVariation(makeVariation(id: 1, productID: parentProduct1ID)) + try await insertVariation(makeVariation(id: 2, productID: parentProduct2ID)) + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: searchTerm, + grdbManager: grdbManager, + variationsRemote: variationsRemote, + analytics: mockAnalytics + ) + + // When + let result = try await strategy.fetchVariations(parentProductID: parentProduct1ID, pageNumber: 1) + + // Then + #expect(result.items.count == 1) + #expect(result.items.first?.productVariationID == 1) + #expect(result.items.first?.productID == parentProduct1ID) + } + + // MARK: - Helper Methods + + private func makeProduct( + id: Int64, + name: String, + siteID: Int64? = nil, + productTypeKey: String = "simple", + sku: String? = nil, + globalUniqueID: String? = nil, + downloadable: Bool = false + ) -> PersistedProduct { + PersistedProduct( + id: id, + siteID: siteID ?? self.siteID, + name: name, + productTypeKey: productTypeKey, + fullDescription: nil, + shortDescription: nil, + sku: sku, + globalUniqueID: globalUniqueID, + price: "10.00", + downloadable: downloadable, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock", + statusKey: "publish" + ) + } + + private func insertProduct(_ product: PersistedProduct) async throws { + try await grdbManager.databaseConnection.write { db in + try product.insert(db) + } + } + + private func makeVariation( + id: Int64, + productID: Int64, + siteID: Int64? = nil, + sku: String? = nil, + price: String = "10.00", + downloadable: Bool = false + ) -> PersistedProductVariation { + PersistedProductVariation( + id: id, + siteID: siteID ?? self.siteID, + productID: productID, + sku: sku, + globalUniqueID: nil, + price: price, + downloadable: downloadable, + fullDescription: nil, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + } + + private func insertVariation(_ variation: PersistedProductVariation) async throws { + try await grdbManager.databaseConnection.write { db in + try variation.insert(db) + } + } +} diff --git a/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift new file mode 100644 index 00000000000..13392276abd --- /dev/null +++ b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Testing +import Networking +@testable import Storage +@testable import Yosemite + +@Suite("SearchDebounceStrategy Tests") +struct SearchDebounceStrategyTests { + + // MARK: - Equatable Tests + + @Test("Smart strategies with same duration are equal") + func test_smart_strategies_with_same_duration_are_equal() { + let strategy1: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Smart strategies with different durations are not equal") + func test_smart_strategies_with_different_durations_are_not_equal() { + let strategy1: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .smart(duration: 300 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Simple strategies with same duration and no threshold are equal") + func test_simple_strategies_with_same_duration_and_no_threshold_are_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Simple strategies with same duration and same threshold are equal") + func test_simple_strategies_with_same_duration_and_threshold_are_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Simple strategies with different durations are not equal") + func test_simple_strategies_with_different_durations_are_not_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 200 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Simple strategies with different thresholds are not equal") + func test_simple_strategies_with_different_thresholds_are_not_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 400 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Immediate strategies are equal") + func test_immediate_strategies_are_equal() { + let strategy1: SearchDebounceStrategy = .immediate + let strategy2: SearchDebounceStrategy = .immediate + + #expect(strategy1 == strategy2) + } + + @Test("Different strategy types are not equal") + func test_different_strategy_types_are_not_equal() { + let smartStrategy: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let simpleStrategy: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + let immediateStrategy: SearchDebounceStrategy = .immediate + + #expect(smartStrategy != simpleStrategy) + #expect(smartStrategy != immediateStrategy) + #expect(simpleStrategy != immediateStrategy) + } +} + +@Suite("Fetch Strategy Debouncing Tests") +struct FetchStrategyDebouncingTests { + private let siteID: Int64 = 123 + private let mockAnalytics = MockPOSItemFetchAnalyticsTracking() + private let mockProductsRemote = MockProductsRemote() + private let mockVariationsRemote = MockProductVariationsRemote() + private let mockCouponStoreMethods = MockCouponStoreMethods() + + // MARK: - Local Search Strategy Tests + + @Test("Local search strategy returns simple debouncing with loading delay threshold") + func test_local_search_strategy_returns_simple_debouncing_with_threshold() async throws { + let grdbManager = try GRDBManager() + + // Initialize site + try await grdbManager.databaseConnection.write { db in + try PersistedSite(id: siteID).insert(db) + } + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "test", + grdbManager: grdbManager, + variationsRemote: mockVariationsRemote, + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + // MARK: - Remote Search Strategy Tests + + @Test("Remote search strategy returns smart debouncing") + func test_remote_search_strategy_returns_smart_debouncing() { + let strategy = PointOfSaleSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "test", + productsRemote: mockProductsRemote, + variationsRemote: mockVariationsRemote, + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + // MARK: - Default Strategy Tests + + @Test("Default purchasable item strategy returns immediate debouncing") + func test_default_purchasable_item_strategy_returns_immediate_debouncing() { + let strategy = PointOfSaleDefaultPurchasableItemFetchStrategy( + siteID: siteID, + productsRemote: mockProductsRemote, + variationsRemote: mockVariationsRemote, + analytics: mockAnalytics + ) + + #expect(strategy.debounceStrategy == .immediate) + } + + // MARK: - Coupon Strategy Tests + + @Test("Search coupon strategy returns smart debouncing") + func test_search_coupon_strategy_returns_smart_debouncing() { + let strategy = PointOfSaleSearchCouponFetchStrategy( + siteID: siteID, + currencySettings: .init(), + storage: MockStorageManager(), + couponStoreMethods: MockCouponStoreMethods(), + searchTerm: "test", + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + @Test("Default coupon strategy returns immediate debouncing") + func test_default_coupon_strategy_returns_immediate_debouncing() { + let strategy = PointOfSaleDefaultCouponFetchStrategy( + siteID: siteID, + currencySettings: .init(), + storage: MockStorageManager(), + couponStoreMethods: MockCouponStoreMethods() + ) + + #expect(strategy.debounceStrategy == .immediate) + } +} diff --git a/WooCommerce/Classes/Analytics/TracksProvider.swift b/WooCommerce/Classes/Analytics/TracksProvider.swift index b2845b75087..832bafaef6f 100644 --- a/WooCommerce/Classes/Analytics/TracksProvider.swift +++ b/WooCommerce/Classes/Analytics/TracksProvider.swift @@ -150,6 +150,7 @@ private extension TracksProvider { WooAnalyticsStat.pointOfSaleKeyboardDismissedInSearch, WooAnalyticsStat.pointOfSaleItemsNextPageLoaded, WooAnalyticsStat.pointOfSaleSearchRemoteResultsFetched, + WooAnalyticsStat.pointOfSaleSearchResultsFetched, WooAnalyticsStat.pointOfSaleBarcodeScanningMenuItemTapped, WooAnalyticsStat.pointOfSaleBarcodeScanningExplanationDialogShown, WooAnalyticsStat.pointOfSaleBarcodeScannerSetupFlowShown, diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 809ed604240..89b525315c6 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -48,16 +48,21 @@ final class POSTabCoordinator { /// Local catalog eligibility service - created asynchronously during init private(set) var localCatalogEligibilityService: POSLocalCatalogEligibilityServiceProtocol? - private lazy var posItemFetchStrategyFactory: PointOfSaleItemFetchStrategyFactory = { + /// Creates item fetch strategy factory with current local catalog eligibility + private func createItemFetchStrategyFactory(isLocalCatalogEnabled: Bool) -> PointOfSaleItemFetchStrategyFactory { PointOfSaleItemFetchStrategyFactory(siteID: siteID, credentials: credentials, selectedSite: defaultSitePublisher, - appPasswordSupportState: isAppPasswordSupported) - }() + appPasswordSupportState: isAppPasswordSupported, + grdbManager: isLocalCatalogEnabled ? ServiceLocator.grdbManager : nil, + isLocalCatalogEnabled: isLocalCatalogEnabled) + } - private lazy var posPopularItemFetchStrategyFactory: PointOfSaleFixedItemFetchStrategyFactory = { - PointOfSaleFixedItemFetchStrategyFactory(fixedStrategy: posItemFetchStrategyFactory.popularStrategy()) - }() + /// Creates popular item fetch strategy factory with current local catalog eligibility + private func createPopularItemFetchStrategyFactory(isLocalCatalogEnabled: Bool) -> PointOfSaleFixedItemFetchStrategyFactory { + let itemFactory = createItemFetchStrategyFactory(isLocalCatalogEnabled: isLocalCatalogEnabled) + return PointOfSaleFixedItemFetchStrategyFactory(fixedStrategy: itemFactory.popularStrategy()) + } private lazy var posCouponFetchStrategyFactory: PointOfSaleCouponFetchStrategyFactory = { PointOfSaleCouponFetchStrategyFactory(siteID: siteID, @@ -251,8 +256,8 @@ private extension POSTabCoordinator { let posView = PointOfSaleEntryPointView( siteID: siteID, - itemFetchStrategyFactory: posItemFetchStrategyFactory, - popularItemFetchStrategyFactory: posPopularItemFetchStrategyFactory, + itemFetchStrategyFactory: createItemFetchStrategyFactory(isLocalCatalogEnabled: isLocalCatalogEligible), + popularItemFetchStrategyFactory: createPopularItemFetchStrategyFactory(isLocalCatalogEnabled: isLocalCatalogEligible), couponProvider: posCouponProvider, couponFetchStrategyFactory: posCouponFetchStrategyFactory, orderListFetchStrategyFactory: POSOrderListFetchStrategyFactory(