11package com.powersync.sync
22
3+ import app.cash.turbine.turbineScope
34import co.touchlab.kermit.Logger
45import co.touchlab.kermit.Severity
56import co.touchlab.kermit.TestConfig
67import co.touchlab.kermit.TestLogWriter
8+ import com.powersync.bucket.*
9+ import com.powersync.bucket.BucketChecksum
710import com.powersync.bucket.BucketStorage
11+ import com.powersync.bucket.Checkpoint
12+ import com.powersync.bucket.OplogEntry
813import com.powersync.connectors.PowerSyncBackendConnector
914import com.powersync.connectors.PowerSyncCredentials
1015import com.powersync.db.crud.CrudEntry
1116import com.powersync.db.crud.UpdateType
17+ import com.powersync.testutils.MockSyncService
18+ import com.powersync.testutils.waitFor
19+ import com.powersync.utils.JsonUtil
20+ import dev.mokkery.*
1221import dev.mokkery.answering.returns
13- import dev.mokkery.everySuspend
14- import dev.mokkery.mock
15- import dev.mokkery.verify
22+ import dev.mokkery.matcher.any
23+ import dev.mokkery.verify.VerifyMode.Companion.order
24+ import io.ktor.client.engine.mock.*
25+ import kotlinx.coroutines.channels.Channel
1626import kotlinx.coroutines.delay
27+ import kotlinx.coroutines.flow.receiveAsFlow
1728import kotlinx.coroutines.launch
1829import kotlinx.coroutines.test.runTest
1930import kotlinx.coroutines.withTimeout
31+ import kotlinx.serialization.encodeToString
2032import kotlinx.serialization.json.JsonObject
2133import kotlin.test.BeforeTest
2234import kotlin.test.Test
2335import kotlin.test.assertContains
2436import kotlin.test.assertEquals
37+ import kotlin.time.Duration.Companion.seconds
2538
2639@OptIn(co.touchlab.kermit.ExperimentalKermitApi ::class )
2740class SyncStreamTest {
@@ -39,11 +52,33 @@ class SyncStreamTest {
3952 logWriterList = listOf (testLogWriter),
4053 ),
4154 )
55+ private val assertNoHttpEngine = MockEngine { request ->
56+ error(" Unexpected HTTP request: $request " )
57+ }
4258
4359 @BeforeTest
4460 fun setup () {
45- bucketStorage = mock<BucketStorage >()
46- connector = mock<PowerSyncBackendConnector >()
61+ bucketStorage = mock<BucketStorage > {
62+ everySuspend { getClientId() } returns " test-client-id"
63+ everySuspend { getBucketStates() } returns emptyList()
64+ everySuspend { removeBuckets(any()) } returns Unit
65+ everySuspend { setTargetCheckpoint(any()) } returns Unit
66+ everySuspend { saveSyncData(any()) } returns Unit
67+ everySuspend { syncLocalDatabase(any(), any()) } returns SyncLocalDatabaseResult (
68+ ready = true ,
69+ checkpointValid = true ,
70+ checkpointFailures = emptyList()
71+ )
72+ }
73+ connector =
74+ mock<PowerSyncBackendConnector > {
75+ everySuspend { getCredentialsCached() } returns
76+ PowerSyncCredentials (
77+ token = " test-token" ,
78+ userId = " test-user" ,
79+ endpoint = " https://test.com" ,
80+ )
81+ }
4782 }
4883
4984 @Test
@@ -58,6 +93,7 @@ class SyncStreamTest {
5893 SyncStream (
5994 bucketStorage = bucketStorage,
6095 connector = connector,
96+ httpEngine = assertNoHttpEngine,
6197 uploadCrud = {},
6298 logger = logger,
6399 params = JsonObject (emptyMap()),
@@ -92,6 +128,7 @@ class SyncStreamTest {
92128 SyncStream (
93129 bucketStorage = bucketStorage,
94130 connector = connector,
131+ httpEngine = assertNoHttpEngine,
95132 uploadCrud = { },
96133 retryDelayMs = 10 ,
97134 logger = logger,
@@ -126,20 +163,11 @@ class SyncStreamTest {
126163 everySuspend { getBucketStates() } returns emptyList()
127164 }
128165
129- connector =
130- mock<PowerSyncBackendConnector > {
131- everySuspend { getCredentialsCached() } returns
132- PowerSyncCredentials (
133- token = " test-token" ,
134- userId = " test-user" ,
135- endpoint = " https://test.com" ,
136- )
137- }
138-
139166 syncStream =
140167 SyncStream (
141168 bucketStorage = bucketStorage,
142169 connector = connector,
170+ httpEngine = assertNoHttpEngine,
143171 uploadCrud = { },
144172 retryDelayMs = 10 ,
145173 logger = logger,
@@ -166,4 +194,103 @@ class SyncStreamTest {
166194 // Clean up
167195 job.cancel()
168196 }
197+
198+ @Test
199+ fun testPartialSync () = runTest {
200+ // TODO: It would be neat if we could use in-memory sqlite instances instead of mocking everything
201+ // Revisit https://github.com/powersync-ja/powersync-kotlin/pull/117/files at some point
202+ val syncLines = Channel <SyncLine >()
203+ val client = MockSyncService .client(this , syncLines.receiveAsFlow())
204+
205+ syncStream = SyncStream (
206+ bucketStorage = bucketStorage,
207+ connector = connector,
208+ httpEngine = client,
209+ uploadCrud = { },
210+ retryDelayMs = 10 ,
211+ logger = logger,
212+ params = JsonObject (emptyMap()),
213+ )
214+
215+ val job = launch { syncStream.streamingSync() }
216+ var operationId = 1
217+
218+ suspend fun pushData (priority : Int ) {
219+ val id = operationId++
220+
221+ syncLines.send(SyncLine .SyncDataBucket (
222+ bucket = " prio$priority " ,
223+ data = listOf (OplogEntry (
224+ checksum = (priority + 10 ).toLong(),
225+ data = JsonUtil .json.encodeToString(mapOf (" foo" to " bar" )),
226+ op = OpType .PUT ,
227+ opId = id.toString(),
228+ rowId = " prio$priority " ,
229+ rowType = " customers"
230+ )),
231+ after = null ,
232+ nextAfter = null ,
233+ ))
234+ }
235+
236+ turbineScope(timeout= 10.0 .seconds) {
237+ val turbine = syncStream.status.asFlow().testIn(this )
238+ turbine.waitFor { it.connected }
239+ resetCalls(bucketStorage)
240+
241+ // Start a sync flow
242+ syncLines.send(SyncLine .FullCheckpoint (Checkpoint (
243+ lastOpId = " 4" ,
244+ checksums = buildList {
245+ for (priority in 0 .. 3 ) {
246+ add(BucketChecksum (
247+ bucket = " prio$priority " ,
248+ priority = BucketPriority (priority),
249+ checksum = 10 + priority,
250+ ))
251+ }
252+ }
253+ )))
254+
255+ // Emit a partial sync complete for each priority but the last.
256+ for (priorityNo in 0 .. < 3 ) {
257+ val priority = BucketPriority (priorityNo)
258+ pushData(priorityNo)
259+ syncLines.send(SyncLine .CheckpointPartiallyComplete (
260+ lastOpId = operationId.toString(),
261+ priority = priority,
262+ ))
263+
264+ turbine.waitFor { it.priorityStatusFor(priority).hasSynced == true }
265+
266+ verifySuspend(order) {
267+ if (priorityNo == 0 ) {
268+ bucketStorage.removeBuckets(any())
269+ bucketStorage.setTargetCheckpoint(any())
270+ }
271+
272+ bucketStorage.saveSyncData(any())
273+ bucketStorage.syncLocalDatabase(any(), priority)
274+ }
275+ }
276+
277+ // Then complete the sync
278+ pushData(3 )
279+ syncLines.send(SyncLine .CheckpointComplete (
280+ lastOpId = operationId.toString(),
281+ ))
282+
283+ turbine.waitFor { it.hasSynced == true }
284+ verifySuspend {
285+ bucketStorage.saveSyncData(any())
286+ bucketStorage.syncLocalDatabase(any(), null )
287+ }
288+
289+ turbine.cancel()
290+ }
291+
292+ verifyNoMoreCalls(bucketStorage)
293+ job.cancel()
294+ syncLines.close()
295+ }
169296}
0 commit comments