Skip to content

Commit 0452494

Browse files
committed
Add function to stop ongoing syncs when we log out
1 parent 87b06f9 commit 0452494

File tree

7 files changed

+231
-3
lines changed

7 files changed

+231
-3
lines changed

Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,10 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol
641641
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
642642
return false
643643
}
644+
645+
func stopOngoingSyncs(for siteID: Int64) async {
646+
// Preview implementation - no-op
647+
}
644648
}
645649

646650
#endif

Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ public protocol POSCatalogSyncCoordinatorProtocol {
4242
/// - maxDays: Maximum number of days before a sync is considered stale
4343
/// - Returns: True if the last sync is older than the specified days or if there has been no sync
4444
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool
45+
46+
/// Stops all ongoing sync tasks for the specified site
47+
/// - Parameter siteID: The site ID to stop syncs for
48+
func stopOngoingSyncs(for siteID: Int64) async
4549
}
4650

4751
public extension POSCatalogSyncCoordinatorProtocol {
@@ -81,6 +85,12 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
8185
/// Tracks ongoing incremental syncs by site ID to prevent duplicates
8286
private var ongoingIncrementalSyncs: Set<Int64> = []
8387

88+
/// Tracks ongoing full sync tasks by site ID for cancellation
89+
private var ongoingFullSyncTasks: [Int64: Task<Void, Error>] = [:]
90+
91+
/// Tracks ongoing incremental sync tasks by site ID for cancellation
92+
private var ongoingIncrementalSyncTasks: [Int64: Task<Void, Error>] = [:]
93+
8494
/// Observable model for full sync state updates
8595
public nonisolated let fullSyncStateModel: POSCatalogSyncStateModel = .init()
8696

@@ -117,10 +127,22 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
117127
let allowCellular = isFirstSync || siteSettings.getPOSLocalCatalogCellularDataAllowed(siteID: siteID)
118128
DDLogInfo("🔄 POSCatalogSyncCoordinator starting full sync for site \(siteID)")
119129

120-
do {
130+
// Create a task to perform the sync
131+
let syncTask = Task<Void, Error> {
121132
_ = try await fullSyncService.startFullSync(for: siteID,
122133
regenerateCatalog: regenerateCatalog,
123134
allowCellular: allowCellular)
135+
}
136+
137+
// Store the task for potential cancellation
138+
ongoingFullSyncTasks[siteID] = syncTask
139+
140+
defer {
141+
ongoingFullSyncTasks.removeValue(forKey: siteID)
142+
}
143+
144+
do {
145+
try await syncTask.value
124146
emitSyncState(.syncCompleted(siteID: siteID))
125147
} catch AFError.explicitlyCancelled, is CancellationError {
126148
if isFirstSync {
@@ -245,10 +267,22 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
245267

246268
DDLogInfo("🔄 POSCatalogSyncCoordinator starting incremental sync for site \(siteID)")
247269

248-
do {
270+
// Create a task to perform the sync
271+
let syncTask = Task<Void, Error> {
249272
try await incrementalSyncService.startIncrementalSync(for: siteID,
250273
lastFullSyncDate: lastFullSyncDate,
251-
lastIncrementalSyncDate: lastIncrementalSyncDate(for: siteID))
274+
lastIncrementalSyncDate: await lastIncrementalSyncDate(for: siteID))
275+
}
276+
277+
// Store the task for potential cancellation
278+
ongoingIncrementalSyncTasks[siteID] = syncTask
279+
280+
defer {
281+
ongoingIncrementalSyncTasks.removeValue(forKey: siteID)
282+
}
283+
284+
do {
285+
try await syncTask.value
252286
} catch AFError.explicitlyCancelled, is CancellationError {
253287
throw POSCatalogSyncError.requestCancelled
254288
}
@@ -349,6 +383,42 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
349383

350384
return lastFullSync < thresholdDate
351385
}
386+
387+
public func stopOngoingSyncs(for siteID: Int64) async {
388+
DDLogInfo("🛑 POSCatalogSyncCoordinator: Stopping ongoing syncs for site \(siteID)")
389+
390+
// Cancel ongoing full sync task if exists
391+
if let fullSyncTask = ongoingFullSyncTasks[siteID] {
392+
fullSyncTask.cancel()
393+
ongoingFullSyncTasks.removeValue(forKey: siteID)
394+
DDLogInfo("🛑 POSCatalogSyncCoordinator: Cancelled full sync task for site \(siteID)")
395+
}
396+
397+
// Cancel ongoing incremental sync task if exists
398+
if let incrementalSyncTask = ongoingIncrementalSyncTasks[siteID] {
399+
incrementalSyncTask.cancel()
400+
ongoingIncrementalSyncTasks.removeValue(forKey: siteID)
401+
DDLogInfo("🛑 POSCatalogSyncCoordinator: Cancelled incremental sync task for site \(siteID)")
402+
}
403+
404+
// Clean up incremental sync tracking
405+
if ongoingIncrementalSyncs.contains(siteID) {
406+
ongoingIncrementalSyncs.remove(siteID)
407+
DDLogInfo("🛑 POSCatalogSyncCoordinator: Cleaned up incremental sync tracking for site \(siteID)")
408+
}
409+
410+
// Update sync state to reflect that syncs are being stopped
411+
// This will prevent new syncs from starting for this site
412+
if let currentState = fullSyncStateModel.state[siteID] {
413+
switch currentState {
414+
case .initialSyncStarted, .syncStarted:
415+
emitSyncState(.syncFailed(siteID: siteID, error: POSCatalogSyncError.requestCancelled))
416+
DDLogInfo("🛑 POSCatalogSyncCoordinator: Updated sync state to cancelled for site \(siteID)")
417+
default:
418+
break
419+
}
420+
}
421+
}
352422
}
353423

354424
// MARK: - Syncing State

Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,6 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
7474
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
7575
return isSyncStaleResult
7676
}
77+
78+
func stopOngoingSyncs(for siteID: Int64) async {}
7779
}

Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,125 @@ extension POSCatalogSyncCoordinatorTests {
930930
#expect(isStale == true)
931931
}
932932

