From d414c29d124dad5b0ab8377210e161a71d01a8cb Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 4 Apr 2025 18:01:05 +0200 Subject: [PATCH 1/6] Start extracting setup into scope context --- .../com/powersync/testutils/TestUtils.kt | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index e10cb099..5e7466fb 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -1,8 +1,23 @@ package com.powersync.testutils +import co.touchlab.kermit.ExperimentalKermitApi import co.touchlab.kermit.LogWriter +import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity +import co.touchlab.kermit.TestConfig +import co.touchlab.kermit.TestLogWriter import com.powersync.DatabaseDriverFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlin.coroutines.CoroutineContext expect val factory: DatabaseDriverFactory @@ -21,3 +36,46 @@ fun generatePrintLogWriter() = println("[$severity:$tag] - $message") } } + +@OptIn(ExperimentalKermitApi::class) +class DatabaseTestScope : CoroutineContext.Element { + val logWriter = + TestLogWriter( + loggable = Severity.Debug, + ) + val logger = + Logger( + TestConfig( + minSeverity = Severity.Debug, + logWriterList = listOf(logWriter, generatePrintLogWriter()), + ), + ) + + val testDirectory by lazy { + getTempDir() ?: SystemFileSystem.resolve(Path(".")).name + } + + val databaseName by lazy { + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + CharArray(8) { allowedChars.random() }.concatToString() + } + + private val cleanupItems: MutableList Unit> = mutableListOf() + + override val key: CoroutineContext.Key<*> + get() = Companion + + companion object : CoroutineContext.Key +} + +val CoroutineContext.database: DatabaseTestScope get() = get(DatabaseTestScope) ?: error("Not in PowerSync test: $this") + +fun databaseTest( + testBody: suspend TestScope.() -> Unit +) = runTest { + val test = DatabaseTestScope() + + withContext(test) { + testBody() + } +} From f8f95784b957662af26020e4d8f53de5c6ae0ed0 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 7 Apr 2025 11:03:25 +0200 Subject: [PATCH 2/6] Isolate tests --- core/build.gradle.kts | 1 + .../kotlin/com/powersync/DatabaseTest.kt | 179 ++++++---------- .../com/powersync/SyncIntegrationTest.kt | 192 ++++-------------- .../testutils/PowerSyncTestFixtures.kt | 140 +++++++++++++ .../com/powersync/db/PowerSyncDatabaseImpl.kt | 124 +++++++---- .../kotlin/com/powersync/testutils/WaitFor.kt | 9 +- gradle/libs.versions.toml | 2 + 7 files changed, 327 insertions(+), 320 deletions(-) create mode 100644 core/src/commonIntegrationTest/kotlin/com/powersync/testutils/PowerSyncTestFixtures.kt diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 96f77274..57a8b494 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -261,6 +261,7 @@ kotlin { implementation(libs.kotlin.test) implementation(libs.test.coroutines) implementation(libs.test.turbine) + implementation(libs.test.kotest.assertions) implementation(libs.kermit.test) implementation(libs.ktor.client.mock) implementation(libs.test.turbine) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 7e4614ca..1c0dc3dc 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -2,109 +2,58 @@ package com.powersync import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi -import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity -import co.touchlab.kermit.TestConfig -import co.touchlab.kermit.TestLogWriter import com.powersync.db.ActiveDatabaseGroup -import com.powersync.db.getString import com.powersync.db.schema.Schema +import com.powersync.testutils.PowerSyncTestFixtures import com.powersync.testutils.UserRow -import com.powersync.testutils.generatePrintLogWriter import com.powersync.testutils.getTempDir import com.powersync.testutils.waitFor +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext -import kotlin.test.AfterTest -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertNotNull -import kotlin.test.assertTrue @OptIn(ExperimentalKermitApi::class) -class DatabaseTest { - private val logWriter = - TestLogWriter( - loggable = Severity.Debug, - ) - - private val logger = - Logger( - TestConfig( - minSeverity = Severity.Debug, - logWriterList = listOf(logWriter, generatePrintLogWriter()), - ), - ) - - private lateinit var database: PowerSyncDatabase - - private fun openDB() = - PowerSyncDatabase( - factory = com.powersync.testutils.factory, - schema = Schema(UserRow.table), - dbFilename = "testdb", - logger = logger, - ) - - @BeforeTest - fun setupDatabase() { - logWriter.reset() - - database = openDB() - - runBlocking { - database.disconnectAndClear(true) - } - } - - @AfterTest - fun tearDown() { - runBlocking { - if (!database.closed) { - database.disconnectAndClear(true) - } - } - com.powersync.testutils.cleanup("testdb") - } - +class DatabaseTest: PowerSyncTestFixtures() { @Test fun testLinksPowerSync() = - runTest { + databaseTest { database.get("SELECT powersync_rs_version();") { it.getString(0)!! } } @Test fun testWAL() = - runTest { - val mode = - database.get( + databaseTest { + val mode = database.get( "PRAGMA journal_mode", mapper = { it.getString(0)!! }, ) - assertEquals(mode, "wal") + mode shouldBe "wal" } @Test fun testFTS() = - runTest { + databaseTest { val mode = database.get( "SELECT sqlite_compileoption_used('ENABLE_FTS5');", mapper = { it.getLong(0)!! }, ) - assertEquals(mode, 1) + mode shouldBe 1 } @Test fun testConcurrentReads() = - runTest { + databaseTest { database.execute( "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", listOf( @@ -117,7 +66,7 @@ class DatabaseTest { val transactionItemCreated = CompletableDeferred() // Start a long running writeTransaction val transactionJob = - async { + scope.async { database.writeTransaction { tx -> // Create another user // External readers should not see this user while the transaction is open @@ -155,7 +104,7 @@ class DatabaseTest { @Test fun testTransactionReads() = - runTest { + databaseTest { database.execute( "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", listOf( @@ -186,18 +135,18 @@ class DatabaseTest { @Test fun testTableUpdates() = - runTest { + databaseTest { turbineScope { val query = database.watch("SELECT * FROM users") { UserRow.from(it) }.testIn(this) // Wait for initial query - assertEquals(0, query.awaitItem().size) + query.awaitItem() shouldHaveSize 0 database.execute( "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", listOf("Test", "test@example.org"), ) - assertEquals(1, query.awaitItem().size) + query.awaitItem() shouldHaveSize 1 database.writeTransaction { it.execute( @@ -210,7 +159,7 @@ class DatabaseTest { ) } - assertEquals(3, query.awaitItem().size) + query.awaitItem() shouldHaveSize 3 try { database.writeTransaction { @@ -225,7 +174,7 @@ class DatabaseTest { "INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?)", listOf("Test4", "test4@example.org"), ) - assertEquals(4, query.awaitItem().size) + query.awaitItem() shouldHaveSize 4 query.expectNoEvents() query.cancel() @@ -234,12 +183,12 @@ class DatabaseTest { @Test fun testClosingReadPool() = - runTest { + databaseTest { val pausedLock = CompletableDeferred() val inLock = CompletableDeferred() // Request a lock val lockJob = - async { + scope.async { database.readLock { inLock.complete(Unit) runBlocking { @@ -254,63 +203,51 @@ class DatabaseTest { // Close the database. This should close the read pool // The pool should wait for jobs to complete before closing val closeJob = - async { + scope.async { database.close() } // Wait a little for testing - // Spawns in a different context for the delay to actually take affect - async { withContext(Dispatchers.Default) { delay(500) } }.await() + // Spawns in a different context for the delay to actually take effect + scope.async { withContext(Dispatchers.Default) { delay(500) } }.await() // The database should not close yet assertEquals(actual = database.closed, expected = false) // Any new readLocks should throw - val exception = assertFailsWith { database.readLock {} } - assertEquals( - expected = "Cannot process connection pool request", - actual = exception.message, - ) + val exception = shouldThrow { database.readLock {} } + exception.message shouldBe "Cannot process connection pool request" + // Release the lock pausedLock.complete(Unit) lockJob.await() closeJob.await() - assertEquals(actual = database.closed, expected = true) + database.closed shouldBe true } @Test fun openDBWithDirectory() = - runTest { + databaseTest { val tempDir = getTempDir() ?: // SQLiteR, which is used on iOS, does not support opening dbs from directories - return@runTest - - val dbFilename = "testdb" - - val db = - PowerSyncDatabase( - factory = com.powersync.testutils.factory, - schema = Schema(UserRow.table), - dbFilename = dbFilename, - dbDirectory = getTempDir(), - logger = logger, - ) + return@databaseTest - val path = db.get("SELECT file FROM pragma_database_list;") { it.getString(0)!! } - assertTrue { path.contains(tempDir) } - db.close() + // On platforms that support it, openDatabase() from our test utils should use a temporary + // location. + val path = database.get("SELECT file FROM pragma_database_list;") { it.getString(0)!! } + path shouldContain tempDir } @Test fun warnsMultipleInstances() = - runTest { + databaseTest { // Opens a second DB with the same database filename - val db2 = openDB() + val db2 = openDatabase() waitFor { assertNotNull( - logWriter.logs.find { + logWriter().logs.find { it.message == ActiveDatabaseGroup.multipleInstancesMessage }, ) @@ -320,37 +257,37 @@ class DatabaseTest { @Test fun readConnectionsReadOnly() = - runTest { - val exception = - assertFailsWith { - database.getOptional( - """ + databaseTest { + val exception = shouldThrow { + database.getOptional( + """ INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?) RETURNING * """.trimIndent(), - parameters = listOf("steven", "steven@journeyapps.com"), - ) {} - } + parameters = listOf("steven", "steven@journeyapps.com"), + ) {} + } + // The exception messages differ slightly between drivers - assertTrue { exception.message!!.contains("write a readonly database") } + exception.message shouldContain "write a readonly database" } @Test fun basicReadTransaction() = - runTest { + databaseTest { val count = database.readTransaction { it -> it.get("SELECT COUNT(*) from users") { it.getLong(0)!! } } - assertEquals(expected = 0, actual = count) + count shouldBe 0 } @Test fun localOnlyCRUD() = - runTest { + databaseTest { database.updateSchema( schema = Schema( @@ -374,16 +311,16 @@ class DatabaseTest { ) val count = database.get("SELECT COUNT(*) FROM local_users") { it.getLong(0)!! } - assertEquals(actual = count, expected = 1) + count shouldBe 1 // No CRUD entries should be present for local only tables val crudItems = database.getAll("SELECT id from ps_crud") { it.getLong(0)!! } - assertEquals(actual = crudItems.size, expected = 0) + crudItems shouldHaveSize 0 } @Test fun insertOnlyCRUD() = - runTest { + databaseTest { database.updateSchema(schema = Schema(UserRow.table.copy(insertOnly = true))) database.execute( @@ -396,15 +333,15 @@ class DatabaseTest { ) val crudItems = database.getAll("SELECT id from ps_crud") { it.getLong(0)!! } - assertEquals(actual = crudItems.size, expected = 1) + crudItems shouldHaveSize 1 val count = database.get("SELECT COUNT(*) from users") { it.getLong(0)!! } - assertEquals(actual = count, expected = 0) + count shouldBe 0 } @Test fun viewOverride() = - runTest { + databaseTest { database.updateSchema(schema = Schema(UserRow.table.copy(viewNameOverride = "people"))) database.execute( @@ -417,9 +354,9 @@ class DatabaseTest { ) val crudItems = database.getAll("SELECT id from ps_crud") { it.getLong(0)!! } - assertEquals(actual = crudItems.size, expected = 1) + crudItems shouldHaveSize 1 val count = database.get("SELECT COUNT(*) from people") { it.getLong(0)!! } - assertEquals(actual = count, expected = 1) + count shouldBe 1 } } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt index cfefbf7b..12b794ad 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt @@ -2,126 +2,40 @@ package com.powersync import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi -import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity -import co.touchlab.kermit.TestConfig -import co.touchlab.kermit.TestLogWriter import com.powersync.bucket.BucketChecksum import com.powersync.bucket.BucketPriority import com.powersync.bucket.Checkpoint import com.powersync.bucket.OpType import com.powersync.bucket.OplogEntry -import com.powersync.connectors.PowerSyncBackendConnector -import com.powersync.connectors.PowerSyncCredentials import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema import com.powersync.sync.SyncLine -import com.powersync.sync.SyncStream -import com.powersync.testutils.MockSyncService +import com.powersync.testutils.PowerSyncTestFixtures import com.powersync.testutils.UserRow -import com.powersync.testutils.cleanup -import com.powersync.testutils.factory -import com.powersync.testutils.generatePrintLogWriter import com.powersync.testutils.waitFor import com.powersync.utils.JsonUtil -import dev.mokkery.answering.returns -import dev.mokkery.everySuspend -import dev.mokkery.mock import dev.mokkery.verify +import io.kotest.assertions.withClue +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runTest import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.JsonObject -import kotlin.test.AfterTest -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertFalse import kotlin.test.assertNotNull -import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds -@OptIn(ExperimentalKermitApi::class) -class SyncIntegrationTest { - private val logWriter = - TestLogWriter( - loggable = Severity.Debug, - ) - - private val logger = - Logger( - TestConfig( - minSeverity = Severity.Debug, - logWriterList = listOf(logWriter, generatePrintLogWriter()), - ), - ) - private lateinit var database: PowerSyncDatabaseImpl - private lateinit var connector: PowerSyncBackendConnector - private lateinit var syncLines: Channel - - @BeforeTest - fun setup() { - cleanup("testdb") - logWriter.reset() - database = openDb() - connector = - mock { - everySuspend { getCredentialsCached() } returns - PowerSyncCredentials( - token = "test-token", - userId = "test-user", - endpoint = "https://test.com", - ) - - everySuspend { invalidateCredentials() } returns Unit - } - syncLines = Channel() - - runBlocking { - database.disconnectAndClear(true) - } - } - - @AfterTest - fun teardown() { - runBlocking { - database.close() - } - cleanup("testdb") - } - - private fun openDb() = - PowerSyncDatabase( - factory = factory, - schema = Schema(UserRow.table), - dbFilename = "testdb", - ) as PowerSyncDatabaseImpl - - private fun syncStream(): SyncStream { - val client = MockSyncService(syncLines) - return SyncStream( - bucketStorage = database.bucketStorage, - connector = connector, - httpEngine = client, - uploadCrud = { }, - retryDelayMs = 10, - logger = logger, - params = JsonObject(emptyMap()), - ) - } - - private suspend fun expectUserCount(amount: Int) { - val users = database.getAll("SELECT * FROM users;") { UserRow.from(it) } - assertEquals(amount, users.size, "Expected $amount users, got $users") +class SyncIntegrationTest: PowerSyncTestFixtures() { + private suspend fun PowerSyncDatabase.expectUserCount(amount: Int) { + val users = getAll("SELECT * FROM users;") { UserRow.from(it) } + users shouldHaveSize amount } @Test @OptIn(DelicateCoroutinesApi::class) fun closesResponseStreamOnDatabaseClose() = - runTest { + databaseTest { val syncStream = syncStream() database.connectInternal(syncStream, 1000L) @@ -135,13 +49,15 @@ class SyncIntegrationTest { } // Closing the database should have closed the channel - assertTrue { syncLines.isClosedForSend } + withClue("Should have closed sync stream") { + syncLines.isClosedForSend shouldBe true + } } @Test @OptIn(DelicateCoroutinesApi::class) fun cleansResourcesOnDisconnect() = - runTest { + databaseTest { val syncStream = syncStream() database.connectInternal(syncStream, 1000L) @@ -155,7 +71,9 @@ class SyncIntegrationTest { } // Disconnecting should have closed the channel - assertTrue { syncLines.isClosedForSend } + withClue("Should have closed sync stream") { + syncLines.isClosedForSend shouldBe true + } // And called invalidateCredentials on the connector verify { connector.invalidateCredentials() } @@ -163,7 +81,7 @@ class SyncIntegrationTest { @Test fun cannotUpdateSchemaWhileConnected() = - runTest { + databaseTest { val syncStream = syncStream() database.connectInternal(syncStream, 1000L) @@ -182,7 +100,7 @@ class SyncIntegrationTest { @Test fun testPartialSync() = - runTest { + databaseTest { val syncStream = syncStream() database.connectInternal(syncStream, 1000L) @@ -232,7 +150,7 @@ class SyncIntegrationTest { turbineScope(timeout = 10.0.seconds) { val turbine = syncStream.status.asFlow().testIn(this) turbine.waitFor { it.connected } - expectUserCount(0) + database.expectUserCount(0) syncLines.send( SyncLine.FullCheckpoint( @@ -255,7 +173,7 @@ class SyncIntegrationTest { ) turbine.waitFor { it.statusForPriority(priority).hasSynced == true } - expectUserCount(priorityNo + 1) + database.expectUserCount(priorityNo + 1) } // Then complete the sync @@ -266,17 +184,15 @@ class SyncIntegrationTest { ), ) turbine.waitFor { it.hasSynced == true } - expectUserCount(4) + database.expectUserCount(4) turbine.cancel() } - - syncLines.close() } @Test fun testRemembersLastPartialSync() = - runTest { + databaseTest { val syncStream = syncStream() database.connectInternal(syncStream, 1000L) @@ -306,16 +222,14 @@ class SyncIntegrationTest { database.close() // Connect to the same database again - database = openDb() - assertFalse { database.currentStatus.hasSynced == true } - assertTrue { database.currentStatus.statusForPriority(BucketPriority(1)).hasSynced == true } - database.close() - syncLines.close() + database = openDatabaseAndInitialize() + database.currentStatus.hasSynced shouldBe false + database.currentStatus.statusForPriority(BucketPriority(1)).hasSynced shouldBe true } @Test fun setsDownloadingState() = - runTest { + databaseTest { val syncStream = syncStream() database.connectInternal(syncStream, 1000L) @@ -343,14 +257,11 @@ class SyncIntegrationTest { turbine.waitFor { !it.downloading } turbine.cancel() } - - database.close() - syncLines.close() } @Test fun setsConnectingState() = - runTest { + databaseTest { turbineScope(timeout = 10.0.seconds) { val syncStream = syncStream() val turbine = database.currentStatus.asFlow().testIn(this) @@ -363,14 +274,11 @@ class SyncIntegrationTest { turbine.waitFor { !it.connecting && !it.connected } turbine.cancel() } - - database.close() - syncLines.close() } @Test fun testMultipleSyncsDoNotCreateMultipleStatusEntries() = - runTest { + databaseTest { val syncStream = syncStream() database.connectInternal(syncStream, 1000L) @@ -408,21 +316,13 @@ class SyncIntegrationTest { turbine.cancel() } - - database.close() - syncLines.close() } @Test + @OptIn(ExperimentalKermitApi::class) fun warnsMultipleConnectionAttempts() = - runTest { - val db2 = - PowerSyncDatabase( - factory = factory, - schema = Schema(UserRow.table), - dbFilename = "testdb", - logger = logger, - ) as PowerSyncDatabaseImpl + databaseTest { + val db2 = openDatabaseAndInitialize() turbineScope(timeout = 10.0.seconds) { // Connect the first database @@ -440,22 +340,12 @@ class SyncIntegrationTest { db2.disconnect() database.disconnect() } - - db2.close() - database.close() - syncLines.close() } @Test fun queuesMultipleConnectionAttempts() = - runTest { - val db2 = - PowerSyncDatabase( - factory = factory, - schema = Schema(UserRow.table), - dbFilename = "testdb", - logger = Logger, - ) as PowerSyncDatabaseImpl + databaseTest { + val db2 = openDatabaseAndInitialize() turbineScope(timeout = 10.0.seconds) { val turbine1 = database.currentStatus.asFlow().testIn(this) @@ -468,7 +358,7 @@ class SyncIntegrationTest { db2.connect(connector) // Should not be connecting yet - assertEquals(false, db2.currentStatus.connecting) + db2.currentStatus.connecting shouldBe false database.disconnect() turbine1.waitFor { !it.connecting } @@ -481,15 +371,11 @@ class SyncIntegrationTest { turbine1.cancel() turbine2.cancel() } - - db2.close() - database.close() - syncLines.close() } @Test fun reconnectsAfterDisconnecting() = - runTest { + databaseTest { turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -506,14 +392,11 @@ class SyncIntegrationTest { turbine.cancel() } - - database.close() - syncLines.close() } @Test fun reconnects() = - runTest { + databaseTest { turbineScope(timeout = 10.0.seconds) { val turbine = database.currentStatus.asFlow().testIn(this) @@ -525,8 +408,5 @@ class SyncIntegrationTest { turbine.cancel() } - - database.close() - syncLines.close() } } diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/PowerSyncTestFixtures.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/PowerSyncTestFixtures.kt new file mode 100644 index 00000000..d6a7b5e1 --- /dev/null +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/PowerSyncTestFixtures.kt @@ -0,0 +1,140 @@ +package com.powersync.testutils + +import co.touchlab.kermit.ExperimentalKermitApi +import co.touchlab.kermit.Logger +import co.touchlab.kermit.Severity +import co.touchlab.kermit.TestConfig +import co.touchlab.kermit.TestLogWriter +import com.powersync.PowerSyncDatabase +import com.powersync.connectors.PowerSyncBackendConnector +import com.powersync.connectors.PowerSyncCredentials +import com.powersync.db.PowerSyncDatabaseImpl +import com.powersync.db.schema.Schema +import com.powersync.sync.SyncLine +import com.powersync.sync.SyncStream +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.mock +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import kotlinx.serialization.json.JsonObject +import kotlin.coroutines.CoroutineContext + +open class PowerSyncTestFixtures { + internal fun databaseTest( + testBody: suspend CurrentTest.() -> Unit + ) = runTest { + val running = CurrentTest(this) + // Make sure the database is initialized, we're using internal APIs that expect initialization. + running.database = running.openDatabaseAndInitialize() + + withContext(running) { + running.testBody() + } + + running.cleanup() + } + + internal suspend fun currentTest(): CurrentTest { + return currentCoroutineContext()[CurrentTestKey] ?: error("Not in a running database test") + } + + @OptIn(ExperimentalKermitApi::class) + suspend fun logWriter(): TestLogWriter = currentTest().logWriter + + @OptIn(ExperimentalKermitApi::class) + internal class CurrentTest(val scope: TestScope) : CoroutineContext.Element { + private val cleanupItems: MutableList Unit> = mutableListOf() + + lateinit var database: PowerSyncDatabaseImpl + + val logWriter = + TestLogWriter( + loggable = Severity.Debug, + ) + val logger = + Logger( + TestConfig( + minSeverity = Severity.Debug, + logWriterList = listOf(logWriter, generatePrintLogWriter()), + ), + ) + + val syncLines = Channel() + + val testDirectory by lazy { getTempDir() } + val databaseName by lazy { + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + val suffix = CharArray(8) { allowedChars.random() }.concatToString() + + "db-$suffix" + } + + val connector = mock { + everySuspend { getCredentialsCached() } returns + PowerSyncCredentials( + token = "test-token", + userId = "test-user", + endpoint = "https://test.com", + ) + + everySuspend { invalidateCredentials() } returns Unit + } + + fun openDatabase(): PowerSyncDatabaseImpl { + logger.d { "Opening database $databaseName in directory $testDirectory" } + val db = PowerSyncDatabase( + factory = factory, + schema = Schema(UserRow.table), + dbFilename = databaseName, + dbDirectory = testDirectory, + logger = logger, + scope = scope, + ) + doOnCleanup { db.close() } + return db as PowerSyncDatabaseImpl + } + + suspend fun openDatabaseAndInitialize(): PowerSyncDatabaseImpl { + return openDatabase().also { it.readLock { } } + } + + fun syncStream(): SyncStream { + val client = MockSyncService(syncLines) + return SyncStream( + bucketStorage = database.bucketStorage, + connector = connector, + httpEngine = client, + uploadCrud = { }, + retryDelayMs = 10, + logger = logger, + params = JsonObject(emptyMap()), + ) + } + + fun doOnCleanup(action: suspend () -> Unit) { + cleanupItems += action + } + + suspend fun cleanup() { + for (item in cleanupItems) { + item() + } + + var path = databaseName + testDirectory?.let { + path = Path(it, path).name + } + cleanup(path) + } + + override val key: CoroutineContext.Key<*> + get() = CurrentTestKey + } + + private object CurrentTestKey : CoroutineContext.Key +} \ No newline at end of file diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 244d9516..aee081ca 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -29,12 +29,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Instant @@ -98,41 +100,48 @@ internal class PowerSyncDatabaseImpl( private val mutex = Mutex() private var syncSupervisorJob: Job? = null - // This is set in the init + // This is set before the initialization job completes private lateinit var powerSyncVersion: String + private val initializeJob = scope.launch { initialize() } - init { - runBlocking { - val sqliteVersion = internalDb.get("SELECT sqlite_version()") { it.getString(0)!! } - logger.d { "SQLiteVersion: $sqliteVersion" } - powerSyncVersion = - internalDb.get("SELECT powersync_rs_version()") { it.getString(0)!! } - checkVersion(powerSyncVersion) - logger.d { "PowerSyncVersion: ${getPowerSyncVersion()}" } - - internalDb.writeTransaction { tx -> - tx.getOptional("SELECT powersync_init()") {} - } + private suspend fun initialize() { + val sqliteVersion = internalDb.get("SELECT sqlite_version()") { it.getString(0)!! } + logger.d { "SQLiteVersion: $sqliteVersion" } + powerSyncVersion = internalDb.get("SELECT powersync_rs_version()") { it.getString(0)!! } + checkVersion(powerSyncVersion) + logger.d { "PowerSyncVersion: $powerSyncVersion" } - updateSchema(schema) - updateHasSynced() + internalDb.writeTransaction { tx -> + tx.getOptional("SELECT powersync_init()") {} } + + updateSchemaInternal(schema) + updateHasSynced() + } + + private suspend fun waitReady() { + initializeJob.join() } override suspend fun updateSchema(schema: Schema) = runWrappedSuspending { - mutex.withLock { - if (this.syncSupervisorJob != null) { - throw PowerSyncException( - "Cannot update schema while connected", - cause = Exception("PowerSync client is already connected"), - ) - } - val schemaJson = JsonUtil.json.encodeToString(schema.toSerializable()) - internalDb.updateSchema(schemaJson) - this.schema = schema + waitReady() + updateSchemaInternal(schema) + } + + private suspend fun updateSchemaInternal(schema: Schema) { + mutex.withLock { + if (this.syncSupervisorJob != null) { + throw PowerSyncException( + "Cannot update schema while connected", + cause = Exception("PowerSync client is already connected"), + ) } + val schemaJson = JsonUtil.json.encodeToString(schema.toSerializable()) + internalDb.updateSchema(schemaJson) + this.schema = schema } + } override suspend fun connect( connector: PowerSyncBackendConnector, @@ -140,6 +149,7 @@ internal class PowerSyncDatabaseImpl( retryDelayMs: Long, params: Map, ) = mutex.withLock { + waitReady() disconnectInternal() connectInternal( @@ -156,7 +166,7 @@ internal class PowerSyncDatabaseImpl( } @OptIn(FlowPreview::class) - internal fun connectInternal( + internal suspend fun connectInternal( stream: SyncStream, crudThrottleMs: Long, ) { @@ -235,6 +245,7 @@ internal class PowerSyncDatabaseImpl( } override suspend fun getCrudBatch(limit: Int): CrudBatch? { + waitReady() if (!bucketStorage.hasCrud()) { return null } @@ -268,6 +279,7 @@ internal class PowerSyncDatabaseImpl( } override suspend fun getNextCrudTransaction(): CrudTransaction? { + waitReady() return internalDb.readTransaction { transaction -> val entry = bucketStorage.nextCrudItem(transaction) @@ -300,46 +312,76 @@ internal class PowerSyncDatabaseImpl( } } - // The initialization sets powerSyncVersion. We currently run the init as a blocking operation - override suspend fun getPowerSyncVersion(): String = powerSyncVersion + override suspend fun getPowerSyncVersion(): String { + // The initialization sets powerSyncVersion. + waitReady() + return powerSyncVersion + } override suspend fun get( sql: String, parameters: List?, mapper: (SqlCursor) -> RowType, - ): RowType = internalDb.get(sql, parameters, mapper) + ): RowType { + waitReady() + return internalDb.get(sql, parameters, mapper) + } override suspend fun getAll( sql: String, parameters: List?, mapper: (SqlCursor) -> RowType, - ): List = internalDb.getAll(sql, parameters, mapper) + ): List { + waitReady() + return internalDb.getAll(sql, parameters, mapper) + } override suspend fun getOptional( sql: String, parameters: List?, mapper: (SqlCursor) -> RowType, - ): RowType? = internalDb.getOptional(sql, parameters, mapper) + ): RowType? { + waitReady() + return internalDb.getOptional(sql, parameters, mapper) + } override fun watch( sql: String, parameters: List?, throttleMs: Long?, mapper: (SqlCursor) -> RowType, - ): Flow> = internalDb.watch(sql, parameters, throttleMs, mapper) + ): Flow> = flow { + waitReady() + emitAll(internalDb.watch(sql, parameters, throttleMs, mapper)) + } - override suspend fun readLock(callback: ThrowableLockCallback): R = internalDb.readLock(callback) + override suspend fun readLock(callback: ThrowableLockCallback): R { + waitReady() + return internalDb.readLock(callback) + } - override suspend fun readTransaction(callback: ThrowableTransactionCallback): R = internalDb.writeTransaction(callback) + override suspend fun readTransaction(callback: ThrowableTransactionCallback): R { + waitReady() + return internalDb.writeTransaction(callback) + } - override suspend fun writeLock(callback: ThrowableLockCallback): R = internalDb.writeLock(callback) + override suspend fun writeLock(callback: ThrowableLockCallback): R { + waitReady() + return internalDb.writeLock(callback) + } - override suspend fun writeTransaction(callback: ThrowableTransactionCallback): R = internalDb.writeTransaction(callback) + override suspend fun writeTransaction(callback: ThrowableTransactionCallback): R { + waitReady() + return internalDb.writeTransaction(callback) + } override suspend fun execute( sql: String, parameters: List?, - ): Long = internalDb.execute(sql, parameters) + ): Long { + waitReady() + return internalDb.execute(sql, parameters) + } private suspend fun handleWriteCheckpoint( lastTransactionId: Int, @@ -365,7 +407,10 @@ internal class PowerSyncDatabaseImpl( } } - override suspend fun disconnect() = mutex.withLock { disconnectInternal() } + override suspend fun disconnect() { + waitReady() + mutex.withLock { disconnectInternal() } + } private suspend fun disconnectInternal() { val syncJob = syncSupervisorJob @@ -460,6 +505,7 @@ internal class PowerSyncDatabaseImpl( if (closed) { return@withLock } + initializeJob.cancelAndJoin() disconnectInternal() internalDb.close() resource.dispose() diff --git a/core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt b/core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt index cbc8fdb5..86588c94 100644 --- a/core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt +++ b/core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt @@ -4,13 +4,14 @@ import kotlinx.coroutines.delay import kotlinx.datetime.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource -internal suspend fun waitFor( +internal suspend inline fun waitFor( timeout: Duration = 500.milliseconds, interval: Duration = 100.milliseconds, test: () -> Unit, ) { - val begin = Clock.System.now().toEpochMilliseconds() + val begin = TimeSource.Monotonic.markNow() do { try { test() @@ -18,8 +19,8 @@ internal suspend fun waitFor( } catch (_: Error) { // Treat exceptions as failed } - delay(interval.inWholeMilliseconds) - } while ((Clock.System.now().toEpochMilliseconds() - begin) < timeout.inWholeMilliseconds) + delay(interval) + } while (begin.elapsedNow() < timeout) throw Exception("Timeout reached") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 224c0422..f608d95f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ powersync-core = "0.3.12" sqlite-jdbc = "3.49.1.0" sqliter = "1.3.1" turbine = "1.2.0" +kotest = "5.9.1" sqlDelight = "2.0.2" stately = "2.1.0" @@ -64,6 +65,7 @@ test-android-runner = { module = "androidx.test:runner", version.ref = "androidx test-android-rules = { module = "androidx.test:rules", version.ref = "androidx-test-rules" } test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } test-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } +test-kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } From 5d3f05fcebb041d0979eba9ea96efa97f6f3f5a1 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 7 Apr 2025 11:11:10 +0200 Subject: [PATCH 3/6] Remove unused wrapper --- .../kotlin/com/powersync/DatabaseTest.kt | 6 +- .../com/powersync/SyncIntegrationTest.kt | 4 +- .../testutils/PowerSyncTestFixtures.kt | 140 ------------------ .../com/powersync/testutils/TestUtils.kt | 112 ++++++++++---- 4 files changed, 92 insertions(+), 170 deletions(-) delete mode 100644 core/src/commonIntegrationTest/kotlin/com/powersync/testutils/PowerSyncTestFixtures.kt diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 1c0dc3dc..7c7bb6bc 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -4,8 +4,8 @@ import app.cash.turbine.turbineScope import co.touchlab.kermit.ExperimentalKermitApi import com.powersync.db.ActiveDatabaseGroup import com.powersync.db.schema.Schema -import com.powersync.testutils.PowerSyncTestFixtures import com.powersync.testutils.UserRow +import com.powersync.testutils.databaseTest import com.powersync.testutils.getTempDir import com.powersync.testutils.waitFor import io.kotest.assertions.throwables.shouldThrow @@ -23,7 +23,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull @OptIn(ExperimentalKermitApi::class) -class DatabaseTest: PowerSyncTestFixtures() { +class DatabaseTest { @Test fun testLinksPowerSync() = databaseTest { @@ -247,7 +247,7 @@ class DatabaseTest: PowerSyncTestFixtures() { val db2 = openDatabase() waitFor { assertNotNull( - logWriter().logs.find { + logWriter.logs.find { it.message == ActiveDatabaseGroup.multipleInstancesMessage }, ) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt index 12b794ad..7779506f 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt @@ -10,8 +10,8 @@ import com.powersync.bucket.OplogEntry import com.powersync.db.PowerSyncDatabaseImpl import com.powersync.db.schema.Schema import com.powersync.sync.SyncLine -import com.powersync.testutils.PowerSyncTestFixtures import com.powersync.testutils.UserRow +import com.powersync.testutils.databaseTest import com.powersync.testutils.waitFor import com.powersync.utils.JsonUtil import dev.mokkery.verify @@ -26,7 +26,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.time.Duration.Companion.seconds -class SyncIntegrationTest: PowerSyncTestFixtures() { +class SyncIntegrationTest { private suspend fun PowerSyncDatabase.expectUserCount(amount: Int) { val users = getAll("SELECT * FROM users;") { UserRow.from(it) } users shouldHaveSize amount diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/PowerSyncTestFixtures.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/PowerSyncTestFixtures.kt deleted file mode 100644 index d6a7b5e1..00000000 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/PowerSyncTestFixtures.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.powersync.testutils - -import co.touchlab.kermit.ExperimentalKermitApi -import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity -import co.touchlab.kermit.TestConfig -import co.touchlab.kermit.TestLogWriter -import com.powersync.PowerSyncDatabase -import com.powersync.connectors.PowerSyncBackendConnector -import com.powersync.connectors.PowerSyncCredentials -import com.powersync.db.PowerSyncDatabaseImpl -import com.powersync.db.schema.Schema -import com.powersync.sync.SyncLine -import com.powersync.sync.SyncStream -import dev.mokkery.answering.returns -import dev.mokkery.everySuspend -import dev.mokkery.mock -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext -import kotlinx.io.files.Path -import kotlinx.serialization.json.JsonObject -import kotlin.coroutines.CoroutineContext - -open class PowerSyncTestFixtures { - internal fun databaseTest( - testBody: suspend CurrentTest.() -> Unit - ) = runTest { - val running = CurrentTest(this) - // Make sure the database is initialized, we're using internal APIs that expect initialization. - running.database = running.openDatabaseAndInitialize() - - withContext(running) { - running.testBody() - } - - running.cleanup() - } - - internal suspend fun currentTest(): CurrentTest { - return currentCoroutineContext()[CurrentTestKey] ?: error("Not in a running database test") - } - - @OptIn(ExperimentalKermitApi::class) - suspend fun logWriter(): TestLogWriter = currentTest().logWriter - - @OptIn(ExperimentalKermitApi::class) - internal class CurrentTest(val scope: TestScope) : CoroutineContext.Element { - private val cleanupItems: MutableList Unit> = mutableListOf() - - lateinit var database: PowerSyncDatabaseImpl - - val logWriter = - TestLogWriter( - loggable = Severity.Debug, - ) - val logger = - Logger( - TestConfig( - minSeverity = Severity.Debug, - logWriterList = listOf(logWriter, generatePrintLogWriter()), - ), - ) - - val syncLines = Channel() - - val testDirectory by lazy { getTempDir() } - val databaseName by lazy { - val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') - val suffix = CharArray(8) { allowedChars.random() }.concatToString() - - "db-$suffix" - } - - val connector = mock { - everySuspend { getCredentialsCached() } returns - PowerSyncCredentials( - token = "test-token", - userId = "test-user", - endpoint = "https://test.com", - ) - - everySuspend { invalidateCredentials() } returns Unit - } - - fun openDatabase(): PowerSyncDatabaseImpl { - logger.d { "Opening database $databaseName in directory $testDirectory" } - val db = PowerSyncDatabase( - factory = factory, - schema = Schema(UserRow.table), - dbFilename = databaseName, - dbDirectory = testDirectory, - logger = logger, - scope = scope, - ) - doOnCleanup { db.close() } - return db as PowerSyncDatabaseImpl - } - - suspend fun openDatabaseAndInitialize(): PowerSyncDatabaseImpl { - return openDatabase().also { it.readLock { } } - } - - fun syncStream(): SyncStream { - val client = MockSyncService(syncLines) - return SyncStream( - bucketStorage = database.bucketStorage, - connector = connector, - httpEngine = client, - uploadCrud = { }, - retryDelayMs = 10, - logger = logger, - params = JsonObject(emptyMap()), - ) - } - - fun doOnCleanup(action: suspend () -> Unit) { - cleanupItems += action - } - - suspend fun cleanup() { - for (item in cleanupItems) { - item() - } - - var path = databaseName - testDirectory?.let { - path = Path(it, path).name - } - cleanup(path) - } - - override val key: CoroutineContext.Key<*> - get() = CurrentTestKey - } - - private object CurrentTestKey : CoroutineContext.Key -} \ No newline at end of file diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index 5e7466fb..69e7d1f9 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -7,17 +7,21 @@ import co.touchlab.kermit.Severity import co.touchlab.kermit.TestConfig import co.touchlab.kermit.TestLogWriter import com.powersync.DatabaseDriverFactory -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.job -import kotlinx.coroutines.launch +import com.powersync.PowerSyncDatabase +import com.powersync.connectors.PowerSyncBackendConnector +import com.powersync.connectors.PowerSyncCredentials +import com.powersync.db.PowerSyncDatabaseImpl +import com.powersync.db.schema.Schema +import com.powersync.sync.SyncLine +import com.powersync.sync.SyncStream +import dev.mokkery.answering.returns +import dev.mokkery.everySuspend +import dev.mokkery.mock +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext import kotlinx.io.files.Path -import kotlinx.io.files.SystemFileSystem -import kotlin.coroutines.CoroutineContext +import kotlinx.serialization.json.JsonObject expect val factory: DatabaseDriverFactory @@ -37,8 +41,26 @@ fun generatePrintLogWriter() = } } +internal fun databaseTest( + testBody: suspend ActiveDatabaseTest.() -> Unit +) = runTest { + val running = ActiveDatabaseTest(this) + // Make sure the database is initialized, we're using internal APIs that expect initialization. + running.database = running.openDatabaseAndInitialize() + + try { + running.testBody() + } finally { + running.cleanup() + } +} + @OptIn(ExperimentalKermitApi::class) -class DatabaseTestScope : CoroutineContext.Element { +internal class ActiveDatabaseTest(val scope: TestScope) { + private val cleanupItems: MutableList Unit> = mutableListOf() + + lateinit var database: PowerSyncDatabaseImpl + val logWriter = TestLogWriter( loggable = Severity.Debug, @@ -51,31 +73,71 @@ class DatabaseTestScope : CoroutineContext.Element { ), ) - val testDirectory by lazy { - getTempDir() ?: SystemFileSystem.resolve(Path(".")).name - } + val syncLines = Channel() + val testDirectory by lazy { getTempDir() } val databaseName by lazy { val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') - CharArray(8) { allowedChars.random() }.concatToString() + val suffix = CharArray(8) { allowedChars.random() }.concatToString() + + "db-$suffix" } - private val cleanupItems: MutableList Unit> = mutableListOf() + val connector = mock { + everySuspend { getCredentialsCached() } returns + PowerSyncCredentials( + token = "test-token", + userId = "test-user", + endpoint = "https://test.com", + ) + + everySuspend { invalidateCredentials() } returns Unit + } + + fun openDatabase(): PowerSyncDatabaseImpl { + logger.d { "Opening database $databaseName in directory $testDirectory" } + val db = PowerSyncDatabase( + factory = factory, + schema = Schema(UserRow.table), + dbFilename = databaseName, + dbDirectory = testDirectory, + logger = logger, + scope = scope, + ) + doOnCleanup { db.close() } + return db as PowerSyncDatabaseImpl + } - override val key: CoroutineContext.Key<*> - get() = Companion + suspend fun openDatabaseAndInitialize(): PowerSyncDatabaseImpl { + return openDatabase().also { it.readLock { } } + } - companion object : CoroutineContext.Key -} + fun syncStream(): SyncStream { + val client = MockSyncService(syncLines) + return SyncStream( + bucketStorage = database.bucketStorage, + connector = connector, + httpEngine = client, + uploadCrud = { }, + retryDelayMs = 10, + logger = logger, + params = JsonObject(emptyMap()), + ) + } -val CoroutineContext.database: DatabaseTestScope get() = get(DatabaseTestScope) ?: error("Not in PowerSync test: $this") + fun doOnCleanup(action: suspend () -> Unit) { + cleanupItems += action + } -fun databaseTest( - testBody: suspend TestScope.() -> Unit -) = runTest { - val test = DatabaseTestScope() + suspend fun cleanup() { + for (item in cleanupItems) { + item() + } - withContext(test) { - testBody() + var path = databaseName + testDirectory?.let { + path = Path(it, path).name + } + cleanup(path) } } From 0968fd0da0f1cfaa0556d09c77b73e30f69a22b0 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 7 Apr 2025 11:20:21 +0200 Subject: [PATCH 4/6] Reformat --- .../kotlin/com/powersync/DatabaseTest.kt | 16 +++--- .../com/powersync/testutils/TestUtils.kt | 57 ++++++++++--------- .../com/powersync/db/PowerSyncDatabaseImpl.kt | 9 +-- .../kotlin/com/powersync/testutils/WaitFor.kt | 1 - 4 files changed, 43 insertions(+), 40 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt index 7c7bb6bc..d65d6eb4 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/DatabaseTest.kt @@ -33,7 +33,8 @@ class DatabaseTest { @Test fun testWAL() = databaseTest { - val mode = database.get( + val mode = + database.get( "PRAGMA journal_mode", mapper = { it.getString(0)!! }, ) @@ -258,18 +259,19 @@ class DatabaseTest { @Test fun readConnectionsReadOnly() = databaseTest { - val exception = shouldThrow { - database.getOptional( - """ + val exception = + shouldThrow { + database.getOptional( + """ INSERT INTO users (id, name, email) VALUES (uuid(), ?, ?) RETURNING * """.trimIndent(), - parameters = listOf("steven", "steven@journeyapps.com"), - ) {} - } + parameters = listOf("steven", "steven@journeyapps.com"), + ) {} + } // The exception messages differ slightly between drivers exception.message shouldContain "write a readonly database" diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt index 69e7d1f9..5d164515 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/testutils/TestUtils.kt @@ -41,22 +41,23 @@ fun generatePrintLogWriter() = } } -internal fun databaseTest( - testBody: suspend ActiveDatabaseTest.() -> Unit -) = runTest { - val running = ActiveDatabaseTest(this) - // Make sure the database is initialized, we're using internal APIs that expect initialization. - running.database = running.openDatabaseAndInitialize() - - try { - running.testBody() - } finally { - running.cleanup() +internal fun databaseTest(testBody: suspend ActiveDatabaseTest.() -> Unit) = + runTest { + val running = ActiveDatabaseTest(this) + // Make sure the database is initialized, we're using internal APIs that expect initialization. + running.database = running.openDatabaseAndInitialize() + + try { + running.testBody() + } finally { + running.cleanup() + } } -} @OptIn(ExperimentalKermitApi::class) -internal class ActiveDatabaseTest(val scope: TestScope) { +internal class ActiveDatabaseTest( + val scope: TestScope, +) { private val cleanupItems: MutableList Unit> = mutableListOf() lateinit var database: PowerSyncDatabaseImpl @@ -83,34 +84,34 @@ internal class ActiveDatabaseTest(val scope: TestScope) { "db-$suffix" } - val connector = mock { - everySuspend { getCredentialsCached() } returns + val connector = + mock { + everySuspend { getCredentialsCached() } returns PowerSyncCredentials( token = "test-token", userId = "test-user", endpoint = "https://test.com", ) - everySuspend { invalidateCredentials() } returns Unit - } + everySuspend { invalidateCredentials() } returns Unit + } fun openDatabase(): PowerSyncDatabaseImpl { logger.d { "Opening database $databaseName in directory $testDirectory" } - val db = PowerSyncDatabase( - factory = factory, - schema = Schema(UserRow.table), - dbFilename = databaseName, - dbDirectory = testDirectory, - logger = logger, - scope = scope, - ) + val db = + PowerSyncDatabase( + factory = factory, + schema = Schema(UserRow.table), + dbFilename = databaseName, + dbDirectory = testDirectory, + logger = logger, + scope = scope, + ) doOnCleanup { db.close() } return db as PowerSyncDatabaseImpl } - suspend fun openDatabaseAndInitialize(): PowerSyncDatabaseImpl { - return openDatabase().also { it.readLock { } } - } + suspend fun openDatabaseAndInitialize(): PowerSyncDatabaseImpl = openDatabase().also { it.readLock { } } fun syncStream(): SyncStream { val client = MockSyncService(syncLines) diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index aee081ca..69371adb 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -350,10 +350,11 @@ internal class PowerSyncDatabaseImpl( parameters: List?, throttleMs: Long?, mapper: (SqlCursor) -> RowType, - ): Flow> = flow { - waitReady() - emitAll(internalDb.watch(sql, parameters, throttleMs, mapper)) - } + ): Flow> = + flow { + waitReady() + emitAll(internalDb.watch(sql, parameters, throttleMs, mapper)) + } override suspend fun readLock(callback: ThrowableLockCallback): R { waitReady() diff --git a/core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt b/core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt index 86588c94..7be3aaee 100644 --- a/core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt +++ b/core/src/commonTest/kotlin/com/powersync/testutils/WaitFor.kt @@ -1,7 +1,6 @@ package com.powersync.testutils import kotlinx.coroutines.delay -import kotlinx.datetime.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeSource From f3c1a0a64ffa7b784aa71e32565d8cc19b68b1a5 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 7 Apr 2025 11:23:24 +0200 Subject: [PATCH 5/6] Don't make connectInternal suspending --- .../commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt index 69371adb..40973e81 100644 --- a/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt +++ b/core/src/commonMain/kotlin/com/powersync/db/PowerSyncDatabaseImpl.kt @@ -166,7 +166,7 @@ internal class PowerSyncDatabaseImpl( } @OptIn(FlowPreview::class) - internal suspend fun connectInternal( + internal fun connectInternal( stream: SyncStream, crudThrottleMs: Long, ) { From 0a4b1cd74b9038aa8a7ac61991e57f6813ff470f Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Mon, 7 Apr 2025 13:34:50 +0200 Subject: [PATCH 6/6] Wait for isClosedForSend --- .../kotlin/com/powersync/SyncIntegrationTest.kt | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt b/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt index 7779506f..598595e4 100644 --- a/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt +++ b/core/src/commonIntegrationTest/kotlin/com/powersync/SyncIntegrationTest.kt @@ -15,7 +15,6 @@ import com.powersync.testutils.databaseTest import com.powersync.testutils.waitFor import com.powersync.utils.JsonUtil import dev.mokkery.verify -import io.kotest.assertions.withClue import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import kotlinx.coroutines.DelicateCoroutinesApi @@ -48,10 +47,8 @@ class SyncIntegrationTest { turbine.cancel() } - // Closing the database should have closed the channel - withClue("Should have closed sync stream") { - syncLines.isClosedForSend shouldBe true - } + // Closing the database should have closed the channel. + waitFor { syncLines.isClosedForSend shouldBe true } } @Test @@ -71,9 +68,7 @@ class SyncIntegrationTest { } // Disconnecting should have closed the channel - withClue("Should have closed sync stream") { - syncLines.isClosedForSend shouldBe true - } + waitFor { syncLines.isClosedForSend shouldBe true } // And called invalidateCredentials on the connector verify { connector.invalidateCredentials() }