Skip to content
15 changes: 15 additions & 0 deletions Modules/Tests/NetworkingTests/Mocks/MockBackgroundDownloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ final class MockBackgroundDownloader: BackgroundDownloadProtocol {
var backgroundCompletionHandler: (() -> Void)?
var cancelCallCount = 0
var lastCancelledSessionIdentifier: String?
var reconnectSessionCallCount = 0
var lastReconnectSessionIdentifier: String?
var mockFileURL: URL?

private let fileManager: FileManager

Expand Down Expand Up @@ -42,6 +45,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
Expand Down Expand Up @@ -71,6 +83,9 @@ extension MockBackgroundDownloader {
backgroundCompletionHandler = nil
cancelCallCount = 0
lastCancelledSessionIdentifier = nil
reconnectSessionCallCount = 0
lastReconnectSessionIdentifier = nil
mockFileURL = nil
}

/// Simulate calling the background completion handler
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Foundation
import Testing
@testable import Networking

struct BackgroundCatalogDownloadCoordinatorTests {

@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,
startedAt: Date()
)
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")

// Cleanup
BackgroundDownloadState.clear()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAU this clean up is not needed, as we already perform it at the end of handleBackgroundSessionEvent right?

Not necessarily a suggestion, just something to think about: I wonder if there could be test isolation issues when these run in parallel, or a test crashes/fails before cleanup which could affect subsequent tests since these share UserDefaults.standard

}

@Test func handleBackgroundSessionEvent_calls_completion_handler_when_no_state() async {
// Given
BackgroundDownloadState.clear() // Ensure no saved state
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,
startedAt: Date()
)
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,
startedAt: Date()
)
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)

// Cleanup
BackgroundDownloadState.clear()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation
import Testing
@testable import Networking

struct BackgroundDownloadStateTests {

@Test func save_persists_state_to_userdefaults() {
// Given
let state = BackgroundDownloadState(
sessionIdentifier: "test.session.123",
siteID: 456,
startedAt: Date()
)

// When
BackgroundDownloadState.save(state)

// Then
let loaded = BackgroundDownloadState.load(for: "test.session.123")
#expect(loaded?.sessionIdentifier == "test.session.123")
#expect(loaded?.siteID == 456)
#expect(loaded?.startedAt != nil)

// Cleanup
BackgroundDownloadState.clear()
}

@Test func load_returns_nil_for_nonexistent_session() {
// Given
BackgroundDownloadState.clear()

// 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,
startedAt: Date()
)
BackgroundDownloadState.save(state)

// When
let loaded = BackgroundDownloadState.load(for: "session.B")

// Then
#expect(loaded == nil)

// Cleanup
BackgroundDownloadState.clear()
}

@Test func clear_removes_saved_state() {
// Given
let state = BackgroundDownloadState(
sessionIdentifier: "test.session",
siteID: 789,
startedAt: Date()
)
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,
startedAt: Date()
)
BackgroundDownloadState.save(firstState)

let secondState = BackgroundDownloadState(
sessionIdentifier: "session.2",
siteID: 200,
startedAt: Date()
)

// 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)

// Cleanup
BackgroundDownloadState.clear()
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not from this PR, but consider making a helper function in this test suite for clean up the temp files and background state, I think it's easy to miss the difference between .removeItem(at: documentsURL), .removeItem(at: tempFile) or when these should be called per test basis.

Original file line number Diff line number Diff line change
Expand Up @@ -978,3 +978,65 @@ 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"
BackgroundDownloadState.clear() // Start clean

// When
mockBackgroundDownloader.mockFileURL = URL(fileURLWithPath: "/tmp/catalog.json")
network.simulateResponse(requestUrlSuffix: "catalog", filename: "pos-catalog-download")
_ = try? await remote.downloadCatalog(for: sampleSiteID, downloadURL: downloadURL, allowCellular: true)

// Then - state should be saved with session identifier
let savedState = BackgroundDownloadState.load(for: mockBackgroundDownloader.lastSessionIdentifier ?? "")
#expect(savedState?.siteID == sampleSiteID)
#expect(savedState?.sessionIdentifier == mockBackgroundDownloader.lastSessionIdentifier)

// Cleanup
BackgroundDownloadState.clear()
}

@Test func downloadCatalog_clears_state_on_success() async throws {
// Given
let remote = createRemote()
let downloadURL = "https://example.com/catalog.json"

// When
mockBackgroundDownloader.mockFileURL = URL(fileURLWithPath: "/tmp/catalog.json")
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)
}

@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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,22 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
}

func stopOngoingSyncs(for siteID: Int64) async {}

var processBackgroundDownloadResult: Result<Void, Error> = .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
}
}
}
18 changes: 18 additions & 0 deletions Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,24 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol {
}
}

var parseDownloadedCatalogResult: Result<POSCatalogResponse, Error> = .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
Expand Down
Loading