From 215790d68e241b8dd623245e18a584895811ec9c Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Thu, 14 May 2026 21:14:40 +0200 Subject: [PATCH] feat(app): add Ktor HTTP client and SyncService for inventory sync domain/model/SyncError.kt: - Sealed class with ConnectionError, Timeout, AuthError, ServerError, NotConfigured, Unknown subtypes for typed error handling domain/repository/SyncService.kt: - Interface with downloadInventory() and uploadInventory() returning Result for clean error propagation data/sync/SyncServiceImpl.kt: - Ktor Client implementation using OkHttp engine - GET /api/inventory and PUT /api/inventory endpoints - X-API-Key header authentication matching server contract - Server URL and API key read from SettingsRepository - withContext(Dispatchers.IO) for network calls - Catches SocketTimeoutException, ConnectException specifically di/NetworkModule.kt: - Hilt module providing singleton HttpClient with OkHttp engine - ContentNegotiation with kotlinx.serialization JSON - Configurable connect/read/write timeouts (10s/30s/30s) - Binds SyncServiceImpl to SyncService interface Dependencies: - ktor-client-core, ktor-client-okhttp, ktor-client-content-negotiation, ktor-client-mock (test) - ktor-serialization-kotlinx-json (shared with server) - INTERNET permission added to AndroidManifest.xml Tests: 9 tests with Ktor MockEngine covering success, 401, 500, missing config, trailing slash URL normalization Closes #44 --- app/build.gradle.kts | 7 + app/src/main/AndroidManifest.xml | 2 + .../app/data/sync/SyncServiceImpl.kt | 86 ++++++ .../de/krisenvorrat/app/di/NetworkModule.kt | 50 ++++ .../app/domain/model/SyncError.kt | 21 ++ .../app/domain/repository/SyncService.kt | 8 + .../app/data/sync/SyncServiceImplTest.kt | 264 ++++++++++++++++++ gradle/libs.versions.toml | 4 + 8 files changed, 442 insertions(+) create mode 100644 app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/domain/model/SyncError.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt create mode 100644 app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e0abf92..2c66ca1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,12 +71,19 @@ dependencies { // Serialization implementation(libs.kotlinx.serialization.json) + // Ktor Client + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + // Shared module implementation(project(":shared")) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) + testImplementation(libs.ktor.client.mock) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8228ffe..f0d2bee 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + = + executeRequest { serverUrl, apiKey -> + val response = httpClient.get("$serverUrl/api/inventory") { + header("X-API-Key", apiKey) + } + handleResponse(response) + } + + override suspend fun uploadInventory(inventory: InventoryDto): Result = + executeRequest { serverUrl, apiKey -> + val response = httpClient.put("$serverUrl/api/inventory") { + header("X-API-Key", apiKey) + contentType(ContentType.Application.Json) + setBody(inventory) + } + handleResponse(response) + } + + private suspend fun executeRequest( + block: suspend (serverUrl: String, apiKey: String) -> Result + ): Result = withContext(Dispatchers.IO) { + val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) + if (serverUrl.isNullOrBlank()) { + return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt")) + } + + val apiKey = settingsRepository.getValue(KEY_API_KEY) + if (apiKey.isNullOrBlank()) { + return@withContext Result.failure(SyncError.NotConfigured("API-Key nicht gesetzt")) + } + + try { + block(serverUrl.trimEnd('/'), apiKey) + } catch (e: SocketTimeoutException) { + Result.failure(SyncError.Timeout(e)) + } catch (e: ConnectException) { + Result.failure(SyncError.ConnectionError(e)) + } catch (e: Exception) { + Result.failure(SyncError.Unknown(e)) + } + } + + private suspend fun handleResponse( + response: io.ktor.client.statement.HttpResponse + ): Result = when (response.status) { + HttpStatusCode.OK -> Result.success(response.body()) + HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError()) + else -> Result.failure( + SyncError.ServerError( + statusCode = response.status.value, + message = response.status.description + ) + ) + } + + private companion object { + const val KEY_SERVER_URL = "server_url" + const val KEY_API_KEY = "api_key" + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt b/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt new file mode 100644 index 0000000..68a258f --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt @@ -0,0 +1,50 @@ +package de.krisenvorrat.app.di + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import de.krisenvorrat.app.data.sync.SyncServiceImpl +import de.krisenvorrat.app.domain.repository.SyncService +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class NetworkModule { + + @Binds + @Singleton + abstract fun bindSyncService(impl: SyncServiceImpl): SyncService + + companion object { + + @Provides + @Singleton + fun provideHttpClient(): HttpClient = HttpClient(OkHttp) { + engine { + config { + connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } + } + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + encodeDefaults = true + }) + } + } + + private const val CONNECT_TIMEOUT_SECONDS = 10L + private const val READ_TIMEOUT_SECONDS = 30L + private const val WRITE_TIMEOUT_SECONDS = 30L + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/domain/model/SyncError.kt b/app/src/main/java/de/krisenvorrat/app/domain/model/SyncError.kt new file mode 100644 index 0000000..4502b9a --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/domain/model/SyncError.kt @@ -0,0 +1,21 @@ +package de.krisenvorrat.app.domain.model + +internal sealed class SyncError(message: String, cause: Throwable? = null) : Exception(message, cause) { + class ConnectionError(cause: Throwable? = null) : + SyncError("Server nicht erreichbar", cause) + + class Timeout(cause: Throwable? = null) : + SyncError("Zeitüberschreitung bei der Verbindung zum Server", cause) + + class AuthError : + SyncError("Authentifizierung fehlgeschlagen – API-Key ungültig") + + class ServerError(val statusCode: Int, message: String) : + SyncError("Serverfehler ($statusCode): $message") + + class NotConfigured(detail: String) : + SyncError("Sync nicht konfiguriert: $detail") + + class Unknown(cause: Throwable) : + SyncError("Unbekannter Fehler: ${cause.message}", cause) +} diff --git a/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt b/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt new file mode 100644 index 0000000..b406046 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt @@ -0,0 +1,8 @@ +package de.krisenvorrat.app.domain.repository + +import de.krisenvorrat.shared.model.InventoryDto + +internal interface SyncService { + suspend fun downloadInventory(): Result + suspend fun uploadInventory(inventory: InventoryDto): Result +} diff --git a/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt new file mode 100644 index 0000000..1ad7642 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/data/sync/SyncServiceImplTest.kt @@ -0,0 +1,264 @@ +package de.krisenvorrat.app.data.sync + +import de.krisenvorrat.app.domain.model.SyncError +import de.krisenvorrat.app.domain.repository.SettingsRepository +import de.krisenvorrat.shared.model.CategoryDto +import de.krisenvorrat.shared.model.InventoryDto +import de.krisenvorrat.shared.model.ItemDto +import de.krisenvorrat.shared.model.LocationDto +import de.krisenvorrat.shared.model.SettingDto +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.serialization.kotlinx.json.json +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SyncServiceImplTest { + + private val settingsRepository: SettingsRepository = mockk() + private val jsonSerializer = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val testInventory = InventoryDto( + categories = listOf(CategoryDto(id = 1, name = "Konserven")), + locations = listOf(LocationDto(id = 1, name = "Keller")), + items = listOf( + ItemDto( + id = "item-1", + name = "Bohnen", + categoryId = 1, + quantity = 5.0, + unit = "Dose", + unitPrice = 1.29, + kcalPer100g = 100, + expiryDate = "2027-06-01", + locationId = 1, + minStock = 2.0, + notes = "", + lastUpdated = System.currentTimeMillis() + ) + ), + settings = listOf(SettingDto(key = "household_size", value = "2")) + ) + + private lateinit var httpClient: HttpClient + private lateinit var syncService: SyncServiceImpl + + @After + fun tearDown() { + if (::httpClient.isInitialized) { + httpClient.close() + } + } + + private fun createClient(engine: MockEngine): HttpClient = HttpClient(engine) { + install(ContentNegotiation) { + json(jsonSerializer) + } + } + + private fun setupSettings(serverUrl: String? = "http://localhost:8080", apiKey: String? = "test-key") { + coEvery { settingsRepository.getValue("server_url") } returns serverUrl + coEvery { settingsRepository.getValue("api_key") } returns apiKey + } + + // --- downloadInventory --- + + @Test + fun test_downloadInventory_serverReturnsInventory_returnsSuccess() = runTest { + // Given + setupSettings() + val engine = MockEngine { request -> + assertEquals("/api/inventory", request.url.encodedPath) + assertEquals(HttpMethod.Get, request.method) + assertEquals("test-key", request.headers["X-API-Key"]) + respond( + content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.downloadInventory() + + // Then + assertTrue(result.isSuccess) + assertEquals(testInventory, result.getOrNull()) + } + + @Test + fun test_downloadInventory_serverReturns401_returnsAuthError() = runTest { + // Given + setupSettings() + val engine = MockEngine { + respondError(HttpStatusCode.Unauthorized) + } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.downloadInventory() + + // Then + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is SyncError.AuthError) + } + + @Test + fun test_downloadInventory_serverReturns500_returnsServerError() = runTest { + // Given + setupSettings() + val engine = MockEngine { + respondError(HttpStatusCode.InternalServerError) + } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.downloadInventory() + + // Then + assertTrue(result.isFailure) + val error = result.exceptionOrNull() as SyncError.ServerError + assertEquals(500, error.statusCode) + } + + @Test + fun test_downloadInventory_noServerUrl_returnsNotConfigured() = runTest { + // Given + setupSettings(serverUrl = null) + val engine = MockEngine { respondError(HttpStatusCode.OK) } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.downloadInventory() + + // Then + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is SyncError.NotConfigured) + } + + @Test + fun test_downloadInventory_emptyServerUrl_returnsNotConfigured() = runTest { + // Given + setupSettings(serverUrl = " ") + val engine = MockEngine { respondError(HttpStatusCode.OK) } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.downloadInventory() + + // Then + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is SyncError.NotConfigured) + } + + @Test + fun test_downloadInventory_noApiKey_returnsNotConfigured() = runTest { + // Given + setupSettings(apiKey = null) + val engine = MockEngine { respondError(HttpStatusCode.OK) } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.downloadInventory() + + // Then + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is SyncError.NotConfigured) + } + + // --- uploadInventory --- + + @Test + fun test_uploadInventory_serverAccepts_returnsSuccess() = runTest { + // Given + setupSettings() + val engine = MockEngine { request -> + assertEquals("/api/inventory", request.url.encodedPath) + assertEquals(HttpMethod.Put, request.method) + assertEquals("test-key", request.headers["X-API-Key"]) + assertEquals( + ContentType.Application.Json.toString(), + request.body.contentType.toString() + ) + respond( + content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.uploadInventory(testInventory) + + // Then + assertTrue(result.isSuccess) + assertEquals(testInventory, result.getOrNull()) + } + + @Test + fun test_uploadInventory_serverReturns401_returnsAuthError() = runTest { + // Given + setupSettings() + val engine = MockEngine { + respondError(HttpStatusCode.Unauthorized) + } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.uploadInventory(testInventory) + + // Then + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is SyncError.AuthError) + } + + @Test + fun test_uploadInventory_serverUrlHasTrailingSlash_urlIsCorrect() = runTest { + // Given + setupSettings(serverUrl = "http://localhost:8080/") + val engine = MockEngine { request -> + assertEquals("http://localhost:8080/api/inventory", request.url.toString()) + respond( + content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + httpClient = createClient(engine) + syncService = SyncServiceImpl(httpClient, settingsRepository) + + // When + val result = syncService.uploadInventory(testInventory) + + // Then + assertTrue(result.isSuccess) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c5c3b33..ce1f986 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,10 @@ ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth", version.ref = "ktor" } ktor-server-call-logging = { group = "io.ktor", name = "ktor-server-call-logging", version.ref = "ktor" } ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" } +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" } exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" }