Skip to content

Commit dda9ab4

Browse files
committed
test added
1 parent 20aa6d9 commit dda9ab4

File tree

3 files changed

+516
-0
lines changed

3 files changed

+516
-0
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package io.customer.datapipelines.location
2+
3+
import com.segment.analytics.kotlin.core.TrackEvent
4+
import io.customer.commontest.config.TestConfig
5+
import io.customer.commontest.util.ScopeProviderStub
6+
import io.customer.datapipelines.testutils.core.IntegrationTest
7+
import io.customer.datapipelines.testutils.core.testConfiguration
8+
import io.customer.datapipelines.testutils.utils.OutputReaderPlugin
9+
import io.customer.datapipelines.testutils.utils.trackEvents
10+
import io.customer.sdk.communication.Event
11+
import io.customer.sdk.core.di.SDKComponent
12+
import io.customer.sdk.core.util.ScopeProvider
13+
import io.customer.sdk.util.EventNames
14+
import org.amshove.kluent.shouldBeEqualTo
15+
import org.junit.Test
16+
import org.junit.runner.RunWith
17+
import org.robolectric.RobolectricTestRunner
18+
19+
/**
20+
* Integration tests verifying that the location sync filter inside [CustomerIO]
21+
* correctly resets when the identified profile changes or is cleared.
22+
*
23+
* Uses Robolectric because [LocationSyncFilter] calls
24+
* [android.location.Location.distanceBetween] (native method) and
25+
* [LocationSyncStoreImpl] uses real SharedPreferences.
26+
*/
27+
@RunWith(RobolectricTestRunner::class)
28+
class LocationSyncFilterIntegrationTest : IntegrationTest() {
29+
30+
private lateinit var outputReaderPlugin: OutputReaderPlugin
31+
32+
override fun setup(testConfig: TestConfig) {
33+
super.setup(
34+
testConfiguration {
35+
sdkConfig {
36+
autoAddCustomerIODestination(true)
37+
}
38+
diGraph {
39+
sdk {
40+
overrideDependency<ScopeProvider>(ScopeProviderStub.Unconfined())
41+
}
42+
}
43+
}
44+
)
45+
46+
outputReaderPlugin = OutputReaderPlugin()
47+
analytics.add(outputReaderPlugin)
48+
}
49+
50+
private fun locationTrackEvents(): List<TrackEvent> =
51+
outputReaderPlugin.trackEvents.filter { it.event == EventNames.LOCATION_UPDATE }
52+
53+
private fun publishLocation(lat: Double, lng: Double) {
54+
SDKComponent.eventBus.publish(
55+
Event.TrackLocationEvent(Event.LocationData(lat, lng))
56+
)
57+
}
58+
59+
// -- Profile switch --
60+
61+
@Test
62+
fun givenProfileSwitch_expectNewProfileLocationNotSuppressed() {
63+
sdkInstance.identify("user-a")
64+
publishLocation(37.7749, -122.4194)
65+
locationTrackEvents().size shouldBeEqualTo 1
66+
67+
// Switch profile → clearSyncedData() called internally
68+
sdkInstance.identify("user-b")
69+
publishLocation(37.7749, -122.4194)
70+
71+
// Second user's location must not be suppressed by first user's window
72+
locationTrackEvents().size shouldBeEqualTo 2
73+
}
74+
75+
// -- Clear identify --
76+
77+
@Test
78+
fun givenClearIdentify_thenReIdentify_expectLocationNotSuppressed() {
79+
sdkInstance.identify("user-a")
80+
publishLocation(37.7749, -122.4194)
81+
locationTrackEvents().size shouldBeEqualTo 1
82+
83+
// Logout → clears synced data
84+
sdkInstance.clearIdentify()
85+
86+
// Re-identify as new user
87+
sdkInstance.identify("user-b")
88+
publishLocation(37.7749, -122.4194)
89+
90+
locationTrackEvents().size shouldBeEqualTo 2
91+
}
92+
93+
// -- Same user duplicate suppression (control test) --
94+
95+
@Test
96+
fun givenSameUser_duplicateLocationWithin24h_expectSecondSuppressed() {
97+
sdkInstance.identify("user-a")
98+
publishLocation(37.7749, -122.4194)
99+
locationTrackEvents().size shouldBeEqualTo 1
100+
101+
// Same location within 24h → must be suppressed
102+
publishLocation(37.7749, -122.4194)
103+
locationTrackEvents().size shouldBeEqualTo 1
104+
}
105+
106+
// -- No identified user --
107+
108+
@Test
109+
fun givenNoIdentifiedUser_expectLocationNotTracked() {
110+
// No identify call → userId gate blocks
111+
publishLocation(37.7749, -122.4194)
112+
locationTrackEvents().size shouldBeEqualTo 0
113+
}
114+
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
package io.customer.datapipelines.location
2+
3+
import io.customer.datapipelines.store.LocationSyncStore
4+
import org.amshove.kluent.shouldBeEqualTo
5+
import org.amshove.kluent.shouldBeFalse
6+
import org.amshove.kluent.shouldBeTrue
7+
import org.junit.Before
8+
import org.junit.Test
9+
import org.junit.runner.RunWith
10+
import org.robolectric.RobolectricTestRunner
11+
import org.robolectric.annotation.Config
12+
13+
/**
14+
* Tests for [LocationSyncFilter] verifying 24h + 1km filter logic,
15+
* first-time pass behavior, and reset on profile switch/logout.
16+
*
17+
* Uses Robolectric because [LocationSyncFilter.distanceBetween] calls
18+
* [android.location.Location.distanceBetween] which is a native method
19+
* requiring a shadow implementation.
20+
*/
21+
@RunWith(RobolectricTestRunner::class)
22+
@Config(sdk = [35])
23+
class LocationSyncFilterTest {
24+
25+
private lateinit var store: FakeLocationSyncStore
26+
private lateinit var filter: LocationSyncFilter
27+
28+
@Before
29+
fun setup() {
30+
store = FakeLocationSyncStore()
31+
filter = LocationSyncFilter(store)
32+
}
33+
34+
// -- First-time behavior --
35+
36+
@Test
37+
fun givenNeverSynced_expectFilterPasses() {
38+
filter.filterAndRecord(37.7749, -122.4194).shouldBeTrue()
39+
}
40+
41+
@Test
42+
fun givenNeverSynced_expectSyncedDataSaved() {
43+
filter.filterAndRecord(37.7749, -122.4194)
44+
45+
store.getSyncedLatitude() shouldBeCloseTo 37.7749
46+
store.getSyncedLongitude() shouldBeCloseTo -122.4194
47+
store.getSyncedTimestamp().shouldNotBeNull()
48+
}
49+
50+
// -- Time-based filtering (within 24h -> fail before distance check) --
51+
52+
@Test
53+
fun givenSyncedJustNow_expectFilterFails() {
54+
store.saveSyncedLocation(37.7749, -122.4194, System.currentTimeMillis())
55+
56+
// Even with a completely different location, within 24h -> fail
57+
filter.filterAndRecord(40.7128, -74.0060).shouldBeFalse()
58+
}
59+
60+
@Test
61+
fun givenSynced23hAgo_expectFilterFails() {
62+
val twentyThreeHoursAgo = System.currentTimeMillis() - 23 * 60 * 60 * 1000L
63+
store.saveSyncedLocation(37.7749, -122.4194, twentyThreeHoursAgo)
64+
65+
filter.filterAndRecord(40.7128, -74.0060).shouldBeFalse()
66+
}
67+
68+
// -- Time + distance filtering --
69+
70+
@Test
71+
fun givenSynced25hAgo_sameLocation_expectFilterFails() {
72+
val twentyFiveHoursAgo = System.currentTimeMillis() - 25 * 60 * 60 * 1000L
73+
store.saveSyncedLocation(37.7749, -122.4194, twentyFiveHoursAgo)
74+
75+
// Same coordinates -> distance ~ 0 -> below 1km -> fail
76+
filter.filterAndRecord(37.7749, -122.4194).shouldBeFalse()
77+
}
78+
79+
@Test
80+
fun givenSynced25hAgo_nearbyLocation_expectFilterFails() {
81+
val twentyFiveHoursAgo = System.currentTimeMillis() - 25 * 60 * 60 * 1000L
82+
store.saveSyncedLocation(37.7749, -122.4194, twentyFiveHoursAgo)
83+
84+
// ~11 meters away -> well under 1km
85+
filter.filterAndRecord(37.7750, -122.4194).shouldBeFalse()
86+
}
87+
88+
@Test
89+
fun givenSynced25hAgo_farLocation_expectFilterPasses() {
90+
val twentyFiveHoursAgo = System.currentTimeMillis() - 25 * 60 * 60 * 1000L
91+
store.saveSyncedLocation(37.7749, -122.4194, twentyFiveHoursAgo)
92+
93+
// SF -> NYC ~ 4,130 km -> well over 1km
94+
filter.filterAndRecord(40.7128, -74.0060).shouldBeTrue()
95+
}
96+
97+
@Test
98+
fun givenFilterPasses_expectSyncedDataUpdated() {
99+
val twentyFiveHoursAgo = System.currentTimeMillis() - 25 * 60 * 60 * 1000L
100+
store.saveSyncedLocation(37.7749, -122.4194, twentyFiveHoursAgo)
101+
102+
filter.filterAndRecord(40.7128, -74.0060)
103+
104+
store.getSyncedLatitude() shouldBeCloseTo 40.7128
105+
store.getSyncedLongitude() shouldBeCloseTo -74.0060
106+
// Timestamp should be updated to approximately now
107+
val timeDiff = System.currentTimeMillis() - store.getSyncedTimestamp()!!
108+
(timeDiff < 1000).shouldBeTrue()
109+
}
110+
111+
// -- Rapid sequential calls --
112+
113+
@Test
114+
fun givenFirstCallPasses_secondCallImmediately_expectSecondFails() {
115+
// First call: no synced data -> passes
116+
filter.filterAndRecord(37.7749, -122.4194).shouldBeTrue()
117+
118+
// Second call immediately: timestamp just saved -> within 24h -> fails
119+
filter.filterAndRecord(40.7128, -74.0060).shouldBeFalse()
120+
}
121+
122+
// -- Reset behavior --
123+
124+
@Test
125+
fun givenClearedAfterSync_expectFilterPasses() {
126+
store.saveSyncedLocation(37.7749, -122.4194, System.currentTimeMillis())
127+
128+
filter.clearSyncedData()
129+
130+
// Should pass as if first time
131+
filter.filterAndRecord(37.7749, -122.4194).shouldBeTrue()
132+
}
133+
134+
@Test
135+
fun givenClearedAfterSync_expectOldWindowGone() {
136+
store.saveSyncedLocation(37.7749, -122.4194, System.currentTimeMillis())
137+
138+
// Without clear: same location within 24h -> would fail
139+
filter.filterAndRecord(37.7749, -122.4194).shouldBeFalse()
140+
141+
filter.clearSyncedData()
142+
143+
// Same location now passes because synced state is gone
144+
filter.filterAndRecord(37.7749, -122.4194).shouldBeTrue()
145+
}
146+
147+
// -- Store not modified on rejection --
148+
149+
@Test
150+
fun givenFilterFails_expectSyncedDataNotModified() {
151+
val originalTimestamp = System.currentTimeMillis()
152+
store.saveSyncedLocation(37.7749, -122.4194, originalTimestamp)
153+
154+
// Within 24h, far location -> fails
155+
filter.filterAndRecord(40.7128, -74.0060).shouldBeFalse()
156+
157+
// Store must be exactly as it was before the failed call
158+
store.getSyncedLatitude() shouldBeCloseTo 37.7749
159+
store.getSyncedLongitude() shouldBeCloseTo -122.4194
160+
store.getSyncedTimestamp() shouldBeEqualTo originalTimestamp
161+
}
162+
163+
// -- Exact boundary --
164+
165+
@Test
166+
fun givenSyncedExactly24hAgo_farLocation_expectFilterPasses() {
167+
// Code uses `< 24h` (strict less-than), so exactly 24h should pass
168+
val exactly24hAgo = System.currentTimeMillis() - 24 * 60 * 60 * 1000L
169+
store.saveSyncedLocation(37.7749, -122.4194, exactly24hAgo)
170+
171+
// SF -> NYC, distance clearly > 1km
172+
filter.filterAndRecord(40.7128, -74.0060).shouldBeTrue()
173+
}
174+
175+
// -- Missing synced lat/lng edge cases --
176+
177+
@Test
178+
fun givenTimestampExists_butNoLatLng_expectFilterPasses() {
179+
// Only timestamp, no coordinates (shouldn't happen normally but handle gracefully)
180+
store.setTimestampOnly(System.currentTimeMillis() - 25 * 60 * 60 * 1000L)
181+
182+
filter.filterAndRecord(37.7749, -122.4194).shouldBeTrue()
183+
}
184+
185+
// -- Clear on empty store --
186+
187+
@Test
188+
fun givenNoSyncedData_clearSyncedData_expectNoException() {
189+
// Clearing an already-empty store must not throw
190+
filter.clearSyncedData()
191+
192+
// Still passes as first-time after clear
193+
filter.filterAndRecord(37.7749, -122.4194).shouldBeTrue()
194+
}
195+
}
196+
197+
// -- Helpers --
198+
199+
/**
200+
* In-memory fake for testing [LocationSyncFilter] without SharedPreferences.
201+
*/
202+
internal class FakeLocationSyncStore : LocationSyncStore {
203+
private var latitude: Double? = null
204+
private var longitude: Double? = null
205+
private var timestamp: Long? = null
206+
207+
override fun saveSyncedLocation(latitude: Double, longitude: Double, timestamp: Long) {
208+
this.latitude = latitude
209+
this.longitude = longitude
210+
this.timestamp = timestamp
211+
}
212+
213+
override fun getSyncedLatitude(): Double? = latitude
214+
override fun getSyncedLongitude(): Double? = longitude
215+
override fun getSyncedTimestamp(): Long? = timestamp
216+
217+
override fun clearSyncedData() {
218+
latitude = null
219+
longitude = null
220+
timestamp = null
221+
}
222+
223+
/** Sets only the timestamp without coordinates (edge case testing). */
224+
fun setTimestampOnly(timestamp: Long) {
225+
this.timestamp = timestamp
226+
this.latitude = null
227+
this.longitude = null
228+
}
229+
}
230+
231+
private infix fun Double?.shouldBeCloseTo(expected: Double) {
232+
if (this == null) throw AssertionError("Expected $expected but was null")
233+
val diff = kotlin.math.abs(this - expected)
234+
if (diff > 0.0001) throw AssertionError("Expected $expected but was $this (diff=$diff)")
235+
}
236+
237+
private fun Long?.shouldNotBeNull() {
238+
if (this == null) throw AssertionError("Expected non-null but was null")
239+
}

0 commit comments

Comments
 (0)