@@ -8,17 +8,28 @@ import XCTest
88final class OfflineQueueTests : XCTestCase {
99
1010 private var tempFiles : [ URL ] = [ ]
11+ private var tempAux : [ URL ] = [ ]
1112
1213 override func tearDown( ) {
1314 for url in tempFiles {
1415 for suffix in [ " " , " -wal " , " -shm " ] {
1516 try ? FileManager . default. removeItem ( at: URL ( fileURLWithPath: url. path + suffix) )
1617 }
1718 }
19+ for url in tempAux { try ? FileManager . default. removeItem ( at: url) }
1820 tempFiles. removeAll ( )
21+ tempAux. removeAll ( )
1922 super. tearDown ( )
2023 }
2124
25+ /// Create a throwaway file of `bytes` and return its path (tracked for cleanup).
26+ private func makePhotoFile( bytes: Int ) -> String {
27+ let url = FileManager . default. temporaryDirectory. appendingPathComponent ( " photo_ \( UUID ( ) . uuidString) .jpg " )
28+ try ? Data ( repeating: 0xAB , count: bytes) . write ( to: url)
29+ tempAux. append ( url)
30+ return url. path
31+ }
32+
2233 private func tempPath( ) -> URL {
2334 let url = FileManager . default. temporaryDirectory. appendingPathComponent ( " oq_ \( UUID ( ) . uuidString) .sqlite " )
2435 tempFiles. append ( url)
@@ -170,6 +181,92 @@ final class OfflineQueueTests: XCTestCase {
170181 XCTAssertEqual ( q. pendingCount, 0 )
171182 }
172183
184+ // MARK: - Plan BM P1: durability
185+
186+ func testInFlightStrandRecoveredOnReopen( ) {
187+ let path = tempPath ( )
188+ let id : String
189+ do {
190+ let q = OfflineQueue ( path: path)
191+ let a = op ( " s " , at: 100 )
192+ id = a. id
193+ q. enqueue ( a)
194+ q. mark ( a. id, state: . inFlight) // killed mid-delivery
195+ XCTAssertTrue ( q. pending ( ) . isEmpty) // inFlight is invisible to pending()
196+ }
197+ let reopened = OfflineQueue ( path: path) // startup recovery re-arms it
198+ XCTAssertEqual ( reopened. pendingCount, 1 )
199+ XCTAssertEqual ( reopened. pending ( ) . first? . id, id)
200+ }
201+
202+ func testRecoverInFlightReturnsCount( ) {
203+ let q = OfflineQueue ( path: tempPath ( ) )
204+ let a = op ( " s " , at: 100 ) , b = op ( " s " , at: 200 )
205+ q. enqueue ( a) ; q. enqueue ( b)
206+ q. mark ( a. id, state: . inFlight)
207+ q. mark ( b. id, state: . inFlight)
208+ XCTAssertEqual ( q. recoverInFlight ( ) , 2 )
209+ XCTAssertEqual ( q. pendingCount, 2 )
210+ XCTAssertEqual ( q. recoverInFlight ( ) , 0 ) // nothing left to recover
211+ }
212+
213+ func testFlushDrainsBacklogBeyondBatchSize( ) async {
214+ let q = OfflineQueue ( path: tempPath ( ) )
215+ for i in 0 ..< 5 { q. enqueue ( op ( " s " , at: TimeInterval ( i) ) ) }
216+ let engine = SyncEngine ( queue: q, sink: LocalSyncSink ( ) )
217+ engine. batchSize = 2 // 5 ops, 2 per page → must loop
218+ let delivered = await engine. flush ( )
219+ XCTAssertEqual ( delivered, 5 )
220+ XCTAssertEqual ( q. pendingCount, 0 )
221+ }
222+
223+ func testAllTransientBacklogTerminatesAndRetainsOncePerOp( ) async {
224+ let q = OfflineQueue ( path: tempPath ( ) )
225+ for i in 0 ..< 4 { q. enqueue ( op ( " s " , at: TimeInterval ( i) ) ) }
226+ let sink = FakeSink ( ) ; sink. defaultOutcome = . transient( reason: " no signal " )
227+ let engine = SyncEngine ( queue: q, sink: sink)
228+ engine. batchSize = 1 // small page must not spin or skip ops
229+ _ = await engine. flush ( )
230+ XCTAssertEqual ( q. pendingCount, 4 ) // all retained, none lost
231+ XCTAssertEqual ( Set ( q. pending ( ) . map ( \. attempts) ) , [ 1 ] ) // each attempted exactly once
232+ XCTAssertEqual ( sink. deliveredIds. count, 4 ) // every op tried, no spin
233+ }
234+
235+ func testFlushMaintenancePurgesDeliveredNonPhotoTombstones( ) async {
236+ let q = OfflineQueue ( path: tempPath ( ) )
237+ q. enqueue ( op ( " s " , at: 100 ) ) ; q. enqueue ( op ( " s " , at: 200 ) )
238+ let engine = SyncEngine ( queue: q, sink: LocalSyncSink ( ) )
239+ _ = await engine. flush ( ) // delivers → done → maintenance purges
240+ XCTAssertTrue ( q. all ( ) . isEmpty, " delivered log tombstones are reclaimed post-flush " )
241+ }
242+
243+ func testPurgeDoneKeepsPhotoTombstones( ) {
244+ let q = OfflineQueue ( path: tempPath ( ) )
245+ let photo = QueuedOp . make ( kind: . photoUpload, sessionId: " s " , json: [ " path " : " /tmp/x.jpg " ] )
246+ let log = op ( " s " , at: 100 )
247+ q. enqueue ( photo) ; q. enqueue ( log)
248+ q. mark ( photo. id, state: . done)
249+ q. mark ( log. id, state: . done)
250+ q. purgeDone ( )
251+ // Photo tombstone survives (so prunePhotoEvidence can still manage its file); log is gone.
252+ XCTAssertEqual ( q. all ( ) . map ( \. id) , [ photo. id] )
253+ }
254+
255+ func testFlushPrunesDeliveredPhotoEvidencePastCap( ) async {
256+ let q = OfflineQueue ( path: tempPath ( ) )
257+ let paths = ( 0 ..< 3 ) . map { _ in makePhotoFile ( bytes: 100 ) }
258+ for p in paths {
259+ q. enqueue ( QueuedOp . make ( kind: . photoUpload, sessionId: " s " , json: [ " path " : p] ) )
260+ }
261+ let engine = SyncEngine ( queue: q, sink: LocalSyncSink ( ) )
262+ engine. photoEvidenceCapBytes = 150 // 300 bytes on disk, cap 150 → evict 2 oldest
263+ _ = await engine. flush ( )
264+
265+ let onDisk = paths. filter { FileManager . default. fileExists ( atPath: $0) }
266+ XCTAssertEqual ( onDisk. count, 1 , " only the newest delivered photo stays under the cap " )
267+ XCTAssertEqual ( q. all ( limit: 500 ) . filter { $0. kind == . photoUpload } . count, 1 )
268+ }
269+
173270 // MARK: - ConflictResolver
174271
175272 func testConflictResolverDetectsServerAdvance( ) {
0 commit comments