933+
// MARK: - Stop Ongoing Syncs Tests
934+
935+
@Test func stopOngoingSyncs_clears_incremental_sync_tracking() async throws {
936+
// Given - start an incremental sync
937+
let fullSyncDate = Date().addingTimeInterval(-3600)
938+
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: fullSyncDate)
939+
mockIncrementalSyncService.blockNextSync()
940+
941+
let syncTask = Task {
942+
try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge)
943+
}
944+
945+
// Give sync a moment to start
946+
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
947+
948+
// When - stop ongoing syncs
949+
await sut.stopOngoingSyncs(for: sampleSiteID)
950+
951+
// Then - incremental sync tracking should be cleared
952+
// Attempting to start another sync should succeed (not throw syncAlreadyInProgress)
953+
mockIncrementalSyncService.resumeBlockedSync()
954+
_ = try? await syncTask.value
955+
956+
mockIncrementalSyncService.startIncrementalSyncResult = .success(())
957+
try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge)
958+
#expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 2)
959+
}
960+
961+
@Test func stopOngoingSyncs_updates_full_sync_state_when_sync_in_progress() async throws {
962+
// Given - start a full sync
963+
mockSyncService.blockNextSync()
964+
mockSyncService.startFullSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now))
965+
966+
let syncTask = Task {
967+
try await sut.performFullSync(for: sampleSiteID)
968+
}
969+
970+
// Give sync a moment to start
971+
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
972+
973+
// Verify sync is in progress
974+
let stateBeforeStop = await sut.loadLastFullSyncState(for: sampleSiteID)
975+
let isSyncInProgress: Bool = switch stateBeforeStop {
976+
case .initialSyncStarted, .syncStarted: true
977+
default: false
978+
}
979+
#expect(isSyncInProgress)
980+
981+
// When - stop ongoing syncs
982+
await sut.stopOngoingSyncs(for: sampleSiteID)
983+
984+
// Then - sync state should be updated to failed with requestCancelled
985+
let stateAfterStop = await sut.loadLastFullSyncState(for: sampleSiteID)
986+
let isFailed: Bool = switch stateAfterStop {
987+
case .syncFailed(let siteID, let error):
988+
siteID == sampleSiteID && (error as? POSCatalogSyncError) == .requestCancelled
989+
case .initialSyncFailed(let siteID, let error):
990+
siteID == sampleSiteID && (error as? POSCatalogSyncError) == .requestCancelled
991+
default: false
992+
}
993+
#expect(isFailed)
994+
995+
// Cleanup
996+
mockSyncService.resumeBlockedSync()
997+
_ = try? await syncTask.value
998+
}
999+
1000+
@Test func stopOngoingSyncs_does_nothing_when_no_sync_in_progress() async throws {
1001+
// Given - no sync in progress
1002+
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-3600))
1003+
1004+
let stateBeforeStop = await sut.loadLastFullSyncState(for: sampleSiteID)
1005+
1006+
// When - stop ongoing syncs
1007+
await sut.stopOngoingSyncs(for: sampleSiteID)
1008+
1009+
// Then - state should remain unchanged
1010+
let stateAfterStop = await sut.loadLastFullSyncState(for: sampleSiteID)
1011+
#expect(stateBeforeStop == stateAfterStop)
1012+
}
1013+
1014+
@Test func stopOngoingSyncs_handles_different_sites_independently() async throws {
1015+
// Given - syncs for two different sites
1016+
let siteA: Int64 = 123
1017+
let siteB: Int64 = 456
1018+
let fullSyncDate = Date().addingTimeInterval(-3600)
1019+
1020+
try createSiteInDatabase(siteID: siteA, lastFullSyncDate: fullSyncDate)
1021+
try createSiteInDatabase(siteID: siteB, lastFullSyncDate: fullSyncDate)
1022+
1023+
mockIncrementalSyncService.blockNextSync()
1024+
1025+
// Start syncs for both sites
1026+
let syncTaskA = Task {
1027+
try await sut.performIncrementalSyncIfApplicable(for: siteA, maxAge: sampleMaxAge)
1028+
}
1029+
let syncTaskB = Task {
1030+
try await sut.performIncrementalSyncIfApplicable(for: siteB, maxAge: sampleMaxAge)
1031+
}
1032+
1033+
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
1034+
1035+
// When - stop syncs only for siteA
1036+
await sut.stopOngoingSyncs(for: siteA)
1037+
1038+
// Then - siteB sync should still throw syncAlreadyInProgress
1039+
await #expect(throws: POSCatalogSyncError.syncAlreadyInProgress(siteID: siteB)) {
1040+
try await sut.performIncrementalSyncIfApplicable(for: siteB, maxAge: sampleMaxAge)
1041+
}
1042+
1043+
// But siteA should allow new sync
1044+
mockIncrementalSyncService.resumeBlockedSync()
1045+
_ = try? await syncTaskA.value
1046+
_ = try? await syncTaskB.value
1047+
1048+
mockIncrementalSyncService.startIncrementalSyncResult = .success(())
1049+
try await sut.performIncrementalSyncIfApplicable(for: siteA, maxAge: sampleMaxAge)
1050+
}
1051+
9331052
// MARK: - Cellular Data Tests
9341053

