From 33be5cbd3d3c124d9963b000e6ab049093f8d0ed Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 28 Oct 2024 17:37:35 +0100 Subject: [PATCH 1/3] Add basic TaskRepository CRUD unit tests --- gradle/libs.versions.toml | 1 + tasks-app-shared/build.gradle.kts | 7 +- .../tasks/data/TaskRepositoryCRUDTest.kt | 128 ++++++++++++++++++ .../tasks/data/util/runTaskRepositoryTest.kt | 99 ++++++++++++++ 4 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositoryCRUDTest.kt create mode 100644 tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a16797e6..e360d18b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-common = { module = "androidx.room:room-common", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version = "2.8.0" } jetbrains-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version = "2.8.0-alpha10" } diff --git a/tasks-app-shared/build.gradle.kts b/tasks-app-shared/build.gradle.kts index 2a7e5e6d..69372aaa 100644 --- a/tasks-app-shared/build.gradle.kts +++ b/tasks-app-shared/build.gradle.kts @@ -103,6 +103,11 @@ kotlin { @OptIn(ExperimentalComposeLibrary::class) implementation(compose.uiTest) + + implementation(libs.ktor.client.mock) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.testing) } jvmTest.dependencies { @@ -133,7 +138,7 @@ android { testOptions { unitTests { all { - it.exclude("**/ui/**") + it.exclude("**/ui/**", "**/data/**") } } } diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositoryCRUDTest.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositoryCRUDTest.kt new file mode 100644 index 00000000..72da6897 --- /dev/null +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositoryCRUDTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2024 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.data + +import kotlinx.coroutines.flow.firstOrNull +import net.opatry.tasks.data.model.TaskDataModel +import net.opatry.tasks.data.model.TaskListDataModel +import net.opatry.tasks.data.util.runTaskRepositoryTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private suspend fun TaskRepository.createAndGetTaskList(title: String): TaskListDataModel { + createTaskList(title) + return getTaskLists().firstOrNull()?.firstOrNull() ?: error("Task list not found") +} + +private suspend fun TaskRepository.createAndGetTask(taskListId: Long, taskTitle: String): TaskDataModel { + createTask(taskListId, taskTitle) + return getTaskLists().firstOrNull() + ?.firstOrNull { it.id == taskListId } + ?.tasks + ?.firstOrNull { it.title == taskTitle } + ?: error("Task not found") +} + +private suspend fun TaskRepository.createAndGetTask(taskListTitle: String, taskTitle: String): Pair { + val taskList = createAndGetTaskList(taskListTitle) + return taskList to createAndGetTask(taskList.id, taskTitle) +} + +class TaskRepositoryCRUDTest { + + @Test + fun `create task list`() = runTaskRepositoryTest { repository -> + repository.createTaskList("My tasks") + + val taskLists = repository.getTaskLists().firstOrNull() + assertEquals(1, taskLists?.size) + assertEquals("My tasks", taskLists?.first()?.title) + } + + @Test + fun `rename task list`() = runTaskRepositoryTest { repository -> + val taskList = repository.createAndGetTaskList("My tasks") + + repository.renameTaskList(taskList.id, "My renamed list") + val taskListRenamed = repository.getTaskLists().firstOrNull()?.firstOrNull() + assertEquals("My renamed list", taskListRenamed?.title, "Updated name is invalid") + } + + @Test + fun `delete task list`() = runTaskRepositoryTest { repository -> + val taskList = repository.createAndGetTaskList("My tasks") + + repository.deleteTaskList(taskList.id) + val taskLists = repository.getTaskLists().firstOrNull() + assertEquals(0, taskLists?.size, "No task list expected") + } + + @Test + fun `create task`() = runTaskRepositoryTest { repository -> + val taskList = repository.createAndGetTaskList("My tasks") + + repository.createTask(taskList.id, "My task") + val tasks = repository.getTaskLists().firstOrNull()?.firstOrNull()?.tasks + assertEquals(1, tasks?.size) + assertEquals("My task", tasks?.first()?.title) + assertFalse(tasks?.first()?.isCompleted ?: true) + } + + @Test + fun `rename task`() = runTaskRepositoryTest { repository -> + val (_, task) = repository.createAndGetTask("My tasks", "My task") + + repository.updateTaskTitle(task.id, "My renamed task") + val tasks = repository.getTaskLists().firstOrNull()?.firstOrNull()?.tasks + assertEquals("My renamed task", tasks?.first()?.title) + } + + @Test + fun `edit task notes`() = runTaskRepositoryTest { repository -> + val (_, task) = repository.createAndGetTask("My tasks", "My task") + + repository.updateTaskNotes(task.id, "These are some notes") + val tasks = repository.getTaskLists().firstOrNull()?.firstOrNull()?.tasks + assertEquals("These are some notes", tasks?.first()?.notes) + } + + @Test + fun `complete task`() = runTaskRepositoryTest { repository -> + val (_, task) = repository.createAndGetTask("My tasks", "My task") + + repository.toggleTaskCompletionState(task.id) + val tasks = repository.getTaskLists().firstOrNull()?.firstOrNull()?.tasks + assertTrue(tasks?.first()?.isCompleted ?: false) + } + + @Test + fun `delete task`() = runTaskRepositoryTest { repository -> + val (_, task) = repository.createAndGetTask("My tasks", "My task") + + repository.deleteTask(task.id) + val tasks = repository.getTaskLists().firstOrNull()?.firstOrNull()?.tasks + assertEquals(0, tasks?.size, "Task should have been deleted") + } +} \ No newline at end of file diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt new file mode 100644 index 00000000..9a6ce4a7 --- /dev/null +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2024 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.data.util + +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import net.opatry.google.tasks.TaskListsApi +import net.opatry.google.tasks.TasksApi +import net.opatry.google.tasks.model.ErrorResponse +import net.opatry.tasks.data.TaskRepository +import net.opatry.tasks.data.TasksAppDatabase + + +@Suppress("TestFunctionName") +fun NoContentMockEngine() = MockEngine { + respond("", HttpStatusCode.NoContent) +} + +@Suppress("TestFunctionName") +fun ErrorMockEngine(code: HttpStatusCode) = MockEngine { + val errorResponse = ErrorResponse( + ErrorResponse.Error( + code.value, + message = code.description, + errors = listOf( + ErrorResponse.Error.ErrorDetail( + message = code.description, + domain = "global", + reason = "backendError", + ) + ) + ) + ) + respond( + Json.encodeToString(errorResponse), + code, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), + ) +} + +internal fun runTaskRepositoryTest( + mockEngine: MockEngine = NoContentMockEngine(), + test: suspend TestScope.(TaskRepository) -> Unit +) = runTest { + val db = Room.inMemoryDatabaseBuilder() + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(backgroundScope.coroutineContext) + .build() + val taskListDao = db.getTaskListDao() + val taskDao = db.getTaskDao() + + val httpClient = HttpClient(mockEngine) { + install(ContentNegotiation) { + json() + } + } + val taskListsApi = TaskListsApi(httpClient) + val tasksApi = TasksApi(httpClient) + val repository = TaskRepository(taskListDao, taskDao, taskListsApi, tasksApi) + + try { + test(repository) + } finally { + db.close() + } +} From bdc1c36d5fbbf042a3b36c4338b7fc7dc1f4935a Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 28 Oct 2024 18:14:49 +0100 Subject: [PATCH 2/3] Add basic TaskRepository sync unit tests --- .../tasks/data/TaskRepositorySyncTest.kt | 143 ++++++++++++++++++ .../tasks/data/util/runTaskRepositoryTest.kt | 37 +---- .../opatry/tasks/data/util/taskResponses.kt | 70 +++++++++ 3 files changed, 214 insertions(+), 36 deletions(-) create mode 100644 tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt create mode 100644 tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/taskResponses.kt diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt new file mode 100644 index 00000000..bc73e983 --- /dev/null +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/TaskRepositorySyncTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.data + +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respondError +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.flow.firstOrNull +import net.opatry.tasks.data.model.TaskListDataModel +import net.opatry.tasks.data.util.respondNoNetwork +import net.opatry.tasks.data.util.respondWithTaskLists +import net.opatry.tasks.data.util.respondWithTasks +import net.opatry.tasks.data.util.runTaskRepositoryTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.fail + + +class TaskRepositorySyncTest { + @Test + fun `sync remote task lists`() { + MockEngine { request -> + when (val encodedPath = request.url.encodedPath) { + "/tasks/v1/users/@me/lists" -> respondWithTaskLists( + "MTAwNDEyMDI1NDY0NDEwNzQ0NDI6MDow" to "My tasks", + "OXl0d1JibXgyeW1zWWFIMw" to "Other tasks" + ) + + "/tasks/v1/lists/MTAwNDEyMDI1NDY0NDEwNzQ0NDI6MDow/tasks" -> respondWithTasks("dnBVd2IwZUlMcjZWNU84YQ" to "First task TODO") + "/tasks/v1/lists/OXl0d1JibXgyeW1zWWFIMw/tasks" -> respondWithTasks("M3R6eUVFQzJJUzQzZC12Qg" to "Another task") + else -> fail("Unexpected request: $request ($encodedPath)") + } + }.use { mockEngine -> + runTaskRepositoryTest(mockEngine) { repository -> + val initialTaskLists = repository.getTaskLists().firstOrNull() + assertEquals(0, initialTaskLists?.size, "There shouldn't be any task list at start") + repository.sync() + + val taskLists = repository.getTaskLists().firstOrNull() + assertEquals(2, taskLists?.size) + assertContentEquals(listOf("My tasks", "Other tasks"), taskLists?.map(TaskListDataModel::title)) + + val firstTaskListTasks = taskLists?.get(0)?.tasks + assertEquals(1, firstTaskListTasks?.size) + assertEquals("First task TODO", firstTaskListTasks?.firstOrNull()?.title) + + val secondTaskListTasks = taskLists?.get(1)?.tasks + assertEquals(1, secondTaskListTasks?.size) + assertEquals("Another task", secondTaskListTasks?.firstOrNull()?.title) + } + } + } + + @Test + fun `backend error while syncing should do nothing`() { + MockEngine { + respondError(HttpStatusCode.Forbidden) + }.use { mockEngine -> + runTaskRepositoryTest(mockEngine) { repository -> + repository.sync() + assertEquals(0, repository.getTaskLists().firstOrNull()?.size) + } + + assertEquals(1, mockEngine.responseHistory.size) + assertEquals(HttpStatusCode.Forbidden, mockEngine.responseHistory.first().statusCode) + } + } + + @Test + fun `task CRUD without network should create a local only task`() { + MockEngine { + respondNoNetwork() + }.use { mockEngine -> + runTaskRepositoryTest(mockEngine) { repository -> + repository.createTaskList("Task list") + assertEquals(1, repository.getTaskLists().firstOrNull()?.size) + } + + assertEquals(0, mockEngine.responseHistory.size) + } + } + + @Test + fun `local only tasks are synced at next sync`() { + var requestCount = 0 + MockEngine { request -> + ++requestCount + when { + requestCount == 1 + && request.method == HttpMethod.Post + && request.url.encodedPath == "/tasks/v1/users/@me/lists" + -> respondNoNetwork() + + requestCount == 2 + && request.method == HttpMethod.Get + && request.url.encodedPath == "/tasks/v1/users/@me/lists" + -> respondWithTaskLists() + + requestCount == 3 + && request.method == HttpMethod.Post + && request.url.encodedPath == "/tasks/v1/users/@me/lists" + -> respondWithTaskLists("MTAwNDEyMDI1NDY0NDEwNzQ0NDI6MDow" to "Task list") + + else -> fail("Unexpected request: $request") + } + }.use { mockEngine -> + runTaskRepositoryTest(mockEngine) { repository -> + // for first request, no network + repository.createTaskList("Task list") + val taskList = repository.getTaskLists().firstOrNull()?.firstOrNull() + assertNotNull(taskList) + assertEquals(0, mockEngine.responseHistory.size) + + // network is considered back, sync should trigger fetch & push requests + repository.sync() + assertEquals(2, mockEngine.responseHistory.size) + } + } + } +} \ No newline at end of file diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt index 9a6ce4a7..fcba2500 100644 --- a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt @@ -26,53 +26,18 @@ import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.respond import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json import net.opatry.google.tasks.TaskListsApi import net.opatry.google.tasks.TasksApi -import net.opatry.google.tasks.model.ErrorResponse import net.opatry.tasks.data.TaskRepository import net.opatry.tasks.data.TasksAppDatabase -@Suppress("TestFunctionName") -fun NoContentMockEngine() = MockEngine { - respond("", HttpStatusCode.NoContent) -} - -@Suppress("TestFunctionName") -fun ErrorMockEngine(code: HttpStatusCode) = MockEngine { - val errorResponse = ErrorResponse( - ErrorResponse.Error( - code.value, - message = code.description, - errors = listOf( - ErrorResponse.Error.ErrorDetail( - message = code.description, - domain = "global", - reason = "backendError", - ) - ) - ) - ) - respond( - Json.encodeToString(errorResponse), - code, - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()), - ) -} - internal fun runTaskRepositoryTest( - mockEngine: MockEngine = NoContentMockEngine(), + mockEngine: MockEngine = MockEngine { respondNoNetwork() }, test: suspend TestScope.(TaskRepository) -> Unit ) = runTest { val db = Room.inMemoryDatabaseBuilder() diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/taskResponses.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/taskResponses.kt new file mode 100644 index 00000000..bebf75b1 --- /dev/null +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/taskResponses.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.data.util + +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.HttpResponseData +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import net.opatry.google.tasks.model.ResourceListResponse +import net.opatry.google.tasks.model.ResourceType +import net.opatry.google.tasks.model.Task +import net.opatry.google.tasks.model.TaskList +import java.net.ConnectException + + +fun MockRequestHandleScope.respondNoContent(): HttpResponseData = respond("", HttpStatusCode.NoContent) +fun MockRequestHandleScope.respondNoNetwork(): HttpResponseData = throw ConnectException("No network") + +fun MockRequestHandleScope.respondWithTaskLists(vararg idToTitles: Pair): HttpResponseData { + val taskLists = idToTitles.map { TaskList(id = it.first, title = it.second) } + val taskListsResponse = ResourceListResponse( + kind = ResourceType.TaskLists, + etag = "\"ETAG_ETAG_ETAG\"", + items = taskLists + ) + return respond( + content = Json.encodeToString(taskListsResponse), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) +} + +fun MockRequestHandleScope.respondWithTasks(vararg idToTitles: Pair): HttpResponseData { + val tasks = idToTitles.map { Task(id = it.first, title = it.second) } + val tasksResponse = ResourceListResponse( + kind = ResourceType.Tasks, + etag = "\"ETAG_ETAG_ETAG\"", + items = tasks + ) + return respond( + content = Json.encodeToString(tasksResponse), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) +} \ No newline at end of file From 68ffa8ab86f71f8a7d0b8467b2c832742c26b8ab Mon Sep 17 00:00:00 2001 From: Olivier Patry Date: Mon, 28 Oct 2024 19:08:27 +0100 Subject: [PATCH 3/3] Abstract inMemoryTasksAppDatabaseBuilder() --- gradle/libs.versions.toml | 2 ++ tasks-app-shared/build.gradle.kts | 4 +++ ...inMemoryTasksAppDatabaseBuilder.android.kt | 34 +++++++++++++++++++ ...inMemoryTasksAppDatabaseBuilder.android.kt | 32 +++++++++++++++++ .../util/inMemoryTasksAppDatabaseBuilder.kt | 28 +++++++++++++++ .../tasks/data/util/runTaskRepositoryTest.kt | 4 +-- .../inMemoryTasksAppDatabaseBuilder.jvm.kt | 31 +++++++++++++++++ 7 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 tasks-app-shared/src/androidTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.android.kt create mode 100644 tasks-app-shared/src/androidUnitTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.android.kt create mode 100644 tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.kt create mode 100644 tasks-app-shared/src/jvmTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.jvm.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e360d18b..78b499de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,6 +71,8 @@ play-services-auth = { module = "com.google.android.gms:play-services-auth", ver about-libraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "about-libraries" } +androidx-test-core = { module = "androidx.test:core", version = "1.6.1" } + [bundles] ktor-server = ["ktor-server-core", "ktor-server-cio"] ktor-client = [ diff --git a/tasks-app-shared/build.gradle.kts b/tasks-app-shared/build.gradle.kts index 69372aaa..cb7c1800 100644 --- a/tasks-app-shared/build.gradle.kts +++ b/tasks-app-shared/build.gradle.kts @@ -113,6 +113,10 @@ kotlin { jvmTest.dependencies { implementation(compose.desktop.currentOs) } + + androidInstrumentedTest.dependencies { + implementation(libs.androidx.test.core) + } } } diff --git a/tasks-app-shared/src/androidTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.android.kt b/tasks-app-shared/src/androidTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.android.kt new file mode 100644 index 00000000..9f9a2500 --- /dev/null +++ b/tasks-app-shared/src/androidTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.android.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.data.util + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.test.core.app.ApplicationProvider +import net.opatry.tasks.data.TasksAppDatabase + +actual fun inMemoryTasksAppDatabaseBuilder(): RoomDatabase.Builder { + val context = ApplicationProvider.getApplicationContext() + return Room.inMemoryDatabaseBuilder(context, TasksAppDatabase::class.java) +} diff --git a/tasks-app-shared/src/androidUnitTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.android.kt b/tasks-app-shared/src/androidUnitTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.android.kt new file mode 100644 index 00000000..6dfe55bd --- /dev/null +++ b/tasks-app-shared/src/androidUnitTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.android.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.data.util + +import androidx.room.RoomDatabase +import net.opatry.tasks.data.TasksAppDatabase + +actual fun inMemoryTasksAppDatabaseBuilder(): RoomDatabase.Builder { + // not expected to run as Android unit test any time soon + // can only rely on Android context in either Robolectric or Instrumentation tests + TODO() +} diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.kt new file mode 100644 index 00000000..2fb47782 --- /dev/null +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.data.util + +import androidx.room.RoomDatabase +import net.opatry.tasks.data.TasksAppDatabase + +expect fun inMemoryTasksAppDatabaseBuilder(): RoomDatabase.Builder diff --git a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt index fcba2500..4c596424 100644 --- a/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt +++ b/tasks-app-shared/src/commonTest/kotlin/net/opatry/tasks/data/util/runTaskRepositoryTest.kt @@ -22,7 +22,6 @@ package net.opatry.tasks.data.util -import androidx.room.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine @@ -33,14 +32,13 @@ import kotlinx.coroutines.test.runTest import net.opatry.google.tasks.TaskListsApi import net.opatry.google.tasks.TasksApi import net.opatry.tasks.data.TaskRepository -import net.opatry.tasks.data.TasksAppDatabase internal fun runTaskRepositoryTest( mockEngine: MockEngine = MockEngine { respondNoNetwork() }, test: suspend TestScope.(TaskRepository) -> Unit ) = runTest { - val db = Room.inMemoryDatabaseBuilder() + val db = inMemoryTasksAppDatabaseBuilder() .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(backgroundScope.coroutineContext) .build() diff --git a/tasks-app-shared/src/jvmTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.jvm.kt b/tasks-app-shared/src/jvmTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.jvm.kt new file mode 100644 index 00000000..b3a2c279 --- /dev/null +++ b/tasks-app-shared/src/jvmTest/kotlin/net/opatry/tasks/data/util/inMemoryTasksAppDatabaseBuilder.jvm.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 Olivier Patry + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE + * OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package net.opatry.tasks.data.util + +import androidx.room.Room +import androidx.room.RoomDatabase +import net.opatry.tasks.data.TasksAppDatabase + +actual fun inMemoryTasksAppDatabaseBuilder(): RoomDatabase.Builder { + return Room.inMemoryDatabaseBuilder() +}