diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index 77531e9b80d..c6d53530204 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -84,11 +84,24 @@ extension PersistedProduct: FetchableRecord, PersistableRecord { using: PersistedProductImage.image) public static let attributes = hasMany(PersistedProductAttribute.self, + key: "attributes", using: ForeignKey([PersistedProductAttribute.CodingKeys.siteID.stringValue, PersistedProductAttribute.CodingKeys.productID.stringValue], to: primaryKey)) } +// MARK: - Point of Sale Requests +public extension PersistedProduct { + /// Returns a request for POS-supported products (simple and variable, non-downloadable) for a given site, ordered by name + static func posProductsRequest(siteID: Int64) -> QueryInterfaceRequest { + return PersistedProduct + .filter(Columns.siteID == siteID) + .filter([ProductType.simple.rawValue, ProductType.variable.rawValue].contains(Columns.productTypeKey)) + .filter(Columns.downloadable == false) + .order(Columns.name.collating(.localizedCaseInsensitiveCompare)) + } +} + // periphery:ignore - TODO: remove ignore when populating database private extension PersistedProduct { enum CodingKeys: String, CodingKey { @@ -107,4 +120,9 @@ private extension PersistedProduct { case stockQuantity case stockStatusKey } + + enum ProductType: String { + case simple + case variable + } } diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift index 2241ff89dee..545919004b8 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift @@ -80,6 +80,16 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord { key: "image") } +// MARK: - Point of Sale Requests +public extension PersistedProductVariation { + /// Returns a request for non-downloadable variations of a parent product, ordered by ID + static func posVariationsRequest(siteID: Int64, parentProductID: Int64) -> QueryInterfaceRequest { + return PersistedProductVariation + .filter(Columns.siteID == siteID && Columns.productID == parentProductID) + .filter(Columns.downloadable == false) + .order(Columns.id) + } +} // periphery:ignore - TODO: remove ignore when populating database private extension PersistedProductVariation { diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/GRDBObservableDataSource.swift b/Modules/Sources/Yosemite/PointOfSale/Items/GRDBObservableDataSource.swift new file mode 100644 index 00000000000..470f72d85c6 --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/Items/GRDBObservableDataSource.swift @@ -0,0 +1,265 @@ +// periphery:ignore:all +import Foundation +import GRDB +import Combine +import Observation +import Storage +import WooFoundation + +/// Observable data source for GRDB-based POS items using ValueObservation +/// Provides automatic SwiftUI updates when database changes occur +@Observable +public final class GRDBObservableDataSource: POSObservableDataSourceProtocol { + // MARK: - Observable Properties + + public private(set) var productItems: [POSItem] = [] + public private(set) var variationItems: [POSItem] = [] + public private(set) var isLoadingProducts: Bool = false + public private(set) var isLoadingVariations: Bool = false + public private(set) var error: Error? = nil + + public var hasMoreProducts: Bool { + productItems.count >= (pageSize * currentProductPage) && totalProductCount > productItems.count + } + + public var hasMoreVariations: Bool { + variationItems.count >= (pageSize * currentVariationPage) && totalVariationCount > variationItems.count + } + + // MARK: - Private Properties + + private let siteID: Int64 + private let grdbManager: GRDBManagerProtocol + private let itemMapper: PointOfSaleItemMapperProtocol + private let pageSize: Int + + private var currentProductPage: Int = 1 + private var currentVariationPage: Int = 1 + private var currentParentProduct: POSVariableParentProduct? + private var totalProductCount: Int = 0 + private var totalVariationCount: Int = 0 + + // ValueObservation subscriptions + private var productObservationCancellable: AnyCancellable? + private var variationObservationCancellable: AnyCancellable? + private var statisticsObservationCancellable: AnyCancellable? + private var variationStatisticsObservationCancellable: AnyCancellable? + + // MARK: - Initialization + + public init(siteID: Int64, + grdbManager: GRDBManagerProtocol, + currencySettings: CurrencySettings, + itemMapper: PointOfSaleItemMapperProtocol? = nil, + pageSize: Int = 20) { + self.siteID = siteID + self.grdbManager = grdbManager + self.itemMapper = itemMapper ?? PointOfSaleItemMapper(currencySettings: currencySettings) + self.pageSize = pageSize + + setupStatisticsObservation() + } + + deinit { + productObservationCancellable?.cancel() + variationObservationCancellable?.cancel() + statisticsObservationCancellable?.cancel() + variationStatisticsObservationCancellable?.cancel() + } + + // MARK: - POSObservableDataSourceProtocol + + public func loadProducts() { + currentProductPage = 1 + isLoadingProducts = true + setupProductObservation() + } + + public func loadMoreProducts() { + guard hasMoreProducts && !isLoadingProducts else { return } + + isLoadingProducts = true + currentProductPage += 1 + setupProductObservation() + } + + public func loadVariations(for parentProduct: POSVariableParentProduct) { + guard currentParentProduct?.productID != parentProduct.productID else { + return // Same parent - idempotent + } + + currentParentProduct = parentProduct + currentVariationPage = 1 + isLoadingVariations = true + variationItems = [] + + setupVariationObservation(parentProduct: parentProduct) + setupVariationStatisticsObservation(parentProduct: parentProduct) + } + + public func loadMoreVariations() { + guard let parentProduct = currentParentProduct, + hasMoreVariations && !isLoadingVariations else { return } + + isLoadingVariations = true + currentVariationPage += 1 + setupVariationObservation(parentProduct: parentProduct) + } + + public func refresh() { + // No-op: database observation automatically updates when data changes during incremental sync + } + + // MARK: - ValueObservation Setup + + private func setupProductObservation() { + let currentPage = currentProductPage + let observation = ValueObservation + .tracking { [weak self] database -> [POSProduct] in + guard let self else { return [] } + + struct ProductWithRelations: Decodable, FetchableRecord { + let product: PersistedProduct + let images: [PersistedImage]? + let attributes: [PersistedProductAttribute]? + } + + let productsWithRelations = try PersistedProduct + .posProductsRequest(siteID: siteID) + .limit(pageSize * currentPage) + .including(all: PersistedProduct.images) + .including(all: PersistedProduct.attributes) + .asRequest(of: ProductWithRelations.self) + .fetchAll(database) + + return productsWithRelations.map { record in + record.product.toPOSProduct( + images: (record.images ?? []).map { $0.toProductImage() }, + attributes: (record.attributes ?? []).map { $0.toProductAttribute(siteID: record.product.siteID) } + ) + } + } + + productObservationCancellable = observation + .publisher(in: grdbManager.databaseConnection) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + if case .failure(let error) = completion { + self?.error = error + self?.isLoadingProducts = false + } + }, + receiveValue: { [weak self] observedProducts in + guard let self else { return } + let posItems = itemMapper.mapProductsToPOSItems(products: observedProducts) + productItems = posItems + error = nil + isLoadingProducts = false + } + ) + } + + private func setupVariationObservation(parentProduct: POSVariableParentProduct) { + let currentPage = currentVariationPage + let observation = ValueObservation + .tracking { [weak self] database -> [POSProductVariation] in + guard let self else { return [] } + + struct VariationWithRelations: Decodable, FetchableRecord { + let persistedProductVariation: PersistedProductVariation + let attributes: [PersistedProductVariationAttribute]? + let image: PersistedImage? + } + + let variationsWithRelations = try PersistedProductVariation + .posVariationsRequest(siteID: self.siteID, parentProductID: parentProduct.productID) + .limit(self.pageSize * currentPage) + .including(all: PersistedProductVariation.attributes) + .including(optional: PersistedProductVariation.image) + .asRequest(of: VariationWithRelations.self) + .fetchAll(database) + + return variationsWithRelations.map { record in + record.persistedProductVariation.toPOSProductVariation( + attributes: (record.attributes ?? []).map { $0.toProductVariationAttribute() }, + image: record.image?.toProductImage() + ) + } + } + + variationObservationCancellable = observation + .publisher(in: grdbManager.databaseConnection) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + if case .failure(let error) = completion { + self?.error = error + self?.isLoadingVariations = false + } + }, + receiveValue: { [weak self] observedVariations in + guard let self else { return } + let posItems = itemMapper.mapVariationsToPOSItems( + variations: observedVariations, + parentProduct: parentProduct + ) + variationItems = posItems + error = nil + isLoadingVariations = false + } + ) + } + + private func setupStatisticsObservation() { + let observation = ValueObservation + .tracking { [weak self] database in + guard let self else { return 0 } + + let productCount = try PersistedProduct + .posProductsRequest(siteID: siteID) + .fetchCount(database) + + return productCount + } + + statisticsObservationCancellable = observation + .publisher(in: grdbManager.databaseConnection) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { completion in + if case .failure = completion { + // Silently ignore - statistics are not critical + } + }, + receiveValue: { [weak self] productCount in + self?.totalProductCount = productCount + } + ) + } + + private func setupVariationStatisticsObservation(parentProduct: POSVariableParentProduct) { + let observation = ValueObservation + .tracking { [weak self] database in + guard let self else { return 0 } + + return try PersistedProductVariation + .posVariationsRequest(siteID: siteID, parentProductID: parentProduct.productID) + .fetchCount(database) + } + + variationStatisticsObservationCancellable = observation + .publisher(in: grdbManager.databaseConnection) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { completion in + if case .failure = completion { + // Silently ignore - statistics are not critical + } + }, + receiveValue: { [weak self] variationCount in + self?.totalVariationCount = variationCount + } + ) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/POSObservableDataSource.swift b/Modules/Sources/Yosemite/PointOfSale/Items/POSObservableDataSource.swift new file mode 100644 index 00000000000..617b330d1ac --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/Items/POSObservableDataSource.swift @@ -0,0 +1,43 @@ +// periphery:ignore:all +import Foundation + +/// Protocol for observable data sources that provide POS items with automatic updates +public protocol POSObservableDataSourceProtocol { + /// Current products mapped to POSItems + var productItems: [POSItem] { get } + + /// Current variations for the selected parent product mapped to POSItems + var variationItems: [POSItem] { get } + + /// Loading state for products + var isLoadingProducts: Bool { get } + + /// Loading state for variations + var isLoadingVariations: Bool { get } + + /// Whether more products are available to load + var hasMoreProducts: Bool { get } + + /// Whether more variations are available for current parent + var hasMoreVariations: Bool { get } + + /// Current error, if any + var error: Error? { get } + + /// Loads the first page of products + func loadProducts() + + /// Loads the next page of products + func loadMoreProducts() + + /// Loads variations for a specific parent product + func loadVariations(for parentProduct: POSVariableParentProduct) + + /// Loads more variations for the current parent product + func loadMoreVariations() + + /// Refreshes all data + /// Note: For GRDB implementations, this is a no-op as the database observation + /// automatically updates when data changes during incremental sync + func refresh() +} diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSObservableDataSource.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSObservableDataSource.swift new file mode 100644 index 00000000000..7f723770697 --- /dev/null +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSObservableDataSource.swift @@ -0,0 +1,37 @@ +import Foundation +import Observation +import Yosemite + +/// Mock implementation for testing and development +@Observable +final class MockPOSObservableDataSource: POSObservableDataSourceProtocol { + var productItems: [POSItem] = [] + var variationItems: [POSItem] = [] + var isLoadingProducts: Bool = false + var isLoadingVariations: Bool = false + var hasMoreProducts: Bool = false + var hasMoreVariations: Bool = false + var error: Error? = nil + + init() {} + + func loadProducts() { + // Tests set properties directly - no async behavior needed + } + + func loadMoreProducts() { + // Tests set properties directly - no async behavior needed + } + + func loadVariations(for parentProduct: POSVariableParentProduct) { + // Tests set properties directly - no async behavior needed + } + + func loadMoreVariations() { + // Tests set properties directly - no async behavior needed + } + + func refresh() { + // No-op for mock + } +} diff --git a/Modules/Tests/YosemiteTests/PointOfSale/GRDBObservableDataSourceTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/GRDBObservableDataSourceTests.swift new file mode 100644 index 00000000000..e25b71542cb --- /dev/null +++ b/Modules/Tests/YosemiteTests/PointOfSale/GRDBObservableDataSourceTests.swift @@ -0,0 +1,837 @@ +import Foundation +import Testing +import Combine +import WooFoundation +@testable import Storage +@testable import Yosemite + +@Suite("GRDBObservableDataSource Tests") +struct GRDBObservableDataSourceTests { + private let siteID: Int64 = 123 + private var grdbManager: GRDBManager! + private var sut: GRDBObservableDataSource! + + init() async throws { + grdbManager = try GRDBManager() + + let siteID = siteID + try await grdbManager.databaseConnection.write { db in + try PersistedSite(id: siteID).insert(db) + } + + sut = GRDBObservableDataSource( + siteID: siteID, + grdbManager: grdbManager, + currencySettings: CurrencySettings(), + pageSize: 5 + ) + } + + @Test("Initial state has empty items and no loading") + func test_initial_state_has_empty_items_and_no_loading() { + #expect(sut.productItems.isEmpty) + #expect(sut.variationItems.isEmpty) + #expect(sut.isLoadingProducts == false) + #expect(sut.isLoadingVariations == false) + #expect(sut.hasMoreProducts == false) + #expect(sut.hasMoreVariations == false) + #expect(sut.error == nil) + } + + @Test("Load products sets loading state and fetches items from database") + func test_load_products_sets_loading_state_and_fetches_items() async throws { + // Given: Insert test products into database + try await insertTestProducts(count: 3) + + // When: Load products + await waitForProductLoad { + sut.loadProducts() + } + + // Then + #expect(sut.productItems.count == 3) + #expect(sut.isLoadingProducts == false) + #expect(sut.error == nil) + } + + @Test("Load products maps database products to POSItems correctly") + func test_load_products_maps_to_pos_items_correctly() async throws { + // Given: Insert a simple and variable product + let simpleProduct = createPersistedProduct(id: 1, name: "Simple Product", type: "simple") + let variableProduct = createPersistedProduct(id: 2, name: "Variable Product", type: "variable") + try await insertProducts([simpleProduct, variableProduct]) + + // When: Load products and wait for items to be populated + await waitForProductLoad(expectedCount: 2) { + sut.loadProducts() + } + + // Then: Verify correct mapping + #expect(sut.productItems.count == 2) + + guard case .simpleProduct(let simple) = sut.productItems[0] else { + Issue.record("First item should be simple product") + return + } + #expect(simple.name == "Simple Product") + + guard case .variableParentProduct(let variable) = sut.productItems[1] else { + Issue.record("Second item should be variable product") + return + } + #expect(variable.name == "Variable Product") + } + + @Test("Load more products paginates correctly") + func test_load_more_products_paginates_correctly() async throws { + // Given: Insert 8 products with page size of 5 + try await insertTestProducts(count: 8) + + // When: Load first page + await waitForProductLoad { + sut.loadProducts() + } + + // Wait for statistics to indicate more pages + await waitForCondition { + sut.hasMoreProducts == true + } performAction: {} + + // Then: First page loaded + #expect(sut.productItems.count == 5) + #expect(sut.hasMoreProducts == true) + + // When: Load more + await waitForProductLoad { + sut.loadMoreProducts() + } + + // Wait for statistics to indicate no more pages + await waitForCondition { + sut.hasMoreProducts == false + } performAction: {} + + // Then: Both pages loaded + #expect(sut.productItems.count == 8) + #expect(sut.hasMoreProducts == false) + } + + @Test("Load variations for parent product") + func test_load_variations_for_parent_product() async throws { + // Given: Insert parent product and variations + let parentProduct = createPersistedProduct(id: 100, name: "Parent", type: "variable") + try await insertProducts([parentProduct]) + try await insertTestVariations(parentID: 100, count: 3) + + let posParent = POSVariableParentProduct( + id: UUID(), + name: "Parent", + productImageSource: nil, + productID: 100, + allAttributes: [] + ) + + // When: Load variations + await waitForVariationLoad { + sut.loadVariations(for: posParent) + } + + // Then: Variations loaded + #expect(sut.variationItems.count == 3) + #expect(sut.isLoadingVariations == false) + #expect(sut.productItems.isEmpty) // Products should remain unaffected + } + + @Test("Load variations is idempotent for same parent") + func test_load_variations_is_idempotent_for_same_parent() async throws { + // Given: Parent with variations + let parentProduct = createPersistedProduct(id: 100, name: "Parent", type: "variable") + try await insertProducts([parentProduct]) + try await insertTestVariations(parentID: 100, count: 2) + + let posParent = POSVariableParentProduct( + id: UUID(), + name: "Parent", + productImageSource: nil, + productID: 100, + allAttributes: [] + ) + + // When: Load variations first time + await waitForVariationLoad { + sut.loadVariations(for: posParent) + } + let firstCount = sut.variationItems.count + + // When: Load variations second time (should be idempotent) + let isLoadingBefore = sut.isLoadingVariations + sut.loadVariations(for: posParent) + let isLoadingAfter = sut.isLoadingVariations + + // Then: Should not trigger a new load + #expect(firstCount == 2) + #expect(isLoadingBefore == false) + #expect(isLoadingAfter == false) + } + + @Test("Load variations resets when parent changes") + func test_load_variations_resets_when_parent_changes() async throws { + // Given: Two parents with different variations + let parent1 = createPersistedProduct(id: 100, name: "Parent 1", type: "variable") + let parent2 = createPersistedProduct(id: 200, name: "Parent 2", type: "variable") + try await insertProducts([parent1, parent2]) + try await insertTestVariations(parentID: 100, count: 2) + try await insertTestVariations(parentID: 200, count: 3) + + let posParent1 = POSVariableParentProduct(id: UUID(), name: "Parent 1", productImageSource: nil, productID: 100, allAttributes: []) + let posParent2 = POSVariableParentProduct(id: UUID(), name: "Parent 2", productImageSource: nil, productID: 200, allAttributes: []) + + // When: Load parent 1 variations + await waitForVariationLoad { + sut.loadVariations(for: posParent1) + } + #expect(sut.variationItems.count == 2) + + // When: Load parent 2 variations + await waitForVariationLoad { + sut.loadVariations(for: posParent2) + } + + // Then: Should show parent 2 variations + #expect(sut.variationItems.count == 3) + } + + @Test("Database observation updates items automatically") + func test_database_observation_updates_items_automatically() async throws { + // Given: Initial products loaded + try await insertTestProducts(count: 2) + await waitForProductLoad { + sut.loadProducts() + } + #expect(sut.productItems.count == 2) + + // When: Insert new product and wait for observation update + try await waitForProductChange(expectedCount: 3) { + try await insertTestProducts(count: 1, startID: 100) + } + + // Then: Items automatically updated + #expect(sut.productItems.count == 3) + } + + @Test("Refresh is a no-op") + func test_refresh_is_a_no_op() { + // When/Then: Should not crash or change state + sut.refresh() + #expect(sut.productItems.isEmpty) + #expect(sut.isLoadingProducts == false) + } + + @Test("Load more products guards against concurrent loads") + func test_load_more_products_guards_against_concurrent_loads() async throws { + // Given: Products already loaded + try await insertTestProducts(count: 20) + await waitForProductLoad { + sut.loadProducts() + } + + // When: Try to load more while simulating loading state + let canLoadMore = sut.hasMoreProducts && !sut.isLoadingProducts + #expect(canLoadMore == true) + + // Trigger load more + await waitForProductLoad { + sut.loadMoreProducts() + } + + // Then: Should have loaded second page + #expect(sut.productItems.count == 10) // 2 pages of 5 + } + + @Test("Load more variations guards when no parent set") + func test_load_more_variations_guards_when_no_parent_set() { + // When: Try to load more without setting parent first + sut.loadMoreVariations() + + // Then: Should not crash or change state + #expect(sut.variationItems.isEmpty) + #expect(sut.isLoadingVariations == false) + } + + @Test("Products and variations are independent") + func test_products_and_variations_are_independent() async throws { + // Given: Products and variations in database + try await insertTestProducts(count: 3) + let parent = createPersistedProduct(id: 100, name: "Parent", type: "variable") + try await insertProducts([parent]) + try await insertTestVariations(parentID: 100, count: 2) + + // When: Load products + await waitForProductLoad { + sut.loadProducts() + } + #expect(sut.productItems.count == 4) + #expect(sut.variationItems.isEmpty) + + // When: Load variations + let posParent = POSVariableParentProduct(id: UUID(), name: "Parent", productImageSource: nil, productID: 100, allAttributes: []) + await waitForVariationLoad { + sut.loadVariations(for: posParent) + } + + // Then: Both arrays populated independently + #expect(sut.productItems.count == 4) + #expect(sut.variationItems.count == 2) + } + + @Test("Variation pagination only counts variations for specific parent and excludes downloadable") + func test_variation_pagination_counts_only_parent_variations_and_excludes_downloadable() async throws { + // Given: Multiple parents with different variation counts, including downloadable variations + let parent1 = createPersistedProduct(id: 100, name: "Parent 1", type: "variable") + let parent2 = createPersistedProduct(id: 200, name: "Parent 2", type: "variable") + try await insertProducts([parent1, parent2]) + + // Insert 3 non-downloadable variations for parent 1 + try await insertTestVariations(parentID: 100, count: 3) + + // Insert 5 non-downloadable variations for parent 2 + try await insertTestVariations(parentID: 200, count: 5) + + // Insert 2 downloadable variations for parent 1 (should be excluded from count) + try await insertDownloadableVariations(parentID: 100, count: 2, startID: 1000) + + // Insert 3 downloadable variations for parent 2 (should be excluded from count) + try await insertDownloadableVariations(parentID: 200, count: 3, startID: 2000) + + let posParent1 = POSVariableParentProduct(id: UUID(), name: "Parent 1", productImageSource: nil, productID: 100, allAttributes: []) + let posParent2 = POSVariableParentProduct(id: UUID(), name: "Parent 2", productImageSource: nil, productID: 200, allAttributes: []) + + // When: Load parent 1 variations (first page of 5) + await waitForVariationLoad { + sut.loadVariations(for: posParent1) + } + + // Wait for statistics showing no more pages + await waitForCondition { + sut.hasMoreVariations == false + } performAction: {} + + // Then: Should show 3 variations for parent 1 (excluding downloadable) + #expect(sut.variationItems.count == 3) + + // Then: hasMoreVariations should be false because there are only 3 non-downloadable variations total + #expect(sut.hasMoreVariations == false, "Should not have more variations - only 3 exist for this parent") + + // When: Load parent 2 variations + await waitForVariationLoad { + sut.loadVariations(for: posParent2) + } + + // Wait for statistics showing no more pages + await waitForCondition { + sut.hasMoreVariations == false + } performAction: {} + + // Then: Should show 5 variations for parent 2 (first page, excluding downloadable) + #expect(sut.variationItems.count == 5) + + // Then: hasMoreVariations should be false because we loaded all 5 variations (exactly one page) + #expect(sut.hasMoreVariations == false, "Parent 2 has exactly 5 variations, should fit in one page") + + // Then: Verify downloadable variations were excluded from the items + // Total variations in DB: 3 + 5 + 2 + 3 = 13, but only 5 non-downloadable for parent 2 should be loaded + #expect(sut.variationItems.count == 5, "Should only show non-downloadable variations for current parent") + } + + @Test("Variation statistics are scoped to parent product only") + func test_variation_statistics_are_scoped_to_parent_product() async throws { + // Given: Two parents with vastly different variation counts + let parent1 = createPersistedProduct(id: 100, name: "Parent 1", type: "variable") + let parent2 = createPersistedProduct(id: 200, name: "Parent 2", type: "variable") + try await insertProducts([parent1, parent2]) + + // Parent 1: 2 variations (less than page size of 5) + try await insertTestVariations(parentID: 100, count: 2) + + // Parent 2: 8 variations (more than page size of 5) + try await insertTestVariations(parentID: 200, count: 8) + + let posParent1 = POSVariableParentProduct(id: UUID(), name: "Parent 1", productImageSource: nil, productID: 100, allAttributes: []) + let posParent2 = POSVariableParentProduct(id: UUID(), name: "Parent 2", productImageSource: nil, productID: 200, allAttributes: []) + + // When: Load parent 1 variations + await waitForVariationLoad { + sut.loadVariations(for: posParent1) + } + + // Wait for statistics showing no more pages + await waitForCondition { + sut.hasMoreVariations == false + } performAction: {} + + // Then: Should load 2 variations + #expect(sut.variationItems.count == 2) + + #expect(sut.hasMoreVariations == false, "Parent 1 has only 2 variations, should not indicate more pages") + + // When: Load parent 2 variations + await waitForVariationLoad { + sut.loadVariations(for: posParent2) + } + + // Wait for statistics showing more pages available + await waitForCondition { + sut.hasMoreVariations == true + } performAction: {} + + // Then: Should load first page (5 variations) + #expect(sut.variationItems.count == 5) + + // Then: Should have more variations (3 more on page 2) + #expect(sut.hasMoreVariations == true, "Parent 2 has 8 variations total, first page shows 5, should indicate more") + + // When: Load more variations for parent 2 + await waitForVariationLoad { + sut.loadMoreVariations() + } + + // Wait for statistics showing no more pages + await waitForCondition { + sut.hasMoreVariations == false + } performAction: {} + + // Then: Should load all 8 variations + #expect(sut.variationItems.count == 8) + + // Then: Should not have more variations + #expect(sut.hasMoreVariations == false, "All 8 variations loaded, no more pages") + } + + @Test("Products load with associated images and attributes") + func test_products_load_with_images_and_attributes() async throws { + // Given: Products with images and attributes + // Note: Products are ordered alphabetically by name, so "Hoodie" comes before "T-Shirt" + let hoodie = createPersistedProduct(id: 1, name: "Hoodie", type: "variable") + let tshirt = createPersistedProduct(id: 2, name: "T-Shirt", type: "simple") + try await insertProducts([hoodie, tshirt]) + + // Create images for products + try await insertImage(id: 100, src: "https://example.com/hoodie.jpg", name: "Hoodie Main") + try await insertImage(id: 200, src: "https://example.com/tshirt-front.jpg", name: "T-Shirt Front") + try await insertImage(id: 201, src: "https://example.com/tshirt-back.jpg", name: "T-Shirt Back") + + // Link images to products + try await linkImageToProduct(imageID: 100, productID: 1) + try await linkImageToProduct(imageID: 200, productID: 2) + try await linkImageToProduct(imageID: 201, productID: 2) + + // Create attributes for products + try await insertProductAttribute( + productID: 1, + remoteAttributeID: 1, + name: "Material", + position: 0, + variation: true, + options: ["Cotton", "Polyester"] + ) + try await insertProductAttribute( + productID: 2, + remoteAttributeID: 2, + name: "Color", + position: 0, + options: ["Red", "Blue", "Green"] + ) + try await insertProductAttribute( + productID: 2, + remoteAttributeID: 3, + name: "Size", + position: 1, + options: ["S", "M", "L", "XL"] + ) + + // When: Load products + await waitForProductLoad(expectedCount: 2) { + sut.loadProducts() + } + + // Then: Products loaded with correct count + #expect(sut.productItems.count == 2) + + // Then: Verify first product (Hoodie - alphabetically first) loads with image + guard case .variableParentProduct(let hoodieItem) = sut.productItems[0] else { + Issue.record("First item should be variable product (Hoodie)") + return + } + + #expect(hoodieItem.name == "Hoodie") + #expect(hoodieItem.productImageSource == "https://example.com/hoodie.jpg", + "Should load image via .including()") + #expect(hoodieItem.allAttributes.count == 1, "Should load attributes via .including()") + #expect(hoodieItem.allAttributes.first?.name == "Material") + #expect(hoodieItem.allAttributes.first?.options == ["Cotton", "Polyester"]) + + // Then: Verify second product (T-Shirt - alphabetically second) loads with image and attributes + guard case .simpleProduct(let tshirtItem) = sut.productItems[1] else { + Issue.record("Second item should be simple product (T-Shirt)") + return + } + + #expect(tshirtItem.name == "T-Shirt") + #expect(tshirtItem.productImageSource == "https://example.com/tshirt-front.jpg", + "Should load first image via .including()") + } + + @Test("Variations load with associated images") + func test_variations_load_with_images() async throws { + // Given: Variable product with variations that have images + let parent = createPersistedProduct(id: 100, name: "Variable Hoodie", type: "variable") + try await insertProducts([parent]) + + // Create variations + try await grdbManager.databaseConnection.write { db in + // Variation 1: Red, Small + let variation1 = PersistedProductVariation( + id: 1001, + siteID: siteID, + productID: 100, + sku: "VAR-RED-S", + globalUniqueID: nil, + price: "29.99", + downloadable: false, + fullDescription: nil, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + try variation1.insert(db) + + // Variation 2: Blue, Large + let variation2 = PersistedProductVariation( + id: 1002, + siteID: siteID, + productID: 100, + sku: "VAR-BLUE-L", + globalUniqueID: nil, + price: "34.99", + downloadable: false, + fullDescription: nil, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + try variation2.insert(db) + } + + // Create and link images for variations + try await insertImage(id: 1001, src: "https://example.com/red-small.jpg", name: "Red Small") + try await insertImage(id: 1002, src: "https://example.com/blue-large.jpg", name: "Blue Large") + try await linkImageToVariation(imageID: 1001, variationID: 1001) + try await linkImageToVariation(imageID: 1002, variationID: 1002) + + // Create parent product attributes (for variation name generation) + // Note: variation: true is required for attributes to be included in allAttributes + try await insertProductAttribute( + productID: 100, + remoteAttributeID: 1, + name: "Color", + position: 0, + variation: true, + options: ["Red", "Blue"] + ) + try await insertProductAttribute( + productID: 100, + remoteAttributeID: 2, + name: "Size", + position: 1, + variation: true, + options: ["Small", "Large"] + ) + + // Create attributes for variations + try await insertVariationAttribute(variationID: 1001, remoteAttributeID: 1, name: "Color", option: "Red") + try await insertVariationAttribute(variationID: 1001, remoteAttributeID: 2, name: "Size", option: "Small") + try await insertVariationAttribute(variationID: 1002, remoteAttributeID: 1, name: "Color", option: "Blue") + try await insertVariationAttribute(variationID: 1002, remoteAttributeID: 2, name: "Size", option: "Large") + + // Load the parent product first to get its attributes + await waitForProductLoad(expectedCount: 1) { + sut.loadProducts() + } + + guard case .variableParentProduct(let loadedParent) = sut.productItems.first else { + Issue.record("Expected variable parent product") + return + } + + let posParent = loadedParent + + // When: Load variations + await waitForVariationLoad { + sut.loadVariations(for: posParent) + } + + // Then: Variations loaded with correct count + #expect(sut.variationItems.count == 2) + + // Then: Verify first variation has image and attributes + guard case .variation(let variation1) = sut.variationItems[0] else { + Issue.record("First item should be variation") + return + } + + #expect(variation1.price == "29.99") + #expect(variation1.productImageSource == "https://example.com/red-small.jpg", + "Should load variation-specific image via .including()") + #expect(variation1.name.contains("Red"), "Variation name should include 'Red' from attributes loaded via .including()") + #expect(variation1.name.contains("Small"), "Variation name should include 'Small' from attributes loaded via .including()") + + // Then: Verify second variation has image and attributes + guard case .variation(let variation2) = sut.variationItems[1] else { + Issue.record("Second item should be variation") + return + } + + #expect(variation2.price == "34.99") + #expect(variation2.productImageSource == "https://example.com/blue-large.jpg", + "Should load variation-specific image via .including()") + #expect(variation2.name.contains("Blue"), "Variation name should include 'Blue' from attributes loaded via .including()") + #expect(variation2.name.contains("Large"), "Variation name should include 'Large' from attributes loaded via .including()") + } + + // MARK: - Helper Methods + + private func waitForProductLoad(expectedCount: Int? = nil, action: () -> Void) async { + await waitForCondition { + let loadingComplete = !sut.isLoadingProducts + let countMatches = expectedCount.map { sut.productItems.count == $0 } ?? true + return loadingComplete && countMatches + } performAction: { + action() + } + } + + private func waitForVariationLoad(action: () -> Void) async { + await waitForCondition { + !sut.isLoadingVariations + } performAction: { + action() + } + } + + private func waitForProductChange(expectedCount: Int, action: () async throws -> Void) async rethrows { + try await waitForCondition { + sut.productItems.count == expectedCount + } performAction: { + try await action() + } + } + + private func waitForCondition( + _ condition: @escaping @MainActor () -> Bool, + performAction action: () async throws -> Void + ) async rethrows { + try await action() + + // Use withObservationTracking recursively until condition is met, with timeout as backstop + await withCheckedContinuation { continuation in + var hasResumed = false + + // Timeout backstop to ensure we don't hang forever + Task { + try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) + if !hasResumed { + hasResumed = true + continuation.resume() + } + } + + @MainActor func observe() { + let conditionMet = withObservationTracking { + // Access the observable properties and check condition + _ = sut.productItems + _ = sut.variationItems + _ = sut.isLoadingProducts + _ = sut.isLoadingVariations + _ = sut.hasMoreProducts + _ = sut.hasMoreVariations + + return condition() + } onChange: { + // Re-observe on the main actor when changes occur + Task { @MainActor in + observe() + } + } + + if conditionMet && !hasResumed { + hasResumed = true + continuation.resume() + } + } + + Task { @MainActor in + observe() + } + } + } + + private func insertTestProducts(count: Int, startID: Int64 = 1) async throws { + let products = (0.. PersistedProduct { + PersistedProduct( + id: id, + siteID: siteID, + name: name, + productTypeKey: type, + fullDescription: nil, + shortDescription: nil, + sku: nil, + globalUniqueID: nil, + price: "10.00", + downloadable: false, + parentID: 0, + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + } + + // MARK: - Image Helpers + + private func insertImage(id: Int64, src: String, name: String?) async throws { + try await grdbManager.databaseConnection.write { db in + let image = PersistedImage( + siteID: siteID, + id: id, + dateCreated: Date(), + dateModified: nil, + src: src, + name: name, + alt: nil + ) + try image.insert(db) + } + } + + private func linkImageToProduct(imageID: Int64, productID: Int64) async throws { + try await grdbManager.databaseConnection.write { db in + let link = PersistedProductImage( + siteID: siteID, + productID: productID, + imageID: imageID + ) + try link.insert(db) + } + } + + private func linkImageToVariation(imageID: Int64, variationID: Int64) async throws { + try await grdbManager.databaseConnection.write { db in + let link = PersistedProductVariationImage( + siteID: siteID, + productVariationID: variationID, + imageID: imageID + ) + try link.insert(db) + } + } + + // MARK: - Attribute Helpers + + private func insertProductAttribute( + productID: Int64, + remoteAttributeID: Int64, + name: String, + position: Int64, + variation: Bool = false, + options: [String] + ) async throws { + try await grdbManager.databaseConnection.write { db in + var attribute = PersistedProductAttribute( + id: nil, + siteID: siteID, + productID: productID, + remoteAttributeID: remoteAttributeID, + name: name, + position: position, + visible: true, + variation: variation, + options: options + ) + try attribute.insert(db) + } + } + + private func insertVariationAttribute( + variationID: Int64, + remoteAttributeID: Int64, + name: String, + option: String + ) async throws { + try await grdbManager.databaseConnection.write { db in + var attribute = PersistedProductVariationAttribute( + id: nil, + siteID: siteID, + productVariationID: variationID, + remoteAttributeID: remoteAttributeID, + name: name, + option: option + ) + try attribute.insert(db) + } + } +}