@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,247 @@
1
+ package com.livetracking
2
+
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.content.SharedPreferences
6
+ import com.livetracking.receiver.BootReceiver
7
+ import com.livetracking.receiver.TrackingStateStore
8
+ import io.mockk.*
9
+ import org.junit.Assert.*
10
+ import org.junit.Before
11
+ import org.junit.Test
12
+
13
+ /**
14
+ * Unit tests for BootReceiver and TrackingStateStore.
15
+ *
16
+ * NOTE: TrackingStateStore uses SharedPreferences which requires Android Context.
17
+ * These tests verify:
18
+ * - TrackingStateStore constants and structure
19
+ * - BootReceiver intent action filtering logic
20
+ * - SharedPreferences interaction patterns (mocked)
21
+ *
22
+ * Full SharedPreferences persistence tests require instrumented tests (androidTest)
23
+ * or Robolectric for simulating Android framework behavior.
24
+ */
25
+ class BootReceiverTest {
26
+
27
+ private lateinit var mockContext: Context
28
+ private lateinit var mockSharedPreferences: SharedPreferences
29
+ private lateinit var mockEditor: SharedPreferences.Editor
30
+
31
+ @Before
32
+ fun setup() {
33
+ mockContext = mockk(relaxed = true)
34
+ mockSharedPreferences = mockk(relaxed = true)
35
+ mockEditor = mockk(relaxed = true)
36
+
37
+ every {
38
+ mockContext.getSharedPreferences("live_tracking_prefs", Context.MODE_PRIVATE)
39
+ } returns mockSharedPreferences
40
+
41
+ every { mockSharedPreferences.edit() } returns mockEditor
42
+ every { mockEditor.putBoolean(any(), any()) } returns mockEditor
43
+ every { mockEditor.apply() } just Runs
44
+ }
45
+
46
+ // --- TrackingStateStore Constants Tests ---
47
+
48
+ @Test
49
+ fun `TrackingStateStore uses correct preferences name`() {
50
+ // Verify the prefs name by checking the mock interaction
51
+ TrackingStateStore.saveTrackingActive(mockContext, true)
52
+
53
+ verify {
54
+ mockContext.getSharedPreferences("live_tracking_prefs", Context.MODE_PRIVATE)
55
+ }
56
+ }
57
+
58
+ @Test
59
+ fun `TrackingStateStore uses correct key for tracking state`() {
60
+ TrackingStateStore.saveTrackingActive(mockContext, true)
61
+
62
+ verify {
63
+ mockEditor.putBoolean("is_tracking_active", true)
64
+ }
65
+ }
66
+
67
+ // --- TrackingStateStore Save Tests ---
68
+
69
+ @Test
70
+ fun `saveTrackingActive with true saves true to SharedPreferences`() {
71
+ TrackingStateStore.saveTrackingActive(mockContext, true)
72
+
73
+ verify(exactly = 1) { mockEditor.putBoolean("is_tracking_active", true) }
74
+ verify(exactly = 1) { mockEditor.apply() }
75
+ }
76
+
77
+ @Test
78
+ fun `saveTrackingActive with false saves false to SharedPreferences`() {
79
+ TrackingStateStore.saveTrackingActive(mockContext, false)
80
+
81
+ verify(exactly = 1) { mockEditor.putBoolean("is_tracking_active", false) }
82
+ verify(exactly = 1) { mockEditor.apply() }
83
+ }
84
+
85
+ // --- TrackingStateStore Read Tests ---
86
+
87
+ @Test
88
+ fun `isTrackingActive returns true when stored value is true`() {
89
+ every {
90
+ mockSharedPreferences.getBoolean("is_tracking_active", false)
91
+ } returns true
92
+
93
+ val result = TrackingStateStore.isTrackingActive(mockContext)
94
+
95
+ assertTrue("Should return true when tracking was active", result)
96
+ }
97
+
98
+ @Test
99
+ fun `isTrackingActive returns false when stored value is false`() {
100
+ every {
101
+ mockSharedPreferences.getBoolean("is_tracking_active", false)
102
+ } returns false
103
+
104
+ val result = TrackingStateStore.isTrackingActive(mockContext)
105
+
106
+ assertFalse("Should return false when tracking was not active", result)
107
+ }
108
+
109
+ @Test
110
+ fun `isTrackingActive returns false by default when no value stored`() {
111
+ // Default value is false when key doesn't exist
112
+ every {
113
+ mockSharedPreferences.getBoolean("is_tracking_active", false)
114
+ } returns false
115
+
116
+ val result = TrackingStateStore.isTrackingActive(mockContext)
117
+
118
+ assertFalse("Should return false by default", result)
119
+ }
120
+
121
+ // --- TrackingStateStore Round-Trip Tests ---
122
+
123
+ @Test
124
+ fun `save and read tracking state round-trip with true`() {
125
+ // Simulate save
126
+ TrackingStateStore.saveTrackingActive(mockContext, true)
127
+ verify { mockEditor.putBoolean("is_tracking_active", true) }
128
+
129
+ // Simulate read after save
130
+ every {
131
+ mockSharedPreferences.getBoolean("is_tracking_active", false)
132
+ } returns true
133
+
134
+ val result = TrackingStateStore.isTrackingActive(mockContext)
135
+ assertTrue(result)
136
+ }
137
+
138
+ @Test
139
+ fun `save and read tracking state round-trip with false`() {
140
+ // Simulate save
141
+ TrackingStateStore.saveTrackingActive(mockContext, false)
142
+ verify { mockEditor.putBoolean("is_tracking_active", false) }
143
+
144
+ // Simulate read after save
145
+ every {
146
+ mockSharedPreferences.getBoolean("is_tracking_active", false)
147
+ } returns false
148
+
149
+ val result = TrackingStateStore.isTrackingActive(mockContext)
150
+ assertFalse(result)
151
+ }
152
+
153
+ // --- BootReceiver Intent Filtering Tests ---
154
+
155
+ @Test
156
+ fun `BootReceiver class exists in correct package`() {
157
+ val clazz = BootReceiver::class.java
158
+ assertEquals("com.livetracking.receiver.BootReceiver", clazz.name)
159
+ }
160
+
161
+ @Test
162
+ fun `BootReceiver extends BroadcastReceiver`() {
163
+ val receiver = BootReceiver()
164
+ assertTrue(
165
+ "BootReceiver should extend BroadcastReceiver",
166
+ receiver is android.content.BroadcastReceiver
167
+ )
168
+ }
169
+
170
+ @Test
171
+ fun `ACTION_BOOT_COMPLETED constant is correct`() {
172
+ assertEquals(
173
+ "android.intent.action.BOOT_COMPLETED",
174
+ Intent.ACTION_BOOT_COMPLETED
175
+ )
176
+ }
177
+
178
+ @Test
179
+ fun `BootReceiver only responds to BOOT_COMPLETED action`() {
180
+ // Verify the intent action check logic
181
+ val bootIntent = mockk<Intent> {
182
+ every { action } returns Intent.ACTION_BOOT_COMPLETED
183
+ }
184
+
185
+ val otherIntent = mockk<Intent> {
186
+ every { action } returns "com.some.other.ACTION"
187
+ }
188
+
189
+ // The receiver should only process BOOT_COMPLETED
190
+ assertEquals(Intent.ACTION_BOOT_COMPLETED, bootIntent.action)
191
+ assertNotEquals(Intent.ACTION_BOOT_COMPLETED, otherIntent.action)
192
+ }
193
+
194
+ @Test
195
+ fun `BootReceiver does not start service when tracking is not active`() {
196
+ every {
197
+ mockSharedPreferences.getBoolean("is_tracking_active", false)
198
+ } returns false
199
+
200
+ val isActive = TrackingStateStore.isTrackingActive(mockContext)
201
+
202
+ assertFalse("Should not start service when tracking is inactive", isActive)
203
+ }
204
+
205
+ @Test
206
+ fun `BootReceiver starts service when tracking is active`() {
207
+ every {
208
+ mockSharedPreferences.getBoolean("is_tracking_active", false)
209
+ } returns true
210
+
211
+ val isActive = TrackingStateStore.isTrackingActive(mockContext)
212
+
213
+ assertTrue("Should start service when tracking was active", isActive)
214
+ }
215
+
216
+ // --- TrackingStateStore Object Tests ---
217
+
218
+ @Test
219
+ fun `TrackingStateStore is a singleton object`() {
220
+ // Kotlin object declarations are singletons
221
+ val ref1 = TrackingStateStore
222
+ val ref2 = TrackingStateStore
223
+ assertSame(ref1, ref2)
224
+ }
225
+
226
+ @Test
227
+ fun `TrackingStateStore uses MODE_PRIVATE for SharedPreferences`() {
228
+ TrackingStateStore.isTrackingActive(mockContext)
229
+
230
+ verify {
231
+ mockContext.getSharedPreferences("live_tracking_prefs", Context.MODE_PRIVATE)
232
+ }
233
+ }
234
+
235
+ /*
236
+ * ============================================================================
237
+ * NOTE: The following tests require a real Android Context and cannot run as
238
+ * local unit tests. They should be implemented as instrumented tests (androidTest):
239
+ *
240
+ * - SharedPreferences actually persists data across reads
241
+ * - TrackingStateStore survives process restart (SharedPreferences persistence)
242
+ * - BootReceiver.onReceive actually starts TrackingForegroundService
243
+ * - BootReceiver handles null intent gracefully on real device
244
+ * - Service restart behavior after actual device reboot
245
+ * ============================================================================
246
+ */
247
+ }
@@ -0,0 +1,337 @@
1
+ package com.livetracking
2
+
3
+ import com.livetracking.queue.QueuedLocation
4
+ import com.livetracking.sync.FirebaseSyncEngine
5
+ import com.livetracking.sync.SyncCallback
6
+ import io.mockk.*
7
+ import org.junit.Assert.*
8
+ import org.junit.Before
9
+ import org.junit.Test
10
+
11
+ /**
12
+ * Unit tests for FirebaseSyncEngine.
13
+ *
14
+ * Focuses on the pure logic that can be tested without Firebase SDK initialization:
15
+ * - Exponential backoff delay calculation
16
+ * - Null path error handling
17
+ * - Empty batch handling
18
+ *
19
+ * Full Firebase write tests require either:
20
+ * - Instrumented tests with Firebase emulator
21
+ * - Integration tests with a test Firebase project
22
+ */
23
+ class FirebaseSyncEngineTest {
24
+
25
+ private lateinit var syncEngine: FirebaseSyncEngine
26
+ private lateinit var syncEngineNoCurrentPath: FirebaseSyncEngine
27
+ private lateinit var syncEngineNoHistoryPath: FirebaseSyncEngine
28
+
29
+ @Before
30
+ fun setup() {
31
+ syncEngine = FirebaseSyncEngine(
32
+ service = "RTDB",
33
+ currentLocationPath = "users/test-user/currentLocation",
34
+ historyPath = "users/test-user/history"
35
+ )
36
+
37
+ syncEngineNoCurrentPath = FirebaseSyncEngine(
38
+ service = "RTDB",
39
+ currentLocationPath = null,
40
+ historyPath = "users/test-user/history"
41
+ )
42
+
43
+ syncEngineNoHistoryPath = FirebaseSyncEngine(
44
+ service = "RTDB",
45
+ currentLocationPath = "users/test-user/currentLocation",
46
+ historyPath = null
47
+ )
48
+ }
49
+
50
+ // --- Exponential Backoff Delay Tests ---
51
+
52
+ @Test
53
+ fun `calculateBackoffDelay attempt 1 returns approximately 1000ms`() {
54
+ // Formula: 1000 * 2^(1-1) ± 200 = 1000 ± 200
55
+ // Expected range: [800, 1200]
56
+ val delays = (1..100).map { syncEngine.calculateBackoffDelay(1) }
57
+
58
+ delays.forEach { delay ->
59
+ assertTrue(
60
+ "Attempt 1 delay should be between 800 and 1200ms, got $delay",
61
+ delay in 800..1200
62
+ )
63
+ }
64
+ }
65
+
66
+ @Test
67
+ fun `calculateBackoffDelay attempt 2 returns approximately 2000ms`() {
68
+ // Formula: 1000 * 2^(2-1) ± 200 = 2000 ± 200
69
+ // Expected range: [1800, 2200]
70
+ val delays = (1..100).map { syncEngine.calculateBackoffDelay(2) }
71
+
72
+ delays.forEach { delay ->
73
+ assertTrue(
74
+ "Attempt 2 delay should be between 1800 and 2200ms, got $delay",
75
+ delay in 1800..2200
76
+ )
77
+ }
78
+ }
79
+
80
+ @Test
81
+ fun `calculateBackoffDelay attempt 3 returns approximately 4000ms`() {
82
+ // Formula: 1000 * 2^(3-1) ± 200 = 4000 ± 200
83
+ // Expected range: [3800, 4200]
84
+ val delays = (1..100).map { syncEngine.calculateBackoffDelay(3) }
85
+
86
+ delays.forEach { delay ->
87
+ assertTrue(
88
+ "Attempt 3 delay should be between 3800 and 4200ms, got $delay",
89
+ delay in 3800..4200
90
+ )
91
+ }
92
+ }
93
+
94
+ @Test
95
+ fun `calculateBackoffDelay attempt 4 returns approximately 8000ms`() {
96
+ // Formula: 1000 * 2^(4-1) ± 200 = 8000 ± 200
97
+ // Expected range: [7800, 8200]
98
+ val delays = (1..100).map { syncEngine.calculateBackoffDelay(4) }
99
+
100
+ delays.forEach { delay ->
101
+ assertTrue(
102
+ "Attempt 4 delay should be between 7800 and 8200ms, got $delay",
103
+ delay in 7800..8200
104
+ )
105
+ }
106
+ }
107
+
108
+ @Test
109
+ fun `calculateBackoffDelay attempt 5 returns approximately 16000ms`() {
110
+ // Formula: 1000 * 2^(5-1) ± 200 = 16000 ± 200
111
+ // Expected range: [15800, 16200]
112
+ val delays = (1..100).map { syncEngine.calculateBackoffDelay(5) }
113
+
114
+ delays.forEach { delay ->
115
+ assertTrue(
116
+ "Attempt 5 delay should be between 15800 and 16200ms, got $delay",
117
+ delay in 15800..16200
118
+ )
119
+ }
120
+ }
121
+
122
+ @Test
123
+ fun `calculateBackoffDelay is never negative`() {
124
+ // Test across many attempts to ensure maxOf(0L, ...) guard works
125
+ val delays = (1..10).flatMap { attempt ->
126
+ (1..100).map { syncEngine.calculateBackoffDelay(attempt) }
127
+ }
128
+
129
+ delays.forEach { delay ->
130
+ assertTrue("Delay should never be negative, got $delay", delay >= 0)
131
+ }
132
+ }
133
+
134
+ @Test
135
+ fun `calculateBackoffDelay includes jitter variation`() {
136
+ // Run multiple times for the same attempt and verify we get different values (jitter)
137
+ val delays = (1..50).map { syncEngine.calculateBackoffDelay(1) }.toSet()
138
+
139
+ // With ±200ms jitter, we should get multiple distinct values over 50 runs
140
+ assertTrue(
141
+ "Expected jitter to produce multiple distinct delay values, got ${delays.size}",
142
+ delays.size > 1
143
+ )
144
+ }
145
+
146
+ @Test
147
+ fun `calculateBackoffDelay grows exponentially`() {
148
+ // Average of many samples should show exponential growth
149
+ val avgDelay1 = (1..100).map { syncEngine.calculateBackoffDelay(1) }.average()
150
+ val avgDelay2 = (1..100).map { syncEngine.calculateBackoffDelay(2) }.average()
151
+ val avgDelay3 = (1..100).map { syncEngine.calculateBackoffDelay(3) }.average()
152
+
153
+ // Each level should be approximately 2x the previous
154
+ assertTrue("Delay 2 should be ~2x delay 1", avgDelay2 > avgDelay1 * 1.5)
155
+ assertTrue("Delay 3 should be ~2x delay 2", avgDelay3 > avgDelay2 * 1.5)
156
+ }
157
+
158
+ // --- Null Path Error Handling Tests ---
159
+
160
+ @Test
161
+ fun `updateCurrentLocation with null path calls onError`() {
162
+ var errorCode: String? = null
163
+ var errorMessage: String? = null
164
+
165
+ val callback = object : SyncCallback {
166
+ override fun onSuccess() {
167
+ fail("Should not call onSuccess when path is null")
168
+ }
169
+
170
+ override fun onError(code: String, message: String) {
171
+ errorCode = code
172
+ errorMessage = message
173
+ }
174
+ }
175
+
176
+ syncEngineNoCurrentPath.updateCurrentLocation(
177
+ latitude = 37.7749,
178
+ longitude = -122.4194,
179
+ timestamp = System.currentTimeMillis(),
180
+ accuracy = 10.0f,
181
+ speed = null,
182
+ callback = callback
183
+ )
184
+
185
+ assertEquals("NO_PATH", errorCode)
186
+ assertNotNull(errorMessage)
187
+ assertTrue(errorMessage!!.contains("currentLocationPath"))
188
+ }
189
+
190
+ @Test
191
+ fun `pushHistoryBatch with null path calls onError`() {
192
+ var errorCode: String? = null
193
+ var errorMessage: String? = null
194
+
195
+ val callback = object : SyncCallback {
196
+ override fun onSuccess() {
197
+ fail("Should not call onSuccess when path is null")
198
+ }
199
+
200
+ override fun onError(code: String, message: String) {
201
+ errorCode = code
202
+ errorMessage = message
203
+ }
204
+ }
205
+
206
+ val locations = listOf(
207
+ QueuedLocation("1", 37.0, -122.0, 1000L, 5.0f, null, null, null, 1000L)
208
+ )
209
+
210
+ syncEngineNoHistoryPath.pushHistoryBatch(locations, callback)
211
+
212
+ assertEquals("NO_PATH", errorCode)
213
+ assertNotNull(errorMessage)
214
+ assertTrue(errorMessage!!.contains("historyPath"))
215
+ }
216
+
217
+ // --- Empty Batch Handling Tests ---
218
+
219
+ @Test
220
+ fun `pushHistoryBatch with empty list calls onSuccess immediately`() {
221
+ var successCalled = false
222
+
223
+ val callback = object : SyncCallback {
224
+ override fun onSuccess() {
225
+ successCalled = true
226
+ }
227
+
228
+ override fun onError(code: String, message: String) {
229
+ fail("Should not call onError for empty list")
230
+ }
231
+ }
232
+
233
+ syncEngine.pushHistoryBatch(emptyList(), callback)
234
+
235
+ assertTrue("onSuccess should be called for empty batch", successCalled)
236
+ }
237
+
238
+ @Test
239
+ fun `pushHistoryBatch with empty list and null path calls onError`() {
240
+ // null path check happens before empty list check
241
+ var errorCode: String? = null
242
+
243
+ val callback = object : SyncCallback {
244
+ override fun onSuccess() {
245
+ fail("Should not call onSuccess when path is null")
246
+ }
247
+
248
+ override fun onError(code: String, message: String) {
249
+ errorCode = code
250
+ }
251
+ }
252
+
253
+ syncEngineNoHistoryPath.pushHistoryBatch(emptyList(), callback)
254
+
255
+ assertEquals("NO_PATH", errorCode)
256
+ }
257
+
258
+ // --- SyncCallback Interface Tests ---
259
+
260
+ @Test
261
+ fun `SyncCallback onSuccess can be called`() {
262
+ var called = false
263
+ val callback = object : SyncCallback {
264
+ override fun onSuccess() { called = true }
265
+ override fun onError(code: String, message: String) {}
266
+ }
267
+
268
+ callback.onSuccess()
269
+ assertTrue(called)
270
+ }
271
+
272
+ @Test
273
+ fun `SyncCallback onError provides error code and message`() {
274
+ var receivedCode: String? = null
275
+ var receivedMessage: String? = null
276
+
277
+ val callback = object : SyncCallback {
278
+ override fun onSuccess() {}
279
+ override fun onError(code: String, message: String) {
280
+ receivedCode = code
281
+ receivedMessage = message
282
+ }
283
+ }
284
+
285
+ callback.onError("TEST_ERROR", "Something went wrong")
286
+
287
+ assertEquals("TEST_ERROR", receivedCode)
288
+ assertEquals("Something went wrong", receivedMessage)
289
+ }
290
+
291
+ // --- Constructor / Configuration Tests ---
292
+
293
+ @Test
294
+ fun `FirebaseSyncEngine accepts RTDB service type`() {
295
+ val engine = FirebaseSyncEngine("RTDB", "path/current", "path/history")
296
+ assertNotNull(engine)
297
+ }
298
+
299
+ @Test
300
+ fun `FirebaseSyncEngine accepts Firestore service type`() {
301
+ val engine = FirebaseSyncEngine("Firestore", "collection/doc", "collection")
302
+ assertNotNull(engine)
303
+ }
304
+
305
+ @Test
306
+ fun `FirebaseSyncEngine accepts null paths`() {
307
+ val engine = FirebaseSyncEngine("RTDB", null, null)
308
+ assertNotNull(engine)
309
+ }
310
+
311
+ // --- Backoff Constants Verification ---
312
+
313
+ @Test
314
+ fun `backoff base delay is 1000ms for attempt 1`() {
315
+ // The base delay without jitter should center around 1000ms
316
+ val delays = (1..1000).map { syncEngine.calculateBackoffDelay(1) }
317
+ val average = delays.average()
318
+
319
+ // Average should be very close to 1000 (jitter averages to ~0)
320
+ assertTrue(
321
+ "Average delay for attempt 1 should be near 1000ms, got $average",
322
+ average in 900.0..1100.0
323
+ )
324
+ }
325
+
326
+ @Test
327
+ fun `backoff jitter range is within +-200ms`() {
328
+ // For attempt 1, base is 1000ms, so all values should be in [800, 1200]
329
+ val delays = (1..1000).map { syncEngine.calculateBackoffDelay(1) }
330
+
331
+ val min = delays.min()
332
+ val max = delays.max()
333
+
334
+ assertTrue("Min delay should be >= 800, got $min", min >= 800)
335
+ assertTrue("Max delay should be <= 1200, got $max", max <= 1200)
336
+ }
337
+ }