feat(app): User-Konzept App-Phase - JWT-Auth, Login, WebSocket-Client (#57)

- SettingsKeys: API_KEY entfernt, AUTH_ACCESS_TOKEN/REFRESH_TOKEN/USERNAME hinzugefügt
- SyncService: login() und logout() Interface-Methoden
- SyncServiceImpl: Bearer-Token statt X-API-Key, Auto-Refresh bei 401
- AuthModels: LoginRequest, LoginResponse, RefreshRequest
- WebSocketClient: Interface + Impl mit exponentiellem Backoff
- SettingsViewModel: Login/Logout, WebSocket-Connect, FullSyncRequired auto-pullSync
- SettingsScreen: Login-Formular (Username + Passwort) statt API-Key-Feld
- NetworkModule: WebSocketClient als Singleton gebunden
- Alle Tests gruen (70 Tasks up-to-date)
This commit is contained in:
Jens Reinemann 2026-05-16 19:45:11 +02:00
parent 14631c7327
commit 4c2f5f08a4
14 changed files with 498 additions and 66 deletions

View file

@ -84,6 +84,7 @@ dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.websockets)
// Shared module
implementation(project(":shared"))

View file

@ -0,0 +1,12 @@
package de.krisenvorrat.app.data.sync
import kotlinx.serialization.Serializable
@Serializable
internal data class LoginRequest(val username: String, val password: String)
@Serializable
internal data class LoginResponse(val accessToken: String, val refreshToken: String)
@Serializable
internal data class RefreshRequest(val refreshToken: String)

View file