9351054
@Test func performFullSync_allows_cellular_for_first_sync() async throws {

WooCommerce/Classes/Yosemite/DefaultStoresManager.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,13 @@ class DefaultStoresManager: StoresManager {
300300
dispatch(resetAction)
301301
}
302302

303+
// Stop any ongoing catalog sync tasks before resetting session
304+
if let siteID = sessionManager.defaultStoreID {
305+
Task {
306+
await posCatalogSyncCoordinator?.stopOngoingSyncs(for: siteID)
307+
}
308+
}
309+
303310
sessionManager.deleteApplicationPassword(locally: true)
304311
sessionManager.reset()
305312
state = DeauthenticatedState()

WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,4 +301,6 @@ private final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProt
301301
func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool {
302302
return false
303303
}
304+
305+
func stopOngoingSyncs(for siteID: Int64) async {}
304306
}

WooCommerce/WooCommerceTests/Yosemite/StoresManagerTests.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,30 @@ final class StoresManagerTests: XCTestCase {
157157
XCTAssertTrue(cardPresentPaymentOnboardingStateCache.invalidateCalled)
158158
}
159159

160+
/// Verifies that `deauthenticate` handles catalog sync cleanup gracefully when there is a default store.
161+
///
162+
@MainActor
163+
func testDeauthenticate_handles_catalog_sync_cleanup_with_default_store() async {
164+
// Arrange
165+
let sessionManager = SessionManager.testingInstance
166+
let manager = DefaultStoresManager(sessionManager: sessionManager,
167+
notificationCenter: MockNotificationCenter.testingInstance)
168+
manager.authenticate(credentials: SessionSettings.wpcomCredentials)
169+
manager.updateDefaultStore(storeID: 123)
170+
171+
XCTAssertEqual(sessionManager.defaultStoreID, 123, "Store ID should be set before deauthentication")
172+
173+
// Action - should not crash even with default store ID set
174+
manager.deauthenticate()
175+
176+
// Give any async cleanup time to execute
177+
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
178+
179+
// Assert - verify deauthentication completed successfully
180+
XCTAssertFalse(manager.isAuthenticated, "Manager should be deauthenticated")
181+
XCTAssertNil(sessionManager.defaultStoreID, "Default store ID should be cleared after deauthentication")
182+
}
183+
160184
/// Verifies that `authenticate(username: authToken:)` persists the Credentials in the Keychain Storage.
161185
///
162186
func testAuthenticatePersistsDefaultCredentialsInKeychain() {

0 commit comments

Comments
 (0)