diff --git a/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift b/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift index fe2573fdb0b..32f66163926 100644 --- a/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift +++ b/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift @@ -112,7 +112,7 @@ struct V001InitialSchema { private static func createProductVariationImageTable(_ db: Database) throws { try db.create(table: "productVariationImage") { productVariationImageTable in productVariationImageTable.primaryKey("id", .integer).notNull() - productVariationImageTable.belongsTo("productVariation").notNull() + productVariationImageTable.belongsTo("productVariation", onDelete: .cascade).notNull() productVariationImageTable.column("dateCreated", .datetime).notNull() productVariationImageTable.column("dateModified", .datetime) diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 66f25415e59..5d5f0fbeb1d 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -3,6 +3,7 @@ import protocol Networking.POSCatalogSyncRemoteProtocol import class Networking.AlamofireNetwork import class Networking.POSCatalogSyncRemote import CocoaLumberjackSwift +import Storage // TODO - remove the periphery ignore comment when the catalog is integrated with POS. // periphery:ignore @@ -26,20 +27,23 @@ public struct POSCatalog { public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol { private let syncRemote: POSCatalogSyncRemoteProtocol private let batchSize: Int + private let persistenceService: POSCatalogPersistenceServiceProtocol - public convenience init?(credentials: Credentials?, batchSize: Int = 2) { + public convenience init?(credentials: Credentials?, batchSize: Int = 2, grdbManager: GRDBManagerProtocol) { guard let credentials else { DDLogError("⛔️ Could not create POSCatalogFullSyncService 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 } // MARK: - Protocol Conformance @@ -48,10 +52,17 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol DDLogInfo("🔄 Starting full catalog sync for site ID: \(siteID)") do { + // Sync from network let catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote) DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)") + + // Persist to database + try await persistenceService.replaceAllCatalogData(catalog, siteID: siteID) + DDLogInfo("✅ Persisted \(catalog.products.count) products and \(catalog.variations.count) variations to database for siteID \(siteID)") + return catalog } catch { + DDLogError("❌ Failed to sync and persist catalog: \(error)") throw error } } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift new file mode 100644 index 00000000000..22c991c157f --- /dev/null +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift @@ -0,0 +1,105 @@ +// periphery:ignore:all +import Foundation +import Storage + +protocol POSCatalogPersistenceServiceProtocol { + /// Clears existing data and persists new catalog data + /// - Parameters: + /// - catalog: The catalog to persist + /// - siteID: The site ID to associate the catalog with + func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws +} + +final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { + private let grdbManager: GRDBManagerProtocol + + init(grdbManager: GRDBManagerProtocol) { + self.grdbManager = grdbManager + } + + func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws { + DDLogInfo("💾 Persisting catalog with \(catalog.products.count) products and \(catalog.variations.count) variations") + + try await grdbManager.databaseConnection.write { db in + DDLogInfo("🗑️ Clearing catalog data for site \(siteID)") + // currently, we can't save for more than one site as entity IDs are not namespaced. + try PersistedSite.deleteAll(db) + + let site = PersistedSite(id: siteID) + try site.insert(db) + + for product in catalog.productsToPersist { + try product.insert(db, onConflict: .ignore) + } + + for image in catalog.productImagesToPersist { + try image.insert(db, onConflict: .ignore) + } + + for var attribute in catalog.productAttributesToPersist { + try attribute.insert(db) + } + + for variation in catalog.variationsToPersist { + try variation.insert(db, onConflict: .ignore) + } + + for image in catalog.variationImagesToPersist { + try image.insert(db, onConflict: .ignore) + } + + for var attribute in catalog.variationAttributesToPersist { + try attribute.insert(db) + } + } + + DDLogInfo("✅ 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("Persisted \(productCount) products, \(productImageCount) product images, " + + "\(productAttributeCount) product attributes, \(variationCount) variations, " + + "\(variationImageCount) variation images, \(variationAttributeCount) variation attributes") + } + } +} + +private extension POSCatalog { + var productsToPersist: [PersistedProduct] { + products.map { PersistedProduct(from: $0) } + } + + var productImagesToPersist: [PersistedProductImage] { + products.flatMap { product in + product.images.map { PersistedProductImage(from: $0, productID: product.productID) } + } + } + + var productAttributesToPersist: [PersistedProductAttribute] { + products.flatMap { product in + product.attributes.map { PersistedProductAttribute(from: $0, productID: product.productID) } + } + } + + var variationsToPersist: [PersistedProductVariation] { + variations.map { PersistedProductVariation(from: $0) } + } + + var variationImagesToPersist: [PersistedProductVariationImage] { + variations.compactMap { variation in + variation.image.map { PersistedProductVariationImage(from: $0, productVariationID: variation.productVariationID) } + } + } + + var variationAttributesToPersist: [PersistedProductVariationAttribute] { + variations.flatMap { variation in + variation.attributes.map { PersistedProductVariationAttribute(from: $0, productVariationID: variation.productVariationID) } + } + } +} diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift index 1a0053fc8b8..f973647a2e7 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift @@ -2,15 +2,18 @@ import Foundation import Testing @testable import Networking @testable import Yosemite +@testable import Storage struct POSCatalogFullSyncServiceTests { private let sut: POSCatalogFullSyncService private let mockSyncRemote: MockPOSCatalogSyncRemote + private let mockPersistenceService: MockPOSCatalogPersistenceService private let sampleSiteID: Int64 = 134 init() { self.mockSyncRemote = MockPOSCatalogSyncRemote() - self.sut = POSCatalogFullSyncService(syncRemote: mockSyncRemote, batchSize: 2) + self.mockPersistenceService = MockPOSCatalogPersistenceService() + self.sut = POSCatalogFullSyncService(syncRemote: mockSyncRemote, batchSize: 2, persistenceService: mockPersistenceService) } // MARK: - Full Sync Tests @@ -128,20 +131,24 @@ struct POSCatalogFullSyncServiceTests { // MARK: - Initialization Tests - @Test func init_with_valid_credentials_creates_service() { + @Test func init_with_valid_credentials_creates_service() throws { // Given let credentials = Credentials.wpcom(username: "test", authToken: "token", siteAddress: "site.com") + let grdbManager = try GRDBManager() // When - let service = POSCatalogFullSyncService(credentials: credentials) + let service = POSCatalogFullSyncService(credentials: credentials, grdbManager: grdbManager) // Then #expect(service != nil) } - @Test func init_with_nil_credentials_returns_nil() { - // Given/When - let service = POSCatalogFullSyncService(credentials: nil) + @Test func init_with_nil_credentials_returns_nil() throws { + // Given + let grdbManager = try GRDBManager() + + // When + let service = POSCatalogFullSyncService(credentials: nil, grdbManager: grdbManager) // Then #expect(service == nil) @@ -152,7 +159,9 @@ struct POSCatalogFullSyncServiceTests { let customBatchSize = 5 // When - let service = POSCatalogFullSyncService(syncRemote: mockSyncRemote, batchSize: customBatchSize) + let service = POSCatalogFullSyncService(syncRemote: mockSyncRemote, + batchSize: customBatchSize, + persistenceService: mockPersistenceService) _ = try await service.startFullSync(for: sampleSiteID) // Then @@ -231,3 +240,17 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { return fallbackVariationResult } } + +// MARK: - Mock POSCatalogPersistenceService + +final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { + private(set) var replaceAllCatalogDataCallCount = 0 + private(set) var lastPersistedCatalog: POSCatalog? + private(set) var lastPersistedSiteID: Int64? + + func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws { + replaceAllCatalogDataCallCount += 1 + lastPersistedSiteID = siteID + lastPersistedCatalog = catalog + } +} diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift new file mode 100644 index 00000000000..6777b379277 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift @@ -0,0 +1,224 @@ +import Foundation +import Testing +@testable import Storage +@testable import Yosemite + +struct POSCatalogPersistenceServiceTests { + private let grdbManager: GRDBManager + private let sut: POSCatalogPersistenceService + private let sampleSiteID: Int64 = 134 + + init() throws { + self.grdbManager = try GRDBManager() + self.sut = POSCatalogPersistenceService(grdbManager: grdbManager) + } + + // MARK: - Replace Catalog Data Tests + + @Test func replaceAllCatalogData_saves_site_products_and_variations() async throws { + // Given + let catalog = POSCatalog( + products: [ + POSProduct.fake().copy(siteID: sampleSiteID, productID: 1), + POSProduct.fake().copy(siteID: sampleSiteID, productID: 2, productTypeKey: "variable") + ], + variations: [ + POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 2, productVariationID: 1), + POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 2, productVariationID: 2) + ] + ) + + // When + try await sut.replaceAllCatalogData(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) + let variationCount = try PersistedProductVariation.fetchCount(db) + + #expect(siteCount == 1) + #expect(productCount == 2) + #expect(variationCount == 2) + + let site = try PersistedSite.fetchOne(db) + #expect(site?.id == sampleSiteID) + } + } + + @Test func replaceAllCatalogData_saves_product_images_and_attributes() async throws { + // Given + let productWithRelations = POSProduct.fake().copy( + siteID: sampleSiteID, + productID: 1, + images: [ProductImage.fake().copy(imageID: 100), ProductImage.fake().copy(imageID: 101)], + attributes: [ProductAttribute.fake(), ProductAttribute.fake()] + ) + let catalog = POSCatalog(products: [productWithRelations], variations: []) + + // When + try await sut.replaceAllCatalogData(catalog, siteID: sampleSiteID) + + // Then + let db = grdbManager.databaseConnection + try await db.read { db in + let imageCount = try PersistedProductImage.fetchCount(db) + let attributeCount = try PersistedProductAttribute.fetchCount(db) + + #expect(imageCount == 2) + #expect(attributeCount == 2) + } + } + + @Test func replaceAllCatalogData_saves_variation_images_and_attributes() async throws { + // Given + let variationWithRelations = POSProductVariation.fake().copy( + siteID: sampleSiteID, + productID: 15, + productVariationID: 1, + attributes: [ProductVariationAttribute.fake(), ProductVariationAttribute.fake()], image: ProductImage.fake().copy(imageID: 200) + ) + let catalog = POSCatalog(products: [POSProduct.fake().copy(siteID: sampleSiteID, productID: 15)], + variations: [variationWithRelations]) + + // When + try await sut.replaceAllCatalogData(catalog, siteID: sampleSiteID) + + // Then + let db = grdbManager.databaseConnection + try await db.read { db in + let imageCount = try PersistedProductVariationImage.fetchCount(db) + let attributeCount = try PersistedProductVariationAttribute.fetchCount(db) + + #expect(imageCount == 1) + #expect(attributeCount == 2) + } + } + + @Test func replaceAllCatalogData_handles_duplicate_image_ids_gracefully() async throws { + // Given - products with same image ID + let sharedImageID: Int64 = 300 + let product1 = POSProduct.fake().copy( + siteID: sampleSiteID, + productID: 1, + images: [ProductImage.fake().copy(imageID: sharedImageID)] + ) + let product2 = POSProduct.fake().copy( + siteID: sampleSiteID, + productID: 2, + images: [ProductImage.fake().copy(imageID: sharedImageID)] + ) + let catalog = POSCatalog(products: [product1, product2], variations: []) + + // When + try await sut.replaceAllCatalogData(catalog, siteID: sampleSiteID) + + // Then - should not fail and should handle duplicates + let db = grdbManager.databaseConnection + try await db.read { db in + let productCount = try PersistedProduct.fetchCount(db) + let imageCount = try PersistedProductImage.fetchCount(db) + + #expect(productCount == 2) + // While there's only one, the current implementation doesn't + // have a join table so only one product has a reference to it + #expect(imageCount == 1) + } + } + + @Test func replaceAllCatalogData_clears_existing_and_persists_new() async throws { + // Given - existing data + let existingCatalog = POSCatalog( + products: [POSProduct.fake().copy(siteID: sampleSiteID, productID: 80)], + variations: [POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 80, productVariationID: 100)] + ) + try await sut.replaceAllCatalogData(existingCatalog, siteID: sampleSiteID) + + // When - replace with new data + let newCatalog = POSCatalog( + products: [POSProduct.fake().copy(siteID: sampleSiteID, productID: 180)], + variations: [POSProductVariation.fake().copy(siteID: sampleSiteID, productID: 180, productVariationID: 200)] + ) + try await sut.replaceAllCatalogData(newCatalog, siteID: sampleSiteID) + + // Then - should have only new data + let db = grdbManager.databaseConnection + try await db.read { db in + let productCount = try PersistedProduct.fetchCount(db) + let variationCount = try PersistedProductVariation.fetchCount(db) + + #expect(productCount == 1) + #expect(variationCount == 1) + + let product = try PersistedProduct.fetchOne(db) + let variation = try PersistedProductVariation.fetchOne(db) + + #expect(product?.id == 180) + #expect(variation?.id == 200) + } + } + + @Test func replaceAllCatalogData_removes_related_images_and_attributes_for_products() async throws { + // Given - existing data with relations + let existingProduct = POSProduct.fake().copy( + siteID: sampleSiteID, + productID: 1, + images: [ProductImage.fake()], + attributes: [ProductAttribute.fake()] + ) + let existingCatalog = POSCatalog(products: [existingProduct], variations: []) + try await sut.replaceAllCatalogData(existingCatalog, siteID: sampleSiteID) + + // When - replace with empty catalog + let emptyCatalog = POSCatalog(products: [], variations: []) + try await sut.replaceAllCatalogData(emptyCatalog, siteID: sampleSiteID) + + // Then - all related data should be gone + let db = grdbManager.databaseConnection + try await db.read { db in + let productCount = try PersistedProduct.fetchCount(db) + let imageCount = try PersistedProductImage.fetchCount(db) + let attributeCount = try PersistedProductAttribute.fetchCount(db) + + #expect(productCount == 0) + #expect(imageCount == 0) + #expect(attributeCount == 0) + } + } + + @Test func replaceAllCatalogData_removes_related_images_and_attributes_for_variations() async throws { + // Given - existing data with variation relations + let parentProduct = POSProduct.fake().copy( + siteID: sampleSiteID, + productID: 10 + ) + let existingVariation = POSProductVariation.fake().copy( + siteID: sampleSiteID, + productID: 10, + productVariationID: 5, + attributes: [ProductVariationAttribute.fake()], + image: ProductImage.fake().copy(imageID: 500) + ) + let existingCatalog = POSCatalog(products: [parentProduct], variations: [existingVariation]) + try await sut.replaceAllCatalogData(existingCatalog, siteID: sampleSiteID) + + // When - replace with catalog containing only parent product (no variations) + let catalogWithoutVariations = POSCatalog(products: [parentProduct], variations: []) + try await sut.replaceAllCatalogData(catalogWithoutVariations, siteID: sampleSiteID) + + // Then - variation and its related data should be gone + let db = grdbManager.databaseConnection + try await db.read { db in + let productCount = try PersistedProduct.fetchCount(db) + let variationCount = try PersistedProductVariation.fetchCount(db) + let variationImageCount = try PersistedProductVariationImage.fetchCount(db) + let variationAttributeCount = try PersistedProductVariationAttribute.fetchCount(db) + + #expect(productCount == 1) // Parent product should remain + #expect(variationCount == 0) // Variation should be gone + #expect(variationImageCount == 0) // Variation image should be gone + #expect(variationAttributeCount == 0) // Variation attributes should be gone + } + } +}