@kafitra/react-native-live-tracking 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +396 -0
  3. package/android/build.gradle +71 -0
  4. package/android/gradle.properties +7 -0
  5. package/android/src/main/AndroidManifest.xml +40 -0
  6. package/android/src/main/java/com/livetracking/LiveTrackingModuleImpl.kt +728 -0
  7. package/android/src/main/java/com/livetracking/LiveTrackingPackage.kt +16 -0
  8. package/android/src/main/java/com/livetracking/location/LocationEngine.kt +93 -0
  9. package/android/src/main/java/com/livetracking/network/NetworkListener.kt +127 -0
  10. package/android/src/main/java/com/livetracking/optimizer/ActivityRecognitionHandler.kt +248 -0
  11. package/android/src/main/java/com/livetracking/optimizer/MotionSleepManager.kt +130 -0
  12. package/android/src/main/java/com/livetracking/permissions/PermissionHandler.kt +145 -0
  13. package/android/src/main/java/com/livetracking/queue/QueueEngine.kt +167 -0
  14. package/android/src/main/java/com/livetracking/queue/QueuedLocation.kt +16 -0
  15. package/android/src/main/java/com/livetracking/queue/TrackingDatabase.kt +239 -0
  16. package/android/src/main/java/com/livetracking/receiver/BootReceiver.kt +53 -0
  17. package/android/src/main/java/com/livetracking/service/TrackingForegroundService.kt +145 -0
  18. package/android/src/main/java/com/livetracking/sync/FirebaseSyncEngine.kt +277 -0
  19. package/android/src/main/java/com/livetracking/sync/LocationDataPoint.kt +31 -0
  20. package/android/src/main/java/com/livetracking/sync/SyncEngineController.kt +220 -0
  21. package/android/src/main/java/com/livetracking/sync/SyncTargetConfig.kt +20 -0
  22. package/android/src/main/java/com/livetracking/sync/TargetHandler.kt +601 -0
  23. package/android/src/newarch/java/com/livetracking/LiveTrackingModule.kt +64 -0
  24. package/android/src/oldarch/java/com/livetracking/LiveTrackingModule.kt +70 -0
  25. package/android/src/test/java/com/livetracking/BackoffCalculationTest.kt +216 -0
  26. package/android/src/test/java/com/livetracking/BatchAccumulatorTest.kt +391 -0
  27. package/android/src/test/java/com/livetracking/BootReceiverTest.kt +247 -0
  28. package/android/src/test/java/com/livetracking/FirebaseSyncEngineTest.kt +337 -0
  29. package/android/src/test/java/com/livetracking/LocationEngineTest.kt +202 -0
  30. package/android/src/test/java/com/livetracking/MotionSleepManagerTest.kt +420 -0
  31. package/android/src/test/java/com/livetracking/OfflineQueueTest.kt +462 -0
  32. package/android/src/test/java/com/livetracking/PermissionHandlerTest.kt +200 -0
  33. package/android/src/test/java/com/livetracking/QueueEngineTest.kt +335 -0
  34. package/android/src/test/java/com/livetracking/SyncEngineControllerTest.kt +855 -0
  35. package/ios/ActivityRecognitionHandler.swift +196 -0
  36. package/ios/BackgroundModeHelper.swift +132 -0
  37. package/ios/FirebaseSyncEngine.swift +276 -0
  38. package/ios/LiveTracking-Bridging-Header.h +2 -0
  39. package/ios/LiveTracking.m +37 -0
  40. package/ios/LiveTracking.swift +773 -0
  41. package/ios/LocationDataPoint.swift +56 -0
  42. package/ios/LocationEngine.swift +160 -0
  43. package/ios/MotionSleepManager.swift +151 -0
  44. package/ios/NetworkListener.swift +105 -0
  45. package/ios/OfflineQueueManager.swift +503 -0
  46. package/ios/PermissionHandler.swift +148 -0
  47. package/ios/QueueEngine.swift +249 -0
  48. package/ios/SyncEngineController.swift +396 -0
  49. package/ios/SyncTargetConfig.swift +36 -0
  50. package/ios/TargetHandler.swift +715 -0
  51. package/ios/Tests/ActivityRecognitionHandlerTests.swift +259 -0
  52. package/ios/Tests/FirebaseSyncEngineTests.swift +303 -0
  53. package/ios/Tests/LocationEngineTests.swift +244 -0
  54. package/ios/Tests/MotionSleepManagerTests.swift +355 -0
  55. package/ios/Tests/NetworkListenerTests.swift +188 -0
  56. package/ios/Tests/OfflineQueueFlushTests.swift +375 -0
  57. package/ios/Tests/PermissionHandlerTests.swift +238 -0
  58. package/ios/Tests/QueueEngineTests.swift +346 -0
  59. package/ios/TrackingCleanup.swift +93 -0
  60. package/ios/TrackingNotificationManager.swift +187 -0
  61. package/lib/commonjs/EventEmitter.js +113 -0
  62. package/lib/commonjs/EventEmitter.js.map +1 -0
  63. package/lib/commonjs/LiveTracking.js +134 -0
  64. package/lib/commonjs/LiveTracking.js.map +1 -0
  65. package/lib/commonjs/NativeLiveTracking.js +21 -0
  66. package/lib/commonjs/NativeLiveTracking.js.map +1 -0
  67. package/lib/commonjs/filters/distanceTimeFilter.js +63 -0
  68. package/lib/commonjs/filters/distanceTimeFilter.js.map +1 -0
  69. package/lib/commonjs/index.js +103 -0
  70. package/lib/commonjs/index.js.map +1 -0
  71. package/lib/commonjs/serialization/locationSerializer.js +51 -0
  72. package/lib/commonjs/serialization/locationSerializer.js.map +1 -0
  73. package/lib/commonjs/types.js +77 -0
  74. package/lib/commonjs/types.js.map +1 -0
  75. package/lib/commonjs/utils/distance.js +63 -0
  76. package/lib/commonjs/utils/distance.js.map +1 -0
  77. package/lib/commonjs/utils/retry.js +80 -0
  78. package/lib/commonjs/utils/retry.js.map +1 -0
  79. package/lib/commonjs/validation.js +463 -0
  80. package/lib/commonjs/validation.js.map +1 -0
  81. package/lib/module/EventEmitter.js +105 -0
  82. package/lib/module/EventEmitter.js.map +1 -0
  83. package/lib/module/LiveTracking.js +127 -0
  84. package/lib/module/LiveTracking.js.map +1 -0
  85. package/lib/module/NativeLiveTracking.js +16 -0
  86. package/lib/module/NativeLiveTracking.js.map +1 -0
  87. package/lib/module/filters/distanceTimeFilter.js +58 -0
  88. package/lib/module/filters/distanceTimeFilter.js.map +1 -0
  89. package/lib/module/index.js +32 -0
  90. package/lib/module/index.js.map +1 -0
  91. package/lib/module/serialization/locationSerializer.js +45 -0
  92. package/lib/module/serialization/locationSerializer.js.map +1 -0
  93. package/lib/module/types.js +94 -0
  94. package/lib/module/types.js.map +1 -0
  95. package/lib/module/utils/distance.js +56 -0
  96. package/lib/module/utils/distance.js.map +1 -0
  97. package/lib/module/utils/retry.js +72 -0
  98. package/lib/module/utils/retry.js.map +1 -0
  99. package/lib/module/validation.js +456 -0
  100. package/lib/module/validation.js.map +1 -0
  101. package/lib/typescript/EventEmitter.d.ts +65 -0
  102. package/lib/typescript/EventEmitter.d.ts.map +1 -0
  103. package/lib/typescript/LiveTracking.d.ts +23 -0
  104. package/lib/typescript/LiveTracking.d.ts.map +1 -0
  105. package/lib/typescript/NativeLiveTracking.d.ts +25 -0
  106. package/lib/typescript/NativeLiveTracking.d.ts.map +1 -0
  107. package/lib/typescript/filters/distanceTimeFilter.d.ts +44 -0
  108. package/lib/typescript/filters/distanceTimeFilter.d.ts.map +1 -0
  109. package/lib/typescript/index.d.ts +21 -0
  110. package/lib/typescript/index.d.ts.map +1 -0
  111. package/lib/typescript/serialization/locationSerializer.d.ts +39 -0
  112. package/lib/typescript/serialization/locationSerializer.d.ts.map +1 -0
  113. package/lib/typescript/types.d.ts +217 -0
  114. package/lib/typescript/types.d.ts.map +1 -0
  115. package/lib/typescript/utils/distance.d.ts +38 -0
  116. package/lib/typescript/utils/distance.d.ts.map +1 -0
  117. package/lib/typescript/utils/retry.d.ts +60 -0
  118. package/lib/typescript/utils/retry.d.ts.map +1 -0
  119. package/lib/typescript/validation.d.ts +26 -0
  120. package/lib/typescript/validation.d.ts.map +1 -0
  121. package/package.json +126 -0
  122. package/react-native-live-tracking.podspec +47 -0
  123. package/src/EventEmitter.ts +118 -0
  124. package/src/LiveTracking.ts +159 -0
  125. package/src/NativeLiveTracking.ts +29 -0
  126. package/src/filters/distanceTimeFilter.ts +75 -0
  127. package/src/index.ts +51 -0
  128. package/src/serialization/locationSerializer.ts +57 -0
  129. package/src/types.ts +252 -0
  130. package/src/utils/distance.ts +68 -0
  131. package/src/utils/retry.ts +75 -0
  132. package/src/validation.ts +552 -0
@@ -0,0 +1,855 @@
1
+ package com.livetracking
2
+
3
+ import com.livetracking.sync.*
4
+ import org.junit.Assert.*
5
+ import org.junit.Test
6
+ import java.util.concurrent.*
7
+ import java.util.concurrent.atomic.AtomicBoolean
8
+ import java.util.concurrent.atomic.AtomicInteger
9
+
10
+ /**
11
+ * Unit tests for SyncEngineController and TargetHandler.
12
+ *
13
+ * Tests verify:
14
+ * - Multi-target parallel dispatch (Property 9: target isolation)
15
+ * - Batch accumulation invariant (Property 7)
16
+ * - Partial batch flush on stop (Property 8)
17
+ * - Target isolation on failure (Property 9)
18
+ * - Retry cancellation on newer data for set/update (Property 11)
19
+ *
20
+ * Validates: Requirements 3.1, 3.5, 4.1, 4.2, 4.4, 7.4, 7.7
21
+ */
22
+ class SyncEngineControllerTest {
23
+
24
+ private fun makeLocation(
25
+ lat: Double = 37.7749,
26
+ lng: Double = -122.4194,
27
+ timestamp: Long = System.currentTimeMillis(),
28
+ accuracy: Float = 10.0f,
29
+ speed: Float? = null,
30
+ altitude: Double? = null,
31
+ bearing: Float? = null
32
+ ): LocationDataPoint {
33
+ return LocationDataPoint(
34
+ latitude = lat,
35
+ longitude = lng,
36
+ timestamp = timestamp,
37
+ accuracy = accuracy,
38
+ speed = speed,
39
+ altitude = altitude,
40
+ bearing = bearing
41
+ )
42
+ }
43
+
44
+ // =========================================================================
45
+ // Property 7: Batch accumulation invariant
46
+ // For any SyncTarget with batchSize N > 1, after dispatching M location data
47
+ // points (where 1 ≤ M < N), zero write operations SHALL have been initiated.
48
+ // After dispatching exactly N points, exactly one batch write SHALL be initiated.
49
+ // Validates: Requirements 4.1, 4.2
50
+ // =========================================================================
51
+
52
+ @Test
53
+ fun `batch accumulation - no write until batchSize reached`() {
54
+ // Arrange: Create a TargetHandler with batchSize=5 and track writes
55
+ val writeCount = AtomicInteger(0)
56
+
57
+ val handler = createTestableTargetHandler(
58
+ config = SyncTargetConfig(path = "test/batch", method = "push", batchSize = 5),
59
+ onWrite = { writeCount.incrementAndGet() }
60
+ )
61
+
62
+ // Act: Dispatch 4 points (less than batchSize of 5)
63
+ repeat(4) {
64
+ handler.dispatch(makeLocation(timestamp = 1000L + it))
65
+ }
66
+
67
+ // Allow executor to process
68
+ Thread.sleep(200)
69
+
70
+ // Assert: No writes should have occurred
71
+ assertEquals(
72
+ "No write should occur before batchSize is reached",
73
+ 0, writeCount.get()
74
+ )
75
+
76
+ handler.shutdown()
77
+ }
78
+
79
+ @Test
80
+ fun `batch accumulation - write initiated at exactly batchSize`() {
81
+ // Arrange: Create a TargetHandler with batchSize=3 and track writes
82
+ val writeCount = AtomicInteger(0)
83
+
84
+ val handler = createTestableTargetHandler(
85
+ config = SyncTargetConfig(path = "test/batch", method = "push", batchSize = 3),
86
+ onWrite = { writeCount.incrementAndGet() }
87
+ )
88
+
89
+ // Act: Dispatch exactly 3 points (equals batchSize)
90
+ repeat(3) {
91
+ handler.dispatch(makeLocation(timestamp = 1000L + it))
92
+ }
93
+
94
+ // Allow executor to process
95
+ Thread.sleep(300)
96
+
97
+ // Assert: Exactly one write should have occurred
98
+ assertEquals(
99
+ "Exactly one write should occur when batchSize is reached",
100
+ 1, writeCount.get()
101
+ )
102
+
103
+ handler.shutdown()
104
+ }
105
+
106
+ @Test
107
+ fun `batch accumulation - two writes for 2x batchSize points`() {
108
+ // Arrange: batchSize=2, dispatch 4 points → expect 2 writes
109
+ val writeCount = AtomicInteger(0)
110
+
111
+ val handler = createTestableTargetHandler(
112
+ config = SyncTargetConfig(path = "test/batch", method = "push", batchSize = 2),
113
+ onWrite = { writeCount.incrementAndGet() }
114
+ )
115
+
116
+ // Act: Dispatch 4 points
117
+ repeat(4) {
118
+ handler.dispatch(makeLocation(timestamp = 1000L + it))
119
+ }
120
+
121
+ // Allow executor to process
122
+ Thread.sleep(300)
123
+
124
+ // Assert: Two writes should have occurred
125
+ assertEquals(
126
+ "Two writes should occur for 2x batchSize points",
127
+ 2, writeCount.get()
128
+ )
129
+
130
+ handler.shutdown()
131
+ }
132
+
133
+ @Test
134
+ fun `batch accumulation - immediate write when batchSize is 1`() {
135
+ // Arrange: batchSize=1 means every point triggers a write
136
+ val writeCount = AtomicInteger(0)
137
+
138
+ val handler = createTestableTargetHandler(
139
+ config = SyncTargetConfig(path = "test/immediate", method = "set", batchSize = 1),
140
+ onWrite = { writeCount.incrementAndGet() }
141
+ )
142
+
143
+ // Act: Dispatch 3 points
144
+ repeat(3) {
145
+ handler.dispatch(makeLocation(timestamp = 1000L + it))
146
+ }
147
+
148
+ // Allow executor to process
149
+ Thread.sleep(300)
150
+
151
+ // Assert: 3 writes (one per point)
152
+ assertEquals(
153
+ "Each point should trigger a write when batchSize is 1",
154
+ 3, writeCount.get()
155
+ )
156
+
157
+ handler.shutdown()
158
+ }
159
+
160
+ // =========================================================================
161
+ // Property 8: Partial batch flush on stop
162
+ // For any set of SyncTargets with partially-filled batches, when stop() is
163
+ // called, the Sync_Engine SHALL flush all accumulated points for every target.
164
+ // Validates: Requirements 4.4
165
+ // =========================================================================
166
+
167
+ @Test
168
+ fun `partial batch flush on stop - flushes accumulated points`() {
169
+ // Arrange: batchSize=10, dispatch 3 points (partial batch)
170
+ val writeCount = AtomicInteger(0)
171
+ val writtenPointCount = AtomicInteger(0)
172
+
173
+ val handler = createTestableTargetHandler(
174
+ config = SyncTargetConfig(path = "test/flush", method = "push", batchSize = 10),
175
+ onWriteWithPoints = { points ->
176
+ writeCount.incrementAndGet()
177
+ writtenPointCount.addAndGet(points)
178
+ }
179
+ )
180
+
181
+ // Act: Dispatch 3 points (less than batchSize of 10)
182
+ repeat(3) {
183
+ handler.dispatch(makeLocation(timestamp = 1000L + it))
184
+ }
185
+ Thread.sleep(200)
186
+
187
+ // Verify no write yet
188
+ assertEquals(0, writeCount.get())
189
+
190
+ // Act: Flush (simulates stop)
191
+ handler.flush()
192
+
193
+ // Assert: The partial batch should have been flushed
194
+ assertEquals(
195
+ "Flush should trigger a write for partial batch",
196
+ 1, writeCount.get()
197
+ )
198
+ assertEquals(
199
+ "All 3 accumulated points should be written",
200
+ 3, writtenPointCount.get()
201
+ )
202
+
203
+ handler.shutdown()
204
+ }
205
+
206
+ @Test
207
+ fun `partial batch flush on stop - SyncEngineController flushAll flushes all targets`() {
208
+ // Arrange: Two targets with partial batches
209
+ val target1Writes = AtomicInteger(0)
210
+ val target2Writes = AtomicInteger(0)
211
+
212
+ val handler1 = createTestableTargetHandler(
213
+ config = SyncTargetConfig(path = "target/one", method = "push", batchSize = 10),
214
+ onWrite = { target1Writes.incrementAndGet() }
215
+ )
216
+ val handler2 = createTestableTargetHandler(
217
+ config = SyncTargetConfig(path = "target/two", method = "push", batchSize = 10),
218
+ onWrite = { target2Writes.incrementAndGet() }
219
+ )
220
+
221
+ // Dispatch partial batches to both
222
+ repeat(3) { handler1.dispatch(makeLocation(timestamp = 1000L + it)) }
223
+ repeat(5) { handler2.dispatch(makeLocation(timestamp = 2000L + it)) }
224
+ Thread.sleep(200)
225
+
226
+ // Verify no writes yet
227
+ assertEquals(0, target1Writes.get())
228
+ assertEquals(0, target2Writes.get())
229
+
230
+ // Act: Flush both (simulates SyncEngineController.flushAll())
231
+ handler1.flush()
232
+ handler2.flush()
233
+
234
+ // Assert: Both targets should have flushed
235
+ assertEquals("Target 1 should flush partial batch", 1, target1Writes.get())
236
+ assertEquals("Target 2 should flush partial batch", 1, target2Writes.get())
237
+
238
+ handler1.shutdown()
239
+ handler2.shutdown()
240
+ }
241
+
242
+ @Test
243
+ fun `partial batch flush on stop - no write when batch is empty`() {
244
+ // Arrange: No points dispatched
245
+ val writeCount = AtomicInteger(0)
246
+
247
+ val handler = createTestableTargetHandler(
248
+ config = SyncTargetConfig(path = "test/empty", method = "push", batchSize = 10),
249
+ onWrite = { writeCount.incrementAndGet() }
250
+ )
251
+
252
+ // Act: Flush with empty batch
253
+ handler.flush()
254
+
255
+ // Assert: No write should occur
256
+ assertEquals(
257
+ "No write should occur when flushing an empty batch",
258
+ 0, writeCount.get()
259
+ )
260
+
261
+ handler.shutdown()
262
+ }
263
+
264
+ // =========================================================================
265
+ // Property 9: Target isolation on failure
266
+ // If a write operation fails for one target, all other targets SHALL continue
267
+ // to receive and process new location data points independently.
268
+ // Validates: Requirements 3.5, 7.4
269
+ // =========================================================================
270
+
271
+ @Test
272
+ fun `target isolation - failure in one target does not block others`() {
273
+ // Arrange: Two handlers - one that always fails, one that succeeds
274
+ val failingWriteCount = AtomicInteger(0)
275
+ val successWriteCount = AtomicInteger(0)
276
+
277
+ val failingHandler = createTestableTargetHandler(
278
+ config = SyncTargetConfig(path = "target/failing", method = "set", batchSize = 1),
279
+ onWrite = {
280
+ failingWriteCount.incrementAndGet()
281
+ throw RuntimeException("Simulated Firebase failure")
282
+ }
283
+ )
284
+
285
+ val successHandler = createTestableTargetHandler(
286
+ config = SyncTargetConfig(path = "target/success", method = "set", batchSize = 1),
287
+ onWrite = { successWriteCount.incrementAndGet() }
288
+ )
289
+
290
+ // Act: Dispatch locations to both handlers
291
+ val location = makeLocation()
292
+ failingHandler.dispatch(location)
293
+ successHandler.dispatch(location)
294
+
295
+ // Allow processing
296
+ Thread.sleep(300)
297
+
298
+ // Assert: Success handler should have processed the write
299
+ assertTrue(
300
+ "Successful target should process writes regardless of other target failures",
301
+ successWriteCount.get() >= 1
302
+ )
303
+
304
+ failingHandler.shutdown()
305
+ successHandler.shutdown()
306
+ }
307
+
308
+ @Test
309
+ fun `target isolation - multiple targets process independently`() {
310
+ // Arrange: Three targets with different batch sizes
311
+ val writes = Array(3) { AtomicInteger(0) }
312
+
313
+ val handlers = listOf(
314
+ createTestableTargetHandler(
315
+ config = SyncTargetConfig(path = "target/a", method = "push", batchSize = 1),
316
+ onWrite = { writes[0].incrementAndGet() }
317
+ ),
318
+ createTestableTargetHandler(
319
+ config = SyncTargetConfig(path = "target/b", method = "push", batchSize = 2),
320
+ onWrite = { writes[1].incrementAndGet() }
321
+ ),
322
+ createTestableTargetHandler(
323
+ config = SyncTargetConfig(path = "target/c", method = "push", batchSize = 3),
324
+ onWrite = { writes[2].incrementAndGet() }
325
+ )
326
+ )
327
+
328
+ // Act: Dispatch 3 locations to all handlers
329
+ repeat(3) { i ->
330
+ val loc = makeLocation(timestamp = 1000L + i)
331
+ handlers.forEach { it.dispatch(loc) }
332
+ }
333
+
334
+ Thread.sleep(400)
335
+
336
+ // Assert: Each target should have written according to its own batchSize
337
+ // target/a (batchSize=1): 3 writes (one per point)
338
+ // target/b (batchSize=2): 1 write (2 points), 1 point still buffered
339
+ // target/c (batchSize=3): 1 write (3 points)
340
+ assertEquals("Target A (batchSize=1) should have 3 writes", 3, writes[0].get())
341
+ assertEquals("Target B (batchSize=2) should have 1 write", 1, writes[1].get())
342
+ assertEquals("Target C (batchSize=3) should have 1 write", 1, writes[2].get())
343
+
344
+ handlers.forEach { it.shutdown() }
345
+ }
346
+
347
+ @Test
348
+ fun `target isolation - SyncEngineController dispatches to all targets in parallel`() {
349
+ // Arrange: Create a controller with multiple targets via JSON
350
+ val targetsJson = """[
351
+ {"path": "parallel/target1", "method": "set", "batchSize": 1},
352
+ {"path": "parallel/target2", "method": "push", "batchSize": 1},
353
+ {"path": "parallel/target3", "method": "update", "batchSize": 1}
354
+ ]"""
355
+
356
+ // We can't easily mock Firebase in the controller, but we can verify
357
+ // that the controller creates handlers for all targets
358
+ val controller = SyncEngineController(
359
+ targetsJson = targetsJson,
360
+ firebaseService = "RTDB",
361
+ networkChecker = { true }
362
+ )
363
+
364
+ // Assert: All target paths are registered
365
+ val paths = controller.getTargetPaths()
366
+ assertEquals(3, paths.size)
367
+ assertTrue(paths.contains("parallel/target1"))
368
+ assertTrue(paths.contains("parallel/target2"))
369
+ assertTrue(paths.contains("parallel/target3"))
370
+
371
+ controller.shutdown()
372
+ }
373
+
374
+ // =========================================================================
375
+ // Property 11: Retry cancellation on newer data for overwrite targets
376
+ // For set/update targets with an active retry, when new data arrives,
377
+ // the in-progress retry SHALL be cancelled and only the newest data written.
378
+ // Validates: Requirements 7.7
379
+ // =========================================================================
380
+
381
+ @Test
382
+ fun `retry cancellation - set target cancels retry when newer data arrives`() {
383
+ // Arrange: A set target that fails on first write (triggering retry)
384
+ // then receives new data which should cancel the retry
385
+ val writeAttempts = mutableListOf<Long>() // Track timestamps of write attempts
386
+ val writeLatch = CountDownLatch(1)
387
+ var firstCallFailed = false
388
+
389
+ val handler = createTestableTargetHandlerWithRetry(
390
+ config = SyncTargetConfig(path = "test/set-cancel", method = "set", batchSize = 1),
391
+ onWrite = { timestamp ->
392
+ synchronized(writeAttempts) {
393
+ writeAttempts.add(timestamp)
394
+ }
395
+ if (!firstCallFailed) {
396
+ firstCallFailed = true
397
+ // Simulate transient failure to trigger retry
398
+ throw TransientWriteException("Network timeout")
399
+ }
400
+ // Subsequent calls succeed
401
+ writeLatch.countDown()
402
+ }
403
+ )
404
+
405
+ // Act: Dispatch first location (will fail and start retry)
406
+ handler.dispatch(makeLocation(timestamp = 1000L))
407
+ Thread.sleep(100) // Let the first dispatch process and fail
408
+
409
+ // Dispatch newer location (should cancel retry of old data)
410
+ handler.dispatch(makeLocation(timestamp = 2000L))
411
+
412
+ // Wait for the write to complete
413
+ writeLatch.await(3, TimeUnit.SECONDS)
414
+ Thread.sleep(200)
415
+
416
+ // Assert: The last written timestamp should be the newer one (2000L)
417
+ synchronized(writeAttempts) {
418
+ assertTrue(
419
+ "At least one write attempt should have been made",
420
+ writeAttempts.isNotEmpty()
421
+ )
422
+ // The final successful write should be for the newer data
423
+ assertEquals(
424
+ "The newest data (timestamp 2000) should be the last written",
425
+ 2000L, writeAttempts.last()
426
+ )
427
+ }
428
+
429
+ handler.shutdown()
430
+ }
431
+
432
+ @Test
433
+ fun `retry cancellation - update target cancels retry when newer data arrives`() {
434
+ // Same as set target - update should also cancel retries on newer data
435
+ val writeAttempts = mutableListOf<Long>()
436
+ val writeLatch = CountDownLatch(1)
437
+ var firstCallFailed = false
438
+
439
+ val handler = createTestableTargetHandlerWithRetry(
440
+ config = SyncTargetConfig(path = "test/update-cancel", method = "update", batchSize = 1),
441
+ onWrite = { timestamp ->
442
+ synchronized(writeAttempts) {
443
+ writeAttempts.add(timestamp)
444
+ }
445
+ if (!firstCallFailed) {
446
+ firstCallFailed = true
447
+ throw TransientWriteException("Network timeout")
448
+ }
449
+ writeLatch.countDown()
450
+ }
451
+ )
452
+
453
+ // Act: Dispatch first location (will fail and start retry)
454
+ handler.dispatch(makeLocation(timestamp = 1000L))
455
+ Thread.sleep(100)
456
+
457
+ // Dispatch newer location (should cancel retry)
458
+ handler.dispatch(makeLocation(timestamp = 2000L))
459
+
460
+ writeLatch.await(3, TimeUnit.SECONDS)
461
+ Thread.sleep(200)
462
+
463
+ // Assert: Final write should be for newer data
464
+ synchronized(writeAttempts) {
465
+ assertTrue(writeAttempts.isNotEmpty())
466
+ assertEquals(
467
+ "Update target should write newest data after cancelling retry",
468
+ 2000L, writeAttempts.last()
469
+ )
470
+ }
471
+
472
+ handler.shutdown()
473
+ }
474
+
475
+ @Test
476
+ fun `retry cancellation - push target does NOT cancel retry on newer data`() {
477
+ // Push targets should NOT cancel retries - all data must be preserved
478
+ val writeCount = AtomicInteger(0)
479
+
480
+ val handler = createTestableTargetHandler(
481
+ config = SyncTargetConfig(path = "test/push-no-cancel", method = "push", batchSize = 1),
482
+ onWrite = { writeCount.incrementAndGet() }
483
+ )
484
+
485
+ // Act: Dispatch multiple locations rapidly
486
+ repeat(3) {
487
+ handler.dispatch(makeLocation(timestamp = 1000L + it))
488
+ }
489
+
490
+ Thread.sleep(400)
491
+
492
+ // Assert: All writes should proceed (no cancellation for push)
493
+ assertEquals(
494
+ "Push target should write all data without cancellation",
495
+ 3, writeCount.get()
496
+ )
497
+
498
+ handler.shutdown()
499
+ }
500
+
501
+ // =========================================================================
502
+ // SyncEngineController JSON parsing and multi-target creation tests
503
+ // Validates: Requirements 3.1
504
+ // =========================================================================
505
+
506
+ @Test
507
+ fun `SyncEngineController parses valid targets JSON`() {
508
+ val targetsJson = """[
509
+ {"path": "users/abc/location", "method": "set"},
510
+ {"path": "trips/xyz/history", "method": "push", "batchSize": 20, "offlineQueue": true}
511
+ ]"""
512
+
513
+ val controller = SyncEngineController(
514
+ targetsJson = targetsJson,
515
+ firebaseService = "RTDB",
516
+ networkChecker = { true }
517
+ )
518
+
519
+ val paths = controller.getTargetPaths()
520
+ assertEquals(2, paths.size)
521
+ assertEquals("users/abc/location", paths[0])
522
+ assertEquals("trips/xyz/history", paths[1])
523
+
524
+ controller.shutdown()
525
+ }
526
+
527
+ @Test(expected = IllegalArgumentException::class)
528
+ fun `SyncEngineController rejects empty targets array`() {
529
+ SyncEngineController(
530
+ targetsJson = "[]",
531
+ firebaseService = "RTDB",
532
+ networkChecker = { true }
533
+ )
534
+ }
535
+
536
+ @Test(expected = IllegalArgumentException::class)
537
+ fun `SyncEngineController rejects invalid JSON`() {
538
+ SyncEngineController(
539
+ targetsJson = "not valid json",
540
+ firebaseService = "RTDB",
541
+ networkChecker = { true }
542
+ )
543
+ }
544
+
545
+ @Test(expected = IllegalArgumentException::class)
546
+ fun `SyncEngineController rejects target missing path`() {
547
+ SyncEngineController(
548
+ targetsJson = """[{"method": "set"}]""",
549
+ firebaseService = "RTDB",
550
+ networkChecker = { true }
551
+ )
552
+ }
553
+
554
+ @Test(expected = IllegalArgumentException::class)
555
+ fun `SyncEngineController rejects target missing method`() {
556
+ SyncEngineController(
557
+ targetsJson = """[{"path": "test/path"}]""",
558
+ firebaseService = "RTDB",
559
+ networkChecker = { true }
560
+ )
561
+ }
562
+
563
+ @Test(expected = IllegalArgumentException::class)
564
+ fun `SyncEngineController rejects target with invalid method`() {
565
+ SyncEngineController(
566
+ targetsJson = """[{"path": "test/path", "method": "delete"}]""",
567
+ firebaseService = "RTDB",
568
+ networkChecker = { true }
569
+ )
570
+ }
571
+
572
+ @Test
573
+ fun `SyncEngineController getQueuedCounts returns zero for non-queuing targets`() {
574
+ val targetsJson = """[
575
+ {"path": "target/a", "method": "set"},
576
+ {"path": "target/b", "method": "push"}
577
+ ]"""
578
+
579
+ val controller = SyncEngineController(
580
+ targetsJson = targetsJson,
581
+ firebaseService = "RTDB",
582
+ networkChecker = { true }
583
+ )
584
+
585
+ val counts = controller.getQueuedCounts()
586
+ assertEquals(0, counts["target/a"])
587
+ assertEquals(0, counts["target/b"])
588
+
589
+ controller.shutdown()
590
+ }
591
+
592
+ // =========================================================================
593
+ // TargetHandler - offline behavior tests
594
+ // =========================================================================
595
+
596
+ @Test
597
+ fun `TargetHandler discards data when offline and offlineQueue disabled`() {
598
+ val writeCount = AtomicInteger(0)
599
+
600
+ val handler = createTestableTargetHandler(
601
+ config = SyncTargetConfig(path = "test/discard", method = "set", batchSize = 1, offlineQueue = false),
602
+ networkOnline = false,
603
+ onWrite = { writeCount.incrementAndGet() }
604
+ )
605
+
606
+ // Dispatch while offline
607
+ handler.dispatch(makeLocation())
608
+ Thread.sleep(200)
609
+
610
+ // No write should occur (data discarded)
611
+ assertEquals(0, writeCount.get())
612
+
613
+ handler.shutdown()
614
+ }
615
+
616
+ @Test
617
+ fun `TargetHandler does not dispatch after shutdown`() {
618
+ val writeCount = AtomicInteger(0)
619
+
620
+ val handler = createTestableTargetHandler(
621
+ config = SyncTargetConfig(path = "test/shutdown", method = "set", batchSize = 1),
622
+ onWrite = { writeCount.incrementAndGet() }
623
+ )
624
+
625
+ // Shutdown first
626
+ handler.shutdown()
627
+
628
+ // Dispatch after shutdown
629
+ handler.dispatch(makeLocation())
630
+ Thread.sleep(200)
631
+
632
+ // No write should occur
633
+ assertEquals(0, writeCount.get())
634
+ }
635
+
636
+ // =========================================================================
637
+ // Helper: Testable TargetHandler that bypasses Firebase
638
+ // =========================================================================
639
+
640
+ /**
641
+ * Creates a TargetHandler subclass that intercepts write operations
642
+ * for testing without requiring Firebase SDK initialization.
643
+ */
644
+ private fun createTestableTargetHandler(
645
+ config: SyncTargetConfig,
646
+ networkOnline: Boolean = true,
647
+ onWrite: (() -> Unit)? = null,
648
+ onWriteWithPoints: ((Int) -> Unit)? = null
649
+ ): TestableTargetHandler {
650
+ return TestableTargetHandler(
651
+ config = config,
652
+ networkOnline = networkOnline,
653
+ onWrite = onWrite,
654
+ onWriteWithPoints = onWriteWithPoints
655
+ )
656
+ }
657
+
658
+ private fun createTestableTargetHandlerWithRetry(
659
+ config: SyncTargetConfig,
660
+ onWrite: (Long) -> Unit
661
+ ): TestableTargetHandlerWithRetry {
662
+ return TestableTargetHandlerWithRetry(
663
+ config = config,
664
+ onWrite = onWrite
665
+ )
666
+ }
667
+ }
668
+
669
+ /**
670
+ * Exception class to simulate transient Firebase write failures in tests.
671
+ */
672
+ class TransientWriteException(message: String) : RuntimeException(message)
673
+
674
+ /**
675
+ * A testable TargetHandler that bypasses Firebase SDK calls.
676
+ * Instead of writing to Firebase, it invokes the provided callbacks.
677
+ *
678
+ * This allows testing batch accumulation, flush, and dispatch logic
679
+ * without requiring Firebase initialization.
680
+ */
681
+ class TestableTargetHandler(
682
+ private val config: SyncTargetConfig,
683
+ private val networkOnline: Boolean = true,
684
+ private val onWrite: (() -> Unit)? = null,
685
+ private val onWriteWithPoints: ((Int) -> Unit)? = null
686
+ ) {
687
+ private val executor: ExecutorService = Executors.newSingleThreadExecutor()
688
+ private val batch: MutableList<LocationDataPoint> = mutableListOf()
689
+ private val isShutdown = AtomicBoolean(false)
690
+
691
+ val targetPath: String get() = config.path
692
+
693
+ fun dispatch(location: LocationDataPoint) {
694
+ if (isShutdown.get()) return
695
+
696
+ executor.execute {
697
+ // Check if device is offline
698
+ if (!networkOnline) {
699
+ // Discard if offlineQueue is disabled (simplified for testing)
700
+ return@execute
701
+ }
702
+
703
+ // Accumulate in batch
704
+ batch.add(location)
705
+
706
+ // Check if batch is full
707
+ if (batch.size >= config.batchSize) {
708
+ flushBatch()
709
+ }
710
+ }
711
+ }
712
+
713
+ fun flush() {
714
+ if (isShutdown.get()) return
715
+
716
+ val future = executor.submit {
717
+ if (batch.isNotEmpty()) {
718
+ flushBatch()
719
+ }
720
+ }
721
+ try {
722
+ future.get()
723
+ } catch (e: Exception) {
724
+ // Ignore
725
+ }
726
+ }
727
+
728
+ fun shutdown() {
729
+ isShutdown.set(true)
730
+ executor.shutdown()
731
+ }
732
+
733
+ private fun flushBatch() {
734
+ if (batch.isEmpty()) return
735
+
736
+ val pointCount = batch.size
737
+ batch.clear()
738
+
739
+ try {
740
+ onWrite?.invoke()
741
+ onWriteWithPoints?.invoke(pointCount)
742
+ } catch (e: Exception) {
743
+ // Simulated failure - target isolation means we don't propagate
744
+ }
745
+ }
746
+ }
747
+
748
+ /**
749
+ * A testable TargetHandler that supports retry cancellation testing.
750
+ * For set/update targets, newer data cancels in-progress retries.
751
+ */
752
+ class TestableTargetHandlerWithRetry(
753
+ private val config: SyncTargetConfig,
754
+ private val onWrite: (Long) -> Unit
755
+ ) {
756
+ private val executor: ExecutorService = Executors.newSingleThreadExecutor()
757
+ private val batch: MutableList<LocationDataPoint> = mutableListOf()
758
+ private val isShutdown = AtomicBoolean(false)
759
+ private val retryGeneration = AtomicInteger(0)
760
+ private val isRetrying = AtomicBoolean(false)
761
+
762
+ private val maxRetries: Int
763
+ get() = if (config.method == "push") 5 else 3
764
+
765
+ fun dispatch(location: LocationDataPoint) {
766
+ if (isShutdown.get()) return
767
+
768
+ executor.execute {
769
+ // For set/update targets, cancel any in-progress retry
770
+ if (config.method == "set" || config.method == "update") {
771
+ if (isRetrying.get()) {
772
+ retryGeneration.incrementAndGet()
773
+ isRetrying.set(false)
774
+ }
775
+ }
776
+
777
+ // Accumulate in batch
778
+ batch.add(location)
779
+
780
+ // Check if batch is full
781
+ if (batch.size >= config.batchSize) {
782
+ flushBatch()
783
+ }
784
+ }
785
+ }
786
+
787
+ fun shutdown() {
788
+ isShutdown.set(true)
789
+ retryGeneration.incrementAndGet()
790
+ executor.shutdown()
791
+ }
792
+
793
+ private fun flushBatch() {
794
+ if (batch.isEmpty()) return
795
+
796
+ val points = ArrayList(batch)
797
+ batch.clear()
798
+
799
+ val generation = retryGeneration.get()
800
+ executeWithRetry(generation, points)
801
+ }
802
+
803
+ private fun executeWithRetry(generation: Int, points: List<LocationDataPoint>) {
804
+ isRetrying.set(true)
805
+ var attempt = 0
806
+
807
+ fun tryOperation() {
808
+ // Check if superseded
809
+ if (retryGeneration.get() != generation) {
810
+ isRetrying.set(false)
811
+ return
812
+ }
813
+
814
+ attempt++
815
+ val lastPoint = if (config.method == "push") points.first() else points.last()
816
+
817
+ try {
818
+ onWrite(lastPoint.timestamp)
819
+ isRetrying.set(false)
820
+ } catch (e: TransientWriteException) {
821
+ // Check if superseded before retrying
822
+ if (retryGeneration.get() != generation) {
823
+ isRetrying.set(false)
824
+ return
825
+ }
826
+
827
+ if (attempt >= maxRetries) {
828
+ isRetrying.set(false)
829
+ return
830
+ }
831
+
832
+ // Short delay for testing (not real backoff)
833
+ try {
834
+ Thread.sleep(50)
835
+ } catch (ie: InterruptedException) {
836
+ Thread.currentThread().interrupt()
837
+ isRetrying.set(false)
838
+ return
839
+ }
840
+
841
+ // Check again after sleep
842
+ if (retryGeneration.get() != generation) {
843
+ isRetrying.set(false)
844
+ return
845
+ }
846
+
847
+ tryOperation()
848
+ } catch (e: Exception) {
849
+ isRetrying.set(false)
850
+ }
851
+ }
852
+
853
+ tryOperation()
854
+ }
855
+ }