diff --git a/Modules/Sources/Networking/Network/BackgroundCatalogDownloadCoordinator.swift b/Modules/Sources/Networking/Network/BackgroundCatalogDownloadCoordinator.swift new file mode 100644 index 00000000000..1b66d64cdda --- /dev/null +++ b/Modules/Sources/Networking/Network/BackgroundCatalogDownloadCoordinator.swift @@ -0,0 +1,55 @@ +import Foundation +import CocoaLumberjackSwift + +/// Coordinates background catalog downloads, including handling app wake events. +public class BackgroundCatalogDownloadCoordinator { + private let backgroundDownloader: BackgroundDownloadProtocol + + public init(backgroundDownloader: BackgroundDownloadProtocol = BackgroundDownloadService()) { + self.backgroundDownloader = backgroundDownloader + } + + /// Handles a background URLSession wake event. + /// Called from AppDelegate when iOS wakes the app for a completed download. + /// - Parameters: + /// - sessionIdentifier: The session identifier from the callback + /// - completionHandler: Completion handler to call when processing is done + /// - parseHandler: Closure to parse and persist the downloaded file + public func handleBackgroundSessionEvent( + sessionIdentifier: String, + completionHandler: @escaping () -> Void, + parseHandler: @escaping (URL, Int64) async throws -> Void + ) async { + DDLogInfo("🟣 Handling background session event for: \(sessionIdentifier)") + + // Load the saved download state to know which site this is for + guard let state = BackgroundDownloadState.load(for: sessionIdentifier) else { + DDLogError("⛔️ No saved state found for background download session: \(sessionIdentifier)") + completionHandler() + return + } + + // Reconnect to the background session and get the downloaded file + guard let fileURL = await backgroundDownloader.reconnectToSession(identifier: sessionIdentifier, + allowCellular: true, + completionHandler: completionHandler) else { + DDLogError("⛔️ Failed to reconnect to background download session") + BackgroundDownloadState.clear() + return + } + + DDLogInfo("🟣 Background download file ready at: \(fileURL.path)") + + // Parse the catalog file in this background window (~30 seconds) + // TODO: WOOMOB-1677 - For very large catalogs, consider hybrid approach: try immediate parse, defer if timeout. + do { + try await parseHandler(fileURL, state.siteID) + DDLogInfo("✅ Background catalog processing completed successfully") + } catch { + DDLogError("⛔️ Failed to process catalog in background: \(error)") + } + + // Clean up state + BackgroundDownloadState.clear() + } +} diff --git a/Modules/Sources/Networking/Network/BackgroundDownloadProtocol.swift b/Modules/Sources/Networking/Network/BackgroundDownloadProtocol.swift index 6953bfdc709..0a173db5012 100644 --- a/Modules/Sources/Networking/Network/BackgroundDownloadProtocol.swift +++ b/Modules/Sources/Networking/Network/BackgroundDownloadProtocol.swift @@ -15,6 +15,17 @@ public protocol BackgroundDownloadProtocol { /// - Parameter completionHandler: Handler to call when background download completes. func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> Void) + /// Reconnects to an existing background session after app wake. + /// Call this from AppDelegate when iOS wakes the app for background URLSession events. + /// - Parameters: + /// - sessionIdentifier: The session identifier from the callback + /// - allowCellular: Whether cellular data should be allowed + /// - completionHandler: Completion handler to call when all events are processed + /// - Returns: Downloaded file URL if download completed, nil if still in progress + func reconnectToSession(identifier sessionIdentifier: String, + allowCellular: Bool, + completionHandler: @escaping () -> Void) async -> URL? + /// Cancels all active downloads for the session. /// - Parameter sessionIdentifier: The session identifier to cancel. func cancelDownloads(for sessionIdentifier: String) async diff --git a/Modules/Sources/Networking/Network/BackgroundDownloadService.swift b/Modules/Sources/Networking/Network/BackgroundDownloadService.swift index 72a84a5e8dc..7cf292c9a27 100644 --- a/Modules/Sources/Networking/Network/BackgroundDownloadService.swift +++ b/Modules/Sources/Networking/Network/BackgroundDownloadService.swift @@ -36,6 +36,28 @@ extension BackgroundDownloadService: BackgroundDownloadProtocol { backgroundCompletionHandler = completionHandler } + /// Reconnects to an existing background session after app wake. + /// Call this from AppDelegate when iOS wakes the app for background URLSession events. + /// - Parameters: + /// - sessionIdentifier: The session identifier from the callback + /// - completionHandler: Completion handler to call when all events are processed + /// - Returns: Downloaded file URL if download completed, nil if still in progress + public func reconnectToSession(identifier sessionIdentifier: String, + allowCellular: Bool, + completionHandler: @escaping () -> Void) async -> URL? { + DDLogInfo("🟣 Reconnecting to background session: \(sessionIdentifier)") + + setBackgroundCompletionHandler(completionHandler) + + // Create session with same identifier - this reconnects to the existing download + let session = createBackgroundSession(identifier: sessionIdentifier, allowCellular: allowCellular) + + // Wait for delegate callbacks to complete + return try? await withCheckedThrowingContinuation { continuation in + downloadContinuations[sessionIdentifier] = continuation + } + } + public func cancelDownloads(for sessionIdentifier: String) async { if let task = downloadTasks[sessionIdentifier] { task.cancel() diff --git a/Modules/Sources/Networking/Network/BackgroundDownloadState.swift b/Modules/Sources/Networking/Network/BackgroundDownloadState.swift new file mode 100644 index 00000000000..9b7cbbf299a --- /dev/null +++ b/Modules/Sources/Networking/Network/BackgroundDownloadState.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Persisted state for background catalog downloads. +/// Allows the app to resume processing downloads after being terminated. +public struct BackgroundDownloadState: Codable { + let sessionIdentifier: String + let siteID: Int64 + + private static let userDefaultsKey = "com.woocommerce.pos.backgroundDownloadState" + private static var userDefaults: UserDefaults = .standard + + /// Configure UserDefaults instance for testing. + /// - Parameter userDefaults: The UserDefaults instance to use for persistence. + // periphery:ignore - required by tests + public static func configure(userDefaults: UserDefaults) { + self.userDefaults = userDefaults + } + + /// Saves download state for later retrieval. + public static func save(_ state: BackgroundDownloadState) { + let encoder = JSONEncoder() + if let encoded = try? encoder.encode(state) { + userDefaults.set(encoded, forKey: userDefaultsKey) + } + } + + /// Loads saved download state for a specific session identifier. + public static func load(for sessionIdentifier: String) -> BackgroundDownloadState? { + guard let data = userDefaults.data(forKey: userDefaultsKey), + let state = try? JSONDecoder().decode(BackgroundDownloadState.self, from: data), + state.sessionIdentifier == sessionIdentifier else { + return nil + } + return state + } + + /// Clears saved download state. + public static func clear() { + userDefaults.removeObject(forKey: userDefaultsKey) + } +} diff --git a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift index 96e8df683da..6017702eb30 100644 --- a/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift +++ b/Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift @@ -47,6 +47,14 @@ public protocol POSCatalogSyncRemoteProtocol { downloadURL: String, allowCellular: Bool) async throws -> POSCatalogResponse + /// Parses a downloaded catalog file. + /// Used for processing background downloads after app wake. + /// - Parameters: + /// - fileURL: Local file URL of the downloaded catalog. + /// - siteID: Site ID for proper mapping. + /// - Returns: Parsed POS catalog response. + func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse + /// Loads POS products for full sync. /// /// - Parameters: @@ -203,10 +211,25 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { } let sessionIdentifier = "\(POSCatalogSyncConstants.backgroundDownloadSessionPrefix).\(siteID).\(UUID().uuidString)" + + // Save download state so we can resume if app is terminated + let downloadState = BackgroundDownloadState( + sessionIdentifier: sessionIdentifier, + siteID: siteID + ) + BackgroundDownloadState.save(downloadState) + let fileURL = try await backgroundDownloader.downloadFile(from: url, sessionIdentifier: sessionIdentifier, allowCellular: allowCellular) - return try await parseDownloadedCatalog(from: fileURL, siteID: siteID) + + // Download completed - parse the file + let catalogResponse = try await parseDownloadedCatalog(from: fileURL, siteID: siteID) + + // Clear the saved state since we successfully completed + BackgroundDownloadState.clear() + + return catalogResponse } /// Parses the downloaded catalog file. @@ -214,7 +237,7 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol { /// - fileURL: Local file URL of the downloaded catalog. /// - siteID: Site ID for proper mapping. /// - Returns: Parsed POS catalog. - func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse { + public func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse { let data = try Data(contentsOf: fileURL) // Clean up downloaded files, but only if they're in our Documents directory. diff --git a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift index e05de53064e..34c4e71670e 100644 --- a/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift +++ b/Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift @@ -16,6 +16,7 @@ final class POSSettingsLocalCatalogViewModel { private let catalogSettingsService: POSCatalogSettingsServiceProtocol private let catalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol private let siteSettings: SiteSpecificAppSettingsStoreMethodsProtocol + private let syncStateModel: POSCatalogSyncStateModel private let dateFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.dateTimeStyle = .named @@ -23,6 +24,12 @@ final class POSSettingsLocalCatalogViewModel { return formatter }() + private var syncStateObservationTask: Task? + + private var currentSyncState: POSCatalogSyncState { + syncStateModel.state[siteID] ?? .syncNeverDone(siteID: siteID) + } + var allowFullSyncOnCellular: Bool { get { siteSettings.getPOSLocalCatalogCellularDataAllowed(siteID: siteID) @@ -39,7 +46,15 @@ final class POSSettingsLocalCatalogViewModel { self.siteID = siteID self.catalogSettingsService = catalogSettingsService self.catalogSyncCoordinator = catalogSyncCoordinator + self.syncStateModel = catalogSyncCoordinator.fullSyncStateModel self.siteSettings = siteSettings ?? SiteSpecificAppSettingsStoreMethods(fileStorage: PListFileStorage()) + + // Observe sync state changes to update UI when sync completes in background + startObservingSyncState() + } + + deinit { + syncStateObservationTask?.cancel() } @MainActor @@ -63,13 +78,63 @@ final class POSSettingsLocalCatalogViewModel { @MainActor func refreshCatalog() async { isRefreshingCatalog = true - defer { isRefreshingCatalog = false } do { try await catalogSyncCoordinator.performFullSync(for: siteID, regenerateCatalog: true) + // Sync completed synchronously - update UI + isRefreshingCatalog = false await loadCatalogData() } catch { DDLogError("⛔️ POSSettingsLocalCatalog: Failed to refresh catalog: \(error)") + isRefreshingCatalog = false + } + } + + /// Starts observing sync state changes to update UI when sync completes in background + private func startObservingSyncState() { + syncStateObservationTask = Task { @MainActor in + var previousState = currentSyncState + + while !Task.isCancelled { + // Wait for the next state change + await observeNextStateChange() + + // Read the new state after change is detected + let newState = currentSyncState + guard newState != previousState else { continue } + + // Handle terminal states when user initiated refresh + switch newState { + case .syncCompleted, .syncFailed, .initialSyncFailed: + // Sync finished - clear the refreshing state if it was set + if isRefreshingCatalog { + isRefreshingCatalog = false + // Reload catalog data to show updated info + await loadCatalogData() + } + case .syncStarted, .initialSyncStarted: + // Sync is running - keep spinner active + break + case .syncNeverDone: + // No sync has been done + break + } + previousState = newState + } + } + } + + /// Waits for the next change to the observed sync state. + /// Re-registers observation each time it's called. + private func observeNextStateChange() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + withObservationTracking { + // Access the observed property to register observation + _ = currentSyncState + } onChange: { + // When state changes, resume the continuation + continuation.resume() + } } } } diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index 6e7adfe7755..fcfc8699c67 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -645,6 +645,10 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol func stopOngoingSyncs(for siteID: Int64) async { // Preview implementation - no-op } + + func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws { + // no-op + } } #endif diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift index 4aff63741a7..2e48a7edab4 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift @@ -17,6 +17,13 @@ public protocol POSCatalogFullSyncServiceProtocol { /// - allowCellular: Should cellular data be used if required. /// - Returns: The synced catalog containing products and variations func startFullSync(for siteID: Int64, regenerateCatalog: Bool, allowCellular: Bool) async throws -> POSCatalog + + /// Parses and persists a downloaded catalog file from a background download. + /// - Parameters: + /// - fileURL: Local file URL of the downloaded catalog + /// - siteID: Site ID for this catalog + /// - Returns: The parsed catalog + func parseAndPersistBackgroundDownload(fileURL: URL, siteID: Int64) async throws -> POSCatalog } /// POS catalog from full sync. @@ -98,6 +105,27 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol throw error } } + + public func parseAndPersistBackgroundDownload(fileURL: URL, siteID: Int64) async throws -> POSCatalog { + DDLogInfo("🟣 Parsing background catalog download for site \(siteID)") + + let syncStartDate = Date.now + let catalogResponse = try await syncRemote.parseDownloadedCatalog(from: fileURL, siteID: siteID) + + let catalog = POSCatalog( + products: catalogResponse.products, + variations: catalogResponse.variations, + syncDate: syncStartDate + ) + + 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 + } } // MARK: - Remote Loading diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index d0d6ce312d5..e0b8ec32281 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -46,6 +46,14 @@ public protocol POSCatalogSyncCoordinatorProtocol { /// Stops all ongoing sync tasks for the specified site /// - Parameter siteID: The site ID to stop syncs for func stopOngoingSyncs(for siteID: Int64) async + + /// Processes a completed background catalog download. + /// Called when the app is woken by iOS for a background URLSession completion. + /// Parses and persists the catalog, then updates sync state. + /// - Parameters: + /// - fileURL: Local file URL of the downloaded catalog + /// - siteID: Site ID for this catalog + func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws } public extension POSCatalogSyncCoordinatorProtocol { @@ -423,6 +431,21 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { } } } + + public func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws { + DDLogInfo("🟣 POSCatalogSyncCoordinator: Processing background download for site \(siteID)") + + // Parse and persist using the full sync service + let catalog = try await fullSyncService.parseAndPersistBackgroundDownload(fileURL: fileURL, siteID: siteID) + + DDLogInfo("✅ Background catalog processed: \(catalog.products.count) products, \(catalog.variations.count) variations") + + // Update sync state to completed + emitSyncState(.syncCompleted(siteID: siteID)) + + // Record first sync date if needed + recordFirstSyncIfNeeded(for: siteID) + } } // MARK: - Syncing State diff --git a/Modules/Tests/NetworkingTests/Mocks/MockBackgroundDownloader.swift b/Modules/Tests/NetworkingTests/Mocks/MockBackgroundDownloader.swift index 13187b1889a..639fbde247c 100644 --- a/Modules/Tests/NetworkingTests/Mocks/MockBackgroundDownloader.swift +++ b/Modules/Tests/NetworkingTests/Mocks/MockBackgroundDownloader.swift @@ -12,6 +12,10 @@ final class MockBackgroundDownloader: BackgroundDownloadProtocol { var backgroundCompletionHandler: (() -> Void)? var cancelCallCount = 0 var lastCancelledSessionIdentifier: String? + var reconnectSessionCallCount = 0 + var lastReconnectSessionIdentifier: String? + var mockFileURL: URL? + var onDownloadStarted: (() -> Void)? private let fileManager: FileManager @@ -27,8 +31,8 @@ final class MockBackgroundDownloader: BackgroundDownloadProtocol { lastSessionIdentifier = sessionIdentifier lastAllowCellular = allowCellular - // Simulates async behavior - try await Task.sleep(nanoseconds: 1_000_000) // 1ms + // Notify test that download has started + onDownloadStarted?() switch downloadResult { case .success(let fileURL): @@ -42,6 +46,15 @@ final class MockBackgroundDownloader: BackgroundDownloadProtocol { backgroundCompletionHandler = completionHandler } + func reconnectToSession(identifier sessionIdentifier: String, + allowCellular: Bool, + completionHandler: @escaping () -> Void) async -> URL? { + reconnectSessionCallCount += 1 + lastReconnectSessionIdentifier = sessionIdentifier + setBackgroundCompletionHandler(completionHandler) + return mockFileURL + } + func cancelDownloads(for sessionIdentifier: String) async { cancelCallCount += 1 lastCancelledSessionIdentifier = sessionIdentifier @@ -71,6 +84,10 @@ extension MockBackgroundDownloader { backgroundCompletionHandler = nil cancelCallCount = 0 lastCancelledSessionIdentifier = nil + reconnectSessionCallCount = 0 + lastReconnectSessionIdentifier = nil + mockFileURL = nil + onDownloadStarted = nil } /// Simulate calling the background completion handler diff --git a/Modules/Tests/NetworkingTests/Network/BackgroundCatalogDownloadCoordinatorTests.swift b/Modules/Tests/NetworkingTests/Network/BackgroundCatalogDownloadCoordinatorTests.swift new file mode 100644 index 00000000000..0e64b71f7e6 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Network/BackgroundCatalogDownloadCoordinatorTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing +@testable import Networking + +struct BackgroundCatalogDownloadCoordinatorTests { + private let testDefaults: UserDefaults + + init() { + // Create isolated UserDefaults suite for this test + testDefaults = UserDefaults(suiteName: "BackgroundCatalogDownloadCoordinatorTests.\(UUID().uuidString)")! + BackgroundDownloadState.configure(userDefaults: testDefaults) + } + + @Test func handleBackgroundSessionEvent_loads_saved_state() async { + // Given + let sessionIdentifier = "com.woocommerce.pos.catalog.download.123" + let siteID: Int64 = 456 + let state = BackgroundDownloadState( + sessionIdentifier: sessionIdentifier, + siteID: siteID + ) + BackgroundDownloadState.save(state) + + let mockDownloader = MockBackgroundDownloader() + mockDownloader.mockFileURL = URL(fileURLWithPath: "/tmp/test.json") + let coordinator = BackgroundCatalogDownloadCoordinator(backgroundDownloader: mockDownloader) + + var parsedSiteID: Int64? + var parsedFileURL: URL? + + // When + await coordinator.handleBackgroundSessionEvent( + sessionIdentifier: sessionIdentifier, + completionHandler: { }, + parseHandler: { fileURL, siteID in + parsedFileURL = fileURL + parsedSiteID = siteID + } + ) + + // Then + #expect(parsedSiteID == siteID) + #expect(parsedFileURL?.path == "/tmp/test.json") + } + + @Test func handleBackgroundSessionEvent_calls_completion_handler_when_no_state() async { + // Given + let sessionIdentifier = "com.woocommerce.pos.catalog.download.999" + let mockDownloader = MockBackgroundDownloader() + let coordinator = BackgroundCatalogDownloadCoordinator(backgroundDownloader: mockDownloader) + + var completionCalled = false + var parseCalled = false + + // When + await coordinator.handleBackgroundSessionEvent( + sessionIdentifier: sessionIdentifier, + completionHandler: { + completionCalled = true + }, + parseHandler: { _, _ in + parseCalled = true + } + ) + + // Then + #expect(completionCalled == true) + #expect(parseCalled == false) // Should not parse without state + } + + @Test func handleBackgroundSessionEvent_clears_state_after_processing() async { + // Given + let sessionIdentifier = "com.woocommerce.pos.catalog.download.789" + let state = BackgroundDownloadState( + sessionIdentifier: sessionIdentifier, + siteID: 111 + ) + BackgroundDownloadState.save(state) + + let mockDownloader = MockBackgroundDownloader() + mockDownloader.mockFileURL = URL(fileURLWithPath: "/tmp/test.json") + let coordinator = BackgroundCatalogDownloadCoordinator(backgroundDownloader: mockDownloader) + + // When + await coordinator.handleBackgroundSessionEvent( + sessionIdentifier: sessionIdentifier, + completionHandler: { }, + parseHandler: { _, _ in } + ) + + // Then - state should be cleared + let loadedState = BackgroundDownloadState.load(for: sessionIdentifier) + #expect(loadedState == nil) + } + + @Test func handleBackgroundSessionEvent_reconnects_to_session() async { + // Given + let sessionIdentifier = "com.woocommerce.pos.catalog.download.reconnect" + let state = BackgroundDownloadState( + sessionIdentifier: sessionIdentifier, + siteID: 222 + ) + BackgroundDownloadState.save(state) + + let mockDownloader = MockBackgroundDownloader() + mockDownloader.mockFileURL = URL(fileURLWithPath: "/tmp/catalog.json") + let coordinator = BackgroundCatalogDownloadCoordinator(backgroundDownloader: mockDownloader) + + // When + await coordinator.handleBackgroundSessionEvent( + sessionIdentifier: sessionIdentifier, + completionHandler: { }, + parseHandler: { _, _ in } + ) + + // Then + #expect(mockDownloader.reconnectSessionCallCount == 1) + #expect(mockDownloader.lastReconnectSessionIdentifier == sessionIdentifier) + } +} diff --git a/Modules/Tests/NetworkingTests/Network/BackgroundDownloadStateTests.swift b/Modules/Tests/NetworkingTests/Network/BackgroundDownloadStateTests.swift new file mode 100644 index 00000000000..a5d28cfff23 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Network/BackgroundDownloadStateTests.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing +@testable import Networking + +struct BackgroundDownloadStateTests { + private let testDefaults: UserDefaults + + init() { + // Create isolated UserDefaults suite for this test + testDefaults = UserDefaults(suiteName: "BackgroundDownloadStateTests.\(UUID().uuidString)")! + BackgroundDownloadState.configure(userDefaults: testDefaults) + } + + @Test func save_persists_state_to_userdefaults() { + // Given + let state = BackgroundDownloadState( + sessionIdentifier: "test.session.123", + siteID: 456 + ) + + // When + BackgroundDownloadState.save(state) + + // Then + let loaded = BackgroundDownloadState.load(for: "test.session.123") + #expect(loaded?.sessionIdentifier == "test.session.123") + #expect(loaded?.siteID == 456) + } + + @Test func load_returns_nil_for_nonexistent_session() { + // When + let loaded = BackgroundDownloadState.load(for: "nonexistent.session") + + // Then + #expect(loaded == nil) + } + + @Test func load_returns_nil_for_different_session_identifier() { + // Given + let state = BackgroundDownloadState( + sessionIdentifier: "session.A", + siteID: 123 + ) + BackgroundDownloadState.save(state) + + // When + let loaded = BackgroundDownloadState.load(for: "session.B") + + // Then + #expect(loaded == nil) + } + + @Test func clear_removes_saved_state() { + // Given + let state = BackgroundDownloadState( + sessionIdentifier: "test.session", + siteID: 789 + ) + BackgroundDownloadState.save(state) + + // When + BackgroundDownloadState.clear() + + // Then + let loaded = BackgroundDownloadState.load(for: "test.session") + #expect(loaded == nil) + } + + @Test func save_overwrites_previous_state() { + // Given + let firstState = BackgroundDownloadState( + sessionIdentifier: "session.1", + siteID: 100 + ) + BackgroundDownloadState.save(firstState) + + let secondState = BackgroundDownloadState( + sessionIdentifier: "session.2", + siteID: 200 + ) + + // When + BackgroundDownloadState.save(secondState) + + // Then + let loadedFirst = BackgroundDownloadState.load(for: "session.1") + let loadedSecond = BackgroundDownloadState.load(for: "session.2") + + #expect(loadedFirst == nil) // First session is overwritten + #expect(loadedSecond?.sessionIdentifier == "session.2") + #expect(loadedSecond?.siteID == 200) + } +} diff --git a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift index 8934bc440a0..21e08aa4ced 100644 --- a/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/POSCatalogSyncRemoteTests.swift @@ -8,6 +8,13 @@ struct POSCatalogSyncRemoteTests { private let mockBackgroundDownloader = MockBackgroundDownloader() private let mockFileManager = MockFileManager() private let sampleSiteID: Int64 = 1234 + private let testDefaults: UserDefaults + + init() { + // Create isolated UserDefaults suite for this test + testDefaults = UserDefaults(suiteName: "POSCatalogSyncRemoteTests.\(UUID().uuidString)")! + BackgroundDownloadState.configure(userDefaults: testDefaults) + } @Test func loadProducts_sets_correct_parameters() async throws { // Given @@ -978,3 +985,84 @@ private class MockFileManager: FileManager { lastRemovedURL = URL } } + +// MARK: - Background Download State Tests + +extension POSCatalogSyncRemoteTests { + @Test func downloadCatalog_saves_background_state() async throws { + // Given + let remote = createRemote() + let downloadURL = "https://example.com/catalog.json" + + let mockFileURL = mockBackgroundDownloader.createMockDownloadFile(withContent: "[]") + mockBackgroundDownloader.mockSuccessfulDownload(fileURL: mockFileURL) + network.simulateResponse(requestUrlSuffix: "catalog", filename: "pos-catalog-download") + + // When - use continuation to capture state when download starts + let savedState: BackgroundDownloadState? = await withCheckedContinuation { continuation in + mockBackgroundDownloader.onDownloadStarted = { + // Download has started, state should be saved now + if let sessionIdentifier = mockBackgroundDownloader.lastSessionIdentifier { + let state = BackgroundDownloadState.load(for: sessionIdentifier) + continuation.resume(returning: state) + } else { + continuation.resume(returning: nil) + } + } + + Task { + _ = try? await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL, allowCellular: true) + } + } + + // Then - state should have been saved during download + #expect(savedState != nil) + #expect(savedState?.siteID == sampleSiteID) + #expect(savedState?.sessionIdentifier == mockBackgroundDownloader.lastSessionIdentifier) + + // Cleanup + try? FileManager.default.removeItem(at: mockFileURL) + } + + @Test func downloadCatalog_clears_state_on_success() async throws { + // Given + let remote = createRemote() + let downloadURL = "https://example.com/catalog.json" + + // When + let mockFileURL = mockBackgroundDownloader.createMockDownloadFile(withContent: "[]") + mockBackgroundDownloader.mockSuccessfulDownload(fileURL: mockFileURL) + network.simulateResponse(requestUrlSuffix: "catalog", filename: "pos-catalog-download") + _ = try? await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL, allowCellular: true) + + // Then - state should be cleared after successful completion + let savedState = BackgroundDownloadState.load(for: mockBackgroundDownloader.lastSessionIdentifier ?? "") + #expect(savedState == nil) + + // Cleanup + try? FileManager.default.removeItem(at: mockFileURL) + } + + @Test func downloadCatalog_creates_unique_session_identifiers() async throws { + // Given + let remote = createRemote() + let downloadURL = "https://example.com/catalog.json" + mockBackgroundDownloader.mockFileURL = URL(fileURLWithPath: "/tmp/catalog.json") + network.simulateResponse(requestUrlSuffix: "catalog", filename: "pos-catalog-download") + + // When - download twice + _ = try? await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL, allowCellular: true) + let firstSessionID = mockBackgroundDownloader.lastSessionIdentifier + + _ = try? await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL, allowCellular: true) + let secondSessionID = mockBackgroundDownloader.lastSessionIdentifier + + // Then - session IDs should be different + #expect(firstSessionID != secondSessionID) + #expect(firstSessionID?.hasPrefix("com.woocommerce.pos.catalog.download") == true) + #expect(secondSessionID?.hasPrefix("com.woocommerce.pos.catalog.download") == true) + + // Cleanup + BackgroundDownloadState.clear() + } +} diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift index 64987565465..cd7ba3e58af 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift @@ -76,4 +76,22 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { } func stopOngoingSyncs(for siteID: Int64) async {} + + var processBackgroundDownloadResult: Result = .success(()) + private(set) var processBackgroundDownloadCallCount = 0 + private(set) var lastProcessedFileURL: URL? + private(set) var lastProcessedSiteID: Int64? + + func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws { + processBackgroundDownloadCallCount += 1 + lastProcessedFileURL = fileURL + lastProcessedSiteID = siteID + + switch processBackgroundDownloadResult { + case .success: + return + case .failure(let error): + throw error + } + } } diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift index 805ece37cc6..721ac6d9ea7 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift @@ -154,6 +154,24 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol { } } + var parseDownloadedCatalogResult: Result = .success(.init(products: [], variations: [])) + private(set) var parseDownloadedCatalogCallCount = 0 + private(set) var lastParsedFileURL: URL? + private(set) var lastParsedSiteID: Int64? + + func parseDownloadedCatalog(from fileURL: URL, siteID: Int64) async throws -> POSCatalogResponse { + parseDownloadedCatalogCallCount += 1 + lastParsedFileURL = fileURL + lastParsedSiteID = siteID + + switch parseDownloadedCatalogResult { + case .success(let response): + return response + case .failure(let error): + throw error + } + } + // MARK: - Protocol Methods - Catalog size // MARK: - getProductCount tracking diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift index c4067d65b03..8749f3f9ba6 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift @@ -618,6 +618,24 @@ final class MockPOSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol { } } + var parseAndPersistBackgroundDownloadResult: Result = .success(POSCatalog(products: [], variations: [], syncDate: .now)) + private(set) var parseAndPersistBackgroundDownloadCallCount = 0 + private(set) var lastBackgroundDownloadFileURL: URL? + private(set) var lastBackgroundDownloadSiteID: Int64? + + func parseAndPersistBackgroundDownload(fileURL: URL, siteID: Int64) async throws -> POSCatalog { + parseAndPersistBackgroundDownloadCallCount += 1 + lastBackgroundDownloadFileURL = fileURL + lastBackgroundDownloadSiteID = siteID + + switch parseAndPersistBackgroundDownloadResult { + case .success(let catalog): + return catalog + case .failure(let error): + throw error + } + } + func blockNextSync() { shouldBlockSync = true } diff --git a/WooCommerce/Classes/AppDelegate.swift b/WooCommerce/Classes/AppDelegate.swift index 59a20bfd8ee..2fea14fc34e 100644 --- a/WooCommerce/Classes/AppDelegate.swift +++ b/WooCommerce/Classes/AppDelegate.swift @@ -2,7 +2,7 @@ import UIKit import Combine import Storage import class Networking.UserAgent -import Experiments +import class Networking.BackgroundCatalogDownloadCoordinator import protocol WooFoundation.Analytics import protocol Yosemite.StoresManager import struct Yosemite.Site @@ -149,16 +149,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { completionHandler: @escaping () -> Void) { DDLogInfo("🟣 Handling background URLSession events for identifier: \(identifier)") - // Store the completion handler for the background session - // The BackgroundDownloadService will invoke this when all events are processed if identifier.hasPrefix("com.woocommerce.pos.catalog.download") { - // In a production implementation, this would be passed to the BackgroundDownloadService - // For now, we store it and the service will need to retrieve it - // TODO: WOOMOB-1173 - Wire this to BackgroundDownloadService.setBackgroundCompletionHandler - // TODO: WOOMOB-1677 - Catalog parsing happens in the ~30s window after download completes. - // For very large catalogs, consider hybrid approach: try immediate parse, defer if timeout. - DDLogInfo("🟣 Background catalog download session completion handler stored") - completionHandler() + // Handle POS catalog download completion in background + Task { + let downloadCoordinator = BackgroundCatalogDownloadCoordinator() + await downloadCoordinator.handleBackgroundSessionEvent( + sessionIdentifier: identifier, + completionHandler: completionHandler, + parseHandler: { fileURL, siteID in + // Use the POS catalog sync coordinator to parse and persist + guard let coordinator = ServiceLocator.stores.posCatalogSyncCoordinator else { + throw NSError(domain: "com.woocommerce.pos", code: -1, + userInfo: [NSLocalizedDescriptionKey: "POS catalog coordinator not available"]) + } + try await coordinator.processBackgroundDownload(fileURL: fileURL, siteID: siteID) + } + ) + } } else { // Unknown session identifier - call completion handler immediately DDLogWarn("🟣 Unknown background URLSession identifier: \(identifier)") diff --git a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift index db96d99045b..922edf6ad23 100644 --- a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift +++ b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift @@ -303,4 +303,8 @@ private final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProt } func stopOngoingSyncs(for siteID: Int64) async {} + + func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws { + // Not used in these tests + } }