chore(ci): Align GH Workflows with Docker SSoT, new paths; minimal SSoT guard; staticAnalysis (#23)

* chore(MP-21): snapshot pre-refactor state (Epic 1)

* chore(MP-22): scaffold new repo structure, relocate Docker Compose, move frontend/backend modules, update Makefile; add docs mapping and env template

* MP-22 Epic 2: Erfolgreich umgesetzt und verifiziert

* MP-23 Epic 3: Gradle/Build Governance zentralisieren

* MP-23 Epic 3: Gradle/Build Governance zentralisieren

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(ci): Workflows an Docker-SSoT & neue Struktur angepasst, minimaler SSoT-Guard

- ssot-guard.yml: Option B (minimal) → `docker compose -f docker/docker-compose.yaml config` als Lint
- integration-tests.yml: `./gradlew staticAnalysis` vor Integrationstests
- docs-kdoc-sync.yml: Dokka-Task Fallback (dokkaGfmAll || dokkaGfm), YouTrack-Sync nur wenn Script vorhanden
- deploy-proxmox.yml: Compose-Pfade auf docker/docker-compose.yaml + `--env-file docker/.env`; Build/Test Schritte vereinheitlicht
- ci-main.yml: SSoT-Skripte per `if: hashFiles(...)` guarded, Compose-Lint Fallback; OpenAPI‑Pfad → backend/gateway; ADR‑Pfade → docs/adr/**; `staticAnalysis` in Build integriert
- youtrack-sync.yml: unverändert (funktional)

Refs: MP-22, MP-23

* chore(ci): Workflows an Docker-SSoT & neue Struktur angepasst, minimaler SSoT-Guard

- ssot-guard.yml: Option B (minimal) → `docker compose -f docker/docker-compose.yaml config` als Lint
- integration-tests.yml: `./gradlew staticAnalysis` vor Integrationstests
- docs-kdoc-sync.yml: Dokka-Task Fallback (dokkaGfmAll || dokkaGfm), YouTrack-Sync nur wenn Script vorhanden
- deploy-proxmox.yml: Compose-Pfade auf docker/docker-compose.yaml + `--env-file docker/.env`; Build/Test Schritte vereinheitlicht
- ci-main.yml: SSoT-Skripte per `if: hashFiles(...)` guarded, Compose-Lint Fallback; OpenAPI‑Pfad → backend/gateway; ADR‑Pfade → docs/adr/**; `staticAnalysis` in Build integriert
- youtrack-sync.yml: unverändert (funktional)

Refs: MP-22, MP-23

* fix(ci): create .env from example before validating compose config

* fix(ci): update ssot-guard filename (.yaml) and sync workflow state

* fixing

* fix(webpack): correct sql.js fallback configuration for webpack 5
This commit is contained in:
StefanMo
2025-12-03 12:03:40 +01:00
committed by GitHub
parent 034892e890
commit 95fe3e0573
365 changed files with 2283 additions and 15142 deletions
@@ -0,0 +1,126 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
/**
* Dieses Modul kapselt die gesamte UI und Logik für das Authentication-Feature.
* Es kennt seine eigenen technischen Abhängigkeiten (Ktor, Coroutines)
* und den UI-Baukasten (common-ui), aber es kennt keine anderen Features.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21)
jvm()
js {
browser {
testTask {
enabled = false
}
}
}
// WASM, nur wenn explizit aktiviert
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
}
}
sourceSets {
commonMain.dependencies {
// UI Kit (Design System)
implementation(projects.frontend.core.designSystem)
// Shared Konfig & Utilities (AppConfig + BuildConfig)
implementation(projects.frontend.shared)
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// Ktor client for HTTP calls
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth)
// Coroutines and serialization
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
// DateTime for multiplatform time handling
implementation(libs.kotlinx.datetime)
// ViewModel lifecycle
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation("io.ktor:ktor-client-mock:${libs.versions.ktor.get()}")
}
jvmTest.dependencies {
implementation(libs.mockk)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
}
jsMain.dependencies {
implementation(libs.ktor.client.js)
implementation(libs.ktor.client.auth)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
}
// WASM SourceSet, nur wenn aktiviert
if (enableWasm) {
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
// ✅ HINZUFÜGEN: Compose für shared UI components für WASM
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
}
}
}
// KMP Compile-Optionen
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn",
// Suppress beta warning for expect/actual classes as per project decision
"-Xexpect-actual-classes"
)
}
}
@@ -0,0 +1,191 @@
package at.mocode.clients.authfeature
import at.mocode.shared.core.AppConstants
import io.ktor.client.call.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
/**
* Data classes for authentication API communication
*/
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class LoginResponse(
val success: Boolean,
val token: String? = null,
val message: String? = null,
val userId: String? = null,
val username: String? = null
)
/**
* HTTP client for authentication API calls
*/
class AuthApiClient(
// Keycloak Basis-URL (z. B. http://localhost:8180)
private val keycloakBaseUrl: String = AppConstants.KEYCLOAK_URL,
// Realm-Name in Keycloak
private val realm: String = AppConstants.KEYCLOAK_REALM,
// Client-ID (Public Client empfohlen für Frontend-Flows)
private val clientId: String = AppConstants.KEYCLOAK_CLIENT_ID,
// Optional: Client-Secret (nur bei vertraulichen Clients erforderlich)
private val clientSecret: String? = null
) {
private val client = AuthenticatedHttpClient.createUnauthenticated()
/**
* Authenticate user with username and password
*/
suspend fun login(username: String, password: String): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try {
val response = client.submitForm(
url = tokenEndpoint,
formParameters = Parameters.build {
append("grant_type", "password")
append("client_id", clientId)
if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret)
}
append("username", username)
append("password", password)
}
) {
// Explicit: URL-encoded Form
contentType(ContentType.Application.FormUrlEncoded)
}
if (response.status.isSuccess()) {
val kc = response.body<KeycloakTokenResponse>()
LoginResponse(
success = true,
token = kc.access_token,
message = null,
userId = null,
username = username
)
} else {
LoginResponse(
success = false,
message = "Login fehlgeschlagen: HTTP ${response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Verbindungsfehler: ${e.message}"
)
}
}
/**
* Exchange an authorization code (PKCE) for tokens
*/
suspend fun exchangeAuthorizationCode(code: String, codeVerifier: String, redirectUri: String): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try {
val response = client.submitForm(
url = tokenEndpoint,
formParameters = Parameters.build {
append("grant_type", "authorization_code")
append("client_id", clientId)
if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret)
}
append("code", code)
append("code_verifier", codeVerifier)
append("redirect_uri", redirectUri)
}
) {
contentType(ContentType.Application.FormUrlEncoded)
}
if (response.status.isSuccess()) {
val kc = response.body<KeycloakTokenResponse>()
LoginResponse(
success = true,
token = kc.access_token,
message = null
)
} else {
LoginResponse(
success = false,
message = "Code-Exchange fehlgeschlagen: HTTP ${'$'}{response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Code-Exchange Fehler: ${'$'}{e.message}"
)
}
}
/**
* Refresh authentication token
*/
suspend fun refreshToken(refreshToken: String): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try {
val response = client.submitForm(
url = tokenEndpoint,
formParameters = Parameters.build {
append("grant_type", "refresh_token")
append("client_id", clientId)
if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret)
}
append("refresh_token", refreshToken)
}
) {
contentType(ContentType.Application.FormUrlEncoded)
}
if (response.status.isSuccess()) {
val kc = response.body<KeycloakTokenResponse>()
LoginResponse(
success = true,
token = kc.access_token,
message = null
)
} else {
LoginResponse(
success = false,
message = "Token refresh fehlgeschlagen: HTTP ${response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Token refresh Fehler: ${e.message}"
)
}
}
/**
* Logout and invalidate token
*/
suspend fun logout(token: String): Boolean {
// Empfehlung: Frontend-seitig Token lokal verwerfen.
// Optional könnten hier Keycloak-Endpoints für Token-Revocation aufgerufen werden.
return true
}
@Serializable
private data class KeycloakTokenResponse(
val access_token: String,
val expires_in: Long? = null,
val refresh_expires_in: Long? = null,
val refresh_token: String? = null,
val token_type: String? = null,
val not_before_policy: Long? = null,
val session_state: String? = null,
val scope: String? = null
)
}
@@ -0,0 +1,338 @@
package at.mocode.clients.authfeature
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.ExperimentalTime
/**
* Client-side permission enumeration that mirrors server-side BerechtigungE
*/
@Serializable
enum class Permission {
// Person management
PERSON_READ,
PERSON_CREATE,
PERSON_UPDATE,
PERSON_DELETE,
// Club management
VEREIN_READ,
VEREIN_CREATE,
VEREIN_UPDATE,
VEREIN_DELETE,
// Event management
VERANSTALTUNG_READ,
VERANSTALTUNG_CREATE,
VERANSTALTUNG_UPDATE,
VERANSTALTUNG_DELETE,
// Horse management
PFERD_READ,
PFERD_CREATE,
PFERD_UPDATE,
PFERD_DELETE
}
/**
* JWT token payload for basic validation and permissions extraction
*/
@Serializable
data class JwtPayload(
val sub: String? = null, // User ID
val username: String? = null, // Username
val exp: Long? = null, // Expiration timestamp
val iat: Long? = null, // Issued at timestamp
val iss: String? = null, // Issuer
val permissions: List<String>? = null // Permissions array
)
/**
* Authentication state
*/
data class AuthState(
val isAuthenticated: Boolean = false,
val token: String? = null,
val userId: String? = null,
val username: String? = null,
val permissions: List<Permission> = emptyList()
)
/**
* Secure in-memory JWT token manager
*
* For web clients, storing tokens in memory is the most secure approach
* to prevent XSS attacks. The token is lost when the browser tab is closed
* or refreshed, requiring re-authentication.
*/
class AuthTokenManager {
private var currentToken: String? = null
private var tokenPayload: JwtPayload? = null
private val _authState = MutableStateFlow(AuthState())
val authState: StateFlow<AuthState> = _authState.asStateFlow()
/**
* Store JWT token in memory
*/
fun setToken(token: String) {
currentToken = token
tokenPayload = parseJwtPayload(token)
// Parse permissions from token payload
val permissions = tokenPayload?.permissions?.mapNotNull { permissionString ->
try {
Permission.valueOf(permissionString)
} catch (e: IllegalArgumentException) {
// Ignore unknown permissions
null
}
} ?: emptyList()
_authState.value = AuthState(
isAuthenticated = true,
token = token,
userId = tokenPayload?.sub,
username = tokenPayload?.username,
permissions = permissions
)
}
/**
* Get current JWT token
*/
fun getToken(): String? = currentToken
/**
* Check if we have a valid (non-expired) token
*/
@OptIn(ExperimentalTime::class)
fun hasValidToken(): Boolean {
val token = currentToken ?: return false
val payload = tokenPayload ?: return false
// Check expiration
val expiration = payload.exp ?: return false
val currentTime = kotlin.time.Clock.System.now().epochSeconds
return currentTime < expiration
}
/**
* Clear token from memory (logout)
*/
fun clearToken() {
currentToken = null
tokenPayload = null
_authState.value = AuthState()
}
/**
* Get user ID from token
*/
fun getUserId(): String? = tokenPayload?.sub
/**
* Get username from token
*/
fun getUsername(): String? = tokenPayload?.username
/**
* Get current user permissions
*/
fun getPermissions(): List<Permission> = _authState.value.permissions
/**
* Check if user has a specific permission
*/
fun hasPermission(permission: Permission): Boolean {
return _authState.value.permissions.contains(permission)
}
/**
* Check if user has any of the specified permissions
*/
fun hasAnyPermission(vararg permissions: Permission): Boolean {
return permissions.any { _authState.value.permissions.contains(it) }
}
/**
* Check if user has all of the specified permissions
*/
fun hasAllPermissions(vararg permissions: Permission): Boolean {
return permissions.all { _authState.value.permissions.contains(it) }
}
/**
* Check if user can perform read operations
*/
fun canRead(): Boolean {
return hasAnyPermission(
Permission.PERSON_READ,
Permission.VEREIN_READ,
Permission.VERANSTALTUNG_READ,
Permission.PFERD_READ
)
}
/**
* Check if user can perform create operations
*/
fun canCreate(): Boolean {
return hasAnyPermission(
Permission.PERSON_CREATE,
Permission.VEREIN_CREATE,
Permission.VERANSTALTUNG_CREATE,
Permission.PFERD_CREATE
)
}
/**
* Check if user can perform update operations
*/
fun canUpdate(): Boolean {
return hasAnyPermission(
Permission.PERSON_UPDATE,
Permission.VEREIN_UPDATE,
Permission.VERANSTALTUNG_UPDATE,
Permission.PFERD_UPDATE
)
}
/**
* Check if user can perform delete operations (admin-level)
*/
fun canDelete(): Boolean {
return hasAnyPermission(
Permission.PERSON_DELETE,
Permission.VEREIN_DELETE,
Permission.VERANSTALTUNG_DELETE,
Permission.PFERD_DELETE
)
}
/**
* Check if user is admin (has delete permissions)
*/
fun isAdmin(): Boolean = canDelete()
/**
* Check if token expires within specified minutes
*/
@OptIn(ExperimentalTime::class)
fun isTokenExpiringSoon(minutesThreshold: Int = 5): Boolean {
val payload = tokenPayload ?: return false
val expiration = payload.exp ?: return false
val currentTime = kotlin.time.Clock.System.now().epochSeconds
val thresholdTime = currentTime + (minutesThreshold * 60)
return expiration <= thresholdTime
}
/**
* Parse JWT payload for basic validation and user info extraction
* Note: This is for client-side info extraction only, not security validation
*/
@OptIn(ExperimentalEncodingApi::class)
private fun parseJwtPayload(token: String): JwtPayload? {
return try {
val parts = token.split(".")
if (parts.size != 3) return null
// Decode the payload (second part)
val payloadJson = Base64.decode(parts[1]).decodeToString()
// First try to parse with standard approach
val basicPayload = try {
Json.decodeFromString<JwtPayload>(payloadJson)
} catch (e: Exception) {
// If that fails, extract manually
null
}
// If basic parsing succeeded and has permissions, return it
if (basicPayload != null && basicPayload.permissions != null) {
return basicPayload
}
// Otherwise, extract permissions manually from JSON string
val permissions = extractPermissionsFromJson(payloadJson)
// Return payload with manually extracted permissions
JwtPayload(
sub = basicPayload?.sub,
username = basicPayload?.username,
exp = basicPayload?.exp,
iat = basicPayload?.iat,
iss = basicPayload?.iss,
permissions = permissions
)
} catch (e: Exception) {
// Failed to parse - token might be invalid format
null
}
}
/**
* Extract permissions array from JSON string using simple string parsing
*/
private fun extractPermissionsFromJson(jsonString: String): List<String>? {
return try {
// Simple regex to find permissions array
val permissionsRegex = """"permissions":\s*\[(.*?)\]""".toRegex()
val match = permissionsRegex.find(jsonString)
match?.let {
val permissionsContent = it.groupValues[1]
if (permissionsContent.isBlank()) return emptyList()
// Extract individual permission strings
val permissions = permissionsContent
.split(",")
.mapNotNull { permission ->
permission.trim()
.removePrefix("\"")
.removeSuffix("\"")
.takeIf { it.isNotBlank() }
}
permissions
}
} catch (e: Exception) {
null
}
}
/**
* Get token with Bearer prefix for HTTP headers
*/
fun getBearerToken(): String? {
val token = getToken() ?: return null
return "Bearer $token"
}
/**
* Refresh token if needed based on expiry
*/
suspend fun refreshTokenIfNeeded(authApiClient: AuthApiClient): Boolean {
if (!isTokenExpiringSoon()) return true
val currentToken = getToken() ?: return false
val refreshResponse = authApiClient.refreshToken(currentToken)
if (refreshResponse.success && refreshResponse.token != null) {
setToken(refreshResponse.token)
return true
}
// Refresh failed, clear token
clearToken()
return false
}
}
@@ -0,0 +1,62 @@
package at.mocode.clients.authfeature
import at.mocode.shared.core.AppConstants
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
/**
* Singleton object for managing authenticated HTTP client configuration.
* Provides methods to create HTTP clients and add authentication headers manually.
*/
object AuthenticatedHttpClient {
private val authTokenManager = AuthTokenManager()
/**
* Create a basic HTTP client with JSON support
*/
fun create(baseUrl: String = AppConstants.GATEWAY_URL): HttpClient {
return HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
}
/**
* Add an authentication header to an HTTP request builder if a token is available
*/
fun HttpRequestBuilder.addAuthHeader() {
authTokenManager.getBearerToken()?.let { bearerToken ->
header(HttpHeaders.Authorization, bearerToken)
}
}
/**
* Get the shared AuthTokenManager instance
*/
fun getAuthTokenManager(): AuthTokenManager = authTokenManager
/**
* Create an HTTP client without authentication (for login/public endpoints)
*/
fun createUnauthenticated(): HttpClient {
return HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
}
}
@@ -0,0 +1,136 @@
package at.mocode.clients.authfeature
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
authTokenManager: AuthTokenManager,
viewModel: LoginViewModel = viewModel { LoginViewModel(authTokenManager) },
onLoginSuccess: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsState()
val passwordFocusRequester = remember { FocusRequester() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Title
Text(
text = "Anmelden",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 32.dp)
)
// Username field
OutlinedTextField(
value = uiState.username,
onValueChange = viewModel::updateUsername,
label = { Text("Benutzername") },
enabled = !uiState.isLoading,
isError = uiState.usernameError != null,
supportingText = uiState.usernameError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { passwordFocusRequester.requestFocus() }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
// Password field
OutlinedTextField(
value = uiState.password,
onValueChange = viewModel::updatePassword,
label = { Text("Passwort") },
enabled = !uiState.isLoading,
isError = uiState.passwordError != null,
supportingText = uiState.passwordError?.let { { Text(it) } },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (uiState.canLogin) {
viewModel.login()
}
}
),
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocusRequester)
.padding(bottom = 24.dp)
)
// Error message
if (uiState.errorMessage != null) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Text(
text = uiState.errorMessage!!,
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
}
}
// Login button
Button(
onClick = { viewModel.login() },
enabled = uiState.canLogin && !uiState.isLoading,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Anmelden")
}
}
}
// Handle login success
LaunchedEffect(uiState.isAuthenticated) {
if (uiState.isAuthenticated) {
onLoginSuccess()
}
}
}
@@ -0,0 +1,131 @@
package at.mocode.clients.authfeature
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
import at.mocode.shared.core.AppConstants
import io.ktor.client.request.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* UI state for the login screen
*/
data class LoginUiState(
val username: String = "",
val password: String = "",
val isLoading: Boolean = false,
val isAuthenticated: Boolean = false,
val errorMessage: String? = null,
val usernameError: String? = null,
val passwordError: String? = null
) {
val canLogin: Boolean
get() = username.isNotBlank() && password.isNotBlank() && !isLoading
}
/**
* ViewModel for handling login authentication logic
*/
class LoginViewModel(
private val authTokenManager: AuthTokenManager
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private val authApiClient = AuthApiClient()
fun updateUsername(username: String) {
_uiState.value = _uiState.value.copy(
username = username,
usernameError = null,
errorMessage = null
)
}
fun updatePassword(password: String) {
_uiState.value = _uiState.value.copy(
password = password,
passwordError = null,
errorMessage = null
)
}
fun login() {
val currentState = _uiState.value
// Validate input
if (currentState.username.isBlank()) {
_uiState.value = currentState.copy(usernameError = "Benutzername ist erforderlich")
return
}
if (currentState.password.isBlank()) {
_uiState.value = currentState.copy(passwordError = "Passwort ist erforderlich")
return
}
// Start the login process
_uiState.value = currentState.copy(
isLoading = true,
errorMessage = null,
usernameError = null,
passwordError = null
)
viewModelScope.launch {
try {
val loginResponse = authApiClient.login(
username = currentState.username,
password = currentState.password
)
if (loginResponse.success && loginResponse.token != null) {
// Store the JWT token
authTokenManager.setToken(loginResponse.token)
_uiState.value = _uiState.value.copy(
isLoading = false,
isAuthenticated = true,
errorMessage = null
)
// Fire-and-forget: Trigger Backend Sync so the user exists in Members
viewModelScope.launch {
try {
val client = AuthenticatedHttpClient.create()
client.post("${AppConstants.GATEWAY_URL}/api/members/sync") {
addAuthHeader()
}
} catch (_: Exception) {
// Non-fatal: Wir zeigen Sync-Fehler im Login nicht an
}
}
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = loginResponse.message ?: "Anmeldung fehlgeschlagen"
)
}
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "Verbindungsfehler: ${e.message}"
)
}
}
}
fun logout() {
authTokenManager.clearToken()
_uiState.value = LoginUiState()
}
fun checkAuthenticationStatus() {
val isAuthenticated = authTokenManager.hasValidToken()
_uiState.value = _uiState.value.copy(isAuthenticated = isAuthenticated)
}
}
@@ -0,0 +1,12 @@
package at.mocode.clients.authfeature.oauth
data class CallbackParams(val code: String, val state: String?)
expect object AuthCallbackParams {
/**
* Parse OAuth callback parameters from the current environment.
* - JS (web): reads window.location.search for `code` and `state` and removes them from the URL.
* - JVM (desktop): returns null.
*/
fun parse(): CallbackParams?
}
@@ -0,0 +1,34 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.shared.core.AppConstants
data class PkceState(
val state: String,
val codeVerifier: String,
val codeChallenge: String,
val method: String = "S256"
)
object OAuthParams {
const val RESPONSE_TYPE = "code"
const val SCOPE = "openid"
}
/**
* expect/actual service to support PKCE across JS and JVM.
* For the desktop (JVM) target we currently do not start a browser flow,
* but we provide hashing to keep API parity.
*/
expect object OAuthPkceService {
/** Starts a PKCE auth attempt and stores transient state in memory. */
suspend fun startAuth(): PkceState
/** Returns currently active state if any (not persisted). */
fun current(): PkceState?
/** Clears transient state (after success/failure). */
fun clear()
/** Builds the authorize URL for the current state. */
fun buildAuthorizeUrl(state: PkceState, redirectUri: String = AppConstants.webRedirectUri()): String
}
@@ -0,0 +1,19 @@
package at.mocode.clients.authfeature.oauth
import kotlinx.browser.window
actual object AuthCallbackParams {
actual fun parse(): CallbackParams? {
val search = window.location.search
if (search.isBlank()) return null
val params = js("new URLSearchParams(arguments[0])").unsafeCast<(String) -> dynamic>()(search)
val code = params.get("code") as String?
val state = params.get("state") as String?
return if (!code.isNullOrBlank()) {
// Clean up query params to avoid re-processing on recomposition
val url = window.location.origin + window.location.pathname
window.history.replaceState(null, "", url)
CallbackParams(code, state)
} else null
}
}
@@ -0,0 +1,81 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.shared.core.AppConstants
import kotlinx.browser.window
import kotlinx.coroutines.await
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Uint8Array
import kotlin.js.Promise
import kotlin.random.Random
private var currentPkce: PkceState? = null
private fun base64UrlFromBytes(bytes: ByteArray): String {
// Build binary string from bytes
val sb = StringBuilder(bytes.size)
for (b in bytes) sb.append(b.toInt().toChar())
val b64 = window.btoa(sb.toString())
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
}
private fun base64UrlFromArrayBuffer(buf: ArrayBuffer): String {
val arr = Uint8Array(buf)
var binary = ""
val len = arr.length
for (i in 0 until len) {
val v = (arr.asDynamic()[i] as Number).toInt()
binary += fromCharCode(v)
}
val b64 = window.btoa(binary)
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
}
private fun randomUrlSafe(length: Int): String {
val bytes = Random.Default.nextBytes(length)
// Use base64url for entropy; ensure URL-safe by replacing padding removed already
return base64UrlFromBytes(bytes)
}
private fun sha256(input: String): Promise<ArrayBuffer> {
val enc: dynamic = js("new TextEncoder()")
val data = enc.encode(input)
val subtle: dynamic = window.asDynamic().crypto.subtle
return subtle.digest("SHA-256", data) as Promise<ArrayBuffer>
}
actual object OAuthPkceService {
actual suspend fun startAuth(): PkceState {
val codeVerifier = randomUrlSafe(64)
val challengeBuf = sha256(codeVerifier).await()
val codeChallenge = base64UrlFromArrayBuffer(challengeBuf)
val state = randomUrlSafe(16)
val pkce = PkceState(state, codeVerifier, codeChallenge)
currentPkce = pkce
return pkce
}
actual fun current(): PkceState? = currentPkce
actual fun clear() {
currentPkce = null
}
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
val params = listOf(
"response_type" to OAuthParams.RESPONSE_TYPE,
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
"redirect_uri" to redirectUri,
"scope" to OAuthParams.SCOPE,
"state" to state.state,
"code_challenge" to state.codeChallenge,
"code_challenge_method" to state.method
).joinToString("&") { (k, v) -> "$k=" + encodeURIComponent(v) }
return AppConstants.authorizeEndpoint() + "?" + params
}
}
@Suppress("UnsafeCastFromDynamic")
private fun encodeURIComponent(value: String): String = js("encodeURIComponent")(value)
@Suppress("UnsafeCastFromDynamic")
private fun fromCharCode(code: Int): String = js("String.fromCharCode")(code)
@@ -0,0 +1,5 @@
package at.mocode.clients.authfeature.oauth
actual object AuthCallbackParams {
actual fun parse(): CallbackParams? = null
}
@@ -0,0 +1,55 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.shared.core.AppConstants
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
private var currentPkceJvm: PkceState? = null
private fun base64UrlNoPad(bytes: ByteArray): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
private fun randomUrlSafe(length: Int): String {
// Generate bytes and Base64 URL encode (will be > length due to encoding)
val rnd = SecureRandom()
val bytes = ByteArray(length)
rnd.nextBytes(bytes)
return base64UrlNoPad(bytes)
}
private fun sha256Base64Url(input: String): String {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(input.toByteArray(Charsets.UTF_8))
return base64UrlNoPad(digest)
}
actual object OAuthPkceService {
actual suspend fun startAuth(): PkceState {
val codeVerifier = randomUrlSafe(64)
val codeChallenge = sha256Base64Url(codeVerifier)
val state = randomUrlSafe(16)
val pkce = PkceState(state, codeVerifier, codeChallenge)
currentPkceJvm = pkce
return pkce
}
actual fun current(): PkceState? = currentPkceJvm
actual fun clear() {
currentPkceJvm = null
}
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
val params = listOf(
"response_type" to OAuthParams.RESPONSE_TYPE,
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
"redirect_uri" to redirectUri,
"scope" to OAuthParams.SCOPE,
"state" to state.state,
"code_challenge" to state.codeChallenge,
"code_challenge_method" to state.method
).joinToString("&") { (k, v) -> "$k=" + java.net.URLEncoder.encode(v, Charsets.UTF_8) }
return AppConstants.authorizeEndpoint() + "?" + params
}
}
@@ -0,0 +1,128 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
/**
* Dieses Modul kapselt die gesamte UI und Logik für das Authentication-Feature.
* Es kennt seine eigenen technischen Abhängigkeiten (Ktor, Coroutines)
* und den UI-Baukasten (common-ui), aber es kennt keine anderen Features.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21)
jvm()
js {
browser {
testTask {
enabled = false
}
}
binaries.executable()
}
// WASM, nur wenn explizit aktiviert
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
}
sourceSets {
commonMain.dependencies {
// UI Kit (Design System)
implementation(project(":frontend:core:design-system"))
// Shared Konfig & Utilities (AppConfig + BuildConfig)
implementation(project(":frontend:shared"))
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// Ktor client for HTTP calls
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentNegotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth)
// Coroutines and serialization
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
// DateTime for multiplatform time handling
implementation(libs.kotlinx.datetime)
// ViewModel lifecycle
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation("io.ktor:ktor-client-mock:${libs.versions.ktor.get()}")
}
jvmTest.dependencies {
implementation(libs.mockk)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
}
jsMain.dependencies {
implementation(libs.ktor.client.js)
implementation(libs.ktor.client.auth)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
}
// WASM SourceSet, nur wenn aktiviert
if (enableWasm) {
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
// ✅ HINZUFÜGEN: Compose für shared UI components für WASM
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
}
}
}
// KMP Compile-Optionen
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn",
// Suppress beta warning for expect/actual classes as per project decision
"-Xexpect-actual-classes"
)
}
}
@@ -0,0 +1,121 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
/**
* Dieses Modul kapselt die gesamte UI und Logik für das Ping-Feature.
* Es kennt seine eigenen technischen Abhängigkeiten (Ktor, Coroutines)
* und den UI-Baukasten (common-ui), aber es kennt keine anderen Features.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21)
jvm()
js {
browser {
testTask {
enabled = false
}
}
}
// WASM, nur wenn explizit aktiviert
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs {
browser()
}
}
sourceSets {
commonMain.dependencies {
// Contract from backend
implementation(projects.backend.services.ping.pingApi)
// UI Kit (Design System)
implementation(projects.frontend.core.designSystem)
// Shared Konfig & Utilities
implementation(projects.frontend.shared)
// Compose dependencies
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// Ktor client for HTTP calls
implementation(libs.bundles.ktor.client.common)
// Coroutines and serialization
implementation(libs.bundles.kotlinx.core)
// DI (Koin) for resolving apiClient from container
implementation(libs.koin.core)
// ViewModel lifecycle
implementation(libs.bundles.compose.common)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
}
jvmTest.dependencies {
implementation(libs.mockk)
implementation(projects.platform.platformTesting)
implementation(libs.bundles.testing.jvm)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
// Auth-Models Zugriff (nur für JVM)
//implementation(project(":infrastructure:auth:auth-client"))
}
jsMain.dependencies {
implementation(libs.ktor.client.js)
}
// WASM SourceSet, nur wenn aktiviert
if (enableWasm) {
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js) // WASM verwendet JS-Client [cite: 7]
// ✅ HINZUFÜGEN: Compose für shared UI components für WASM
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
}
}
}
// KMP Compile-Optionen
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn"
)
}
}
@@ -0,0 +1,42 @@
package at.mocode.clients.pingfeature
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.shared.core.AppConstants
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class PingApiClient(
private val baseUrl: String = AppConstants.GATEWAY_URL
) : PingApi {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
override suspend fun simplePing(): PingResponse {
return client.get("$baseUrl/api/ping/simple").body()
}
override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse {
return client.get("$baseUrl/api/ping/enhanced") {
parameter("simulate", simulate)
}.body()
}
override suspend fun healthCheck(): HealthResponse {
return client.get("$baseUrl/api/ping/health").body()
}
}
@@ -0,0 +1,14 @@
package at.mocode.clients.pingfeature
import at.mocode.ping.api.PingApi
import io.ktor.client.HttpClient
/**
* Factory for providing a PingApi implementation.
*
* If an HttpClient is provided (e.g., DI-provided "apiClient"), a DI-aware
* implementation is returned. Otherwise, a self-contained client is used
* as a fallback to keep the feature working without DI.
*/
fun providePingApi(httpClient: HttpClient? = null): PingApi =
if (httpClient != null) PingApiKoinClient(httpClient) else PingApiClient()
@@ -0,0 +1,29 @@
package at.mocode.clients.pingfeature
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
/**
* PingApi implementation that uses a provided HttpClient (e.g., DI-provided "apiClient").
*/
class PingApiKoinClient(private val client: HttpClient) : PingApi {
override suspend fun simplePing(): PingResponse {
return client.get("/api/ping/simple").body()
}
override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse {
return client.get("/api/ping/enhanced") {
url.parameters.append("simulate", simulate.toString())
}.body()
}
override suspend fun healthCheck(): HealthResponse {
return client.get("/api/ping/health").body()
}
}
@@ -0,0 +1,308 @@
package at.mocode.clients.pingfeature
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.clients.pingfeature.model.ReitsportRole
import at.mocode.clients.pingfeature.model.ReitsportRoles
import at.mocode.clients.pingfeature.model.RoleCategory
@Composable
fun PingScreen(viewModel: PingViewModel) {
val uiState = viewModel.uiState
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Ping Service",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { viewModel.performSimplePing() },
enabled = !uiState.isLoading,
modifier = Modifier.weight(1f)
) {
Text("Simple Ping")
}
Button(
onClick = { viewModel.performEnhancedPing() },
enabled = !uiState.isLoading,
modifier = Modifier.weight(1f)
) {
Text("Enhanced Ping")
}
Button(
onClick = { viewModel.performHealthCheck() },
enabled = !uiState.isLoading,
modifier = Modifier.weight(1f)
) {
Text("Health Check")
}
}
// Loading indicator
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// Error message
uiState.errorMessage?.let { error ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Error",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = error,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.clearError() }
) {
Text("Dismiss")
}
}
}
}
// Simple Ping Response
uiState.simplePingResponse?.let { response ->
ResponseCard(
title = "Simple Ping Response",
status = response.status,
timestamp = response.timestamp,
service = response.service
)
}
// Enhanced Ping Response
uiState.enhancedPingResponse?.let { response ->
ResponseCard(
title = "Enhanced Ping Response",
status = response.status,
timestamp = response.timestamp,
service = response.service,
additionalInfo = mapOf(
"Circuit Breaker State" to response.circuitBreakerState,
"Response Time" to "${response.responseTime}ms"
)
)
}
// Health Response
uiState.healthResponse?.let { response ->
ResponseCard(
title = "Health Check Response",
status = response.status,
timestamp = response.timestamp,
service = response.service,
additionalInfo = mapOf(
"Healthy" to response.healthy.toString()
)
)
}
// Neue Reitsport-Authentication-Sektion
Spacer(modifier = Modifier.height(24.dp))
ReitsportTestingSection(
viewModel = viewModel,
uiState = uiState
)
}
}
@Composable
private fun ResponseCard(
title: String,
status: String,
timestamp: String,
service: String,
additionalInfo: Map<String, String> = emptyMap()
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
InfoRow("Status", status)
InfoRow("Timestamp", timestamp)
InfoRow("Service", service)
additionalInfo.forEach { (key, value) ->
InfoRow(key, value)
}
}
}
}
@Composable
private fun InfoRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "$label:",
fontWeight = FontWeight.Medium
)
Text(text = value)
}
}
@Composable
private fun ReitsportTestingSection(
viewModel: PingViewModel,
uiState: PingUiState
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🐎",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Reitsport-Authentication-Testing",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
}
Text(
text = "Teste verschiedene Benutzerrollen und ihre Berechtigungen im Meldestelle_Pro System",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
)
// Rollen-Grid
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.height(200.dp) // Feste Höhe für 2 Reihen
) {
items(ReitsportRoles.ALL_ROLES) { role ->
RoleTestButton(
role = role,
onClick = { viewModel.testReitsportRole(role) },
isLoading = uiState.isLoading
)
}
}
}
}
}
@Composable
private fun RoleTestButton(
role: ReitsportRole,
onClick: () -> Unit,
isLoading: Boolean
) {
OutlinedButton(
onClick = onClick,
enabled = !isLoading,
modifier = Modifier
.fillMaxWidth()
.height(80.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = Color.Transparent,
contentColor = when (role.category) {
RoleCategory.SYSTEM -> Color(0xFFFF5722)
RoleCategory.OFFICIAL -> Color(0xFF3F51B5)
RoleCategory.ACTIVE -> Color(0xFF4CAF50)
RoleCategory.PASSIVE -> Color(0xFF9E9E9E)
}
)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = role.icon,
fontSize = 20.sp
)
Text(
text = role.displayName.split(" ").first(), // Erstes Wort nur
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 1
)
Text(
text = "${role.permissions.size} Rechte",
fontSize = 8.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
}
}
}
@@ -0,0 +1,150 @@
package at.mocode.clients.pingfeature
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.clients.pingfeature.api.ReitsportTestApi
import at.mocode.clients.pingfeature.model.DateTimeHelper
import at.mocode.clients.pingfeature.model.ReitsportRole
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import kotlinx.coroutines.launch
data class PingUiState(
val isLoading: Boolean = false,
val simplePingResponse: PingResponse? = null,
val enhancedPingResponse: EnhancedPingResponse? = null,
val healthResponse: HealthResponse? = null,
val errorMessage: String? = null
)
class PingViewModel(
private val apiClient: PingApi = PingApiClient()
) : ViewModel() {
var uiState by mutableStateOf(PingUiState())
private set
fun performSimplePing() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
try {
val response = apiClient.simplePing()
uiState = uiState.copy(
isLoading = false,
simplePingResponse = response
)
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
errorMessage = "Simple ping failed: ${e.message}"
)
}
}
}
fun performEnhancedPing(simulate: Boolean = false) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
try {
val response = apiClient.enhancedPing(simulate)
uiState = uiState.copy(
isLoading = false,
enhancedPingResponse = response
)
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
errorMessage = "Enhanced ping failed: ${e.message}"
)
}
}
}
fun performHealthCheck() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
try {
val response = apiClient.healthCheck()
uiState = uiState.copy(
isLoading = false,
healthResponse = response
)
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
errorMessage = "Health check failed: ${e.message}"
)
}
}
}
fun clearError() {
uiState = uiState.copy(errorMessage = null)
}
/**
* Erweiterte Methode: Echte API-Tests für Reitsport-Rollen
*/
fun testReitsportRole(role: ReitsportRole) {
viewModelScope.launch {
uiState = uiState.copy(
isLoading = true,
errorMessage = null
)
try {
// Echte API-Tests durchführen
val apiClient = ReitsportTestApi()
val testResults = apiClient.testRole(role)
// Erfolgs-Statistiken berechnen
val successful = testResults.count { it.success }
val total = testResults.size
val successRate = if (total > 0) (successful * 100 / total) else 0
// Test-Summary erstellen
val summary = buildString {
appendLine("🎯 ${role.displayName} - Test Abgeschlossen")
appendLine("📊 Erfolgsrate: $successful/$total Tests ($successRate%)")
appendLine("⏱️ Durchschnittsdauer: ${testResults.map { it.duration }.average().toInt()}ms")
appendLine("🔑 Berechtigungen: ${role.permissions.size}")
appendLine("")
appendLine("📋 Test-Ergebnisse:")
testResults.forEach { result ->
val icon = if (result.success) "" else ""
val status = if (result.responseCode != null) " (${result.responseCode})" else ""
appendLine("$icon ${result.scenarioName}$status - ${result.duration}ms")
}
}
// Mock-Response für Anzeige
val mockResponse = PingResponse(
status = summary,
timestamp = DateTimeHelper.formatDateTime(DateTimeHelper.now()),
service = "Reitsport-Auth-Test"
)
uiState = uiState.copy(
isLoading = false,
simplePingResponse = mockResponse
)
println("[DEBUG] Reitsport-API-Test: ${role.displayName}")
println("[DEBUG] Ergebnisse: $successful/$total erfolgreich")
} catch (e: Exception) {
uiState = uiState.copy(
isLoading = false,
errorMessage = "Reitsport-API-Test fehlgeschlagen: ${e.message}"
)
println("[ERROR] Reitsport-Test-Fehler: ${e.message}")
}
}
}
}
@@ -0,0 +1,265 @@
package at.mocode.clients.pingfeature.api
import at.mocode.clients.pingfeature.model.ApiTestResult
import at.mocode.clients.pingfeature.model.DateTimeHelper
import at.mocode.clients.pingfeature.model.ReitsportRole
import at.mocode.clients.pingfeature.model.RolleE
import kotlinx.coroutines.delay
/**
* API-Client für Reitsport-Authentication-Testing
* testet verschiedene Services mit rollenbasierten Tokens
*/
class ReitsportTestApi {
companion object {
// URLs der verfügbaren Services
private const val PING_SERVICE_URL = "http://localhost:8082"
private const val GATEWAY_URL = "http://localhost:8081"
// Mock URLs für auskommentierte Services
private const val MEMBERS_SERVICE_URL = "http://localhost:8083" // Auskommentiert
private const val HORSES_SERVICE_URL = "http://localhost:8084" // Auskommentiert
private const val EVENTS_SERVICE_URL = "http://localhost:8085" // Auskommentiert
}
/**
* Teste eine Rolle gegen verfügbare Services
*/
suspend fun testRole(role: ReitsportRole): List<ApiTestResult> {
val results = mutableListOf<ApiTestResult>()
// 1. Test Ping-Service (immer verfügbar)
results.add(testPingService(role))
// 2. Test Gateway Health (immer verfügbar)
results.add(testGatewayHealth(role))
// 3. Test rollenspezifische Services
when (role.roleType) {
RolleE.ADMIN, RolleE.VEREINS_ADMIN -> {
results.add(testMembersService(role))
results.add(testSystemAccess(role))
}
RolleE.FUNKTIONAER -> {
results.add(testEventsService(role))
results.add(testMembersService(role))
}
RolleE.TIERARZT, RolleE.TRAINER -> {
results.add(testHorsesService(role))
}
RolleE.REITER -> {
results.add(testMembersService(role))
}
RolleE.RICHTER, RolleE.ZUSCHAUER, RolleE.GAST -> {
results.add(testPublicAccess(role))
}
}
return results
}
/**
* Test 1: Ping-Service (verfügbar)
*/
private suspend fun testPingService(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now()
return try {
// Simuliere HTTP-Call zum Ping-Service
delay(200)
val duration = DateTimeHelper.now() - startTime
val endpoint = "$PING_SERVICE_URL/health"
ApiTestResult(
scenarioId = "ping-health",
scenarioName = "Ping Service Health",
endpoint = endpoint,
method = "GET",
expectedResult = "Service erreichbar",
actualResult = "✅ Ping-Service läuft (HTTP 200)",
success = true,
responseCode = 200,
duration = duration,
token = generateMockToken(role),
responseData = """{"status":"pong","service":"ping-service","healthy":true}"""
)
} catch (e: Exception) {
ApiTestResult(
scenarioId = "ping-health",
scenarioName = "Ping Service Health",
endpoint = "$PING_SERVICE_URL/health",
method = "GET",
expectedResult = "Service erreichbar",
actualResult = "❌ Fehler: ${e.message}",
success = false,
duration = DateTimeHelper.now() - startTime,
errorMessage = e.message
)
}
}
/**
* Test 2: Gateway Health (verfügbar)
*/
private suspend fun testGatewayHealth(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now()
return try {
delay(150)
val duration = DateTimeHelper.now() - startTime
val endpoint = "$GATEWAY_URL/actuator/health"
ApiTestResult(
scenarioId = "gateway-health",
scenarioName = "API Gateway Health",
endpoint = endpoint,
method = "GET",
expectedResult = "Gateway gesund",
actualResult = "✅ Gateway erreichbar, Service Discovery aktiv",
success = true,
responseCode = 200,
duration = duration,
token = generateMockToken(role),
responseData = """{"status":"UP","components":{"consul":{"status":"UP"}}}"""
)
} catch (e: Exception) {
ApiTestResult(
scenarioId = "gateway-health",
scenarioName = "API Gateway Health",
endpoint = "$GATEWAY_URL/actuator/health",
method = "GET",
expectedResult = "Gateway gesund",
actualResult = "❌ Gateway nicht erreichbar: ${e.message}",
success = false,
duration = DateTimeHelper.now() - startTime,
errorMessage = e.message
)
}
}
/**
* Test 3: Members-Service (auskommentiert - Graceful Degradation)
*/
private suspend fun testMembersService(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now()
delay(100)
return ApiTestResult(
scenarioId = "members-unavailable",
scenarioName = "Members Service",
endpoint = "$MEMBERS_SERVICE_URL/api/members",
method = "GET",
expectedResult = "Mitglieder-Daten abrufen",
actualResult = "⚠️ Service temporär deaktiviert (in settings.gradle.kts auskommentiert)",
success = false,
responseCode = 503, // Service Unavailable
duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role),
errorMessage = "Service ist in der aktuellen Konfiguration nicht verfügbar"
)
}
/**
* Test 4: Horses-Service (auskommentiert)
*/
private suspend fun testHorsesService(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now()
delay(100)
return ApiTestResult(
scenarioId = "horses-unavailable",
scenarioName = "Horses Service",
endpoint = "$HORSES_SERVICE_URL/api/horses",
method = "GET",
expectedResult = "Pferde-Daten abrufen",
actualResult = "⚠️ Service temporär deaktiviert (in settings.gradle.kts auskommentiert)",
success = false,
responseCode = 503,
duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role),
errorMessage = "Service wird später aktiviert"
)
}
/**
* Test 5: Events-Service (auskommentiert)
*/
private suspend fun testEventsService(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now()
delay(100)
return ApiTestResult(
scenarioId = "events-unavailable",
scenarioName = "Events Service",
endpoint = "$EVENTS_SERVICE_URL/api/events",
method = "GET",
expectedResult = "Veranstaltungs-Daten abrufen",
actualResult = "⚠️ Service temporär deaktiviert (in settings.gradle.kts auskommentiert)",
success = false,
responseCode = 503,
duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role),
errorMessage = "Service in Entwicklung"
)
}
/**
* Test 6: System-Zugriff (für Admins)
*/
private suspend fun testSystemAccess(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now()
delay(300)
val hasSystemAccess = role.roleType == RolleE.ADMIN
return ApiTestResult(
scenarioId = "system-access",
scenarioName = "System-Administration",
endpoint = "$GATEWAY_URL/actuator/info",
method = "GET",
expectedResult = if (hasSystemAccess) "System-Info verfügbar" else "Zugriff verweigert",
actualResult = if (hasSystemAccess) "✅ System-Informationen zugänglich" else "❌ Insufficient permissions",
success = hasSystemAccess,
responseCode = if (hasSystemAccess) 200 else 403,
duration = DateTimeHelper.now() - startTime,
token = generateMockToken(role)
)
}
/**
* Test 7: Öffentlicher Zugriff
*/
private suspend fun testPublicAccess(role: ReitsportRole): ApiTestResult {
val startTime = DateTimeHelper.now()
delay(150)
return ApiTestResult(
scenarioId = "public-access",
scenarioName = "Öffentliche Informationen",
endpoint = "$GATEWAY_URL/api/public/info",
method = "GET",
expectedResult = "Öffentliche Daten verfügbar",
actualResult = "✅ Öffentliche Informationen zugänglich (kein Token erforderlich)",
success = true,
responseCode = 200,
duration = DateTimeHelper.now() - startTime,
token = null // Kein Token für öffentlichen Zugriff
)
}
/**
* Generiere Mock-Token für Tests
*/
private fun generateMockToken(role: ReitsportRole): String {
// Phase 3: Mock-Token (später echte Keycloak-Integration)
val mockPayload = """{"role":"${role.roleType}","permissions":${role.permissions.size}}"""
return "mock.token.${DateTimeHelper.now()}.${role.roleType}"
}
}
@@ -0,0 +1,51 @@
package at.mocode.clients.pingfeature.model
import kotlinx.serialization.Serializable
/**
* Local copy of RolleE enum for multiplatform compatibility
* Mirrors the original from infrastructure:auth:auth-client
*/
@Serializable
enum class RolleE {
ADMIN, // System administrator
VEREINS_ADMIN, // Club administrator
FUNKTIONAER, // Official/functionary
REITER, // Rider
TRAINER, // Trainer
RICHTER, // Judge
TIERARZT, // Veterinarian
ZUSCHAUER, // Spectator
GAST // Guest
}
/**
* Local copy of BerechtigungE enum for multiplatform compatibility
* Mirrors the original from infrastructure:auth:auth-client
*/
@Serializable
enum class BerechtigungE {
// Person management
PERSON_READ,
PERSON_CREATE,
PERSON_UPDATE,
PERSON_DELETE,
// Club management
VEREIN_READ,
VEREIN_CREATE,
VEREIN_UPDATE,
VEREIN_DELETE,
// Event management
VERANSTALTUNG_READ,
VERANSTALTUNG_CREATE,
VERANSTALTUNG_UPDATE,
VERANSTALTUNG_DELETE,
// Horse management
PFERD_READ,
PFERD_CREATE,
PFERD_UPDATE,
PFERD_DELETE
}
@@ -0,0 +1,263 @@
package at.mocode.clients.pingfeature.model
import kotlinx.serialization.Serializable
/**
* Reitsport-spezifische Domain-Modelle für Authentication-Testing
* basiert auf der österreichischen Turnierordnung (ÖTO) und echten Geschäftsprozessen
*/
/**
* Definition einer Benutzerrolle im Reitsport-Kontext.
* Kombiniert die RolleE mit konkreten Berechtigungen und UI-Informationen
*/
@Serializable
data class ReitsportRole(
val roleType: RolleE,
val displayName: String,
val description: String,
val icon: String,
val permissions: List<BerechtigungE>,
val priority: Int, // Für Sortierung in UI (1 = höchste Priorität)
val category: RoleCategory
) {
/**
* Hilfsfunktion: Prüft, ob diese Rolle eine bestimmte Berechtigung hat
*/
fun hasPermission(permission: BerechtigungE): Boolean {
return permissions.contains(permission)
}
/**
* Hilfsfunktion: Gibt alle fehlenden Berechtigungen für eine Liste zurück
*/
fun getMissingPermissions(requiredPermissions: List<BerechtigungE>): List<BerechtigungE> {
return requiredPermissions.filter { !permissions.contains(it) }
}
}
/**
* Kategorisierung der Rollen für bessere UI-Organisation
*/
@Serializable
enum class RoleCategory(val displayName: String, val color: String) {
SYSTEM("System-Verwaltung", "#FF5722"), // Rot
OFFICIAL("Offizielle Funktionen", "#3F51B5"), // Indigo
ACTIVE("Aktive Teilnahme", "#4CAF50"), // Grün
PASSIVE("Information & Zugang", "#9E9E9E") // Grau
}
/**
* Test-Szenario für einen konkreten Geschäftsprozess
*/
@Serializable
data class AuthTestScenario(
val id: String,
val name: String,
val businessProcess: String,
val description: String,
val expectedBehavior: String,
val requiredRole: RolleE,
val requiredPermissions: List<BerechtigungE>,
val testEndpoint: String,
val testMethod: String = "GET",
val priority: TestPriority = TestPriority.NORMAL,
val category: ScenarioCategory
)
/**
* Realistische Kategorisierung der Test-Szenarien basierend auf echten Geschäftsprozessen
*/
@Serializable
enum class ScenarioCategory(val displayName: String, val icon: String) {
// Kern-Geschäftsprozesse
VERANSTALTUNG_SETUP("Veranstaltungs-Einrichtung", "🏟️"),
TURNIER_MANAGEMENT("Turnier-Verwaltung", "🎪"),
BEWERB_KONFIGURATION("Bewerb-Konfiguration", "🏇"),
// Finanzen
KASSABUCH("Kassabuch-Führung", "💰"),
ABRECHNUNG("Turnier-Abrechnung", "🧾"),
// Nennsystem
NENNUNG_WEBFORMULAR("Nenn-Web-Formular", "📝"),
NENNUNG_MOBILE("Mobile Nennung", "📱"),
NENNTAUSCH("Nenntausch-System", "🔄"),
// Startlisten & Zeitplan
ZEITPLAN_ERSTELLUNG("Zeitplan-Erstellung", ""),
STARTERLISTE_FLEXIBEL("Flexible Starterlisten", "📋"),
RICHTER_VALIDATION("Richter-Lizenz-Validierung", "⚖️"),
// Ergebnisse
ERGEBNIS_DRESSUR("Ergebnis-Erfassung Dressur", "🎭"),
ERGEBNIS_SPRINGEN("Ergebnis-Erfassung Springen", "🚀"),
ERGEBNIS_VIELSEITIGKEIT("Ergebnis-Erfassung Vielseitigkeit", "🎯"),
// OEPS Integration
OEPS_SYNC("OEPS-Synchronisation", "🔗"),
TURNIER_NUMMER("Turnier-Nummer-Verwaltung", "🔢"),
// System
SYSTEM_ADMIN("System-Administration", "🔧"),
BENUTZER_VERWALTUNG("Benutzer-Verwaltung", "👥")
}
/**
* Erweiterte Test-Szenarien für realistische Geschäftsprozesse
*/
@Serializable
data class ComplexAuthTestScenario(
val id: String,
val name: String,
val businessProcess: String,
val description: String,
val subProcesses: List<String>, // Multi-Step-Prozesse
val requiredRole: RolleE,
val requiredPermissions: List<BerechtigungE>,
val testEndpoints: List<TestEndpoint>, // Mehrere API-Calls
val mockData: Map<String, String> = emptyMap(),
val expectedOutcome: String,
val priority: TestPriority = TestPriority.NORMAL,
val category: ScenarioCategory,
val oepsIntegrationRequired: Boolean = false
)
@Serializable
data class TestEndpoint(
val name: String,
val url: String,
val method: String = "GET",
val payload: String? = null,
val expectedResponseCode: Int = 200,
val description: String
)
/**
* Priorität von Test-Szenarien
*/
@Serializable
enum class TestPriority(val displayName: String, val level: Int) {
CRITICAL("Kritisch", 1),
HIGH("Hoch", 2),
NORMAL("Normal", 3),
LOW("Niedrig", 4)
}
/**
* Ergebnis eines einzelnen API-Tests
*/
@Serializable
data class ApiTestResult(
val scenarioId: String,
val scenarioName: String,
val endpoint: String,
val method: String,
val expectedResult: String,
val actualResult: String,
val success: Boolean,
val responseCode: Int? = null,
val duration: Long, // in Millisekunden
val timestamp: Long = getTimeMillis(),
val token: String? = null, // Gekürzte Token-Info für Debugging
val errorMessage: String? = null,
val responseData: String? = null
) {
/**
* Hilfsfunktion: Formatiert die Dauer für UI-Anzeige
*/
fun formatDuration(): String = "${duration}ms"
/**
* Hilfsfunktion: Status-Icon für UI
*/
fun getStatusIcon(): String = if (success) "" else ""
}
/**
* Komplettes Ergebnis eines Rollen-basierten Tests
*/
@Serializable
data class ReitsportTestResult(
val testId: String = getTimeMillis().toString(),
val role: ReitsportRole,
val scenarios: List<AuthTestScenario>,
val apiResults: List<ApiTestResult>,
val startTime: Long,
val endTime: Long? = null,
val overallSuccess: Boolean = false,
val summary: TestSummary? = null
) {
/**
* Berechnet die Gesamtdauer des Tests
*/
fun getTotalDuration(): Long = (endTime ?: getTimeMillis()) - startTime
/**
* Berechnet Erfolgsrate in Prozent
*/
fun getSuccessRate(): Double {
if (apiResults.isEmpty()) return 0.0
val successful = apiResults.count { it.success }
return (successful.toDouble() / apiResults.size) * 100
}
/**
* Gibt alle fehlgeschlagenen Tests zurück
*/
fun getFailedTests(): List<ApiTestResult> = apiResults.filter { !it.success }
}
/**
* Zusammenfassung eines Test-Durchlaufs
*/
@Serializable
data class TestSummary(
val totalTests: Int,
val successfulTests: Int,
val failedTests: Int,
val averageDuration: Long,
val criticalFailures: List<String> = emptyList(),
val recommendations: List<String> = emptyList()
) {
val successRate: Double
get() = if (totalTests > 0) (successfulTests.toDouble() / totalTests) * 100 else 0.0
}
/**
* Mock-Daten für Testfälle
*/
@Serializable
data class TestNennung(
val reiterId: String,
val pferdId: String,
val bewerbId: String,
val nennungsDatum: Long = getTimeMillis()
)
@Serializable
data class TestStartbereitschaft(
val nennungId: String,
val confirmed: Boolean = true,
val confirmationTime: Long = getTimeMillis()
)
/**
* Hilfsfunktionen für DateTime (KMP-kompatibel)
* Temporäre Lösung für Phase 1 mit incrementellem Counter
*/
object DateTimeHelper {
private var counter = 1000000000L // Start mit einer realistischen Timestamp
fun now(): Long = counter++
fun formatDateTime(timestamp: Long): String {
// Einfache ISO-ähnliche Formatierung ohne kotlinx-datetime
return "Timestamp: $timestamp" // Temporäre Lösung für Phase 1
}
}
/**
* KMP-kompatible Zeitfunktion für Phase 1
*/
private fun getTimeMillis(): Long = DateTimeHelper.now()
@@ -0,0 +1,220 @@
package at.mocode.clients.pingfeature.model
/**
* Konkrete Rollen-Definitionen für das Reitsport-Authentication-Testing
* Basiert auf den aktuell verfügbaren BerechtigungE und wird mit der fachlichen Implementierung erweitert
*/
object ReitsportRoles {
/**
* System-Administrator - Vollzugriff auf alle Bounded Contexts
*/
val ADMIN = ReitsportRole(
roleType = RolleE.ADMIN,
displayName = "System-Administrator",
description = "Vollzugriff auf alle Microservices und System-Konfiguration",
icon = "🔧",
permissions = BerechtigungE.entries, // Alle verfügbaren Berechtigungen
priority = 1,
category = RoleCategory.SYSTEM
)
/**
* Vereins-Administrator - Vereins-Bounded-Context
*/
val VEREINS_ADMIN = ReitsportRole(
roleType = RolleE.VEREINS_ADMIN,
displayName = "Vereins-Administrator",
description = "Vereinsverwaltung und Mitglieder-Management",
icon = "🏛️",
permissions = listOf(
// Personen (Mitglieder)
BerechtigungE.PERSON_READ,
BerechtigungE.PERSON_CREATE,
BerechtigungE.PERSON_UPDATE,
BerechtigungE.PERSON_DELETE,
// Verein
BerechtigungE.VEREIN_READ,
BerechtigungE.VEREIN_UPDATE,
// Veranstaltungen organisieren
BerechtigungE.VERANSTALTUNG_READ,
BerechtigungE.VERANSTALTUNG_CREATE,
BerechtigungE.VERANSTALTUNG_UPDATE,
// Pferde (für Vereinsmitglieder)
BerechtigungE.PFERD_READ
),
priority = 2,
category = RoleCategory.SYSTEM
)
/**
* Funktionär - Event-Management-Bounded-Context
*/
val FUNKTIONAER = ReitsportRole(
roleType = RolleE.FUNKTIONAER,
displayName = "Funktionär (Meldestelle)",
description = "Turnierorganisation: Nennungen, Starterlisten, Meldestellen-Workflows",
icon = "⚖️",
permissions = listOf(
// Lesen aller relevanten Daten
BerechtigungE.PERSON_READ,
BerechtigungE.PFERD_READ,
BerechtigungE.VERANSTALTUNG_READ,
BerechtigungE.VERANSTALTUNG_UPDATE, // Turnier-Management
// Erweiterte Rechte in Veranstaltungs-Context
// (Hier werden später Nennung-, Startlisten-Berechtigungen hinzugefügt)
),
priority = 3,
category = RoleCategory.OFFICIAL
)
/**
* Richter - Spezialisierte Bewertungs-Rolle
*/
val RICHTER = ReitsportRole(
roleType = RolleE.RICHTER,
displayName = "Richter",
description = "Prüfungs-Bewertung und Ergebnis-Eingabe (ReadOnly-Zugriff auf Stammdaten)",
icon = "⚖️",
permissions = listOf(
// Nur Lese-Zugriff auf relevante Daten
BerechtigungE.PERSON_READ, // Starter-Info
BerechtigungE.PFERD_READ, // Pferde-Info
BerechtigungE.VERANSTALTUNG_READ // Prüfungs-Details
// Ergebnis-Eingabe wird später als eigener Bounded Context hinzugefügt
),
priority = 4,
category = RoleCategory.OFFICIAL
)
/**
* Tierarzt - Veterinär-Bounded-Context
*/
val TIERARZT = ReitsportRole(
roleType = RolleE.TIERARZT,
displayName = "Tierarzt",
description = "Veterinärkontrollen und Pferde-Gesundheits-Management",
icon = "🩺",
permissions = listOf(
BerechtigungE.PFERD_READ,
BerechtigungE.PFERD_UPDATE, // Gesundheitsdaten, Vet-Checks
BerechtigungE.PERSON_READ, // Besitzer-Kontakt
BerechtigungE.VERANSTALTUNG_READ // Turnier-Context für Kontrollen
),
priority = 5,
category = RoleCategory.OFFICIAL
)
/**
* Trainer - Training-Bounded-Context (zukünftig)
*/
val TRAINER = ReitsportRole(
roleType = RolleE.TRAINER,
displayName = "Trainer",
description = "Schützlings-Betreuung und Training-Management",
icon = "🏃‍♂️",
permissions = listOf(
BerechtigungE.PERSON_READ, // Schützlinge
BerechtigungE.PFERD_READ, // Trainingspferde
BerechtigungE.VERANSTALTUNG_READ // Turnier-Planung für Schützlinge
// Training-spezifische Berechtigungen kommen später
),
priority = 6,
category = RoleCategory.ACTIVE
)
/**
* Reiter - Persönlicher Bounded Context
*/
val REITER = ReitsportRole(
roleType = RolleE.REITER,
displayName = "Reiter",
description = "Persönliche Daten, eigene Pferde und Turnier-Teilnahme",
icon = "🐎",
permissions = listOf(
BerechtigungE.PERSON_READ, // Nur eigene Daten
BerechtigungE.PFERD_READ, // Nur eigene Pferde
BerechtigungE.VERANSTALTUNG_READ // Öffentliche Turnier-Infos
// Eigene Daten ändern: Später als PERSON_UPDATE_OWN, PFERD_UPDATE_OWN
),
priority = 7,
category = RoleCategory.ACTIVE
)
/**
* Zuschauer - Public-Read-Only Bounded Context
*/
val ZUSCHAUER = ReitsportRole(
roleType = RolleE.ZUSCHAUER,
displayName = "Zuschauer",
description = "Öffentliche Informationen: Starterlisten, Ergebnisse, Zeitpläne",
icon = "👁️",
permissions = listOf(
BerechtigungE.VERANSTALTUNG_READ // Nur öffentliche Turnier-Daten
// Später: STARTERLISTE_READ_PUBLIC, ERGEBNIS_READ_PUBLIC
),
priority = 8,
category = RoleCategory.PASSIVE
)
/**
* Gast - Keine Authentifizierung erforderlich
*/
val GAST = ReitsportRole(
roleType = RolleE.GAST,
displayName = "Gast",
description = "Öffentliche Basis-Informationen ohne Registrierung",
icon = "🔓",
permissions = emptyList(), // Nur völlig öffentliche Endpunkte
priority = 9,
category = RoleCategory.PASSIVE
)
/**
* Alle definierten Rollen in organisatorischer Reihenfolge
*/
val ALL_ROLES = listOf(
ADMIN,
VEREINS_ADMIN,
FUNKTIONAER,
RICHTER,
TIERARZT,
TRAINER,
REITER,
ZUSCHAUER,
GAST
)
/**
* Rollen nach Bounded Context / Microservice gruppiert
*/
val ROLES_BY_BOUNDED_CONTEXT = mapOf(
"System Management" to listOf(ADMIN),
"Vereins-Service" to listOf(VEREINS_ADMIN),
"Event-Service" to listOf(FUNKTIONAER),
"Bewertungs-Service" to listOf(RICHTER),
"Vet-Service" to listOf(TIERARZT),
"Training-Service" to listOf(TRAINER),
"Member-Service" to listOf(REITER),
"Public-Service" to listOf(ZUSCHAUER, GAST)
)
/**
* Rollen nach UI-Kategorie (für Ping-Dashboard)
*/
val ROLES_BY_CATEGORY = ALL_ROLES.groupBy { it.category }
/**
* Hilfsfunktion: Rolle nach RolleE-Typ finden
*/
fun getRoleByType(roleType: RolleE): ReitsportRole? {
return ALL_ROLES.find { it.roleType == roleType }
}
/**
* Hilfsfunktion: Alle Rollen mit einer bestimmten Berechtigung
*/
fun getRolesWithPermission(permission: BerechtigungE): List<ReitsportRole> {
return ALL_ROLES.filter { it.hasPermission(permission) }
}
}
@@ -0,0 +1,201 @@
package at.mocode.clients.pingfeature
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingResponse
import io.ktor.client.engine.mock.*
import io.ktor.http.*
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
class PingApiClientTest {
private fun createMockApiClient(mockEngine: MockEngine): PingApiClient {
return PingApiClient("http://localhost:8081")
}
@Test
fun `simplePing should return correct response`() = runTest {
// Given
val expectedResponse = PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "ping-service"
)
val mockEngine = MockEngine { request ->
assertEquals("http://localhost:8081/api/ping/simple", request.url.toString())
assertEquals(HttpMethod.Get, request.method)
respond(
content = Json.encodeToString(PingResponse.serializer(), expectedResponse),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
// When
val apiClient = PingApiClient("http://localhost:8081")
// Note: This is a limitation - we can't easily inject the mock engine
// This test demonstrates the structure but would need refactoring of PingApiClient
// to accept HttpClient as dependency for full testability
}
@Test
fun `enhancedPing should include simulate parameter`() = runTest {
// Given
val expectedResponse = EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "ping-service",
circuitBreakerState = "CLOSED",
responseTime = 42L
)
val mockEngine = MockEngine { request ->
assertEquals("http://localhost:8081/api/ping/enhanced", request.url.encodedPath)
assertEquals("true", request.url.parameters["simulate"])
assertEquals(HttpMethod.Get, request.method)
respond(
content = Json.encodeToString(EnhancedPingResponse.serializer(), expectedResponse),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
// When - This test shows the intended structure
// val apiClient = PingApiClient(httpClient = HttpClient(mockEngine))
// val response = apiClient.enhancedPing(simulate = true)
// Then
// assertEquals(expectedResponse, response)
}
@Test
fun `healthCheck should return health response`() = runTest {
// Given
val expectedResponse = HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "ping-service",
healthy = true
)
val mockEngine = MockEngine { request ->
assertEquals("http://localhost:8081/api/ping/health", request.url.toString())
assertEquals(HttpMethod.Get, request.method)
respond(
content = Json.encodeToString(HealthResponse.serializer(), expectedResponse),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
// When - Test structure demonstration
// val apiClient = PingApiClient(httpClient = HttpClient(mockEngine))
// val response = apiClient.healthCheck()
// Then
// assertEquals(expectedResponse, response)
}
@Test
fun `API client should handle HTTP errors correctly`() = runTest {
val mockEngine = MockEngine { request ->
respond(
content = """{"error": "Internal Server Error"}""",
status = HttpStatusCode.InternalServerError,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
// Test structure for error handling
// val apiClient = PingApiClient(httpClient = HttpClient(mockEngine))
// assertFailsWith<Exception> {
// apiClient.simplePing()
// }
}
@Test
fun `API client should handle network errors`() = runTest {
val mockEngine = MockEngine { request ->
throw Exception("Network unreachable")
}
// Test structure for network error handling
// val apiClient = PingApiClient(httpClient = HttpClient(mockEngine))
// assertFailsWith<Exception> {
// apiClient.simplePing()
// }
}
@Test
fun `JSON serialization should work correctly`() {
// Given
val pingResponse = PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service"
)
// When
val json = Json.encodeToString(PingResponse.serializer(), pingResponse)
val deserializedResponse = Json.decodeFromString(PingResponse.serializer(), json)
// Then
assertEquals(pingResponse, deserializedResponse)
}
@Test
fun `Enhanced ping response serialization should work correctly`() {
// Given
val enhancedResponse = EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
circuitBreakerState = "CLOSED",
responseTime = 123L
)
// When
val json = Json.encodeToString(EnhancedPingResponse.serializer(), enhancedResponse)
val deserializedResponse = Json.decodeFromString(EnhancedPingResponse.serializer(), json)
// Then
assertEquals(enhancedResponse, deserializedResponse)
}
@Test
fun `Health response serialization should work correctly`() {
// Given
val healthResponse = HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
healthy = true
)
// When
val json = Json.encodeToString(HealthResponse.serializer(), healthResponse)
val deserializedResponse = Json.decodeFromString(HealthResponse.serializer(), json)
// Then
assertEquals(healthResponse, deserializedResponse)
}
// Note: The HTTP request tests above demonstrate the test structure but are commented out
// because the current PingApiClient implementation doesn't support dependency injection
// of HttpClient. To make these tests fully functional, PingApiClient would need to be
// refactored to accept HttpClient as a constructor parameter:
//
// class PingApiClient(
// private val baseUrl: String = "http://localhost:8081",
// private val httpClient: HttpClient = HttpClient { ... }
// )
//
// This would enable full HTTP mocking and testing capabilities.
}
@@ -0,0 +1,262 @@
package at.mocode.clients.pingfeature
import at.mocode.ping.api.PingResponse
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import kotlinx.coroutines.*
import kotlinx.coroutines.test.*
import kotlin.test.*
@OptIn(ExperimentalCoroutinesApi::class)
class PingViewModelTest {
private lateinit var viewModel: PingViewModel
private lateinit var testApiClient: TestPingApiClient
private val testDispatcher = StandardTestDispatcher()
@BeforeTest
fun setup() {
Dispatchers.setMain(testDispatcher)
testApiClient = TestPingApiClient()
viewModel = PingViewModel(testApiClient)
}
@AfterTest
fun tearDown() {
Dispatchers.resetMain()
testApiClient.reset()
}
@Test
fun `initial state should be empty`() {
// Given & When - initial state
val initialState = viewModel.uiState
// Then
assertFalse(initialState.isLoading)
assertNull(initialState.simplePingResponse)
assertNull(initialState.enhancedPingResponse)
assertNull(initialState.healthResponse)
assertNull(initialState.errorMessage)
}
@Test
fun `performSimplePing should update state with success response`() = runTest(testDispatcher) {
// Given
val expectedResponse = PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service"
)
testApiClient.simplePingResponse = expectedResponse
// When
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertEquals(expectedResponse, finalState.simplePingResponse)
assertNull(finalState.errorMessage)
assertTrue(testApiClient.simplePingCalled)
}
@Test
fun `performSimplePing should set loading state during execution`() = runTest(testDispatcher) {
// Given
testApiClient.simulateDelay = true
testApiClient.delayMs = 100
// When
viewModel.performSimplePing()
testDispatcher.scheduler.advanceTimeBy(1) // Allow the coroutine to start
// Then - should be loading during execution
assertTrue(viewModel.uiState.isLoading)
assertNull(viewModel.uiState.errorMessage)
// When - complete the operation
testDispatcher.scheduler.advanceUntilIdle()
// Then - should not be loading anymore
assertFalse(viewModel.uiState.isLoading)
}
@Test
fun `performSimplePing should handle error and update state`() = runTest(testDispatcher) {
// Given
val errorMessage = "Network error"
testApiClient.shouldThrowException = true
testApiClient.exceptionMessage = errorMessage
// When
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertNull(finalState.simplePingResponse)
assertEquals("Simple ping failed: $errorMessage", finalState.errorMessage)
assertTrue(testApiClient.simplePingCalled)
}
@Test
fun `performEnhancedPing should update state with success response`() = runTest(testDispatcher) {
// Given
val expectedResponse = EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
circuitBreakerState = "CLOSED",
responseTime = 42L
)
testApiClient.enhancedPingResponse = expectedResponse
// When
viewModel.performEnhancedPing(simulate = false)
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertEquals(expectedResponse, finalState.enhancedPingResponse)
assertNull(finalState.errorMessage)
assertEquals(false, testApiClient.enhancedPingCalledWith)
}
@Test
fun `performEnhancedPing should handle simulate parameter correctly`() = runTest(testDispatcher) {
// When
viewModel.performEnhancedPing(simulate = true)
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertEquals(true, testApiClient.enhancedPingCalledWith)
}
@Test
fun `performEnhancedPing should handle error and update state`() = runTest(testDispatcher) {
// Given
val errorMessage = "Enhanced ping error"
testApiClient.shouldThrowException = true
testApiClient.exceptionMessage = errorMessage
// When
viewModel.performEnhancedPing()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertNull(finalState.enhancedPingResponse)
assertEquals("Enhanced ping failed: $errorMessage", finalState.errorMessage)
}
@Test
fun `performHealthCheck should update state with success response`() = runTest(testDispatcher) {
// Given
val expectedResponse = HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "test-service",
healthy = true
)
testApiClient.healthResponse = expectedResponse
// When
viewModel.performHealthCheck()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertEquals(expectedResponse, finalState.healthResponse)
assertNull(finalState.errorMessage)
assertTrue(testApiClient.healthCheckCalled)
}
@Test
fun `performHealthCheck should handle error and update state`() = runTest(testDispatcher) {
// Given
val errorMessage = "Health check error"
testApiClient.shouldThrowException = true
testApiClient.exceptionMessage = errorMessage
// When
viewModel.performHealthCheck()
testDispatcher.scheduler.advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertNull(finalState.healthResponse)
assertEquals("Health check failed: $errorMessage", finalState.errorMessage)
}
@Test
fun `clearError should remove error message from state`() {
// Given - set up an error state by simulating an error
testApiClient.shouldThrowException = true
runTest(testDispatcher) {
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
}
// Verify error is present
assertNotNull(viewModel.uiState.errorMessage)
// When
viewModel.clearError()
// Then
assertNull(viewModel.uiState.errorMessage)
assertFalse(viewModel.uiState.isLoading)
}
@Test
fun `multiple operations should clear previous error messages`() = runTest(testDispatcher) {
// Given - first operation fails
testApiClient.shouldThrowException = true
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
assertNotNull(viewModel.uiState.errorMessage)
// When - second operation succeeds
testApiClient.shouldThrowException = false
val successResponse = PingResponse("SUCCESS", "2025-09-27T21:27:00Z", "test-service")
testApiClient.simplePingResponse = successResponse
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
// Then - error should be cleared
assertNull(viewModel.uiState.errorMessage)
assertEquals(successResponse, viewModel.uiState.simplePingResponse)
}
@Test
fun `loading state should be false after successful operation`() = runTest(testDispatcher) {
// Given
viewModel.performSimplePing()
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertFalse(viewModel.uiState.isLoading)
}
@Test
fun `all operations should call respective API methods`() = runTest(testDispatcher) {
// When
viewModel.performSimplePing()
viewModel.performEnhancedPing(true)
viewModel.performHealthCheck()
testDispatcher.scheduler.advanceUntilIdle()
// Then
assertTrue(testApiClient.simplePingCalled)
assertEquals(true, testApiClient.enhancedPingCalledWith)
assertTrue(testApiClient.healthCheckCalled)
assertEquals(3, testApiClient.callCount)
}
}
@@ -0,0 +1,105 @@
package at.mocode.clients.pingfeature
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
/**
* Test double implementation of PingApi for testing purposes.
* This allows us to test ViewModel behavior without needing MockK.
*/
class TestPingApiClient : PingApi {
// Test configuration properties
var shouldThrowException = false
var exceptionMessage = "Test exception"
var simulateDelay = false
var delayMs = 100L
// Response configuration
var simplePingResponse: PingResponse? = null
var enhancedPingResponse: EnhancedPingResponse? = null
var healthResponse: HealthResponse? = null
// Call tracking
var simplePingCalled = false
var enhancedPingCalledWith: Boolean? = null
var healthCheckCalled = false
var callCount = 0
override suspend fun simplePing(): PingResponse {
simplePingCalled = true
callCount++
if (simulateDelay) {
kotlinx.coroutines.delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return simplePingResponse ?: PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service"
)
}
override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse {
enhancedPingCalledWith = simulate
callCount++
if (simulateDelay) {
kotlinx.coroutines.delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return enhancedPingResponse ?: EnhancedPingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service",
circuitBreakerState = "CLOSED",
responseTime = 42L
)
}
override suspend fun healthCheck(): HealthResponse {
healthCheckCalled = true
callCount++
if (simulateDelay) {
kotlinx.coroutines.delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return healthResponse ?: HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service",
healthy = true
)
}
// Test utilities
fun reset() {
shouldThrowException = false
exceptionMessage = "Test exception"
simulateDelay = false
delayMs = 100L
simplePingResponse = null
enhancedPingResponse = null
healthResponse = null
simplePingCalled = false
enhancedPingCalledWith = null
healthCheckCalled = false
callCount = 0
}
}