diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift index 04bfdb458b7..fc85b5eab34 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductAttribute.swift @@ -34,7 +34,7 @@ extension PersistedProductAttribute: FetchableRecord, MutablePersistableRecord { public enum Columns { static let id = Column(CodingKeys.id) - static let productID = Column(CodingKeys.productID) + public static let productID = Column(CodingKeys.productID) static let name = Column(CodingKeys.name) static let position = Column(CodingKeys.position) static let visible = Column(CodingKeys.visible) diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift index 0e248403ee9..522cd1c4969 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductImage.swift @@ -34,7 +34,7 @@ extension PersistedProductImage: FetchableRecord, PersistableRecord { public enum Columns { static let id = Column(CodingKeys.id) - static let productID = Column(CodingKeys.productID) + public static let productID = Column(CodingKeys.productID) static let dateCreated = Column(CodingKeys.dateCreated) static let dateModified = Column(CodingKeys.dateModified) static let src = Column(CodingKeys.src) diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift index d6dd60a32c7..9fbf496a7ae 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationAttribute.swift @@ -26,7 +26,7 @@ extension PersistedProductVariationAttribute: FetchableRecord, MutablePersistabl public enum Columns { static let id = Column(CodingKeys.id) - static let productVariationID = Column(CodingKeys.productVariationID) + public static let productVariationID = Column(CodingKeys.productVariationID) static let name = Column(CodingKeys.name) static let option = Column(CodingKeys.option) } diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift index b20d5385b23..d941594b1ee 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProductVariationImage.swift @@ -34,7 +34,7 @@ extension PersistedProductVariationImage: FetchableRecord, PersistableRecord { public enum Columns { static let id = Column(CodingKeys.id) - static let productVariationID = Column(CodingKeys.productVariationID) + public static let productVariationID = Column(CodingKeys.productVariationID) static let dateCreated = Column(CodingKeys.dateCreated) static let dateModified = Column(CodingKeys.dateModified) static let src = Column(CodingKeys.src) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift index 6c8f958ee7f..a0281bb049b 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift @@ -20,22 +20,25 @@ public protocol POSCatalogIncrementalSyncServiceProtocol { public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServiceProtocol { private let syncRemote: POSCatalogSyncRemoteProtocol private let batchSize: Int + private let persistenceService: POSCatalogPersistenceServiceProtocol private var lastIncrementalSyncDates: [Int64: Date] = [:] private let batchedLoader: BatchedRequestLoader - public convenience init?(credentials: Credentials?, batchSize: Int = 1) { + public convenience init?(credentials: Credentials?, batchSize: Int = 1, grdbManager: GRDBManagerProtocol) { guard let credentials else { DDLogError("⛔️ Could not create POSCatalogIncrementalSyncService due missing credentials") return nil } let network = AlamofireNetwork(credentials: credentials, ensuresSessionManagerIsInitialized: true) let syncRemote = POSCatalogSyncRemote(network: network) - self.init(syncRemote: syncRemote, batchSize: batchSize) + let persistenceService = POSCatalogPersistenceService(grdbManager: grdbManager) + self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService) } - init(syncRemote: POSCatalogSyncRemoteProtocol, batchSize: Int) { + init(syncRemote: POSCatalogSyncRemoteProtocol, batchSize: Int, persistenceService: POSCatalogPersistenceServiceProtocol) { self.syncRemote = syncRemote self.batchSize = batchSize + self.persistenceService = persistenceService self.batchedLoader = BatchedRequestLoader(batchSize: batchSize) } @@ -51,7 +54,8 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe let catalog = try await loadCatalog(for: siteID, modifiedAfter: modifiedAfter, syncRemote: syncRemote) DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)") - // TODO: WOOMOB-1298 - persist to database + try await persistenceService.persistIncrementalCatalogData(catalog, siteID: siteID) + DDLogInfo("✅ Persisted \(catalog.products.count) products and \(catalog.variations.count) variations to database for siteID \(siteID)") // TODO: WOOMOB-1289 - replace with store settings persistence lastIncrementalSyncDates[siteID] = syncStartDate diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift index 22c991c157f..797d25381f0 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift @@ -1,6 +1,7 @@ // periphery:ignore:all import Foundation import Storage +import GRDB protocol POSCatalogPersistenceServiceProtocol { /// Clears existing data and persists new catalog data @@ -8,6 +9,12 @@ protocol POSCatalogPersistenceServiceProtocol { /// - catalog: The catalog to persist /// - siteID: The site ID to associate the catalog with func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws + + /// Persists incremental catalog data (insert/update) + /// - Parameters: + /// - catalog: The catalog difference to persist + /// - siteID: The site ID to associate the catalog with + func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws } final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { @@ -29,11 +36,11 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { try site.insert(db) for product in catalog.productsToPersist { - try product.insert(db, onConflict: .ignore) + try product.insert(db, onConflict: .replace) } for image in catalog.productImagesToPersist { - try image.insert(db, onConflict: .ignore) + try image.insert(db, onConflict: .replace) } for var attribute in catalog.productAttributesToPersist { @@ -41,11 +48,11 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { } for variation in catalog.variationsToPersist { - try variation.insert(db, onConflict: .ignore) + try variation.insert(db, onConflict: .replace) } for image in catalog.variationImagesToPersist { - try image.insert(db, onConflict: .ignore) + try image.insert(db, onConflict: .replace) } for var attribute in catalog.variationAttributesToPersist { @@ -68,6 +75,67 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { "\(variationImageCount) variation images, \(variationAttributeCount) variation attributes") } } + + func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws { + DDLogInfo("💾 Persisting incremental catalog with \(catalog.products.count) products and \(catalog.variations.count) variations") + + try await grdbManager.databaseConnection.write { db in + for product in catalog.productsToPersist { + try product.insert(db, onConflict: .replace) + + try PersistedProductImage + .filter { $0.productID == product.id } + .deleteAll(db) + + try PersistedProductAttribute + .filter { $0.productID == product.id } + .deleteAll(db) + } + + for image in catalog.productImagesToPersist { + try image.insert(db, onConflict: .replace) + } + + for var attribute in catalog.productAttributesToPersist { + try attribute.insert(db, onConflict: .replace) + } + + for variation in catalog.variationsToPersist { + try variation.insert(db, onConflict: .replace) + + try PersistedProductVariationImage + .filter { $0.productVariationID == variation.id } + .deleteAll(db) + + try PersistedProductVariationAttribute + .filter { $0.productVariationID == variation.id } + .deleteAll(db) + } + + for image in catalog.variationImagesToPersist { + try image.insert(db, onConflict: .replace) + } + + for var attribute in catalog.variationAttributesToPersist { + try attribute.insert(db, onConflict: .replace) + } + } + + DDLogInfo("✅ Incremental catalog persistence complete") + + try await grdbManager.databaseConnection.read { db in + let productCount = try PersistedProduct.fetchCount(db) + let productImageCount = try PersistedProductImage.fetchCount(db) + let productAttributeCount = try PersistedProductAttribute.fetchCount(db) + let variationCount = try PersistedProductVariation.fetchCount(db) + let variationImageCount = try PersistedProductVariationImage.fetchCount(db) + let variationAttributeCount = try PersistedProductVariationAttribute.fetchCount(db) + + DDLogInfo("Total after incremental update: \(productCount) products, \(productImageCount) product images, " + + "\(productAttributeCount) product attributes, \(variationCount) variations, " + + "\(variationImageCount) variation images, \(variationAttributeCount) variation attributes") + } + } } private extension POSCatalog { diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift index 105b0031f82..02a2e33f1a4 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift @@ -2,15 +2,18 @@ import Foundation import Testing @testable import Networking @testable import Yosemite +@testable import Storage struct POSCatalogIncrementalSyncServiceTests { private let sut: POSCatalogIncrementalSyncService private let mockSyncRemote: MockPOSCatalogSyncRemote + private let mockPersistenceService: MockPOSCatalogPersistenceService private let sampleSiteID: Int64 = 134 init() { self.mockSyncRemote = MockPOSCatalogSyncRemote() - self.sut = POSCatalogIncrementalSyncService(syncRemote: mockSyncRemote, batchSize: 2) + self.mockPersistenceService = MockPOSCatalogPersistenceService() + self.sut = POSCatalogIncrementalSyncService(syncRemote: mockSyncRemote, batchSize: 2, persistenceService: mockPersistenceService) } // MARK: - Basic Incremental Sync Tests @@ -32,6 +35,7 @@ struct POSCatalogIncrementalSyncServiceTests { #expect(mockSyncRemote.loadIncrementalProductVariationsCallCount == 2) #expect(mockSyncRemote.lastIncrementalProductsModifiedAfter == lastFullSyncDate) #expect(mockSyncRemote.lastIncrementalVariationsModifiedAfter == lastFullSyncDate) + #expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 1) } @Test func startIncrementalSync_uses_last_incremental_sync_date_as_modifiedAfter_date_when_available() async throws { @@ -73,6 +77,8 @@ struct POSCatalogIncrementalSyncServiceTests { // Then #expect(mockSyncRemote.loadIncrementalProductsCallCount == 4) + let persistedCatalog = try #require(mockPersistenceService.persistIncrementalCatalogDataLastPersistedCatalog) + #expect(persistedCatalog.products.count == 3) } @Test func startIncrementalSync_handles_paginated_variations_correctly() async throws { @@ -92,6 +98,8 @@ struct POSCatalogIncrementalSyncServiceTests { // Then #expect(mockSyncRemote.loadIncrementalProductVariationsCallCount == 2) + let persistedCatalog = try #require(mockPersistenceService.persistIncrementalCatalogDataLastPersistedCatalog) + #expect(persistedCatalog.variations.count == 2) } // MARK: - Error Handling Tests @@ -108,6 +116,7 @@ struct POSCatalogIncrementalSyncServiceTests { await #expect(throws: expectedError) { try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate) } + #expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 0) // When attempting a second sync mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) @@ -115,6 +124,31 @@ struct POSCatalogIncrementalSyncServiceTests { // Then it uses lastFullSyncDate since no incremental date was stored due to previous failure #expect(mockSyncRemote.lastIncrementalProductsModifiedAfter == lastFullSyncDate) + #expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 1) + } + + @Test func startIncrementalSync_throws_error_when_persistence_fails() async throws { + // Given + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + let expectedError = NSError(domain: "persistence", code: 500, userInfo: nil) + + mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + mockPersistenceService.persistIncrementalCatalogDataError = expectedError + + // When/Then + await #expect(throws: Error.self) { + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate) + } + #expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 1) + + // When attempting a second sync + mockPersistenceService.persistIncrementalCatalogDataError = nil // Clear the error + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate) + + // Then it uses lastFullSyncDate since no incremental date was stored due to previous persistence failure + #expect(mockSyncRemote.lastIncrementalProductsModifiedAfter == lastFullSyncDate) + #expect(mockPersistenceService.persistIncrementalCatalogDataCallCount == 2) } // MARK: - Per-Site Behavior Tests @@ -140,3 +174,23 @@ struct POSCatalogIncrementalSyncServiceTests { #expect(site2ModifiedAfter == lastFullSyncDate) } } + +// MARK: - Mock Classes + +private final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { + private(set) var persistIncrementalCatalogDataCallCount = 0 + private(set) var persistIncrementalCatalogDataLastPersistedCatalog: POSCatalog? + private(set) var persistIncrementalCatalogDataLastPersistedSiteID: Int64? + var persistIncrementalCatalogDataError: Error? + + func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {} + + func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws { + persistIncrementalCatalogDataCallCount += 1 + persistIncrementalCatalogDataLastPersistedSiteID = siteID + persistIncrementalCatalogDataLastPersistedCatalog = catalog + if let error = persistIncrementalCatalogDataError { + throw error + } + } +} diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift index 6777b379277..4b883674465 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift @@ -221,4 +221,282 @@ struct POSCatalogPersistenceServiceTests { #expect(variationAttributeCount == 0) // Variation attributes should be gone } } + + // MARK: - Incremental Catalog Data Tests + + @Test func persistIncrementalCatalogData_inserts_new_products_when_database_is_empty() async throws { + // Given + try await sut.replaceAllCatalogData(.init(products: [], variations: []), siteID: sampleSiteID) + + let newProducts = [ + POSProduct.fake().copy(siteID: sampleSiteID, productID: 6), + POSProduct.fake().copy(siteID: sampleSiteID, productID: 2) + ] + let catalog = POSCatalog(products: newProducts, variations: []) + + // When + try await sut.persistIncrementalCatalogData(catalog, siteID: sampleSiteID) + + // Then + let db = grdbManager.databaseConnection + try await db.read { db in + let siteCount = try PersistedSite.fetchCount(db) + let productCount = try PersistedProduct.fetchCount(db) + #expect(siteCount == 1) + #expect(productCount == 2) + + let products = try PersistedProduct.filter(sql: "\(PersistedProduct.Columns.siteID.name) = \(sampleSiteID)").fetchAll(db) + let productIDs = products.map { $0.id }.sorted() + #expect(productIDs == [2, 6]) + } + } + + @Test func persistIncrementalCatalogData_updates_existing_product() async throws { + // Given + let existingProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 1, name: "Old Name") + try await insertProduct(existingProduct) + + // When + let updatedProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 1, name: "New Name") + let updateCatalog = POSCatalog(products: [updatedProduct], variations: []) + try await sut.persistIncrementalCatalogData(updateCatalog, siteID: sampleSiteID) + + // Then + try await grdbManager.databaseConnection.read { db in + let productCount = try PersistedProduct.fetchCount(db) + #expect(productCount == 1) + + let product = try PersistedProduct.fetchOne(db) + #expect(product?.name == "New Name") + #expect(product?.id == 1) + } + } + + @Test func persistIncrementalCatalogData_replaces_attributes_for_updated_product() async throws { + // Given + let attribute1 = Yosemite.ProductAttribute.fake().copy(name: "Color", options: ["Indigo", "Blue"]) + let attribute2 = Yosemite.ProductAttribute.fake().copy(name: "Size") + let product = POSProduct.fake().copy(siteID: sampleSiteID, productID: 1, attributes: [attribute1, attribute2]) + try await insertProduct(product) + + // When + let updatedAttribute1 = attribute1.copy(options: ["Cardinal", "Blue"]) + let newAttribute = ProductAttribute.fake().copy(name: "Material") + let updatedProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 1, attributes: [newAttribute, updatedAttribute1]) + let updateCatalog = POSCatalog(products: [updatedProduct], variations: []) + try await sut.persistIncrementalCatalogData(updateCatalog, siteID: sampleSiteID) + + // Then + try await grdbManager.databaseConnection.read { db in + let attributeCount = try PersistedProductAttribute.fetchCount(db) + #expect(attributeCount == 2) + + let attributes = try PersistedProductAttribute.fetchAll(db).sorted(by: { $0.name < $1.name }) + #expect(attributes[0].name == "Color") + #expect(attributes[0].options == ["Cardinal", "Blue"]) + #expect(attributes[0].productID == 1) + #expect(attributes[1].name == "Material") + #expect(attributes[1].options == []) + #expect(attributes[1].productID == 1) + } + } + + @Test func persistIncrementalCatalogData_replaces_images_for_updated_product() async throws { + // Given + let image1 = ProductImage.fake().copy(imageID: 1, src: "https://example.com/image1.jpg") + let image2 = ProductImage.fake().copy(imageID: 2, src: "https://example.com/image2.jpg") + let product = POSProduct.fake().copy(siteID: sampleSiteID, productID: 1, images: [image2, image1]) + try await insertProduct(product) + + // When + let updatedImage1 = image1.copy(src: "https://example.com/image1-1.jpg") + let newImage = ProductImage.fake().copy(imageID: 3, src: "https://example.com/image3.jpg") + let updatedProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 1, images: [newImage, updatedImage1]) + let updateCatalog = POSCatalog(products: [updatedProduct], variations: []) + try await sut.persistIncrementalCatalogData(updateCatalog, siteID: sampleSiteID) + + // Then + try await grdbManager.databaseConnection.read { db in + let attributeCount = try PersistedProductImage.fetchCount(db) + #expect(attributeCount == 2) + + let attributes = try PersistedProductImage.fetchAll(db).sorted(by: { $0.id < $1.id }) + #expect(attributes[0].src == "https://example.com/image1-1.jpg") + #expect(attributes[0].productID == 1) + #expect(attributes[1].src == "https://example.com/image3.jpg") + #expect(attributes[1].productID == 1) + } + } + + @Test func persistIncrementalCatalogData_replaces_products_with_existing_and_new_products() async throws { + // Given + let existingProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 1, name: "Existing") + try await insertProduct(existingProduct) + + // When + let updatedExistingProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 1, name: "Updated Existing") + let newProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 2, name: "New Product") + let mixedCatalog = POSCatalog(products: [updatedExistingProduct, newProduct], variations: []) + try await sut.persistIncrementalCatalogData(mixedCatalog, siteID: sampleSiteID) + + // Then + try await grdbManager.databaseConnection.read { db in + let productCount = try PersistedProduct.fetchCount(db) + #expect(productCount == 2) + + let products = try PersistedProduct.fetchAll(db).sorted(by: { $0.id < $1.id }) + #expect(products[0].name == "Updated Existing") + #expect(products[0].id == 1) + #expect(products[1].name == "New Product") + #expect(products[1].id == 2) + } + } + + @Test func persistIncrementalCatalogData_inserts_new_variations_when_database_is_empty() async throws { + // Given + try await sut.replaceAllCatalogData(.init(products: [], variations: []), siteID: sampleSiteID) + + let parentProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 10) + let newVariations = [ + POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 10, productVariationID: 6), + POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 10, productVariationID: 2) + ] + let catalog = POSCatalog(products: [parentProduct], variations: newVariations) + + // When + try await sut.persistIncrementalCatalogData(catalog, siteID: sampleSiteID) + + // Then + let db = grdbManager.databaseConnection + try await db.read { db in + let siteCount = try PersistedSite.fetchCount(db) + let variationCount = try PersistedProductVariation.fetchCount(db) + #expect(siteCount == 1) + #expect(variationCount == 2) + + let variations = try PersistedProductVariation.filter(sql: "\(PersistedProductVariation.Columns.siteID.name) = \(sampleSiteID)").fetchAll(db) + let variationIDs = variations.map { $0.id }.sorted() + #expect(variationIDs == [2, 6]) + } + } + + @Test func persistIncrementalCatalogData_updates_existing_variation() async throws { + // Given + let parentProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 10) + let existingVariation = POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 10, productVariationID: 1, price: "10.00") + try await insertProduct(parentProduct) + try await insertVariation(existingVariation) + + // When + let updatedVariation = POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 10, productVariationID: 1, price: "15.00") + let updateCatalog = POSCatalog(products: [parentProduct], variations: [updatedVariation]) + try await sut.persistIncrementalCatalogData(updateCatalog, siteID: sampleSiteID) + + // Then + try await grdbManager.databaseConnection.read { db in + let variationCount = try PersistedProductVariation.fetchCount(db) + #expect(variationCount == 1) + + let variation = try PersistedProductVariation.fetchOne(db) + #expect(variation?.price == "15.00") + #expect(variation?.id == 1) + } + } + + @Test func persistIncrementalCatalogData_replaces_attributes_for_updated_variation() async throws { + // Given + let parentProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 10) + let attribute1 = Yosemite.ProductVariationAttribute.fake().copy(name: "Color", option: "Blue") + let attribute2 = Yosemite.ProductVariationAttribute.fake().copy(name: "Size", option: "M") + let variation = POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 10, productVariationID: 1, attributes: [attribute1, attribute2]) + try await insertProduct(parentProduct) + try await insertVariation(variation) + + // When + let updatedAttribute1 = attribute1.copy(option: "Cardinal") + let newAttribute = ProductVariationAttribute.fake().copy(name: "Material", option: "Cotton") + let updatedVariation = variation.copy(attributes: [newAttribute, updatedAttribute1]) + let updateCatalog = POSCatalog(products: [parentProduct], variations: [updatedVariation]) + try await sut.persistIncrementalCatalogData(updateCatalog, siteID: sampleSiteID) + + // Then + try await grdbManager.databaseConnection.read { db in + let attributeCount = try PersistedProductVariationAttribute.fetchCount(db) + #expect(attributeCount == 2) + + let attributes = try PersistedProductVariationAttribute.fetchAll(db).sorted(by: { $0.name < $1.name }) + #expect(attributes[0].name == "Color") + #expect(attributes[0].option == "Cardinal") + #expect(attributes[0].productVariationID == 1) + #expect(attributes[1].name == "Material") + #expect(attributes[1].option == "Cotton") + #expect(attributes[1].productVariationID == 1) + } + } + + @Test func persistIncrementalCatalogData_replaces_image_for_updated_variation() async throws { + // Given + let parentProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 10) + let image = ProductImage.fake().copy(imageID: 1, src: "https://example.com/variation1.jpg") + let variation = POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 10, productVariationID: 1, image: image) + try await insertProduct(parentProduct) + try await insertVariation(variation) + + // When + let updatedImage = image.copy(src: "https://example.com/variation1-updated.jpg") + let updatedVariation = POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 10, productVariationID: 1, image: updatedImage) + let updateCatalog = POSCatalog(products: [parentProduct], variations: [updatedVariation]) + try await sut.persistIncrementalCatalogData(updateCatalog, siteID: sampleSiteID) + + // Then + try await grdbManager.databaseConnection.read { db in + let imageCount = try PersistedProductVariationImage.fetchCount(db) + #expect(imageCount == 1) + + let image = try PersistedProductVariationImage.fetchOne(db) + #expect(image?.src == "https://example.com/variation1-updated.jpg") + #expect(image?.productVariationID == 1) + } + } + + @Test func persistIncrementalCatalogData_replaces_variations_with_existing_and_new_variations() async throws { + // Given + let parentProduct = POSProduct.fake().copy(siteID: sampleSiteID, productID: 10) + let existingVariation = POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 10, productVariationID: 1, price: "10.00") + try await insertProduct(parentProduct) + try await insertVariation(existingVariation) + + // When + let updatedExistingVariation = existingVariation.copy(price: "12.00") + let newVariation = POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 10, productVariationID: 2, price: "8.00") + let mixedCatalog = POSCatalog(products: [parentProduct], variations: [updatedExistingVariation, newVariation]) + try await sut.persistIncrementalCatalogData(mixedCatalog, siteID: sampleSiteID) + + // Then + try await grdbManager.databaseConnection.read { db in + let variationCount = try PersistedProductVariation.fetchCount(db) + #expect(variationCount == 2) + + let variations = try PersistedProductVariation.fetchAll(db).sorted(by: { $0.id < $1.id }) + #expect(variations[0].price == "12.00") + #expect(variations[0].id == 1) + #expect(variations[1].price == "8.00") + #expect(variations[1].id == 2) + } + } +} + +private extension POSCatalogPersistenceServiceTests { + func insertProduct(_ product: POSProduct) async throws { + let db = grdbManager.databaseConnection + try await db.write { db in + try PersistedSite(id: sampleSiteID).insert(db, onConflict: .ignore) + } + try product.save(to: db) + } + + func insertVariation(_ variation: POSProductVariation) async throws { + let db = grdbManager.databaseConnection + try variation.save(to: db) + } }