@ -1,5 +1,6 @@
package de.krisenvorrat.app.data.sync
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService
@ -8,8 +9,10 @@ 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.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
@ -25,38 +28,47 @@ internal class SyncServiceImpl @Inject constructor(
) : SyncService {
override suspend fun downloadInventory(): Result<InventoryDto> =
executeRequest { serverUrl, apiKey ->
executeRequest { serverUrl, token ->
val response = httpClient.get("$serverUrl/api/inventory") {
header("X-API-Key", apiKey)
header("Authorization", "Bearer $token")
}
handleResponse(response)
}
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> =
executeRequest { serverUrl, apiKey ->
executeRequest { serverUrl, token ->
val response = httpClient.put("$serverUrl/api/inventory") {
header("X-API-Key", apiKey)
header("Authorization", "Bearer $token")
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"))
}
override suspend fun login(
serverUrl: String,
username: String,
password: String
): Result<Unit> = withContext(Dispatchers.IO) {
try {
block(serverUrl.trimEnd('/'), apiKey)
val response = httpClient.post("${serverUrl.trimEnd('/')}/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest(username, password))
}
when (response.status) {
HttpStatusCode.OK -> {
val loginResponse: LoginResponse = response.body()
settingsRepository.setValue(KEY_SERVER_URL, serverUrl)
settingsRepository.setValue(KEY_ACCESS_TOKEN, loginResponse.accessToken)
settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken)
settingsRepository.setValue(KEY_AUTH_USERNAME, username)
Result.success(Unit)
}
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
else -> Result.failure(
SyncError.ServerError(response.status.value, response.status.description)
)
}
} catch (e: SocketTimeoutException) {
Result.failure(SyncError.Timeout(e))
} catch (e: ConnectException) {
@ -66,9 +78,70 @@ internal class SyncServiceImpl @Inject constructor(
}
}
private suspend fun handleResponse(
response: io.ktor.client.statement.HttpResponse
): Result<InventoryDto> = when (response.status) {
override suspend fun logout() {
settingsRepository.setValue(KEY_ACCESS_TOKEN, "")
settingsRepository.setValue(KEY_REFRESH_TOKEN, "")
settingsRepository.setValue(KEY_AUTH_USERNAME, "")
}
private suspend fun executeRequest(
block: suspend (serverUrl: String, token: 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 token = settingsRepository.getValue(KEY_ACCESS_TOKEN)
if (token.isNullOrBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
}
try {
val result = block(serverUrl.trimEnd('/'), token)
// Auto-refresh on 401
if (result.exceptionOrNull() is SyncError.AuthError) {
val refreshed = refreshToken(serverUrl.trimEnd('/'))
if (refreshed) {
val newToken = settingsRepository.getValue(KEY_ACCESS_TOKEN)
?: return@withContext result
block(serverUrl.trimEnd('/'), newToken)
} else {
result
}
} else {
result
}
} 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 refreshToken(serverUrl: String): Boolean {
val refreshToken = settingsRepository.getValue(KEY_REFRESH_TOKEN)
if (refreshToken.isNullOrBlank()) return false
return try {
val response = httpClient.post("$serverUrl/api/auth/refresh") {
contentType(ContentType.Application.Json)
setBody(RefreshRequest(refreshToken))
}
if (response.status == HttpStatusCode.OK) {
val loginResponse: LoginResponse = response.body()
settingsRepository.setValue(KEY_ACCESS_TOKEN, loginResponse.accessToken)
settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken)
true
} else {
false
}
} catch (_: Exception) {
false
}
}
private suspend fun handleResponse(response: HttpResponse): Result<InventoryDto> =
when (response.status) {
HttpStatusCode.OK -> Result.success(response.body())
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
else -> Result.failure(
@ -80,7 +153,9 @@ internal class SyncServiceImpl @Inject constructor(
}
private companion object {
const val KEY_SERVER_URL = "server_url"
const val KEY_API_KEY = "api_key"
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN
val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN
val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME
}
}

View file

@ -0,0 +1,16 @@
package de.krisenvorrat.app.data.sync
import kotlinx.coroutines.flow.SharedFlow
internal interface WebSocketClient {
val events: SharedFlow<WebSocketEvent>
fun connect(serverUrl: String, accessToken: String)
fun disconnect()
}
internal sealed interface WebSocketEvent {
data class InventoryUpdated(val itemId: String) : WebSocketEvent
data object FullSyncRequired : WebSocketEvent
data object Connected : WebSocketEvent
data object Disconnected : WebSocketEvent
}

View file

@ -0,0 +1,99 @@
package de.krisenvorrat.app.data.sync
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
private val _events = MutableSharedFlow<WebSocketEvent>(extraBufferCapacity = 16)
override val events: SharedFlow<WebSocketEvent> = _events.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var connectionJob: Job? = null
private val wsHttpClient = HttpClient(OkHttp) {
install(WebSockets)
}
override fun connect(serverUrl: String, accessToken: String) {
connectionJob?.cancel()
connectionJob = scope.launch {
var backoffMs = INITIAL_BACKOFF_MS
while (isActive) {
try {
val wsUrl = serverUrl.trimEnd('/')
.replace("https://", "wss://")
.replace("http://", "ws://")
wsHttpClient.webSocket("$wsUrl/ws/sync?token=$accessToken") {
backoffMs = INITIAL_BACKOFF_MS
_events.emit(WebSocketEvent.Connected)
for (frame in incoming) {
if (frame is Frame.Text) {
handleFrame(frame.readText())
}
}
}
} catch (e: CancellationException) {
break
} catch (_: Exception) {
// connection failed retry after backoff
}
if (!isActive) break
_events.emit(WebSocketEvent.Disconnected)
delay(backoffMs)
backoffMs = minOf(backoffMs * 2, MAX_BACKOFF_MS)
}
}
}
override fun disconnect() {
connectionJob?.cancel()
connectionJob = null
scope.launch { _events.emit(WebSocketEvent.Disconnected) }
}
private suspend fun handleFrame(text: String) {
try {
val event = json.decodeFromString<WsServerEvent>(text)
when (event.type) {
"inventoryUpdated" -> _events.emit(WebSocketEvent.InventoryUpdated(event.itemId ?: ""))
"fullSyncRequired" -> _events.emit(WebSocketEvent.FullSyncRequired)
}
} catch (_: Exception) {
// ignore malformed events
}
}
private companion object {
const val INITIAL_BACKOFF_MS = 2_000L
const val MAX_BACKOFF_MS = 60_000L
val json = Json { ignoreUnknownKeys = true }
}
}
@Serializable
private data class WsServerEvent(
val type: String,
val itemId: String? = null
)

View file

@ -8,6 +8,8 @@ import dagger.hilt.components.SingletonComponent
import de.krisenvorrat.app.data.remote.OpenAiVisionService
import de.krisenvorrat.app.data.remote.OpenAiVisionServiceImpl
import de.krisenvorrat.app.data.sync.SyncServiceImpl
import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketClientImpl
import de.krisenvorrat.app.domain.repository.SyncService
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
@ -27,6 +29,10 @@ internal abstract class NetworkModule {
@Singleton
abstract fun bindSyncService(impl: SyncServiceImpl): SyncService
@Binds
@Singleton
abstract fun bindWebSocketClient(impl: WebSocketClientImpl): WebSocketClient
@Binds
abstract fun bindOpenAiVisionService(impl: OpenAiVisionServiceImpl): OpenAiVisionService

View file

@ -5,7 +5,9 @@ internal object SettingsKeys {
const val DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
const val AGE_GROUPS = "age_groups"
const val SERVER_URL = "server_url"
const val API_KEY = "api_key"
const val AUTH_ACCESS_TOKEN = "auth_access_token"
const val AUTH_REFRESH_TOKEN = "auth_refresh_token"
const val AUTH_USERNAME = "auth_username"
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
const val OPENAI_API_KEY = "openai_api_key"
}

View file

@ -8,7 +8,7 @@ internal sealed class SyncError(message: String, cause: Throwable? = null) : Exc
SyncError("Zeitüberschreitung bei der Verbindung zum Server", cause)
class AuthError :
SyncError("Authentifizierung fehlgeschlagen API-Key ungültig")
SyncError("Authentifizierung fehlgeschlagen Sitzung abgelaufen oder nicht angemeldet")
class ServerError(val statusCode: Int, message: String) :
SyncError("Serverfehler ($statusCode): $message")

View file

@ -5,4 +5,6 @@ import de.krisenvorrat.shared.model.InventoryDto
internal interface SyncService {
suspend fun downloadInventory(): Result<InventoryDto>
suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto>
suspend fun login(serverUrl: String, username: String, password: String): Result<Unit>
suspend fun logout()
}

View file

@ -238,19 +238,74 @@ internal fun SettingsScreen(
Spacer(modifier = Modifier.height(12.dp))
if (uiState.isLoggedIn) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Angemeldet als: ${uiState.loggedInUsername}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f)
)
TextButton(onClick = viewModel::logout) {
Text("Abmelden")
}
}
} else {
OutlinedTextField(
value = uiState.apiKey,
onValueChange = viewModel::onApiKeyChanged,
label = { Text("API-Key") },
value = uiState.loginUsername,
onValueChange = viewModel::onLoginUsernameChanged,
label = { Text("Benutzername") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = uiState.loginPassword,
onValueChange = viewModel::onLoginPasswordChanged,
label = { Text("Passwort") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = viewModel::login,
enabled = !uiState.isLoggingIn,
modifier = Modifier.fillMaxWidth()
) {
if (uiState.isLoggingIn) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
}
Text("Anmelden")
}
if (uiState.loginError != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = uiState.loginError ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(16.dp))
val isSyncEnabled = uiState.serverUrl.isNotBlank() &&
uiState.apiKey.isNotBlank() &&
val isSyncEnabled = uiState.isLoggedIn &&
uiState.serverUrl.isNotBlank() &&
uiState.syncStatus !is SyncStatus.Running
Row(

View file

@ -15,7 +15,12 @@ internal data class SettingsUiState(
val importResult: ImportResult? = null,
val pendingImportUri: Uri? = null,
val serverUrl: String = "",
val apiKey: String = "",
val isLoggedIn: Boolean = false,
val loggedInUsername: String = "",
val loginUsername: String = "",
val loginPassword: String = "",
val isLoggingIn: Boolean = false,
val loginError: String? = null,
val syncStatus: SyncStatus = SyncStatus.Idle,
val lastSyncTime: String? = null,
val openAiApiKey: String = ""

View file

@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.domain.model.AgeGroup
import de.krisenvorrat.app.domain.model.AgeGroupEntry
import de.krisenvorrat.app.domain.model.SettingsKeys
@ -33,6 +35,7 @@ internal class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository,
private val syncService: SyncService,
private val webSocketClient: WebSocketClient,
@ApplicationContext private val context: Context
) : ViewModel() {
@ -41,6 +44,19 @@ internal class SettingsViewModel @Inject constructor(
init {
loadSettings()
observeWebSocketEvents()
}
private fun observeWebSocketEvents() {
viewModelScope.launch {
webSocketClient.events.collect { event ->
when (event) {
is WebSocketEvent.FullSyncRequired -> pullSync()
is WebSocketEvent.InventoryUpdated -> pullSync()
else -> {}
}
}
}
}
private fun loadSettings() {
@ -48,7 +64,9 @@ internal class SettingsViewModel @Inject constructor(
try {
val ageGroups = loadAgeGroupsWithMigration()
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: ""
val apiKey = settingsRepository.getValue(KEY_API_KEY) ?: ""
val authUsername = settingsRepository.getValue(KEY_AUTH_USERNAME) ?: ""
val accessToken = settingsRepository.getValue(KEY_ACCESS_TOKEN) ?: ""
val isLoggedIn = authUsername.isNotBlank() && accessToken.isNotBlank()
val openAiApiKey = settingsRepository.getValue(KEY_OPENAI_API_KEY) ?: ""
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
@ -56,12 +74,17 @@ internal class SettingsViewModel @Inject constructor(
it.copy(
ageGroups = ageGroups,
serverUrl = serverUrl,
apiKey = apiKey,
isLoggedIn = isLoggedIn,
loggedInUsername = authUsername,
openAiApiKey = openAiApiKey,
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
isLoading = false
)
}
if (isLoggedIn) {
webSocketClient.connect(serverUrl, accessToken)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
@ -121,8 +144,62 @@ internal class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(serverUrl = value, isSaved = false) }
}
fun onApiKeyChanged(value: String) {
_uiState.update { it.copy(apiKey = value, isSaved = false) }
fun onLoginUsernameChanged(value: String) {
_uiState.update { it.copy(loginUsername = value, loginError = null) }
}
fun onLoginPasswordChanged(value: String) {
_uiState.update { it.copy(loginPassword = value, loginError = null) }
}
fun login() {
val serverUrl = _uiState.value.serverUrl.trim()
val username = _uiState.value.loginUsername.trim()
val password = _uiState.value.loginPassword
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
_uiState.update { it.copy(loginError = "Bitte Server-URL, Benutzername und Passwort eingeben") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoggingIn = true, loginError = null) }
syncService.login(serverUrl, username, password).fold(
onSuccess = {
val accessToken = settingsRepository.getValue(KEY_ACCESS_TOKEN) ?: ""
webSocketClient.connect(serverUrl, accessToken)
_uiState.update {
it.copy(
isLoggingIn = false,
isLoggedIn = true,
loggedInUsername = username,
loginUsername = "",
loginPassword = ""
)
}
},
onFailure = { e ->
_uiState.update {
it.copy(
isLoggingIn = false,
loginError = e.message ?: "Anmeldung fehlgeschlagen"
)
}
}
)
}
}
fun logout() {
viewModelScope.launch {
syncService.logout()
webSocketClient.disconnect()
_uiState.update {
it.copy(
isLoggedIn = false,
loggedInUsername = "",
syncStatus = SyncStatus.Idle
)
}
}
}
fun onOpenAiApiKeyChanged(value: String) {
@ -134,7 +211,6 @@ internal class SettingsViewModel @Inject constructor(
try {
settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson())
settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl)
settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey)
settingsRepository.setValue(KEY_OPENAI_API_KEY, _uiState.value.openAiApiKey)
_uiState.update { it.copy(isSaved = true) }
@ -310,7 +386,9 @@ internal class SettingsViewModel @Inject constructor(
val KEY_DAILY_KCAL_PER_PERSON = SettingsKeys.DAILY_KCAL_PER_PERSON
val KEY_AGE_GROUPS = SettingsKeys.AGE_GROUPS
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
val KEY_API_KEY = SettingsKeys.API_KEY
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN
val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN
val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME
val KEY_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP
val KEY_OPENAI_API_KEY = SettingsKeys.OPENAI_API_KEY
}

View file

@ -73,9 +73,10 @@ class SyncServiceImplTest {
}
}
private fun setupSettings(serverUrl: String? = "http://localhost:8080", apiKey: String? = "test-key") {
private fun setupSettings(serverUrl: String? = "http://localhost:8080", accessToken: String? = "test-token") {
coEvery { settingsRepository.getValue("server_url") } returns serverUrl
coEvery { settingsRepository.getValue("api_key") } returns apiKey
coEvery { settingsRepository.getValue("auth_access_token") } returns accessToken
coEvery { settingsRepository.getValue("auth_refresh_token") } returns null
}
// --- downloadInventory ---
@ -87,7 +88,7 @@ class SyncServiceImplTest {
val engine = MockEngine { request ->
assertEquals("/api/inventory", request.url.encodedPath)
assertEquals(HttpMethod.Get, request.method)
assertEquals("test-key", request.headers["X-API-Key"])
assertEquals("Bearer test-token", request.headers["Authorization"])
respond(
content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory),
status = HttpStatusCode.OK,
@ -175,9 +176,9 @@ class SyncServiceImplTest {
}
@Test
fun test_downloadInventory_noApiKey_returnsNotConfigured() = runTest {
fun test_downloadInventory_noAccessToken_returnsNotConfigured() = runTest {
// Given
setupSettings(apiKey = null)
setupSettings(accessToken = null)
val engine = MockEngine { respondError(HttpStatusCode.OK) }
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
@ -199,7 +200,7 @@ class SyncServiceImplTest {
val engine = MockEngine { request ->
assertEquals("/api/inventory", request.url.encodedPath)
assertEquals(HttpMethod.Put, request.method)
assertEquals("test-key", request.headers["X-API-Key"])
assertEquals("Bearer test-token", request.headers["Authorization"])
assertEquals(
ContentType.Application.Json.toString(),
request.body.contentType.toString()

View file

@ -4,6 +4,8 @@ import android.content.Context
import androidx.core.content.FileProvider
import android.content.ContentResolver
import android.net.Uri
import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.ImportExportRepository
@ -45,6 +47,7 @@ class SettingsViewModelTest {
private lateinit var fakeSettingsRepository: FakeSettingsRepository
private lateinit var fakeImportExportRepository: FakeImportExportRepository
private lateinit var fakeSyncService: FakeSyncService
private lateinit var fakeWebSocketClient: FakeWebSocketClient
private lateinit var mockContext: Context
private lateinit var tempDir: File
private lateinit var viewModel: SettingsViewModel
@ -55,6 +58,7 @@ class SettingsViewModelTest {
fakeSettingsRepository = FakeSettingsRepository()
fakeImportExportRepository = FakeImportExportRepository()
fakeSyncService = FakeSyncService()
fakeWebSocketClient = FakeWebSocketClient()
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
tempDir.mkdirs()
mockContext = mockk(relaxed = true) {
@ -73,6 +77,7 @@ class SettingsViewModelTest {
settingsRepository = fakeSettingsRepository,
importExportRepository = fakeImportExportRepository,
syncService = fakeSyncService,
webSocketClient = fakeWebSocketClient,
context = mockContext
)
@ -483,7 +488,8 @@ class SettingsViewModelTest {
fun test_init_withStoredSyncSettings_loadsSyncFields() = runTest(testDispatcher) {
// Given
fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL] = "https://example.com"
fakeSettingsRepository.store[SettingsViewModel.KEY_API_KEY] = "my-secret-key"
fakeSettingsRepository.store[SettingsViewModel.KEY_AUTH_USERNAME] = "testuser"
fakeSettingsRepository.store[SettingsViewModel.KEY_ACCESS_TOKEN] = "access-token-123"
fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP] = "1715700000000"
viewModel = createViewModel()
@ -494,7 +500,8 @@ class SettingsViewModelTest {
// Then
val state = viewModel.uiState.value
assertEquals("https://example.com", state.serverUrl)
assertEquals("my-secret-key", state.apiKey)
assertTrue(state.isLoggedIn)
assertEquals("testuser", state.loggedInUsername)
assertNotNull(state.lastSyncTime)
}
@ -513,26 +520,84 @@ class SettingsViewModelTest {
}
@Test
fun test_onApiKeyChanged_updatesState() = runTest(testDispatcher) {
fun test_onLoginUsernameChanged_updatesState() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.onApiKeyChanged("new-api-key")
viewModel.onLoginUsernameChanged("newuser")
// Then
assertEquals("new-api-key", viewModel.uiState.value.apiKey)
assertFalse(viewModel.uiState.value.isSaved)
assertEquals("newuser", viewModel.uiState.value.loginUsername)
}
@Test
fun test_saveSettings_persistsServerUrlAndApiKey() = runTest(testDispatcher) {
fun test_login_success_setsLoggedIn() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
viewModel.onServerUrlChanged("https://server.com")
viewModel.onLoginUsernameChanged("admin")
viewModel.onLoginPasswordChanged("secret")
// When
viewModel.login()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertTrue(state.isLoggedIn)
assertEquals("admin", state.loggedInUsername)
assertFalse(state.isLoggingIn)
assertNull(state.loginError)
}
@Test
fun test_login_failure_setsLoginError() = runTest(testDispatcher) {
// Given
fakeSyncService.loginShouldFail = true
viewModel = createViewModel()
advanceUntilIdle()
viewModel.onServerUrlChanged("https://server.com")
viewModel.onLoginUsernameChanged("admin")
viewModel.onLoginPasswordChanged("wrong")
// When
viewModel.login()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isLoggedIn)
assertNotNull(state.loginError)
}
@Test
fun test_logout_clearsLoginState() = runTest(testDispatcher) {
// Given
fakeSettingsRepository.store[SettingsViewModel.KEY_AUTH_USERNAME] = "testuser"
fakeSettingsRepository.store[SettingsViewModel.KEY_ACCESS_TOKEN] = "token-123"
fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL] = "https://server.com"
viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.logout()
advanceUntilIdle()
// Then
val state = viewModel.uiState.value
assertFalse(state.isLoggedIn)
assertEquals("", state.loggedInUsername)
}
@Test
fun test_saveSettings_persistsServerUrl() = runTest(testDispatcher) {
// Given
viewModel = createViewModel()
advanceUntilIdle()
viewModel.onServerUrlChanged("https://myserver.com")
viewModel.onApiKeyChanged("secret-key-123")
// When
viewModel.saveSettings()
@ -541,7 +606,6 @@ class SettingsViewModelTest {
// Then
assertTrue(viewModel.uiState.value.isSaved)
assertEquals("https://myserver.com", fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL])
assertEquals("secret-key-123", fakeSettingsRepository.store[SettingsViewModel.KEY_API_KEY])
}
@Test
@ -600,7 +664,7 @@ class SettingsViewModelTest {
fun test_pullSync_authError_setsSyncStatusError() = runTest(testDispatcher) {
// Given
fakeSyncService.downloadShouldFail = true
fakeSyncService.downloadError = SyncError.AuthError()
fakeSyncService.downloadError = de.krisenvorrat.app.domain.model.SyncError.AuthError()
viewModel = createViewModel()
advanceUntilIdle()
@ -690,6 +754,7 @@ private class FakeImportExportRepository : ImportExportRepository {
private class FakeSyncService : SyncService {
var uploadShouldFail = false
var downloadShouldFail = false
var loginShouldFail = false
var uploadError: Exception = RuntimeException("Upload failed")
var downloadError: Exception = RuntimeException("Download failed")
var downloadResult = InventoryDto(
@ -714,6 +779,21 @@ private class FakeSyncService : SyncService {
if (uploadShouldFail) return Result.failure(uploadError)
return Result.success(uploadResult)
}
override suspend fun login(serverUrl: String, username: String, password: String): Result<Unit> {
if (loginShouldFail) return Result.failure(de.krisenvorrat.app.domain.model.SyncError.AuthError())
return Result.success(Unit)
}
override suspend fun logout() {}
}
private class FakeWebSocketClient : WebSocketClient {
private val _events = kotlinx.coroutines.flow.MutableSharedFlow<WebSocketEvent>(extraBufferCapacity = 16)
override val events: kotlinx.coroutines.flow.SharedFlow<WebSocketEvent> = _events
var connectedUrl: String? = null
override fun connect(serverUrl: String, accessToken: String) { connectedUrl = serverUrl }
override fun disconnect() { connectedUrl = null }
}
// endregion