diff --git a/Modules/Sources/Fakes/Yosemite.generated.swift b/Modules/Sources/Fakes/Yosemite.generated.swift index f6634d6526d..a4a74615933 100644 --- a/Modules/Sources/Fakes/Yosemite.generated.swift +++ b/Modules/Sources/Fakes/Yosemite.generated.swift @@ -52,6 +52,16 @@ extension Yosemite.POSSimpleProduct { ) } } +extension Yosemite.POSSite { + /// Returns a "ready to use" type filled with fake values. + /// + public static func fake() -> Yosemite.POSSite { + .init( + siteID: .fake(), + lastIncrementalSyncDate: .fake() + ) + } +} extension Yosemite.ProductReviewFromNoteParcel { /// Returns a "ready to use" type filled with fake values. /// diff --git a/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift b/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift index 32f66163926..c951cde5b67 100644 --- a/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift +++ b/Modules/Sources/Storage/GRDB/Migrations/V001InitialSchema.swift @@ -19,6 +19,7 @@ struct V001InitialSchema { static func createSiteTable(_ db: Database) throws { try db.create(table: "site") { siteTable in siteTable.primaryKey("id", .integer).notNull() + siteTable.column("lastCatalogIncrementalSyncDate", .datetime) } } diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift b/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift index 49c6201d655..ee54b52e88c 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedSite.swift @@ -5,10 +5,13 @@ import GRDB public struct PersistedSite: Codable { // periphery:ignore - TODO: remove ignore when populating database public let id: Int64 + // periphery:ignore - TODO: remove ignore when populating database + public let lastCatalogIncrementalSyncDate: Date? // periphery:ignore - TODO: remove ignore when populating database - public init(id: Int64) { + public init(id: Int64, lastCatalogIncrementalSyncDate: Date? = nil) { self.id = id + self.lastCatalogIncrementalSyncDate = lastCatalogIncrementalSyncDate } } @@ -19,6 +22,8 @@ extension PersistedSite: FetchableRecord, PersistableRecord { public enum Columns { // periphery:ignore - TODO: remove ignore when populating database static let id = Column(CodingKeys.id) + // periphery:ignore - TODO: remove ignore when populating database + static let lastCatalogIncrementalSyncDate = Column(CodingKeys.lastCatalogIncrementalSyncDate) } } @@ -26,5 +31,6 @@ extension PersistedSite: FetchableRecord, PersistableRecord { private extension PersistedSite { enum CodingKeys: String, CodingKey { case id + case lastCatalogIncrementalSyncDate } } diff --git a/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift index 92c81fda9ad..a83ba0a2084 100644 --- a/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Yosemite/Model/Copiable/Models+Copiable.generated.swift @@ -93,6 +93,21 @@ extension Yosemite.POSSimpleProduct { } } +extension Yosemite.POSSite { + public func copy( + siteID: CopiableProp = .copy, + lastIncrementalSyncDate: NullableCopiableProp = .copy + ) -> Yosemite.POSSite { + let siteID = siteID ?? self.siteID + let lastIncrementalSyncDate = lastIncrementalSyncDate ?? self.lastIncrementalSyncDate + + return Yosemite.POSSite( + siteID: siteID, + lastIncrementalSyncDate: lastIncrementalSyncDate + ) + } +} + extension Yosemite.ProductReviewFromNoteParcel { public func copy( note: CopiableProp = .copy, diff --git a/Modules/Sources/Yosemite/Model/Storage/PersistedSite+Conversions.swift b/Modules/Sources/Yosemite/Model/Storage/PersistedSite+Conversions.swift new file mode 100644 index 00000000000..bb3034d94ed --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Storage/PersistedSite+Conversions.swift @@ -0,0 +1,18 @@ +import Foundation +import Storage + +extension PersistedSite { + init(from posSite: POSSite) { + self.init( + id: posSite.siteID, + lastCatalogIncrementalSyncDate: posSite.lastIncrementalSyncDate + ) + } + + func toPOSSite() -> POSSite { + POSSite( + siteID: id, + lastIncrementalSyncDate: lastCatalogIncrementalSyncDate + ) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/POSSite.swift b/Modules/Sources/Yosemite/PointOfSale/POSSite.swift new file mode 100644 index 00000000000..3eba4437d8c --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/POSSite.swift @@ -0,0 +1,13 @@ +// periphery:ignore:all +import Codegen +import Foundation + +public struct POSSite: Equatable, GeneratedCopiable, GeneratedFakeable { + public let siteID: Int64 + public let lastIncrementalSyncDate: Date? + + public init(siteID: Int64, lastIncrementalSyncDate: Date? = nil) { + self.siteID = siteID + self.lastIncrementalSyncDate = lastIncrementalSyncDate + } +} diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift index a0281bb049b..6a21856dde9 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift @@ -21,7 +21,6 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe 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, grdbManager: GRDBManagerProtocol) { @@ -45,7 +44,7 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe // MARK: - Protocol Conformance public func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date) async throws { - let modifiedAfter = lastIncrementalSyncDates[siteID] ?? lastFullSyncDate + let modifiedAfter = try await latestSyncDate(siteID: siteID, lastFullSyncDate: lastFullSyncDate) DDLogInfo("🔄 Starting incremental catalog sync for site ID: \(siteID), modifiedAfter: \(modifiedAfter)") @@ -57,8 +56,7 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe 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 + try await persistenceService.updateSite(.init(siteID: siteID, lastIncrementalSyncDate: syncStartDate)) DDLogInfo("✅ Updated last incremental sync date to \(syncStartDate) for siteID \(siteID)") } catch { DDLogError("❌ Failed to sync and persist catalog incrementally: \(error)") @@ -86,3 +84,11 @@ private extension POSCatalogIncrementalSyncService { return POSCatalog(products: products, variations: variations) } } + +// MARK: - Sync date + +private extension POSCatalogIncrementalSyncService { + func latestSyncDate(siteID: Int64, lastFullSyncDate: Date) async throws -> Date { + try await persistenceService.loadSite(siteID: siteID)?.lastIncrementalSyncDate ?? lastFullSyncDate + } +} diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift index 797d25381f0..28ba311e192 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift @@ -3,6 +3,10 @@ import Foundation import Storage import GRDB +enum POSCatalogPersistenceError: Error, Equatable { + case siteNotFound(siteID: Int64) +} + protocol POSCatalogPersistenceServiceProtocol { /// Clears existing data and persists new catalog data /// - Parameters: @@ -15,6 +19,15 @@ protocol POSCatalogPersistenceServiceProtocol { /// - catalog: The catalog difference to persist /// - siteID: The site ID to associate the catalog with func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws + + /// Loads the POS site for the given site ID + /// - Parameter siteID: The site ID to load the POSSite for + /// - Returns: The loaded POSSite or nil if not found in storage + func loadSite(siteID: Int64) async throws -> POSSite? + + /// Updates the PersistedSite based on POSSite data + /// - Parameter site: The POSSite containing the updated data + func updateSite(_ site: POSSite) async throws } final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { @@ -136,6 +149,23 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { "\(variationImageCount) variation images, \(variationAttributeCount) variation attributes") } } + + func loadSite(siteID: Int64) async throws -> POSSite? { + try await grdbManager.databaseConnection.read { db in + try PersistedSite.filter(key: siteID).fetchOne(db)?.toPOSSite() + } + } + + func updateSite(_ site: POSSite) async throws { + try await grdbManager.databaseConnection.write { db in + guard try PersistedSite.filter(key: site.siteID).fetchOne(db) != nil else { + throw POSCatalogPersistenceError.siteNotFound(siteID: site.siteID) + } + + let persistedSite = PersistedSite(from: site) + try persistedSite.update(db) + } + } } private extension POSCatalog { diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift index 0f738aac281..5f46359bc52 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift @@ -184,4 +184,8 @@ private final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServi } func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {} + + func loadSite(siteID: Int64) async throws -> POSSite? { nil } + + func updateSite(_ site: POSSite) async throws {} } diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift index 02a2e33f1a4..d2e436365e2 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift @@ -183,6 +183,8 @@ private final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServi private(set) var persistIncrementalCatalogDataLastPersistedSiteID: Int64? var persistIncrementalCatalogDataError: Error? + private var storedSites: [Int64: POSSite] = [:] + func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {} func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws { @@ -193,4 +195,12 @@ private final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServi throw error } } + + func loadSite(siteID: Int64) async throws -> POSSite? { + storedSites[siteID] + } + + func updateSite(_ site: POSSite) async throws { + storedSites[site.siteID] = site + } } diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift index 4b883674465..1974f156b06 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogPersistenceServiceTests.swift @@ -8,6 +8,10 @@ struct POSCatalogPersistenceServiceTests { private let sut: POSCatalogPersistenceService private let sampleSiteID: Int64 = 134 + private var db: GRDBDatabaseConnection { + grdbManager.databaseConnection + } + init() throws { self.grdbManager = try GRDBManager() self.sut = POSCatalogPersistenceService(grdbManager: grdbManager) @@ -32,7 +36,6 @@ struct POSCatalogPersistenceServiceTests { 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) @@ -61,7 +64,6 @@ struct POSCatalogPersistenceServiceTests { 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) @@ -86,7 +88,6 @@ struct POSCatalogPersistenceServiceTests { 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) @@ -115,7 +116,6 @@ struct POSCatalogPersistenceServiceTests { 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) @@ -143,7 +143,6 @@ struct POSCatalogPersistenceServiceTests { 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) @@ -175,7 +174,6 @@ struct POSCatalogPersistenceServiceTests { 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) @@ -208,7 +206,6 @@ struct POSCatalogPersistenceServiceTests { 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) @@ -238,7 +235,6 @@ struct POSCatalogPersistenceServiceTests { 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) @@ -367,7 +363,6 @@ struct POSCatalogPersistenceServiceTests { 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) @@ -484,11 +479,145 @@ struct POSCatalogPersistenceServiceTests { #expect(variations[1].id == 2) } } + + // MARK: - Site Management Tests + + @Test func loadSite_returns_nil_when_site_does_not_exist() async throws { + // When + let result = try await sut.loadSite(siteID: 999) + + // Then + #expect(result == nil) + } + + @Test func loadSite_returns_site_when_site_exists() async throws { + // Given + let siteID: Int64 = 123 + let lastSyncDate = Date(timeIntervalSince1970: 1000) + let site = POSSite(siteID: siteID, lastIncrementalSyncDate: lastSyncDate) + try await insertSite(site) + + // When + let result = try await sut.loadSite(siteID: siteID) + + // Then + let loadedSite = try #require(result) + #expect(loadedSite.siteID == siteID) + #expect(loadedSite.lastIncrementalSyncDate == lastSyncDate) + } + + @Test func loadSite_returns_site_with_nil_sync_date_when_no_sync_date_stored() async throws { + // Given + let siteID: Int64 = 456 + let site = POSSite(siteID: siteID, lastIncrementalSyncDate: nil) + try await insertSite(site) + + // When + let result = try await sut.loadSite(siteID: siteID) + + // Then + let loadedSite = try #require(result) + #expect(loadedSite.siteID == siteID) + #expect(loadedSite.lastIncrementalSyncDate == nil) + } + + @Test func updateSite_throws_error_when_site_does_not_exist() async throws { + // Given + let siteID: Int64 = 789 + let lastSyncDate = Date(timeIntervalSince1970: 2000) + let site = POSSite(siteID: siteID, lastIncrementalSyncDate: lastSyncDate) + + // When/Then + await #expect(throws: POSCatalogPersistenceError.siteNotFound(siteID: siteID)) { + try await sut.updateSite(site) + } + + // And verify no site was created + try await db.read { db in + let siteCount = try PersistedSite.fetchCount(db) + #expect(siteCount == 0) + } + } + + @Test func updateSite_updates_existing_site() async throws { + // Given - create initial site + let siteID: Int64 = 101112 + let initialSyncDate = Date(timeIntervalSince1970: 1500) + let initialSite = POSSite(siteID: siteID, lastIncrementalSyncDate: initialSyncDate) + try await insertSite(initialSite) + + // When - update with new sync date + let updatedSyncDate = Date(timeIntervalSince1970: 3000) + let updatedSite = POSSite(siteID: siteID, lastIncrementalSyncDate: updatedSyncDate) + try await sut.updateSite(updatedSite) + + // Then + try await db.read { db in + let siteCount = try PersistedSite.fetchCount(db) + #expect(siteCount == 1) + + let persistedSite = try PersistedSite.fetchOne(db) + #expect(persistedSite?.id == siteID) + #expect(persistedSite?.lastCatalogIncrementalSyncDate == updatedSyncDate) + } + } + + @Test func updateSite_can_set_sync_date_to_nil() async throws { + // Given + let siteID: Int64 = 131415 + let initialSyncDate = Date(timeIntervalSince1970: 4000) + let initialSite = POSSite(siteID: siteID, lastIncrementalSyncDate: initialSyncDate) + try await insertSite(initialSite) + + // When + let updatedSite = POSSite(siteID: siteID, lastIncrementalSyncDate: nil) + try await sut.updateSite(updatedSite) + + // Then + try await db.read { db in + let persistedSite = try PersistedSite.fetchOne(db) + #expect(persistedSite?.id == siteID) + #expect(persistedSite?.lastCatalogIncrementalSyncDate == nil) + } + } + + @Test func loadSite_and_updateSite_work_together_for_multiple_sites() async throws { + // Given + let site1ID: Int64 = 100 + let site2ID: Int64 = 200 + let site1Date = Date(timeIntervalSince1970: 1000) + let site2Date = Date(timeIntervalSince1970: 2000) + + let site1 = POSSite(siteID: site1ID, lastIncrementalSyncDate: site1Date) + let site2 = POSSite(siteID: site2ID, lastIncrementalSyncDate: site2Date) + + // When + try await insertSite(site1) + try await insertSite(site2) + + // Then + let loadedSite1 = try await sut.loadSite(siteID: site1ID) + let loadedSite2 = try await sut.loadSite(siteID: site2ID) + + #expect(loadedSite1?.siteID == site1ID) + #expect(loadedSite1?.lastIncrementalSyncDate == site1Date) + #expect(loadedSite2?.siteID == site2ID) + #expect(loadedSite2?.lastIncrementalSyncDate == site2Date) + + // When loading non-existent site returns nil + let nonExistentSite = try await sut.loadSite(siteID: 999) + #expect(nonExistentSite == nil) + } } private extension POSCatalogPersistenceServiceTests { + func insertSite(_ site: POSSite) async throws { + try await db.write { db in + try PersistedSite(from: site).insert(db, onConflict: .replace) + } + } + func insertProduct(_ product: POSProduct) async throws { - let db = grdbManager.databaseConnection try await db.write { db in try PersistedSite(id: sampleSiteID).insert(db, onConflict: .ignore) } @@ -496,7 +625,6 @@ private extension POSCatalogPersistenceServiceTests { } func insertVariation(_ variation: POSProductVariation) async throws { - let db = grdbManager.databaseConnection try variation.save(to: db) } }