diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 7a2ef02ee40..5228428bf75 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -67,7 +67,14 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { ParameterKey.fields: POSProduct.requestFields.joined(separator: ",") ] - let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, parameters: parameters) + let request = JetpackRequest( + wooApiVersion: .mark3, + method: .get, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true + ) let mapper = ListMapper(siteID: siteID) let (products, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper) @@ -92,7 +99,14 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { ParameterKey.fields: POSProductVariation.requestFields.joined(separator: ",") ] - let request = JetpackRequest(wooApiVersion: .wcAnalytics, method: .get, siteID: siteID, path: path, parameters: parameters) + let request = JetpackRequest( + wooApiVersion: .wcAnalytics, + method: .get, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true + ) let mapper = ListMapper(siteID: siteID) let (variations, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper) @@ -117,7 +131,14 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { ParameterKey.fields: POSProduct.requestFields.joined(separator: ",") ] - let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, parameters: parameters) + let request = JetpackRequest( + wooApiVersion: .mark3, + method: .get, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true + ) let mapper = ListMapper(siteID: siteID) let (products, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper) @@ -140,7 +161,14 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { ParameterKey.fields: POSProductVariation.requestFields.joined(separator: ",") ] - let request = JetpackRequest(wooApiVersion: .wcAnalytics, method: .get, siteID: siteID, path: path, parameters: parameters) + let request = JetpackRequest( + wooApiVersion: .wcAnalytics, + method: .get, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true + ) let mapper = ListMapper(siteID: siteID) let (variations, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper) diff --git a/Modules/Sources/Yosemite/Tools/POS/BatchedRequestLoader.swift b/Modules/Sources/Yosemite/Tools/POS/BatchedRequestLoader.swift new file mode 100644 index 00000000000..951e68bb9ee --- /dev/null +++ b/Modules/Sources/Yosemite/Tools/POS/BatchedRequestLoader.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Generic utility for loading paginated data with batch processing. +final class BatchedRequestLoader { + private let batchSize: Int + + init(batchSize: Int) { + self.batchSize = batchSize + } + + /// Loads all items using a paginated request function. + /// - Parameters: + /// - makeRequest: Function that takes a page number and returns PagedItems. + /// - Returns: Array of all loaded items. + func loadAll(makeRequest: @escaping (Int) async throws -> PagedItems) async throws -> [T] { + var allItems: [T] = [] + var currentPage = 1 + var hasMorePages = true + + while hasMorePages { + let pagesToFetch = Array(currentPage..<(currentPage + batchSize)) + + let batchResults = try await withThrowingTaskGroup(of: PageResult.self) { group in + for pageNumber in pagesToFetch { + group.addTask { + let result = try await makeRequest(pageNumber) + return PageResult(pageNumber: pageNumber, items: result) + } + } + + var results: [PageResult] = [] + for try await result in group { + results.append(result) + } + return results.sorted(by: { $0.pageNumber < $1.pageNumber }) + } + + // Processes results in order and checks if there are more pages. + let newItems = batchResults.flatMap { $0.items.items } + allItems.append(contentsOf: newItems) + + let highestPageResult = batchResults.last?.items + hasMorePages = (highestPageResult?.hasMorePages ?? false) && !newItems.isEmpty + currentPage += batchSize + } + + return allItems + } +} + +private struct PageResult { + let pageNumber: Int + let items: PagedItems +} diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 5d5f0fbeb1d..e5752ebc211 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -28,6 +28,7 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol private let syncRemote: POSCatalogSyncRemoteProtocol private let batchSize: Int private let persistenceService: POSCatalogPersistenceServiceProtocol + private let batchedLoader: BatchedRequestLoader public convenience init?(credentials: Credentials?, batchSize: Int = 2, grdbManager: GRDBManagerProtocol) { guard let credentials else { @@ -44,6 +45,7 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol self.syncRemote = syncRemote self.batchSize = batchSize self.persistenceService = persistenceService + self.batchedLoader = BatchedRequestLoader(batchSize: batchSize) } // MARK: - Protocol Conformance @@ -73,95 +75,19 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol private extension POSCatalogFullSyncService { func loadCatalog(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> POSCatalog { // Loads products and variations in batches in parallel. - async let productsTask = loadAllProducts(for: siteID, syncRemote: syncRemote) - async let variationsTask = loadAllProductVariations(for: siteID, syncRemote: syncRemote) - - let (products, variations) = try await (productsTask, variationsTask) - return POSCatalog(products: products, variations: variations) - } - - func loadAllProducts(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> [POSProduct] { - DDLogInfo("🔄 Starting products sync for site ID: \(siteID)") - - var allProducts: [POSProduct] = [] - var currentPage = 1 - var hasMorePages = true - - while hasMorePages { - let pagesToFetch = Array(currentPage..<(currentPage + batchSize)) - - let batchResults = try await withThrowingTaskGroup(of: PageResult.self) { group in - for pageNumber in pagesToFetch { - group.addTask { - let result = try await syncRemote.loadProducts(siteID: siteID, pageNumber: pageNumber) - return PageResult(pageNumber: pageNumber, items: result) - } - } - - var results: [PageResult] = [] - for try await result in group { - results.append(result) - } - return results.sorted(by: { $0.pageNumber < $1.pageNumber }) + async let productsTask = batchedLoader.loadAll( + makeRequest: { pageNumber in + try await syncRemote.loadProducts(siteID: siteID, pageNumber: pageNumber) } - - // Processes results in order and checks if there are more pages. - let newProducts = batchResults.flatMap { $0.items.items } - allProducts.append(contentsOf: newProducts) - - let highestPageResult = batchResults.last?.items - hasMorePages = (highestPageResult?.hasMorePages ?? false) && !newProducts.isEmpty - currentPage += batchSize - - DDLogInfo("📥 Loaded batch: \(batchResults.count) pages, total products: \(allProducts.count), hasMorePages: \(hasMorePages)") - } - - DDLogInfo("✅ Products sync complete: \(allProducts.count) products loaded") - return allProducts - } - - func loadAllProductVariations(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> [POSProductVariation] { - DDLogInfo("🔄 Starting variations sync for site ID: \(siteID)") - - var allVariations: [POSProductVariation] = [] - var currentPage = 1 - var hasMorePages = true - - while hasMorePages { - let pagesToFetch = Array(currentPage..<(currentPage + batchSize)) - - let batchResults = try await withThrowingTaskGroup(of: PageResult.self) { group in - for pageNumber in pagesToFetch { - group.addTask { - let result = try await syncRemote.loadProductVariations(siteID: siteID, pageNumber: pageNumber) - return PageResult(pageNumber: pageNumber, items: result) - } - } - - var results: [PageResult] = [] - for try await result in group { - results.append(result) - } - return results.sorted(by: { $0.pageNumber < $1.pageNumber }) + ) + async let variationsTask = batchedLoader.loadAll( + makeRequest: { pageNumber in + try await syncRemote.loadProductVariations(siteID: siteID, pageNumber: pageNumber) } + ) - // Processes results in order and checks if there are more pages. - let newVariations = batchResults.flatMap { $0.items.items } - allVariations.append(contentsOf: newVariations) - - let highestPageResult = batchResults.last?.items - hasMorePages = (highestPageResult?.hasMorePages ?? false) && !newVariations.isEmpty - currentPage += batchSize - - DDLogInfo("📥 Loaded batch: \(batchResults.count) pages, total variations: \(allVariations.count), hasMorePages: \(hasMorePages)") - } - - DDLogInfo("✅ Variations sync complete: \(allVariations.count) variations loaded") - return allVariations + let (products, variations) = try await (productsTask, variationsTask) + return POSCatalog(products: products, variations: variations) } -} -private struct PageResult { - let pageNumber: Int - let items: PagedItems } diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift new file mode 100644 index 00000000000..6c8f958ee7f --- /dev/null +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift @@ -0,0 +1,84 @@ +import Foundation +import protocol Networking.POSCatalogSyncRemoteProtocol +import class Networking.AlamofireNetwork +import class Networking.POSCatalogSyncRemote +import CocoaLumberjackSwift +import protocol Storage.GRDBManagerProtocol + +// TODO - remove the periphery ignore comment when the service is integrated with POS. +// periphery:ignore +public protocol POSCatalogIncrementalSyncServiceProtocol { + /// Starts an incremental catalog sync process. + /// - Parameters: + /// - siteID: The site ID to sync catalog for. + /// - lastFullSyncDate: The date of the last full sync to use if no incremental sync date exists. + func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date) async throws +} + +// TODO - remove the periphery ignore comment when the service is integrated with POS. +// periphery:ignore +public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServiceProtocol { + private let syncRemote: POSCatalogSyncRemoteProtocol + private let batchSize: Int + private var lastIncrementalSyncDates: [Int64: Date] = [:] + private let batchedLoader: BatchedRequestLoader + + public convenience init?(credentials: Credentials?, batchSize: Int = 1) { + 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) + } + + init(syncRemote: POSCatalogSyncRemoteProtocol, batchSize: Int) { + self.syncRemote = syncRemote + self.batchSize = batchSize + self.batchedLoader = BatchedRequestLoader(batchSize: batchSize) + } + + // MARK: - Protocol Conformance + + public func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date) async throws { + let modifiedAfter = lastIncrementalSyncDates[siteID] ?? lastFullSyncDate + + DDLogInfo("🔄 Starting incremental catalog sync for site ID: \(siteID), modifiedAfter: \(modifiedAfter)") + + do { + let syncStartDate = Date() + 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 + + // TODO: WOOMOB-1289 - replace with store settings persistence + lastIncrementalSyncDates[siteID] = syncStartDate + DDLogInfo("✅ Updated last incremental sync date to \(syncStartDate) for siteID \(siteID)") + } catch { + DDLogError("❌ Failed to sync and persist catalog incrementally: \(error)") + throw error + } + } +} + +// MARK: - Remote Loading + +private extension POSCatalogIncrementalSyncService { + func loadCatalog(for siteID: Int64, modifiedAfter: Date, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> POSCatalog { + async let productsTask = batchedLoader.loadAll( + makeRequest: { pageNumber in + try await syncRemote.loadProducts(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber) + } + ) + async let variationsTask = batchedLoader.loadAll( + makeRequest: { pageNumber in + try await syncRemote.loadProductVariations(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber) + } + ) + + let (products, variations) = try await (productsTask, variationsTask) + return POSCatalog(products: products, variations: variations) + } +} diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift new file mode 100644 index 00000000000..f85787c9ff5 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift @@ -0,0 +1,128 @@ +import Foundation +@testable import Networking + +final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { + // Dictionary mapping pageNumber to Result for products and variations. + private(set) var productResults: [Int: Result, Error>] = [:] + private(set) var variationResults: [Int: Result, Error>] = [:] + private(set) var incrementalProductResults: [Int: Result, Error>] = [:] + private(set) var incrementalVariationResults: [Int: Result, Error>] = [:] + + private(set) var loadProductsCallCount = 0 + private(set) var loadProductVariationsCallCount = 0 + private(set) var loadIncrementalProductsCallCount = 0 + private(set) var loadIncrementalProductVariationsCallCount = 0 + + private(set) var lastIncrementalProductsModifiedAfter: Date? + private(set) var lastIncrementalVariationsModifiedAfter: Date? + + // Fallback result when no specific page result is configured + private let fallbackResult = PagedItems(items: [] as [POSProduct], hasMorePages: false, totalItems: 0) + private let fallbackVariationResult = PagedItems(items: [] as [POSProductVariation], hasMorePages: false, totalItems: 0) + + // MARK: - Setup Methods for Full Sync + + func setProductResult(pageNumber: Int, result: Result, Error>) { + productResults[pageNumber] = result + } + + func setVariationResult(pageNumber: Int, result: Result, Error>) { + variationResults[pageNumber] = result + } + + func setProductResults(_ results: [PagedItems]) { + for (index, pagedItems) in results.enumerated() { + productResults[index + 1] = .success(pagedItems) + } + } + + func setVariationResults(_ results: [PagedItems]) { + for (index, pagedItems) in results.enumerated() { + variationResults[index + 1] = .success(pagedItems) + } + } + + // MARK: - Setup Methods for Incremental Sync + + func setIncrementalProductResult(pageNumber: Int, result: Result, Error>) { + incrementalProductResults[pageNumber] = result + } + + func setIncrementalVariationResult(pageNumber: Int, result: Result, Error>) { + incrementalVariationResults[pageNumber] = result + } + + func setIncrementalProductResults(_ results: [PagedItems]) { + for (index, pagedItems) in results.enumerated() { + incrementalProductResults[index + 1] = .success(pagedItems) + } + } + + func setIncrementalVariationResults(_ results: [PagedItems]) { + for (index, pagedItems) in results.enumerated() { + incrementalVariationResults[index + 1] = .success(pagedItems) + } + } + + // MARK: - Protocol Methods - Incremental Sync + + func loadProducts(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems { + loadIncrementalProductsCallCount += 1 + lastIncrementalProductsModifiedAfter = modifiedAfter + + if let result = incrementalProductResults[pageNumber] { + switch result { + case .success(let pagedItems): + return pagedItems + case .failure(let error): + throw error + } + } + return fallbackResult + } + + func loadProductVariations(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems { + loadIncrementalProductVariationsCallCount += 1 + lastIncrementalVariationsModifiedAfter = modifiedAfter + + if let result = incrementalVariationResults[pageNumber] { + switch result { + case .success(let pagedItems): + return pagedItems + case .failure(let error): + throw error + } + } + return fallbackVariationResult + } + + // MARK: - Protocol Methods - Full Sync + + func loadProducts(siteID: Int64, pageNumber: Int) async throws -> PagedItems { + loadProductsCallCount += 1 + + if let result = productResults[pageNumber] { + switch result { + case .success(let pagedItems): + return pagedItems + case .failure(let error): + throw error + } + } + return fallbackResult + } + + func loadProductVariations(siteID: Int64, pageNumber: Int) async throws -> PagedItems { + loadProductVariationsCallCount += 1 + + if let result = variationResults[pageNumber] { + switch result { + case .success(let pagedItems): + return pagedItems + case .failure(let error): + throw error + } + } + return fallbackVariationResult + } +} diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift index f973647a2e7..0f738aac281 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogFullSyncServiceTests.swift @@ -170,80 +170,9 @@ struct POSCatalogFullSyncServiceTests { } } -// MARK: - Mock POSCatalogSyncRemote - -final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { - // Dictionary mapping pageNumber to Result for products and variations. - private(set) var productResults: [Int: Result, Error>] = [:] - private(set) var variationResults: [Int: Result, Error>] = [:] - - private(set) var loadProductsCallCount = 0 - private(set) var loadProductVariationsCallCount = 0 - - // Fallback result when no specific page result is configured - private let fallbackResult = PagedItems(items: [] as [POSProduct], hasMorePages: false, totalItems: 0) - private let fallbackVariationResult = PagedItems(items: [] as [POSProductVariation], hasMorePages: false, totalItems: 0) - - func setProductResult(pageNumber: Int, result: Result, Error>) { - productResults[pageNumber] = result - } - - func setVariationResult(pageNumber: Int, result: Result, Error>) { - variationResults[pageNumber] = result - } - - func setProductResults(_ results: [PagedItems]) { - for (index, pagedItems) in results.enumerated() { - productResults[index + 1] = .success(pagedItems) - } - } - - func setVariationResults(_ results: [PagedItems]) { - for (index, pagedItems) in results.enumerated() { - variationResults[index + 1] = .success(pagedItems) - } - } - - func loadProducts(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems { - try await loadProducts(siteID: siteID, pageNumber: pageNumber) - } - - func loadProductVariations(modifiedAfter: Date, siteID: Int64, pageNumber: Int) async throws -> PagedItems { - try await loadProductVariations(siteID: siteID, pageNumber: pageNumber) - } - - func loadProducts(siteID: Int64, pageNumber: Int) async throws -> PagedItems { - loadProductsCallCount += 1 - - if let result = productResults[pageNumber] { - switch result { - case .success(let pagedItems): - return pagedItems - case .failure(let error): - throw error - } - } - return fallbackResult - } - - func loadProductVariations(siteID: Int64, pageNumber: Int) async throws -> PagedItems { - loadProductVariationsCallCount += 1 - - if let result = variationResults[pageNumber] { - switch result { - case .success(let pagedItems): - return pagedItems - case .failure(let error): - throw error - } - } - return fallbackVariationResult - } -} - // MARK: - Mock POSCatalogPersistenceService -final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { +private final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol { private(set) var replaceAllCatalogDataCallCount = 0 private(set) var lastPersistedCatalog: POSCatalog? private(set) var lastPersistedSiteID: Int64? @@ -253,4 +182,6 @@ final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtoc lastPersistedSiteID = siteID lastPersistedCatalog = catalog } + + func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {} } diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift new file mode 100644 index 00000000000..105b0031f82 --- /dev/null +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogIncrementalSyncServiceTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Testing +@testable import Networking +@testable import Yosemite + +struct POSCatalogIncrementalSyncServiceTests { + private let sut: POSCatalogIncrementalSyncService + private let mockSyncRemote: MockPOSCatalogSyncRemote + private let sampleSiteID: Int64 = 134 + + init() { + self.mockSyncRemote = MockPOSCatalogSyncRemote() + self.sut = POSCatalogIncrementalSyncService(syncRemote: mockSyncRemote, batchSize: 2) + } + + // MARK: - Basic Incremental Sync Tests + + @Test func startIncrementalSync_uses_lastFullSyncDate_as_modifiedAfter_date_when_not_synced_before() async throws { + // Given + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + let expectedProducts = [POSProduct.fake(), POSProduct.fake()] + let expectedVariations = [POSProductVariation.fake()] + + mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: expectedProducts, hasMorePages: false, totalItems: 0))) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: expectedVariations, hasMorePages: false, totalItems: 0))) + + // When + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate) + + // Then + #expect(mockSyncRemote.loadIncrementalProductsCallCount == 2) + #expect(mockSyncRemote.loadIncrementalProductVariationsCallCount == 2) + #expect(mockSyncRemote.lastIncrementalProductsModifiedAfter == lastFullSyncDate) + #expect(mockSyncRemote.lastIncrementalVariationsModifiedAfter == lastFullSyncDate) + } + + @Test func startIncrementalSync_uses_last_incremental_sync_date_as_modifiedAfter_date_when_available() async throws { + // Given + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + let lastIncrementalDate = Date(timeIntervalSince1970: 2000) + + // First sync to establish incremental date. + mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastIncrementalDate) + + // When + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate) + + // Then + #expect(mockSyncRemote.lastIncrementalProductsModifiedAfter != lastFullSyncDate) + #expect(mockSyncRemote.lastIncrementalVariationsModifiedAfter != lastFullSyncDate) + } + + // MARK: - Pagination Tests + + @Test func startIncrementalSync_handles_paginated_products_correctly() async throws { + // Given - 3 pages of products + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + let page1Products = [POSProduct.fake()] + let page2Products = [POSProduct.fake()] + let page3Products = [POSProduct.fake()] + + mockSyncRemote.setIncrementalProductResults([ + PagedItems(items: page1Products, hasMorePages: true, totalItems: 3), + PagedItems(items: page2Products, hasMorePages: true, totalItems: 3), + PagedItems(items: page3Products, hasMorePages: false, totalItems: 3) + ]) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + + // When + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate) + + // Then + #expect(mockSyncRemote.loadIncrementalProductsCallCount == 4) + } + + @Test func startIncrementalSync_handles_paginated_variations_correctly() async throws { + // Given - 2 pages of variations + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + let page1Variations = [POSProductVariation.fake()] + let page2Variations = [POSProductVariation.fake()] + + mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + mockSyncRemote.setIncrementalVariationResults([ + PagedItems(items: page1Variations, hasMorePages: true, totalItems: 2), + PagedItems(items: page2Variations, hasMorePages: false, totalItems: 2) + ]) + + // When + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate) + + // Then + #expect(mockSyncRemote.loadIncrementalProductVariationsCallCount == 2) + } + + // MARK: - Error Handling Tests + + @Test func startIncrementalSync_throws_error_when_product_loading_fails() async throws { + // Given + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + let expectedError = NSError(domain: "test", code: 500, userInfo: nil) + + mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .failure(expectedError)) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + + // When/Then + await #expect(throws: expectedError) { + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate) + } + + // When attempting a second sync + mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + try await sut.startIncrementalSync(for: sampleSiteID, lastFullSyncDate: lastFullSyncDate) + + // Then it uses lastFullSyncDate since no incremental date was stored due to previous failure + #expect(mockSyncRemote.lastIncrementalProductsModifiedAfter == lastFullSyncDate) + } + + // MARK: - Per-Site Behavior Tests + + @Test func startIncrementalSync_manages_sync_dates_per_site() async throws { + // Given + let site1ID: Int64 = 123 + let site2ID: Int64 = 456 + let lastFullSyncDate = Date(timeIntervalSince1970: 1000) + + mockSyncRemote.setIncrementalProductResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + mockSyncRemote.setIncrementalVariationResult(pageNumber: 1, result: .success(PagedItems(items: [], hasMorePages: false, totalItems: 0))) + + // When - Sync site 1 + try await sut.startIncrementalSync(for: site1ID, lastFullSyncDate: lastFullSyncDate) + let site1ModifiedAfter = try #require(mockSyncRemote.lastIncrementalProductsModifiedAfter) + + // When - Sync site 2 + try await sut.startIncrementalSync(for: site2ID, lastFullSyncDate: lastFullSyncDate) + let site2ModifiedAfter = try #require(mockSyncRemote.lastIncrementalProductsModifiedAfter) + + #expect(site1ModifiedAfter == lastFullSyncDate) + #expect(site2ModifiedAfter == lastFullSyncDate) + } +}