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:
parent
14631c7327
commit
4c2f5f08a4
14 changed files with 498 additions and 66 deletions
|
|
@ -84,6 +84,7 @@ dependencies {
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
implementation(libs.ktor.client.websockets)
|
||||||
|
|
||||||
// Shared module
|
// Shared module
|
||||||
implementation(project(":shared"))
|
implementation(project(":shared"))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.krisenvorrat.app.data.sync
|
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.model.SyncError
|
||||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||||
import de.krisenvorrat.app.domain.repository.SyncService
|
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.call.body
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.header
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.put
|
import io.ktor.client.request.put
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
|
|
@ -25,38 +28,47 @@ internal class SyncServiceImpl @Inject constructor(
|
||||||
) : SyncService {
|
) : SyncService {
|
||||||
|
|
||||||
override suspend fun downloadInventory(): Result<InventoryDto> =
|
override suspend fun downloadInventory(): Result<InventoryDto> =
|
||||||
executeRequest { serverUrl, apiKey ->
|
executeRequest { serverUrl, token ->
|
||||||
val response = httpClient.get("$serverUrl/api/inventory") {
|
val response = httpClient.get("$serverUrl/api/inventory") {
|
||||||
header("X-API-Key", apiKey)
|
header("Authorization", "Bearer $token")
|
||||||
}
|
}
|
||||||
handleResponse(response)
|
handleResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> =
|
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> =
|
||||||
executeRequest { serverUrl, apiKey ->
|
executeRequest { serverUrl, token ->
|
||||||
val response = httpClient.put("$serverUrl/api/inventory") {
|
val response = httpClient.put("$serverUrl/api/inventory") {
|
||||||
header("X-API-Key", apiKey)
|
header("Authorization", "Bearer $token")
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(inventory)
|
setBody(inventory)
|
||||||
}
|
}
|
||||||
handleResponse(response)
|
handleResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun executeRequest(
|
override suspend fun login(
|
||||||
block: suspend (serverUrl: String, apiKey: String) -> Result<InventoryDto>
|
serverUrl: String,
|
||||||
): Result<InventoryDto> = withContext(Dispatchers.IO) {
|
username: String,
|
||||||
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL)
|
password: String
|
||||||
if (serverUrl.isNullOrBlank()) {
|
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
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 {
|
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) {
|
} catch (e: SocketTimeoutException) {
|
||||||
Result.failure(SyncError.Timeout(e))
|
Result.failure(SyncError.Timeout(e))
|
||||||
} catch (e: ConnectException) {
|
} catch (e: ConnectException) {
|
||||||
|
|
@ -66,21 +78,84 @@ internal class SyncServiceImpl @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleResponse(
|
override suspend fun logout() {
|
||||||
response: io.ktor.client.statement.HttpResponse
|
settingsRepository.setValue(KEY_ACCESS_TOKEN, "")
|
||||||
): Result<InventoryDto> = when (response.status) {
|
settingsRepository.setValue(KEY_REFRESH_TOKEN, "")
|
||||||
HttpStatusCode.OK -> Result.success(response.body())
|
settingsRepository.setValue(KEY_AUTH_USERNAME, "")
|
||||||
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
|
|
||||||
else -> Result.failure(
|
|
||||||
SyncError.ServerError(
|
|
||||||
statusCode = response.status.value,
|
|
||||||
message = response.status.description
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
SyncError.ServerError(
|
||||||
|
statusCode = response.status.value,
|
||||||
|
message = response.status.description
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val KEY_SERVER_URL = "server_url"
|
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
|
||||||
const val KEY_API_KEY = "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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -8,6 +8,8 @@ import dagger.hilt.components.SingletonComponent
|
||||||
import de.krisenvorrat.app.data.remote.OpenAiVisionService
|
import de.krisenvorrat.app.data.remote.OpenAiVisionService
|
||||||
import de.krisenvorrat.app.data.remote.OpenAiVisionServiceImpl
|
import de.krisenvorrat.app.data.remote.OpenAiVisionServiceImpl
|
||||||
import de.krisenvorrat.app.data.sync.SyncServiceImpl
|
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 de.krisenvorrat.app.domain.repository.SyncService
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.engine.okhttp.OkHttp
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
|
@ -27,6 +29,10 @@ internal abstract class NetworkModule {
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindSyncService(impl: SyncServiceImpl): SyncService
|
abstract fun bindSyncService(impl: SyncServiceImpl): SyncService
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindWebSocketClient(impl: WebSocketClientImpl): WebSocketClient
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
abstract fun bindOpenAiVisionService(impl: OpenAiVisionServiceImpl): OpenAiVisionService
|
abstract fun bindOpenAiVisionService(impl: OpenAiVisionServiceImpl): OpenAiVisionService
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ internal object SettingsKeys {
|
||||||
const val DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
|
const val DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
|
||||||
const val AGE_GROUPS = "age_groups"
|
const val AGE_GROUPS = "age_groups"
|
||||||
const val SERVER_URL = "server_url"
|
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 SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
|
||||||
const val OPENAI_API_KEY = "openai_api_key"
|
const val OPENAI_API_KEY = "openai_api_key"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ internal sealed class SyncError(message: String, cause: Throwable? = null) : Exc
|
||||||
SyncError("Zeitüberschreitung bei der Verbindung zum Server", cause)
|
SyncError("Zeitüberschreitung bei der Verbindung zum Server", cause)
|
||||||
|
|
||||||
class AuthError :
|
class AuthError :
|
||||||
SyncError("Authentifizierung fehlgeschlagen – API-Key ungültig")
|
SyncError("Authentifizierung fehlgeschlagen – Sitzung abgelaufen oder nicht angemeldet")
|
||||||
|
|
||||||
class ServerError(val statusCode: Int, message: String) :
|
class ServerError(val statusCode: Int, message: String) :
|
||||||
SyncError("Serverfehler ($statusCode): $message")
|
SyncError("Serverfehler ($statusCode): $message")
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,6 @@ import de.krisenvorrat.shared.model.InventoryDto
|
||||||
internal interface SyncService {
|
internal interface SyncService {
|
||||||
suspend fun downloadInventory(): Result<InventoryDto>
|
suspend fun downloadInventory(): Result<InventoryDto>
|
||||||
suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto>
|
suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto>
|
||||||
|
suspend fun login(serverUrl: String, username: String, password: String): Result<Unit>
|
||||||
|
suspend fun logout()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -238,19 +238,74 @@ internal fun SettingsScreen(
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
if (uiState.isLoggedIn) {
|
||||||
value = uiState.apiKey,
|
Row(
|
||||||
onValueChange = viewModel::onApiKeyChanged,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("API-Key") },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
singleLine = true,
|
) {
|
||||||
modifier = Modifier.fillMaxWidth()
|
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.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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
val isSyncEnabled = uiState.serverUrl.isNotBlank() &&
|
val isSyncEnabled = uiState.isLoggedIn &&
|
||||||
uiState.apiKey.isNotBlank() &&
|
uiState.serverUrl.isNotBlank() &&
|
||||||
uiState.syncStatus !is SyncStatus.Running
|
uiState.syncStatus !is SyncStatus.Running
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,12 @@ internal data class SettingsUiState(
|
||||||
val importResult: ImportResult? = null,
|
val importResult: ImportResult? = null,
|
||||||
val pendingImportUri: Uri? = null,
|
val pendingImportUri: Uri? = null,
|
||||||
val serverUrl: String = "",
|
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 syncStatus: SyncStatus = SyncStatus.Idle,
|
||||||
val lastSyncTime: String? = null,
|
val lastSyncTime: String? = null,
|
||||||
val openAiApiKey: String = ""
|
val openAiApiKey: String = ""
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
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.AgeGroup
|
||||||
import de.krisenvorrat.app.domain.model.AgeGroupEntry
|
import de.krisenvorrat.app.domain.model.AgeGroupEntry
|
||||||
import de.krisenvorrat.app.domain.model.SettingsKeys
|
import de.krisenvorrat.app.domain.model.SettingsKeys
|
||||||
|
|
@ -33,6 +35,7 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val importExportRepository: ImportExportRepository,
|
private val importExportRepository: ImportExportRepository,
|
||||||
private val syncService: SyncService,
|
private val syncService: SyncService,
|
||||||
|
private val webSocketClient: WebSocketClient,
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -41,6 +44,19 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadSettings()
|
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() {
|
private fun loadSettings() {
|
||||||
|
|
@ -48,7 +64,9 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
try {
|
try {
|
||||||
val ageGroups = loadAgeGroupsWithMigration()
|
val ageGroups = loadAgeGroupsWithMigration()
|
||||||
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: ""
|
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 openAiApiKey = settingsRepository.getValue(KEY_OPENAI_API_KEY) ?: ""
|
||||||
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
|
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
|
||||||
|
|
||||||
|
|
@ -56,12 +74,17 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
it.copy(
|
it.copy(
|
||||||
ageGroups = ageGroups,
|
ageGroups = ageGroups,
|
||||||
serverUrl = serverUrl,
|
serverUrl = serverUrl,
|
||||||
apiKey = apiKey,
|
isLoggedIn = isLoggedIn,
|
||||||
|
loggedInUsername = authUsername,
|
||||||
openAiApiKey = openAiApiKey,
|
openAiApiKey = openAiApiKey,
|
||||||
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
|
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
|
webSocketClient.connect(serverUrl, accessToken)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
|
|
@ -121,8 +144,62 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
_uiState.update { it.copy(serverUrl = value, isSaved = false) }
|
_uiState.update { it.copy(serverUrl = value, isSaved = false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onApiKeyChanged(value: String) {
|
fun onLoginUsernameChanged(value: String) {
|
||||||
_uiState.update { it.copy(apiKey = value, isSaved = false) }
|
_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) {
|
fun onOpenAiApiKeyChanged(value: String) {
|
||||||
|
|
@ -134,7 +211,6 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
try {
|
try {
|
||||||
settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson())
|
settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson())
|
||||||
settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl)
|
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)
|
settingsRepository.setValue(KEY_OPENAI_API_KEY, _uiState.value.openAiApiKey)
|
||||||
|
|
||||||
_uiState.update { it.copy(isSaved = true) }
|
_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_DAILY_KCAL_PER_PERSON = SettingsKeys.DAILY_KCAL_PER_PERSON
|
||||||
val KEY_AGE_GROUPS = SettingsKeys.AGE_GROUPS
|
val KEY_AGE_GROUPS = SettingsKeys.AGE_GROUPS
|
||||||
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
|
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_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP
|
||||||
val KEY_OPENAI_API_KEY = SettingsKeys.OPENAI_API_KEY
|
val KEY_OPENAI_API_KEY = SettingsKeys.OPENAI_API_KEY
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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("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 ---
|
// --- downloadInventory ---
|
||||||
|
|
@ -87,7 +88,7 @@ class SyncServiceImplTest {
|
||||||
val engine = MockEngine { request ->
|
val engine = MockEngine { request ->
|
||||||
assertEquals("/api/inventory", request.url.encodedPath)
|
assertEquals("/api/inventory", request.url.encodedPath)
|
||||||
assertEquals(HttpMethod.Get, request.method)
|
assertEquals(HttpMethod.Get, request.method)
|
||||||
assertEquals("test-key", request.headers["X-API-Key"])
|
assertEquals("Bearer test-token", request.headers["Authorization"])
|
||||||
respond(
|
respond(
|
||||||
content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory),
|
content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory),
|
||||||
status = HttpStatusCode.OK,
|
status = HttpStatusCode.OK,
|
||||||
|
|
@ -175,9 +176,9 @@ class SyncServiceImplTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_downloadInventory_noApiKey_returnsNotConfigured() = runTest {
|
fun test_downloadInventory_noAccessToken_returnsNotConfigured() = runTest {
|
||||||
// Given
|
// Given
|
||||||
setupSettings(apiKey = null)
|
setupSettings(accessToken = null)
|
||||||
val engine = MockEngine { respondError(HttpStatusCode.OK) }
|
val engine = MockEngine { respondError(HttpStatusCode.OK) }
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
||||||
|
|
@ -199,7 +200,7 @@ class SyncServiceImplTest {
|
||||||
val engine = MockEngine { request ->
|
val engine = MockEngine { request ->
|
||||||
assertEquals("/api/inventory", request.url.encodedPath)
|
assertEquals("/api/inventory", request.url.encodedPath)
|
||||||
assertEquals(HttpMethod.Put, request.method)
|
assertEquals(HttpMethod.Put, request.method)
|
||||||
assertEquals("test-key", request.headers["X-API-Key"])
|
assertEquals("Bearer test-token", request.headers["Authorization"])
|
||||||
assertEquals(
|
assertEquals(
|
||||||
ContentType.Application.Json.toString(),
|
ContentType.Application.Json.toString(),
|
||||||
request.body.contentType.toString()
|
request.body.contentType.toString()
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import android.content.Context
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.net.Uri
|
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.data.db.entity.SettingsEntity
|
||||||
import de.krisenvorrat.app.domain.model.SyncError
|
import de.krisenvorrat.app.domain.model.SyncError
|
||||||
import de.krisenvorrat.app.domain.repository.ImportExportRepository
|
import de.krisenvorrat.app.domain.repository.ImportExportRepository
|
||||||
|
|
@ -45,6 +47,7 @@ class SettingsViewModelTest {
|
||||||
private lateinit var fakeSettingsRepository: FakeSettingsRepository
|
private lateinit var fakeSettingsRepository: FakeSettingsRepository
|
||||||
private lateinit var fakeImportExportRepository: FakeImportExportRepository
|
private lateinit var fakeImportExportRepository: FakeImportExportRepository
|
||||||
private lateinit var fakeSyncService: FakeSyncService
|
private lateinit var fakeSyncService: FakeSyncService
|
||||||
|
private lateinit var fakeWebSocketClient: FakeWebSocketClient
|
||||||
private lateinit var mockContext: Context
|
private lateinit var mockContext: Context
|
||||||
private lateinit var tempDir: File
|
private lateinit var tempDir: File
|
||||||
private lateinit var viewModel: SettingsViewModel
|
private lateinit var viewModel: SettingsViewModel
|
||||||
|
|
@ -55,6 +58,7 @@ class SettingsViewModelTest {
|
||||||
fakeSettingsRepository = FakeSettingsRepository()
|
fakeSettingsRepository = FakeSettingsRepository()
|
||||||
fakeImportExportRepository = FakeImportExportRepository()
|
fakeImportExportRepository = FakeImportExportRepository()
|
||||||
fakeSyncService = FakeSyncService()
|
fakeSyncService = FakeSyncService()
|
||||||
|
fakeWebSocketClient = FakeWebSocketClient()
|
||||||
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
|
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
|
||||||
tempDir.mkdirs()
|
tempDir.mkdirs()
|
||||||
mockContext = mockk(relaxed = true) {
|
mockContext = mockk(relaxed = true) {
|
||||||
|
|
@ -73,6 +77,7 @@ class SettingsViewModelTest {
|
||||||
settingsRepository = fakeSettingsRepository,
|
settingsRepository = fakeSettingsRepository,
|
||||||
importExportRepository = fakeImportExportRepository,
|
importExportRepository = fakeImportExportRepository,
|
||||||
syncService = fakeSyncService,
|
syncService = fakeSyncService,
|
||||||
|
webSocketClient = fakeWebSocketClient,
|
||||||
context = mockContext
|
context = mockContext
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -483,7 +488,8 @@ class SettingsViewModelTest {
|
||||||
fun test_init_withStoredSyncSettings_loadsSyncFields() = runTest(testDispatcher) {
|
fun test_init_withStoredSyncSettings_loadsSyncFields() = runTest(testDispatcher) {
|
||||||
// Given
|
// Given
|
||||||
fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL] = "https://example.com"
|
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"
|
fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP] = "1715700000000"
|
||||||
|
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
|
|
@ -494,7 +500,8 @@ class SettingsViewModelTest {
|
||||||
// Then
|
// Then
|
||||||
val state = viewModel.uiState.value
|
val state = viewModel.uiState.value
|
||||||
assertEquals("https://example.com", state.serverUrl)
|
assertEquals("https://example.com", state.serverUrl)
|
||||||
assertEquals("my-secret-key", state.apiKey)
|
assertTrue(state.isLoggedIn)
|
||||||
|
assertEquals("testuser", state.loggedInUsername)
|
||||||
assertNotNull(state.lastSyncTime)
|
assertNotNull(state.lastSyncTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -513,26 +520,84 @@ class SettingsViewModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_onApiKeyChanged_updatesState() = runTest(testDispatcher) {
|
fun test_onLoginUsernameChanged_updatesState() = runTest(testDispatcher) {
|
||||||
// Given
|
// Given
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
// When
|
// When
|
||||||
viewModel.onApiKeyChanged("new-api-key")
|
viewModel.onLoginUsernameChanged("newuser")
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
assertEquals("new-api-key", viewModel.uiState.value.apiKey)
|
assertEquals("newuser", viewModel.uiState.value.loginUsername)
|
||||||
assertFalse(viewModel.uiState.value.isSaved)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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
|
// Given
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
viewModel.onServerUrlChanged("https://myserver.com")
|
viewModel.onServerUrlChanged("https://myserver.com")
|
||||||
viewModel.onApiKeyChanged("secret-key-123")
|
|
||||||
|
|
||||||
// When
|
// When
|
||||||
viewModel.saveSettings()
|
viewModel.saveSettings()
|
||||||
|
|
@ -541,7 +606,6 @@ class SettingsViewModelTest {
|
||||||
// Then
|
// Then
|
||||||
assertTrue(viewModel.uiState.value.isSaved)
|
assertTrue(viewModel.uiState.value.isSaved)
|
||||||
assertEquals("https://myserver.com", fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL])
|
assertEquals("https://myserver.com", fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL])
|
||||||
assertEquals("secret-key-123", fakeSettingsRepository.store[SettingsViewModel.KEY_API_KEY])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -600,7 +664,7 @@ class SettingsViewModelTest {
|
||||||
fun test_pullSync_authError_setsSyncStatusError() = runTest(testDispatcher) {
|
fun test_pullSync_authError_setsSyncStatusError() = runTest(testDispatcher) {
|
||||||
// Given
|
// Given
|
||||||
fakeSyncService.downloadShouldFail = true
|
fakeSyncService.downloadShouldFail = true
|
||||||
fakeSyncService.downloadError = SyncError.AuthError()
|
fakeSyncService.downloadError = de.krisenvorrat.app.domain.model.SyncError.AuthError()
|
||||||
viewModel = createViewModel()
|
viewModel = createViewModel()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|
@ -690,6 +754,7 @@ private class FakeImportExportRepository : ImportExportRepository {
|
||||||
private class FakeSyncService : SyncService {
|
private class FakeSyncService : SyncService {
|
||||||
var uploadShouldFail = false
|
var uploadShouldFail = false
|
||||||
var downloadShouldFail = false
|
var downloadShouldFail = false
|
||||||
|
var loginShouldFail = false
|
||||||
var uploadError: Exception = RuntimeException("Upload failed")
|
var uploadError: Exception = RuntimeException("Upload failed")
|
||||||
var downloadError: Exception = RuntimeException("Download failed")
|
var downloadError: Exception = RuntimeException("Download failed")
|
||||||
var downloadResult = InventoryDto(
|
var downloadResult = InventoryDto(
|
||||||
|
|
@ -714,6 +779,21 @@ private class FakeSyncService : SyncService {
|
||||||
if (uploadShouldFail) return Result.failure(uploadError)
|
if (uploadShouldFail) return Result.failure(uploadError)
|
||||||
return Result.success(uploadResult)
|
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
|
// endregion
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue