@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,462 @@
1
+ package com.livetracking
2
+
3
+ import android.content.Context
4
+ import androidx.test.core.app.ApplicationProvider
5
+ import com.livetracking.queue.QueuedLocation
6
+ import com.livetracking.queue.TrackingDatabase
7
+ import org.junit.After
8
+ import org.junit.Assert.*
9
+ import org.junit.Before
10
+ import org.junit.Test
11
+ import org.junit.runner.RunWith
12
+ import org.robolectric.RobolectricTestRunner
13
+ import org.robolectric.annotation.Config
14
+ import java.util.UUID
15
+
16
+ /**
17
+ * Unit tests for the offline queue (TrackingDatabase per-target methods).
18
+ * Uses Robolectric to provide an in-memory SQLite database.
19
+ *
20
+ * **Validates: Requirements 5.1, 5.2, 5.4, 5.7, 10.1, 10.2, 10.3**
21
+ * **Properties 12, 13, 14, 15**
22
+ */
23
+ @RunWith(RobolectricTestRunner::class)
24
+ @Config(sdk = [28], manifest = Config.NONE)
25
+ class OfflineQueueTest {
26
+
27
+ private lateinit var db: TrackingDatabase
28
+
29
+ @Before
30
+ fun setup() {
31
+ val context = ApplicationProvider.getApplicationContext<Context>()
32
+ // Create a fresh database instance for each test
33
+ db = TrackingDatabase(context)
34
+ }
35
+
36
+ @After
37
+ fun teardown() {
38
+ db.close()
39
+ }
40
+
41
+ // --- Helper ---
42
+
43
+ private fun createLocation(
44
+ id: String = UUID.randomUUID().toString(),
45
+ latitude: Double = 37.7749,
46
+ longitude: Double = -122.4194,
47
+ timestamp: Long = System.currentTimeMillis(),
48
+ accuracy: Float = 5.0f,
49
+ speed: Float? = null,
50
+ altitude: Double? = null,
51
+ bearing: Float? = null,
52
+ createdAt: Long = System.currentTimeMillis()
53
+ ): QueuedLocation {
54
+ return QueuedLocation(
55
+ id = id,
56
+ latitude = latitude,
57
+ longitude = longitude,
58
+ timestamp = timestamp,
59
+ accuracy = accuracy,
60
+ speed = speed,
61
+ altitude = altitude,
62
+ bearing = bearing,
63
+ createdAt = createdAt
64
+ )
65
+ }
66
+
67
+ // =========================================================================
68
+ // Property 12: Offline queue size invariant
69
+ // For any SyncTarget with offlineQueue: true, the number of queued data
70
+ // points for that target SHALL never exceed 10,000. When the queue is at
71
+ // capacity and a new point arrives, the oldest point SHALL be evicted.
72
+ // Validates: Requirements 5.1, 5.7
73
+ // =========================================================================
74
+
75
+ @Test
76
+ fun `insertForTarget enforces 10000 cap by evicting oldest`() {
77
+ val targetPath = "drivers/abc/location"
78
+
79
+ // Insert exactly MAX_QUEUE_SIZE_PER_TARGET items
80
+ for (i in 1..TrackingDatabase.MAX_QUEUE_SIZE_PER_TARGET) {
81
+ val location = createLocation(
82
+ id = "loc-$i",
83
+ createdAt = i.toLong()
84
+ )
85
+ db.insertForTarget(location, targetPath)
86
+ }
87
+
88
+ assertEquals(TrackingDatabase.MAX_QUEUE_SIZE_PER_TARGET, db.getCountForTarget(targetPath))
89
+
90
+ // Insert one more — should evict the oldest (createdAt = 1)
91
+ val newLocation = createLocation(
92
+ id = "loc-new",
93
+ createdAt = (TrackingDatabase.MAX_QUEUE_SIZE_PER_TARGET + 1).toLong()
94
+ )
95
+ db.insertForTarget(newLocation, targetPath)
96
+
97
+ // Count should still be at the cap
98
+ assertEquals(TrackingDatabase.MAX_QUEUE_SIZE_PER_TARGET, db.getCountForTarget(targetPath))
99
+
100
+ // The oldest item (createdAt = 1) should have been evicted
101
+ val oldest = db.getOldestBatchForTarget(targetPath, 1)
102
+ assertEquals(1, oldest.size)
103
+ // The oldest remaining should be createdAt = 2 (the first was evicted)
104
+ assertEquals(2L, oldest[0].createdAt)
105
+ }
106
+
107
+ @Test
108
+ fun `insertForTarget evicts oldest when at capacity - new item is preserved`() {
109
+ val targetPath = "trips/xyz/history"
110
+
111
+ // Fill to capacity
112
+ for (i in 1..TrackingDatabase.MAX_QUEUE_SIZE_PER_TARGET) {
113
+ db.insertForTarget(createLocation(id = "item-$i", createdAt = i.toLong()), targetPath)
114
+ }
115
+
116
+ // Insert a new item
117
+ val newId = "newest-item"
118
+ db.insertForTarget(
119
+ createLocation(id = newId, createdAt = 99999L),
120
+ targetPath
121
+ )
122
+
123
+ // The new item should be in the queue
124
+ val newest = db.getOldestBatchForTarget(targetPath, TrackingDatabase.MAX_QUEUE_SIZE_PER_TARGET)
125
+ assertTrue(newest.any { it.id == newId })
126
+ }
127
+
128
+ @Test
129
+ fun `queue never exceeds 10000 after multiple inserts beyond cap`() {
130
+ val targetPath = "test/overflow"
131
+
132
+ // Insert 10,050 items (50 over the cap)
133
+ for (i in 1..10_050) {
134
+ db.insertForTarget(createLocation(id = "item-$i", createdAt = i.toLong()), targetPath)
135
+ }
136
+
137
+ // Count should be exactly at the cap
138
+ assertEquals(TrackingDatabase.MAX_QUEUE_SIZE_PER_TARGET, db.getCountForTarget(targetPath))
139
+
140
+ // The oldest remaining should be item 51 (items 1-50 were evicted)
141
+ val oldest = db.getOldestBatchForTarget(targetPath, 1)
142
+ assertEquals(51L, oldest[0].createdAt)
143
+ }
144
+
145
+ // =========================================================================
146
+ // Property 13: Chronological flush ordering
147
+ // When connectivity is restored, queued data points SHALL be flushed in
148
+ // chronological order (oldest first).
149
+ // Validates: Requirements 5.2
150
+ // =========================================================================
151
+
152
+ @Test
153
+ fun `getOldestBatchForTarget returns items in chronological order`() {
154
+ val targetPath = "drivers/abc/location"
155
+
156
+ // Insert items in non-sequential order
157
+ db.insertForTarget(createLocation(id = "third", createdAt = 300L), targetPath)
158
+ db.insertForTarget(createLocation(id = "first", createdAt = 100L), targetPath)
159
+ db.insertForTarget(createLocation(id = "second", createdAt = 200L), targetPath)
160
+
161
+ val batch = db.getOldestBatchForTarget(targetPath, 3)
162
+
163
+ assertEquals(3, batch.size)
164
+ assertEquals("first", batch[0].id)
165
+ assertEquals("second", batch[1].id)
166
+ assertEquals("third", batch[2].id)
167
+ }
168
+
169
+ @Test
170
+ fun `getOldestBatchForTarget respects limit parameter`() {
171
+ val targetPath = "test/batch"
172
+
173
+ for (i in 1..10) {
174
+ db.insertForTarget(createLocation(id = "item-$i", createdAt = i.toLong()), targetPath)
175
+ }
176
+
177
+ val batch = db.getOldestBatchForTarget(targetPath, 5)
178
+
179
+ assertEquals(5, batch.size)
180
+ // Should be the 5 oldest
181
+ assertEquals(1L, batch[0].createdAt)
182
+ assertEquals(5L, batch[4].createdAt)
183
+ }
184
+
185
+ @Test
186
+ fun `deleteByIds removes items and subsequent dequeue skips them`() {
187
+ val targetPath = "test/flush"
188
+
189
+ // Insert 5 items
190
+ for (i in 1..5) {
191
+ db.insertForTarget(createLocation(id = "item-$i", createdAt = i.toLong()), targetPath)
192
+ }
193
+
194
+ // Simulate flush: dequeue first 2, then delete them
195
+ val firstBatch = db.getOldestBatchForTarget(targetPath, 2)
196
+ assertEquals(2, firstBatch.size)
197
+ db.deleteByIds(firstBatch.map { it.id })
198
+
199
+ // Next dequeue should start from item-3
200
+ val secondBatch = db.getOldestBatchForTarget(targetPath, 2)
201
+ assertEquals(2, secondBatch.size)
202
+ assertEquals("item-3", secondBatch[0].id)
203
+ assertEquals("item-4", secondBatch[1].id)
204
+ }
205
+
206
+ @Test
207
+ fun `chronological ordering is maintained after eviction`() {
208
+ val targetPath = "test/order-after-eviction"
209
+
210
+ // Fill to capacity with items createdAt = 1..10000
211
+ for (i in 1..TrackingDatabase.MAX_QUEUE_SIZE_PER_TARGET) {
212
+ db.insertForTarget(createLocation(id = "item-$i", createdAt = i.toLong()), targetPath)
213
+ }
214
+
215
+ // Insert 5 more (evicts items 1-5)
216
+ for (i in 1..5) {
217
+ db.insertForTarget(
218
+ createLocation(id = "new-$i", createdAt = (10000 + i).toLong()),
219
+ targetPath
220
+ )
221
+ }
222
+
223
+ // Dequeue should still be in chronological order
224
+ val batch = db.getOldestBatchForTarget(targetPath, 5)
225
+ assertEquals(5, batch.size)
226
+ // Oldest remaining should be item-6 (createdAt = 6)
227
+ assertEquals(6L, batch[0].createdAt)
228
+ assertEquals(7L, batch[1].createdAt)
229
+ assertEquals(8L, batch[2].createdAt)
230
+ assertEquals(9L, batch[3].createdAt)
231
+ assertEquals(10L, batch[4].createdAt)
232
+ }
233
+
234
+ // =========================================================================
235
+ // Property 14: Queue count aggregation
236
+ // getQueuedLocations() SHALL return c1 + c2 + ... + cn, and
237
+ // getQueuedLocationsByTarget() SHALL return a record mapping each target
238
+ // path to its respective count.
239
+ // Validates: Requirements 10.1, 10.2
240
+ // =========================================================================
241
+
242
+ @Test
243
+ fun `getCountsByTarget returns correct per-target counts`() {
244
+ val target1 = "drivers/abc/location"
245
+ val target2 = "trips/xyz/history"
246
+ val target3 = "fleet/status"
247
+
248
+ // Insert different counts per target
249
+ for (i in 1..3) {
250
+ db.insertForTarget(createLocation(createdAt = i.toLong()), target1)
251
+ }
252
+ for (i in 1..7) {
253
+ db.insertForTarget(createLocation(createdAt = i.toLong()), target2)
254
+ }
255
+ for (i in 1..2) {
256
+ db.insertForTarget(createLocation(createdAt = i.toLong()), target3)
257
+ }
258
+
259
+ val counts = db.getCountsByTarget()
260
+
261
+ assertEquals(3, counts[target1])
262
+ assertEquals(7, counts[target2])
263
+ assertEquals(2, counts[target3])
264
+ }
265
+
266
+ @Test
267
+ fun `getCountsByTarget aggregation sums to total count`() {
268
+ val target1 = "path/a"
269
+ val target2 = "path/b"
270
+
271
+ for (i in 1..5) {
272
+ db.insertForTarget(createLocation(createdAt = i.toLong()), target1)
273
+ }
274
+ for (i in 1..8) {
275
+ db.insertForTarget(createLocation(createdAt = i.toLong()), target2)
276
+ }
277
+
278
+ val counts = db.getCountsByTarget()
279
+ val totalFromAggregation = counts.values.sum()
280
+ val totalFromCount = db.getCount()
281
+
282
+ assertEquals(totalFromCount, totalFromAggregation)
283
+ assertEquals(13, totalFromAggregation)
284
+ }
285
+
286
+ @Test
287
+ fun `getCountForTarget returns correct count for specific target`() {
288
+ val target1 = "target/one"
289
+ val target2 = "target/two"
290
+
291
+ for (i in 1..4) {
292
+ db.insertForTarget(createLocation(createdAt = i.toLong()), target1)
293
+ }
294
+ for (i in 1..6) {
295
+ db.insertForTarget(createLocation(createdAt = i.toLong()), target2)
296
+ }
297
+
298
+ assertEquals(4, db.getCountForTarget(target1))
299
+ assertEquals(6, db.getCountForTarget(target2))
300
+ }
301
+
302
+ @Test
303
+ fun `getCountsByTarget returns empty map when no items queued`() {
304
+ val counts = db.getCountsByTarget()
305
+ assertTrue(counts.isEmpty())
306
+ }
307
+
308
+ @Test
309
+ fun `getCountsByTarget updates after deletions`() {
310
+ val target = "test/delete"
311
+
312
+ for (i in 1..5) {
313
+ db.insertForTarget(createLocation(id = "del-$i", createdAt = i.toLong()), target)
314
+ }
315
+
316
+ // Delete 2 items
317
+ db.deleteByIds(listOf("del-1", "del-2"))
318
+
319
+ val counts = db.getCountsByTarget()
320
+ assertEquals(3, counts[target])
321
+ }
322
+
323
+ // =========================================================================
324
+ // Property 15: Non-queuing targets report zero
325
+ // For any SyncTarget with offlineQueue set to false or undefined,
326
+ // getQueuedLocationsByTarget() SHALL report 0 queued locations for that
327
+ // target's path.
328
+ // Validates: Requirements 10.3
329
+ // =========================================================================
330
+
331
+ @Test
332
+ fun `getCountForTarget returns zero for target with no queued items`() {
333
+ // A target that has never had items queued (simulates offlineQueue: false)
334
+ val nonQueuingTarget = "realtime/current"
335
+
336
+ assertEquals(0, db.getCountForTarget(nonQueuingTarget))
337
+ }
338
+
339
+ @Test
340
+ fun `getCountsByTarget does not include targets with zero items`() {
341
+ val queuingTarget = "history/path"
342
+ val nonQueuingTarget = "realtime/path"
343
+
344
+ // Only insert for the queuing target
345
+ db.insertForTarget(createLocation(createdAt = 1L), queuingTarget)
346
+
347
+ val counts = db.getCountsByTarget()
348
+
349
+ // The queuing target should be present
350
+ assertEquals(1, counts[queuingTarget])
351
+ // The non-queuing target should not appear (or be absent from the map)
352
+ assertNull(counts[nonQueuingTarget])
353
+ }
354
+
355
+ @Test
356
+ fun `non-queuing target reports zero regardless of other targets having items`() {
357
+ val queuingTarget1 = "target/queued1"
358
+ val queuingTarget2 = "target/queued2"
359
+ val nonQueuingTarget = "target/no-queue"
360
+
361
+ // Insert items for queuing targets
362
+ for (i in 1..10) {
363
+ db.insertForTarget(createLocation(createdAt = i.toLong()), queuingTarget1)
364
+ }
365
+ for (i in 1..5) {
366
+ db.insertForTarget(createLocation(createdAt = i.toLong()), queuingTarget2)
367
+ }
368
+
369
+ // Non-queuing target should report zero
370
+ assertEquals(0, db.getCountForTarget(nonQueuingTarget))
371
+
372
+ // Verify queuing targets have their counts
373
+ assertEquals(10, db.getCountForTarget(queuingTarget1))
374
+ assertEquals(5, db.getCountForTarget(queuingTarget2))
375
+ }
376
+
377
+ // =========================================================================
378
+ // Per-target queue isolation (Property 12 extension)
379
+ // Validates: Requirements 5.4
380
+ // =========================================================================
381
+
382
+ @Test
383
+ fun `per-target queues are isolated - insert to one does not affect another`() {
384
+ val target1 = "drivers/abc/location"
385
+ val target2 = "trips/xyz/history"
386
+
387
+ for (i in 1..5) {
388
+ db.insertForTarget(createLocation(id = "t1-$i", createdAt = i.toLong()), target1)
389
+ }
390
+ for (i in 1..3) {
391
+ db.insertForTarget(createLocation(id = "t2-$i", createdAt = i.toLong()), target2)
392
+ }
393
+
394
+ assertEquals(5, db.getCountForTarget(target1))
395
+ assertEquals(3, db.getCountForTarget(target2))
396
+ }
397
+
398
+ @Test
399
+ fun `per-target queues are isolated - dequeue from one does not affect another`() {
400
+ val target1 = "path/a"
401
+ val target2 = "path/b"
402
+
403
+ for (i in 1..5) {
404
+ db.insertForTarget(createLocation(id = "a-$i", createdAt = i.toLong()), target1)
405
+ }
406
+ for (i in 1..5) {
407
+ db.insertForTarget(createLocation(id = "b-$i", createdAt = i.toLong()), target2)
408
+ }
409
+
410
+ // Dequeue from target1
411
+ val batch = db.getOldestBatchForTarget(target1, 5)
412
+ assertEquals(5, batch.size)
413
+ // All items should belong to target1
414
+ batch.forEach { assertTrue(it.id.startsWith("a-")) }
415
+
416
+ // target2 should be unaffected
417
+ assertEquals(5, db.getCountForTarget(target2))
418
+ }
419
+
420
+ @Test
421
+ fun `per-target cap enforcement is isolated - overflow in one does not evict from another`() {
422
+ val target1 = "target/capped"
423
+ val target2 = "target/safe"
424
+
425
+ // Fill target2 with 100 items
426
+ for (i in 1..100) {
427
+ db.insertForTarget(createLocation(id = "safe-$i", createdAt = i.toLong()), target2)
428
+ }
429
+
430
+ // Fill target1 to capacity and overflow
431
+ for (i in 1..(TrackingDatabase.MAX_QUEUE_SIZE_PER_TARGET + 10)) {
432
+ db.insertForTarget(createLocation(id = "capped-$i", createdAt = i.toLong()), target1)
433
+ }
434
+
435
+ // target1 should be at cap
436
+ assertEquals(TrackingDatabase.MAX_QUEUE_SIZE_PER_TARGET, db.getCountForTarget(target1))
437
+ // target2 should be completely unaffected
438
+ assertEquals(100, db.getCountForTarget(target2))
439
+ }
440
+
441
+ @Test
442
+ fun `evictOldestForTarget only evicts from specified target`() {
443
+ val target1 = "target/evict"
444
+ val target2 = "target/keep"
445
+
446
+ db.insertForTarget(createLocation(id = "evict-1", createdAt = 1L), target1)
447
+ db.insertForTarget(createLocation(id = "evict-2", createdAt = 2L), target1)
448
+ db.insertForTarget(createLocation(id = "keep-1", createdAt = 1L), target2)
449
+ db.insertForTarget(createLocation(id = "keep-2", createdAt = 2L), target2)
450
+
451
+ // Evict oldest from target1
452
+ db.evictOldestForTarget(target1)
453
+
454
+ // target1 should have lost its oldest
455
+ assertEquals(1, db.getCountForTarget(target1))
456
+ val remaining = db.getOldestBatchForTarget(target1, 1)
457
+ assertEquals("evict-2", remaining[0].id)
458
+
459
+ // target2 should be unaffected
460
+ assertEquals(2, db.getCountForTarget(target2))
461
+ }
462
+ }
@@ -0,0 +1,200 @@
1
+ package com.livetracking
2
+
3
+ import com.livetracking.permissions.PermissionHandler
4
+ import com.livetracking.permissions.PermissionResult
5
+ import org.junit.Assert.*
6
+ import org.junit.Before
7
+ import org.junit.Test
8
+
9
+ /**
10
+ * Unit tests for PermissionHandler.
11
+ *
12
+ * NOTE: PermissionHandler relies on ContextCompat.checkSelfPermission() and LocationManager,
13
+ * both of which require Android Context. These tests verify:
14
+ * - Error code constants are correct
15
+ * - PermissionResult sealed class structure
16
+ * - PermissionHandler can be instantiated
17
+ *
18
+ * Full permission check tests require instrumented tests (androidTest) with a real Context,
19
+ * or Robolectric for simulating Android framework behavior.
20
+ */
21
+ class PermissionHandlerTest {
22
+
23
+ private lateinit var permissionHandler: PermissionHandler
24
+
25
+ @Before
26
+ fun setup() {
27
+ permissionHandler = PermissionHandler()
28
+ }
29
+
30
+ // --- Error Code Constants Tests ---
31
+
32
+ @Test
33
+ fun `ERROR_PERMISSION_DENIED constant is PERMISSION_DENIED`() {
34
+ assertEquals("PERMISSION_DENIED", PermissionHandler.ERROR_PERMISSION_DENIED)
35
+ }
36
+
37
+ @Test
38
+ fun `ERROR_GPS_DISABLED constant is GPS_DISABLED`() {
39
+ assertEquals("GPS_DISABLED", PermissionHandler.ERROR_GPS_DISABLED)
40
+ }
41
+
42
+ @Test
43
+ fun `error codes are distinct`() {
44
+ assertNotEquals(
45
+ PermissionHandler.ERROR_PERMISSION_DENIED,
46
+ PermissionHandler.ERROR_GPS_DISABLED
47
+ )
48
+ }
49
+
50
+ // --- PermissionResult Sealed Class Tests ---
51
+
52
+ @Test
53
+ fun `PermissionResult Granted is a singleton object`() {
54
+ val result1 = PermissionResult.Granted
55
+ val result2 = PermissionResult.Granted
56
+ assertSame(result1, result2)
57
+ }
58
+
59
+ @Test
60
+ fun `PermissionResult Denied contains errorCode and message`() {
61
+ val denied = PermissionResult.Denied(
62
+ errorCode = "PERMISSION_DENIED",
63
+ message = "Location permissions denied"
64
+ )
65
+
66
+ assertEquals("PERMISSION_DENIED", denied.errorCode)
67
+ assertEquals("Location permissions denied", denied.message)
68
+ }
69
+
70
+ @Test
71
+ fun `PermissionResult Denied supports data class equality`() {
72
+ val denied1 = PermissionResult.Denied("CODE", "message")
73
+ val denied2 = PermissionResult.Denied("CODE", "message")
74
+
75
+ assertEquals(denied1, denied2)
76
+ }
77
+
78
+ @Test
79
+ fun `PermissionResult Denied with different codes are not equal`() {
80
+ val denied1 = PermissionResult.Denied("PERMISSION_DENIED", "msg")
81
+ val denied2 = PermissionResult.Denied("GPS_DISABLED", "msg")
82
+
83
+ assertNotEquals(denied1, denied2)
84
+ }
85
+
86
+ @Test
87
+ fun `PermissionResult Denied with different messages are not equal`() {
88
+ val denied1 = PermissionResult.Denied("CODE", "message 1")
89
+ val denied2 = PermissionResult.Denied("CODE", "message 2")
90
+
91
+ assertNotEquals(denied1, denied2)
92
+ }
93
+
94
+ @Test
95
+ fun `PermissionResult can be checked with when expression`() {
96
+ val granted: PermissionResult = PermissionResult.Granted
97
+ val denied: PermissionResult = PermissionResult.Denied("CODE", "msg")
98
+
99
+ val grantedResult = when (granted) {
100
+ is PermissionResult.Granted -> "granted"
101
+ is PermissionResult.Denied -> "denied"
102
+ }
103
+
104
+ val deniedResult = when (denied) {
105
+ is PermissionResult.Granted -> "granted"
106
+ is PermissionResult.Denied -> "denied"
107
+ }
108
+
109
+ assertEquals("granted", grantedResult)
110
+ assertEquals("denied", deniedResult)
111
+ }
112
+
113
+ @Test
114
+ fun `PermissionResult Denied can be destructured`() {
115
+ val denied = PermissionResult.Denied("PERMISSION_DENIED", "Access denied")
116
+ val (code, message) = denied
117
+
118
+ assertEquals("PERMISSION_DENIED", code)
119
+ assertEquals("Access denied", message)
120
+ }
121
+
122
+ // --- PermissionHandler Instantiation Tests ---
123
+
124
+ @Test
125
+ fun `PermissionHandler can be instantiated`() {
126
+ val handler = PermissionHandler()
127
+ assertNotNull(handler)
128
+ }
129
+
130
+ @Test
131
+ fun `PermissionHandler class exists in correct package`() {
132
+ val clazz = PermissionHandler::class.java
133
+ assertEquals("com.livetracking.permissions.PermissionHandler", clazz.name)
134
+ }
135
+
136
+ @Test
137
+ fun `PermissionHandler has checkLocationPermissions method`() {
138
+ val method = PermissionHandler::class.java.methods.find { it.name == "checkLocationPermissions" }
139
+ assertNotNull("checkLocationPermissions method should exist", method)
140
+ }
141
+
142
+ @Test
143
+ fun `PermissionHandler has checkBackgroundLocationPermission method`() {
144
+ val method = PermissionHandler::class.java.methods.find { it.name == "checkBackgroundLocationPermission" }
145
+ assertNotNull("checkBackgroundLocationPermission method should exist", method)
146
+ }
147
+
148
+ @Test
149
+ fun `PermissionHandler has isGpsEnabled method`() {
150
+ val method = PermissionHandler::class.java.methods.find { it.name == "isGpsEnabled" }
151
+ assertNotNull("isGpsEnabled method should exist", method)
152
+ }
153
+
154
+ @Test
155
+ fun `PermissionHandler has checkAllTrackingRequirements method`() {
156
+ val method = PermissionHandler::class.java.methods.find { it.name == "checkAllTrackingRequirements" }
157
+ assertNotNull("checkAllTrackingRequirements method should exist", method)
158
+ }
159
+
160
+ // --- GPS_DISABLED Error Scenario Tests ---
161
+
162
+ @Test
163
+ fun `GPS_DISABLED error result has correct structure`() {
164
+ val result = PermissionResult.Denied(
165
+ errorCode = PermissionHandler.ERROR_GPS_DISABLED,
166
+ message = "GPS/Location services are disabled. Please enable location services in device settings."
167
+ )
168
+
169
+ assertEquals("GPS_DISABLED", result.errorCode)
170
+ assertTrue(result.message.contains("GPS"))
171
+ assertTrue(result.message.contains("disabled"))
172
+ }
173
+
174
+ @Test
175
+ fun `PERMISSION_DENIED error result has correct structure`() {
176
+ val result = PermissionResult.Denied(
177
+ errorCode = PermissionHandler.ERROR_PERMISSION_DENIED,
178
+ message = "Location permissions denied: ACCESS_FINE_LOCATION. Please grant location permissions."
179
+ )
180
+
181
+ assertEquals("PERMISSION_DENIED", result.errorCode)
182
+ assertTrue(result.message.contains("ACCESS_FINE_LOCATION"))
183
+ }
184
+
185
+ /*
186
+ * ============================================================================
187
+ * NOTE: The following tests require Android Context and cannot run as local
188
+ * unit tests. They should be implemented as instrumented tests (androidTest):
189
+ *
190
+ * - checkLocationPermissions returns Granted when both permissions are granted
191
+ * - checkLocationPermissions returns Denied when fine location is denied
192
+ * - checkLocationPermissions returns Denied when coarse location is denied
193
+ * - checkBackgroundLocationPermission returns Granted on API < 29
194
+ * - checkBackgroundLocationPermission returns Denied on API >= 29 without permission
195
+ * - isGpsEnabled returns true when GPS provider is enabled
196
+ * - isGpsEnabled returns false when GPS provider is disabled
197
+ * - checkAllTrackingRequirements returns first Denied result encountered
198
+ * ============================================================================
199
+ */
200
+ }