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<InventoryDto> 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
This commit is contained in:
parent
cb9bd2bdf4
commit
215790d68e
8 changed files with 442 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".KrisenvorratApp"
|
||||
android:allowBackup="true"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
package de.krisenvorrat.app.data.sync
|
||||
|
||||
import de.krisenvorrat.app.domain.model.SyncError
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import de.krisenvorrat.app.domain.repository.SyncService
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.put
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class SyncServiceImpl @Inject constructor(
|
||||
private val httpClient: HttpClient,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : SyncService {
|
||||
|
||||
override suspend fun downloadInventory(): Result<InventoryDto> =
|
||||
executeRequest { serverUrl, apiKey ->
|
||||
val response = httpClient.get("$serverUrl/api/inventory") {
|
||||
header("X-API-Key", apiKey)
|
||||
}
|
||||
handleResponse(response)
|
||||
}
|
||||
|
||||
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> =
|
||||
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<InventoryDto>
|
||||
): Result<InventoryDto> = 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<InventoryDto> = 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"
|
||||
}
|
||||
}
|
||||
50
app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt
Normal file
50
app/src/main/java/de/krisenvorrat/app/di/NetworkModule.kt
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package de.krisenvorrat.app.domain.repository
|
||||
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
|
||||
internal interface SyncService {
|
||||
suspend fun downloadInventory(): Result<InventoryDto>
|
||||
suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue