@@ -46,7 +46,7 @@ struct POSCatalogSyncCoordinatorTests {
4646 mockSyncService. startFullSyncResult = . success( expectedCatalog)
4747
4848 // When
49- _ = try await sut. performFullSync ( for: sampleSiteID)
49+ try await sut. performFullSync ( for: sampleSiteID)
5050 let afterSync = Date ( )
5151
5252 // Then
@@ -66,7 +66,7 @@ struct POSCatalogSyncCoordinatorTests {
6666
6767 // When/Then
6868 await #expect( throws: expectedError) {
69- _ = try await sut. performFullSync ( for: sampleSiteID)
69+ try await sut. performFullSync ( for: sampleSiteID)
7070 }
7171
7272 // Should not store timestamp on failure
@@ -75,59 +75,59 @@ struct POSCatalogSyncCoordinatorTests {
7575
7676 // MARK: - Should Sync Decision Tests
7777
78- @Test func shouldPerformFullSync_returns_true_when_site_is_not_in_database_with_no_sync_history( ) {
78+ @Test func shouldPerformFullSync_returns_true_when_site_is_not_in_database_with_no_sync_history( ) async {
7979 // Given - site doesn't exist in database AND has no sync history
8080 mockSettingsStore. storedDates = [ : ]
8181 // Note: NOT creating site in database
8282
8383 // When
84- let shouldSync = sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 60 * 60 )
84+ let shouldSync = await sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 60 * 60 )
8585
8686 // Then - should sync because site doesn't exist in database
8787 #expect( shouldSync == true )
8888 }
8989
90- @Test func shouldPerformFullSync_returns_true_when_site_is_in_database_with_no_previous_sync( ) throws {
90+ @Test func shouldPerformFullSync_returns_true_when_site_is_in_database_with_no_previous_sync( ) async throws {
9191 // Given - no previous sync date stored, but site exists in database
9292 // This is much less likely to happen, but could help at a migration point
9393 mockSettingsStore. storedDates = [ : ]
9494 try createSiteInDatabase ( siteID: sampleSiteID)
9595
9696 // When
97- let shouldSync = sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 3600 )
97+ let shouldSync = await sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 3600 )
9898
9999 // Then
100100 #expect( shouldSync == true )
101101 #expect( mockSettingsStore. getPOSLastFullSyncDateCallCount == 1 )
102102 }
103103
104- @Test func shouldPerformFullSync_returns_true_when_sync_is_stale( ) throws {
104+ @Test func shouldPerformFullSync_returns_true_when_sync_is_stale( ) async throws {
105105 // Given - previous sync was 2 hours ago, and site exists in database
106106 let twoHoursAgo = Date ( ) . addingTimeInterval ( - 2 * 60 * 60 )
107107 mockSettingsStore. storedDates [ sampleSiteID] = twoHoursAgo
108108 try createSiteInDatabase ( siteID: sampleSiteID)
109109
110110 // When - max age is 1 hour
111- let shouldSync = sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 60 * 60 )
111+ let shouldSync = await sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 60 * 60 )
112112
113113 // Then
114114 #expect( shouldSync == true )
115115 }
116116
117- @Test func shouldPerformFullSync_returns_false_when_sync_is_fresh( ) throws {
117+ @Test func shouldPerformFullSync_returns_false_when_sync_is_fresh( ) async throws {
118118 // Given - previous sync was 30 minutes ago, and site exists in database
119119 let thirtyMinutesAgo = Date ( ) . addingTimeInterval ( - 30 * 60 )
120120 mockSettingsStore. storedDates [ sampleSiteID] = thirtyMinutesAgo
121121 try createSiteInDatabase ( siteID: sampleSiteID)
122122
123123 // When - max age is 1 hour
124- let shouldSync = sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 60 * 60 )
124+ let shouldSync = await sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 60 * 60 )
125125
126126 // Then
127127 #expect( shouldSync == false )
128128 }
129129
130- @Test func shouldPerformFullSync_handles_different_sites_independently( ) throws {
130+ @Test func shouldPerformFullSync_handles_different_sites_independently( ) async throws {
131131 // Given
132132 let siteA : Int64 = 123
133133 let siteB : Int64 = 456
@@ -141,55 +141,121 @@ struct POSCatalogSyncCoordinatorTests {
141141 try createSiteInDatabase ( siteID: siteB)
142142
143143 // When
144- let shouldSyncA = sut. shouldPerformFullSync ( for: siteA, maxAge: 2 * 60 * 60 ) // 2 hours
145- let shouldSyncB = sut. shouldPerformFullSync ( for: siteB, maxAge: 2 * 60 * 60 ) // 2 hours
144+ let shouldSyncA = await sut. shouldPerformFullSync ( for: siteA, maxAge: 2 * 60 * 60 ) // 2 hours
145+ let shouldSyncB = await sut. shouldPerformFullSync ( for: siteB, maxAge: 2 * 60 * 60 ) // 2 hours
146146
147147 // Then
148148 #expect( shouldSyncA == false ) // Recent sync exists
149149 #expect( shouldSyncB == true ) // No previous sync
150150 }
151151
152- @Test func shouldPerformFullSync_with_zero_maxAge_always_returns_true( ) throws {
152+ @Test func shouldPerformFullSync_with_zero_maxAge_always_returns_true( ) async throws {
153153 // Given - previous sync was just now, and site exists in database
154154 let justNow = Date ( )
155155 mockSettingsStore. storedDates [ sampleSiteID] = justNow
156156 try createSiteInDatabase ( siteID: sampleSiteID)
157157
158158 // When - max age is 0 (always sync)
159- let shouldSync = sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 0 )
159+ let shouldSync = await sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 0 )
160160
161161 // Then
162162 #expect( shouldSync == true )
163163 }
164164
165165 // MARK: - Database Check Tests
166166
167- @Test func shouldPerformFullSync_returns_true_when_site_not_in_database( ) {
167+ @Test func shouldPerformFullSync_returns_true_when_site_not_in_database( ) async {
168168 // Given - site does not exist in database, but has recent sync date
169169 let recentSyncDate = Date ( ) . addingTimeInterval ( - 30 * 60 ) // 30 minutes ago
170170 mockSettingsStore. storedDates [ sampleSiteID] = recentSyncDate
171171 // Note: not creating site in database so it won't exist
172172
173173 // When - max age is 1 hour (normally wouldn't sync)
174- let shouldSync = sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 60 * 60 )
174+ let shouldSync = await sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 60 * 60 )
175175
176176 // Then - should sync because site doesn't exist in database
177177 #expect( shouldSync == true )
178178 }
179179
180- @Test func shouldPerformFullSync_respects_time_when_site_exists_in_database( ) throws {
180+ @Test func shouldPerformFullSync_respects_time_when_site_exists_in_database( ) async throws {
181181 // Given - site exists in database with recent sync date
182182 let recentSyncDate = Date ( ) . addingTimeInterval ( - 30 * 60 ) // 30 minutes ago
183183 mockSettingsStore. storedDates [ sampleSiteID] = recentSyncDate
184184 try createSiteInDatabase ( siteID: sampleSiteID)
185185
186186 // When - max age is 1 hour
187- let shouldSync = sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 60 * 60 )
187+ let shouldSync = await sut. shouldPerformFullSync ( for: sampleSiteID, maxAge: 60 * 60 )
188188
189189 // Then - should not sync because site exists and time hasn't passed
190190 #expect( shouldSync == false )
191191 }
192192
193+ // MARK: - Sync Tracking Tests
194+
195+ @Test func performFullSync_throws_error_when_sync_already_in_progress( ) async throws {
196+ // Given - block the sync service so first sync will wait
197+ let expectedCatalog = POSCatalog ( products: [ ] , variations: [ ] )
198+ mockSyncService. startFullSyncResult = . success( expectedCatalog)
199+ mockSyncService. blockNextSync ( )
200+
201+ // Start first sync in a task (it will block waiting for continuation)
202+ let firstSyncTask = Task {
203+ try await sut. performFullSync ( for: sampleSiteID)
204+ }
205+
206+ // Give first sync a moment to start and get blocked
207+ try await Task . sleep ( nanoseconds: 10_000_000 ) // 10ms
208+
209+ // When - try to start second sync while first is blocked
210+ do {
211+ _ = try await sut. performFullSync ( for: sampleSiteID)
212+ #expect( Bool ( false ) , " Should have thrown syncAlreadyInProgress error " )
213+ } catch let error as POSCatalogSyncError {
214+ // Then
215+ #expect( error == POSCatalogSyncError . syncAlreadyInProgress ( siteID: sampleSiteID) )
216+ }
217+
218+ // Cleanup - resume the first sync and wait for it to complete
219+ mockSyncService. resumeBlockedSync ( )
220+ _ = try await firstSyncTask. value
221+ }
222+
223+ @Test func performFullSync_allows_concurrent_syncs_for_different_sites( ) async throws {
224+ // Given
225+ let siteA : Int64 = 123
226+ let siteB : Int64 = 456
227+ let expectedCatalog = POSCatalog ( products: [ ] , variations: [ ] )
228+ mockSyncService. startFullSyncResult = . success( expectedCatalog)
229+
230+ // When - start syncs for different sites concurrently
231+ async let syncA : ( ) = sut. performFullSync ( for: siteA)
232+ async let syncB : ( ) = sut. performFullSync ( for: siteB)
233+
234+ // Then - both should complete successfully
235+ try await syncA
236+ try await syncB
237+ #expect( mockSyncService. startFullSyncCallCount == 2 )
238+ }
239+
240+ @Test func sync_tracking_cleaned_up_on_error( ) async throws {
241+ // Given
242+ let expectedError = NSError ( domain: " test " , code: 1 , userInfo: nil )
243+ mockSyncService. startFullSyncResult = . failure( expectedError)
244+
245+ // When - sync fails
246+ do {
247+ _ = try await sut. performFullSync ( for: sampleSiteID)
248+ #expect( Bool ( false ) , " Should have thrown error " )
249+ } catch {
250+ // Expected error
251+ }
252+
253+ // Then - subsequent sync should be allowed
254+ mockSyncService. startFullSyncResult = . success( POSCatalog ( products: [ ] , variations: [ ] ) )
255+
256+ try await sut. performFullSync ( for: sampleSiteID)
257+ }
258+
193259 // MARK: - Helper Methods
194260
195261 private func createSiteInDatabase( siteID: Int64 ) throws {
@@ -204,6 +270,11 @@ struct POSCatalogSyncCoordinatorTests {
204270
205271final class MockPOSCatalogFullSyncService : POSCatalogFullSyncServiceProtocol {
206272 var startFullSyncResult : Result < POSCatalog , Error > = . success( POSCatalog ( products: [ ] , variations: [ ] ) )
273+ var syncDelay : UInt64 = 0 // nanoseconds to delay before returning
274+
275+ // Controlled sync mechanism
276+ private var syncContinuation : CheckedContinuation < Void , Never > ?
277+ private var shouldBlockSync = false
207278
208279 private( set) var startFullSyncCallCount = 0
209280 private( set) var lastSyncSiteID : Int64 ?
@@ -212,11 +283,33 @@ final class MockPOSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol {
212283 startFullSyncCallCount += 1
213284 lastSyncSiteID = siteID
214285
286+ // If we should block, wait for continuation to be resumed
287+ if shouldBlockSync {
288+ await withCheckedContinuation { continuation in
289+ syncContinuation = continuation
290+ }
291+ }
292+
293+ // Add delay if specified
294+ if syncDelay > 0 {
295+ try await Task . sleep ( nanoseconds: syncDelay)
296+ }
297+
215298 switch startFullSyncResult {
216299 case . success( let catalog) :
217300 return catalog
218301 case . failure( let error) :
219302 throw error
220303 }
221304 }
305+
306+ func blockNextSync( ) {
307+ shouldBlockSync = true
308+ }
309+
310+ func resumeBlockedSync( ) {
311+ syncContinuation? . resume ( )
312+ syncContinuation = nil
313+ shouldBlockSync = false
314+ }
222315}
0 commit comments