chore: entferne veraltete turnier-feature Artefakte und ViewModels nach Migration auf Module Structure Blueprint

This commit is contained in:
2026-04-19 17:39:28 +02:00
parent ef5d4fdc81
commit 691861a706
61 changed files with 56 additions and 8724 deletions
@@ -2,11 +2,11 @@ package at.mocode.frontend.features.ping.di
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.ping.api.PingApi
import at.mocode.ping.feature.data.PingApiKoinClient
import at.mocode.ping.feature.data.PingEventRepositoryImpl
import at.mocode.ping.feature.domain.PingSyncService
import at.mocode.ping.feature.domain.PingSyncServiceImpl
import at.mocode.ping.feature.presentation.PingViewModel
import at.mocode.frontend.features.ping.data.PingApiKoinClient
import at.mocode.frontend.features.ping.data.PingEventRepositoryImpl
import at.mocode.frontend.features.ping.domain.PingSyncService
import at.mocode.frontend.features.ping.domain.PingSyncServiceImpl
import at.mocode.frontend.features.ping.presentation.PingViewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
@@ -1,41 +0,0 @@
package at.mocode.ping.feature.data
import at.mocode.ping.api.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
/**
* PingApi-Implementierung, die einen bereitgestellten HttpClient verwendet (z. B. den per Dependency Injection
* bereitgestellten "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()
}
override suspend fun publicPing(): PingResponse {
return client.get("/api/ping/public").body()
}
override suspend fun securePing(): PingResponse {
return client.get("/api/ping/secure").body()
}
override suspend fun syncPings(since: Long): List<PingEvent> {
return client.get("/api/ping/sync") {
url.parameters.append("since", since.toString())
}.body()
}
}
@@ -1,43 +0,0 @@
package at.mocode.ping.feature.data
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.sync.SyncableRepository
import at.mocode.ping.api.PingEvent
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
/**
** ARCH-BLUEPRINT: Dieses Repository implementiert das generische Syncable Repository
** für eine bestimmte Entität und überbrückt so die Lücke zwischen dem Sync-Core und der
** lokalen Datenbank.
*/
class PingEventRepositoryImpl(
private val db: AppDatabase
) : SyncableRepository<PingEvent> {
// Der `since`-Parameter für unsere Synchronisierung ist der Zeitstempel des letzten Ereignisses.
// Das Backend erwartet einen Long (Timestamp), keinen String (UUID).
override suspend fun getLatestSince(): String? {
println("PingEventRepositoryImpl: getLatestSince called - fetching latest timestamp")
// Wir holen den letzten Timestamp aus der DB.
val lastModified = db.appDatabaseQueries.selectLatestPingEventTimestamp().awaitAsOneOrNull()
// Wir geben ihn als String zurück, da das Interface String? erwartet.
// Der SyncManager wird ihn als Parameter "since" an den Request hängen.
// Das Backend erwartet "since" als Long, aber HTTP Parameter sind Strings.
// Spring Boot konvertiert "123456789" automatisch in Long 123456789.
return lastModified?.toString()
}
override suspend fun upsert(items: List<PingEvent>) {
// Führen Sie Massenoperationen immer innerhalb einer Transaktion durch.
db.transaction {
items.forEach { event ->
db.appDatabaseQueries.upsertPingEvent(
id = event.id,
message = event.message,
last_modified = event.lastModified
)
}
}
}
}
@@ -1,26 +0,0 @@
package at.mocode.ping.feature.domain
import at.mocode.frontend.core.sync.SyncManager
import at.mocode.frontend.core.sync.SyncableRepository
import at.mocode.ping.api.PingEvent
/**
* Interface für den Ping-Sync-Dienst zur einfacheren Prüfung und Entkopplung.
*/
interface PingSyncService {
suspend fun syncPings()
}
/**
* Implementierung des PingSyncService unter Verwendung des generischen SyncManager.
*/
class PingSyncServiceImpl(
private val syncManager: SyncManager,
private val repository: SyncableRepository<PingEvent>
) : PingSyncService {
override suspend fun syncPings() {
// Corrected endpoint: /api/ping/sync (singular)
syncManager.performSync(repository, "/api/ping/sync")
}
}
@@ -1,102 +0,0 @@
package at.mocode.ping.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.NetworkCheck
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.Dimens
/**
* Eine modulare Gruppe von Test-Buttons für die Konnektivitäts-Diagnose.
* Plug-and-Play fähig für Ping-Screen oder Sidebar.
*/
@Composable
fun PingActionGroup(
viewModel: PingViewModel,
modifier: Modifier = Modifier
) {
val uiState = viewModel.uiState
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
Text(
text = "DIAGNOSE-TESTS",
style = MaterialTheme.typography.labelSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
modifier = Modifier.padding(bottom = Dimens.SpacingXS)
)
// Grid-ähnliches Layout für die Buttons
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
PingTestButton(
text = "Simple Ping",
icon = Icons.Default.NetworkCheck,
onClick = { viewModel.performSimplePing() },
isLoading = uiState.isLoading,
modifier = Modifier.weight(1f)
)
PingTestButton(
text = "Secure Ping",
icon = Icons.Default.Lock,
onClick = { viewModel.performSecurePing() },
isLoading = uiState.isLoading,
modifier = Modifier.weight(1f)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
PingTestButton(
text = "Health Check",
icon = Icons.Default.HealthAndSafety,
onClick = { viewModel.performHealthCheck() },
isLoading = uiState.isLoading,
modifier = Modifier.weight(1f)
)
PingTestButton(
text = "Delta Sync",
icon = Icons.Default.Sync,
onClick = { viewModel.triggerSync() },
isLoading = uiState.isSyncing,
modifier = Modifier.weight(1f)
)
}
// Zusätzlicher Button für Enhanced Ping (Circuit Breaker Test)
OutlinedButton(
onClick = { viewModel.performEnhancedPing() },
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isLoading
) {
Text("Enhanced Ping (Simulation)", fontSize = 12.sp)
}
}
}
@Composable
private fun PingTestButton(
text: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
isLoading: Boolean,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
modifier = modifier.height(48.dp),
enabled = !isLoading,
contentPadding = PaddingValues(horizontal = Dimens.SpacingS)
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(Dimens.SpacingXS))
Text(text, fontSize = 12.sp, maxLines = 1)
}
}
@@ -1,237 +0,0 @@
package at.mocode.ping.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.unit.dp
import at.mocode.frontend.core.auth.presentation.AuthStatusCard
import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.frontend.core.designsystem.components.MsCard
import at.mocode.frontend.core.designsystem.theme.Dimens
import org.koin.compose.koinInject
@Composable
fun PingScreen(
viewModel: PingViewModel,
onBack: () -> Unit = {},
onNavigateToLogin: () -> Unit = {}
) {
val uiState = viewModel.uiState
val authViewModel: LoginViewModel = koinInject()
// Wir nutzen jetzt das globale Theme (Hintergrund kommt vom Theme)
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(Dimens.SpacingS) // Globales Spacing
) {
// 1. Header
PingHeader(
onBack = onBack,
isSyncing = uiState.isSyncing,
isLoading = uiState.isLoading
)
Spacer(Modifier.height(Dimens.SpacingS))
// 2. Auth Status Area (Plug-and-Play)
AuthStatusCard(
viewModel = authViewModel,
onLoginClick = onNavigateToLogin
)
Spacer(Modifier.height(Dimens.SpacingS))
// 3. Main Dashboard Area (Split View)
Row(modifier = Modifier.weight(1f)) {
// Left Panel: Controls & Status Grid (60%)
Column(
modifier = Modifier
.weight(0.6f)
.fillMaxHeight()
.padding(end = Dimens.SpacingS)
) {
PingActionGroup(viewModel)
Spacer(Modifier.height(Dimens.SpacingS))
StatusGrid(uiState)
}
// Right Panel: Terminal Log (40%)
TerminalConsole(
logs = uiState.logs,
onClear = { viewModel.clearLogs() },
modifier = Modifier
.weight(0.4f)
.fillMaxHeight()
)
}
Spacer(Modifier.height(Dimens.SpacingXS))
// 4. Footer
PingStatusBar(uiState.lastSyncResult)
}
}
@Composable
private fun PingHeader(
onBack: () -> Unit,
isSyncing: Boolean,
isLoading: Boolean
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().height(40.dp)
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MaterialTheme.colorScheme.onBackground)
}
Text(
"KONNEKTIVITÄTS-DIAGNOSE // DASHBOARD",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.weight(1f).padding(start = Dimens.SpacingS)
)
if (isLoading) {
StatusBadge("BUSY", Color(0xFFFFA000)) // Amber
Spacer(Modifier.width(Dimens.SpacingS))
}
if (isSyncing) {
StatusBadge("SYNCING", MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(Dimens.SpacingS))
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
StatusBadge("IDLE", Color(0xFF388E3C)) // Green
}
}
}
@Composable
private fun StatusBadge(text: String, color: Color) {
Surface(
color = color.copy(alpha = 0.1f),
contentColor = color,
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, color)
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
@Composable
private fun StatusGrid(uiState: PingUiState) {
Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
// Row 1
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
MsCard(modifier = Modifier.weight(1f)) {
StatusHeader("SIMPLE / SECURE PING")
if (uiState.simplePingResponse != null) {
KeyValueRow("Status", uiState.simplePingResponse.status)
KeyValueRow("Service", uiState.simplePingResponse.service)
KeyValueRow("Time", uiState.simplePingResponse.timestamp)
} else {
EmptyStateText()
}
}
MsCard(modifier = Modifier.weight(1f)) {
StatusHeader("HEALTH CHECK")
if (uiState.healthResponse != null) {
KeyValueRow("Status", uiState.healthResponse.status)
KeyValueRow("Healthy", uiState.healthResponse.healthy.toString())
KeyValueRow("Service", uiState.healthResponse.service)
} else {
EmptyStateText()
}
}
}
// Row 2
MsCard(modifier = Modifier.fillMaxWidth()) {
StatusHeader("ENHANCED PING (RESILIENCE)")
if (uiState.enhancedPingResponse != null) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
KeyValueRow("Status", uiState.enhancedPingResponse.status)
KeyValueRow("Timestamp", uiState.enhancedPingResponse.timestamp)
}
Column(modifier = Modifier.weight(1f)) {
KeyValueRow("Circuit Breaker", uiState.enhancedPingResponse.circuitBreakerState)
KeyValueRow("Latency", "${uiState.enhancedPingResponse.responseTime}ms")
}
}
} else {
EmptyStateText()
}
}
}
}
@Composable
private fun StatusHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = Dimens.SpacingXS)
)
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp)
Spacer(Modifier.height(Dimens.SpacingXS))
}
@Composable
private fun EmptyStateText() {
Text("No Data", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun KeyValueRow(key: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = "$key:",
modifier = Modifier.width(100.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@Composable
private fun PingStatusBar(lastSync: String?) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = lastSync ?: "Ready",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = Dimens.SpacingS, vertical = 2.dp)
)
}
}
@@ -1,156 +0,0 @@
package at.mocode.ping.feature.presentation
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.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse
import at.mocode.ping.feature.domain.PingSyncService
import kotlinx.coroutines.launch
import kotlin.time.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
data class LogEntry(
val timestamp: String,
val source: String,
val message: String,
val isError: Boolean = false
)
data class PingUiState(
val isLoading: Boolean = false,
val simplePingResponse: PingResponse? = null,
val enhancedPingResponse: EnhancedPingResponse? = null,
val healthResponse: HealthResponse? = null,
val errorMessage: String? = null,
val isSyncing: Boolean = false,
val lastSyncResult: String? = null,
val logs: List<LogEntry> = emptyList()
)
open class PingViewModel(
private val apiClient: PingApi,
private val syncService: PingSyncService
) : ViewModel() {
var uiState by mutableStateOf(PingUiState())
internal set
private fun addLog(source: String, message: String, isError: Boolean = false) {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val timeString = "${now.hour.toString().padStart(2, '0')}:${now.minute.toString().padStart(2, '0')}:${
now.second.toString().padStart(2, '0')
}"
val entry = LogEntry(timeString, source, message, isError)
uiState = uiState.copy(logs = listOf(entry) + uiState.logs) // Prepend for newest first
}
fun performSimplePing() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("SimplePing", "Sending request...")
try {
val response = apiClient.simplePing()
uiState = uiState.copy(
isLoading = false,
simplePingResponse = response
)
addLog("SimplePing", "Success: ${response.status} from ${response.service}")
} catch (e: Exception) {
val msg = "Simple ping failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("SimplePing", "Failed: ${e.message}", isError = true)
}
}
}
fun performEnhancedPing(simulate: Boolean = false) {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("EnhancedPing", "Sending request (simulate=$simulate)...")
try {
val response = apiClient.enhancedPing(simulate)
uiState = uiState.copy(
isLoading = false,
enhancedPingResponse = response
)
addLog("EnhancedPing", "Success: CB=${response.circuitBreakerState}, Time=${response.responseTime}ms")
} catch (e: Exception) {
val msg = "Enhanced ping failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("EnhancedPing", "Failed: ${e.message}", isError = true)
}
}
}
fun performHealthCheck() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("HealthCheck", "Checking system health...")
try {
val response = apiClient.healthCheck()
uiState = uiState.copy(
isLoading = false,
healthResponse = response
)
addLog("HealthCheck", "Status: ${response.status}, Healthy: ${response.healthy}")
} catch (e: Exception) {
val msg = "Health check failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("HealthCheck", "Failed: ${e.message}", isError = true)
}
}
}
fun performSecurePing() {
viewModelScope.launch {
uiState = uiState.copy(isLoading = true, errorMessage = null)
addLog("SecurePing", "Sending authenticated request...")
try {
val response = apiClient.securePing()
uiState = uiState.copy(
isLoading = false,
simplePingResponse = response
)
addLog("SecurePing", "Success: Authorized access granted.")
} catch (e: Exception) {
val msg = "Secure ping failed: ${e.message}"
uiState = uiState.copy(isLoading = false, errorMessage = msg)
addLog("SecurePing", "Access Denied/Error: ${e.message}", isError = true)
}
}
}
fun triggerSync() {
viewModelScope.launch {
uiState = uiState.copy(isSyncing = true, errorMessage = null)
addLog("Sync", "Starting delta sync...")
try {
syncService.syncPings()
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
uiState = uiState.copy(
isSyncing = false,
lastSyncResult = "Sync successful at $now"
)
addLog("Sync", "Sync completed successfully.")
} catch (e: Exception) {
val msg = "Sync failed: ${e.message}"
uiState = uiState.copy(isSyncing = false, errorMessage = msg)
addLog("Sync", "Sync failed: ${e.message}", isError = true)
}
}
}
fun clearLogs() {
uiState = uiState.copy(logs = emptyList())
}
fun clearError() {
uiState = uiState.copy(errorMessage = null)
}
}
@@ -1,66 +0,0 @@
package at.mocode.ping.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.Dimens
/**
* Eine universelle Terminal-Konsole zur Anzeige von Log-Einträgen.
* Plug-and-Play ist fähig für verschiedene Features (Ping, Sync, Auth-Logs).
*/
@Composable
fun TerminalConsole(
logs: List<LogEntry>,
modifier: Modifier = Modifier,
onClear: () -> Unit = {}
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = Dimens.SpacingXS),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("EVENT LOG", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold)
TextButton(
onClick = onClear,
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(24.dp)
) {
Text("CLEAR", style = MaterialTheme.typography.labelSmall)
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF1E1E1E)) // Terminallook (Dunkel)
.padding(Dimens.SpacingXS)
) {
items(logs) { log ->
val color = if (log.isError) Color(0xFFFF5555) else Color(0xFF55FF55)
Text(
text = "[${log.timestamp}] [${log.source}] ${log.message}",
color = color,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
lineHeight = 14.sp
)
}
}
}
}
@@ -1,173 +0,0 @@
package at.mocode.ping.feature.data
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingResponse
import io.ktor.client.*
import io.ktor.client.engine.mock.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
class PingApiKoinClientTest {
// Hilfe zur Erstellung eines testbaren Clients mithilfe der neuen DI-freundlichen Implementierung
private fun createTestClient(mockEngine: MockEngine): PingApiKoinClient {
val client = HttpClient(mockEngine) {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
return PingApiKoinClient(client)
}
@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("/api/ping/simple", request.url.encodedPath)
assertEquals(HttpMethod.Get, request.method)
respond(
content = Json.encodeToString(PingResponse.serializer(), expectedResponse),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
// When
val apiClient = createTestClient(mockEngine)
val response = apiClient.simplePing()
// Then
assertEquals(expectedResponse, response)
}
@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("/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
val apiClient = createTestClient(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("/api/ping/health", request.url.encodedPath)
assertEquals(HttpMethod.Get, request.method)
respond(
content = Json.encodeToString(HealthResponse.serializer(), expectedResponse),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
// When
val apiClient = createTestClient(mockEngine)
val response = apiClient.healthCheck()
// Then
assertEquals(expectedResponse, response)
}
@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)
}
}
@@ -1,70 +0,0 @@
package at.mocode.ping.feature.integration
import at.mocode.frontend.core.sync.SyncManager
import at.mocode.ping.feature.domain.PingSyncServiceImpl
import at.mocode.ping.feature.test.FakePingEventRepository
import io.ktor.client.*
import io.ktor.client.engine.mock.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PingSyncIntegrationTest {
@Test
fun `syncPings should fetch data from API and store in repository`() = runTest {
// Given
val fakeRepo = FakePingEventRepository()
// Mock API Response
val mockEngine = MockEngine { request ->
assertEquals("/api/ping/sync", request.url.encodedPath)
val responseBody = """
[
{
"id": "event-1",
"message": "Ping 1",
"lastModified": 1000
},
{
"id": "event-2",
"message": "Ping 2",
"lastModified": 2000
}
]
""".trimIndent()
respond(
content = responseBody,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
val httpClient = HttpClient(mockEngine) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
}
val syncManager = SyncManager(httpClient)
val syncService = PingSyncServiceImpl(syncManager, fakeRepo)
// When
syncService.syncPings()
// Then
assertEquals(2, fakeRepo.storedEvents.size)
assertTrue(fakeRepo.storedEvents.any { it.id == "event-1" && it.message == "Ping 1" })
assertTrue(fakeRepo.storedEvents.any { it.id == "event-2" && it.message == "Ping 2" })
}
}
@@ -1,312 +0,0 @@
package at.mocode.ping.feature.presentation
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingResponse
import at.mocode.ping.feature.test.FakePingSyncService
import at.mocode.ping.feature.test.TestPingApiClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class PingViewModelTest {
private lateinit var viewModel: PingViewModel
private lateinit var testApiClient: TestPingApiClient
private lateinit var fakeSyncService: FakePingSyncService
private val testDispatcher = StandardTestDispatcher()
@BeforeTest
fun setup() {
Dispatchers.setMain(testDispatcher)
testApiClient = TestPingApiClient()
fakeSyncService = FakePingSyncService()
viewModel = PingViewModel(
apiClient = testApiClient,
syncService = fakeSyncService
)
}
@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()
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
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()
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)
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)
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()
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()
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()
advanceUntilIdle()
// Then
val finalState = viewModel.uiState
assertFalse(finalState.isLoading)
assertNull(finalState.healthResponse)
assertEquals("Health check failed: $errorMessage", finalState.errorMessage)
}
@Test
fun `triggerSync should call syncService and update state`() = runTest(testDispatcher) {
// When
viewModel.triggerSync()
advanceUntilIdle()
// Then
assertTrue(fakeSyncService.syncPingsCalled)
assertFalse(viewModel.uiState.isSyncing)
assertNotNull(viewModel.uiState.lastSyncResult)
assertNull(viewModel.uiState.errorMessage)
}
@Test
fun `triggerSync should handle error and update state`() = runTest(testDispatcher) {
// Given
fakeSyncService.shouldThrowException = true
fakeSyncService.exceptionMessage = "Sync failed"
// When
viewModel.triggerSync()
advanceUntilIdle()
// Then
assertTrue(fakeSyncService.syncPingsCalled)
assertFalse(viewModel.uiState.isSyncing)
assertEquals("Sync failed: Sync failed", viewModel.uiState.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()
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()
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()
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()
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()
advanceUntilIdle()
// Then
assertTrue(testApiClient.simplePingCalled)
assertEquals(true, testApiClient.enhancedPingCalledWith)
assertTrue(testApiClient.healthCheckCalled)
assertEquals(3, testApiClient.callCount)
}
}
@@ -1,186 +0,0 @@
package at.mocode.ping.feature.test
import at.mocode.frontend.core.sync.SyncableRepository
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingEvent
import at.mocode.ping.api.PingResponse
import at.mocode.ping.feature.domain.PingSyncService
import kotlinx.coroutines.delay
/**
* Fake implementation of PingSyncService for testing.
*/
class FakePingSyncService : PingSyncService {
var syncPingsCalled = false
var shouldThrowException = false
var exceptionMessage = "Sync failed"
override suspend fun syncPings() {
syncPingsCalled = true
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
}
}
/**
* Fake implementation of PingEventRepository for testing.
*/
class FakePingEventRepository : SyncableRepository<PingEvent> {
var storedEvents = mutableListOf<PingEvent>()
var latestSince: String? = null
override suspend fun getLatestSince(): String? {
return latestSince
}
override suspend fun upsert(items: List<PingEvent>) {
// Simple upsert logic: remove existing with same ID, add new
val ids = items.map { it.id }.toSet()
storedEvents.removeAll { it.id in ids }
storedEvents.addAll(items)
}
}
/**
* 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
var publicPingResponse: PingResponse? = null
var securePingResponse: PingResponse? = null
var syncPingsResponse: List<PingEvent> = emptyList()
// Call tracking
var simplePingCalled = false
var enhancedPingCalledWith: Boolean? = null
var healthCheckCalled = false
var publicPingCalled = false
var securePingCalled = false
var syncPingsCalledWith: Long? = null
var callCount = 0
override suspend fun simplePing(): PingResponse {
simplePingCalled = true
callCount++
return handleRequest(simplePingResponse)
}
override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse {
enhancedPingCalledWith = simulate
callCount++
if (simulateDelay) {
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) {
delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return healthResponse ?: HealthResponse(
status = "UP",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service",
healthy = true
)
}
override suspend fun publicPing(): PingResponse {
publicPingCalled = true
callCount++
return handleRequest(publicPingResponse)
}
override suspend fun securePing(): PingResponse {
securePingCalled = true
callCount++
return handleRequest(securePingResponse)
}
override suspend fun syncPings(since: Long): List<PingEvent> {
syncPingsCalledWith = since
callCount++
if (simulateDelay) {
delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return syncPingsResponse
}
private suspend fun handleRequest(response: PingResponse?): PingResponse {
if (simulateDelay) {
delay(delayMs)
}
if (shouldThrowException) {
throw Exception(exceptionMessage)
}
return response ?: PingResponse(
status = "OK",
timestamp = "2025-09-27T21:27:00Z",
service = "test-ping-service"
)
}
// Test utilities
fun reset() {
shouldThrowException = false
exceptionMessage = "Test exception"
simulateDelay = false
delayMs = 100L
simplePingResponse = null
enhancedPingResponse = null
healthResponse = null
publicPingResponse = null
securePingResponse = null
syncPingsResponse = emptyList()
simplePingCalled = false
enhancedPingCalledWith = null
healthCheckCalled = false
publicPingCalled = false
securePingCalled = false
syncPingsCalledWith = null
callCount = 0
}
}
@@ -1,105 +0,0 @@
package at.mocode.ping.feature.presentation
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import at.mocode.frontend.core.designsystem.preview.ComponentPreview
import at.mocode.ping.api.*
import at.mocode.ping.feature.domain.PingSyncService
// ─────────────────────────────────────────────────────────────────────────────
// Fake-Implementierungen für Preview (kein Koin, kein Netzwerk nötig)
// ─────────────────────────────────────────────────────────────────────────────
private val fakePingResponse = PingResponse(
status = "OK", timestamp = "2026-03-26T12:00:00Z", service = "ping-service"
)
private val fakeEnhancedResponse = EnhancedPingResponse(
status = "OK", timestamp = "2026-03-26T12:00:00Z", service = "ping-service",
circuitBreakerState = "CLOSED", responseTime = 42L
)
private val fakeHealthResponse = HealthResponse(
status = "UP", timestamp = "2026-03-26T12:00:00Z", service = "ping-service", healthy = true
)
private object FakePingApi : PingApi {
override suspend fun simplePing() = fakePingResponse
override suspend fun enhancedPing(simulate: Boolean) = fakeEnhancedResponse
override suspend fun healthCheck() = fakeHealthResponse
override suspend fun publicPing() = fakePingResponse
override suspend fun securePing() = fakePingResponse
override suspend fun syncPings(since: Long): List<PingEvent> = emptyList()
}
private object FakePingSyncService : PingSyncService {
override suspend fun syncPings() { /* no-op */
}
}
// Subclass um uiState für Preview direkt setzen zu können
private class PreviewPingViewModel(state: PingUiState) :
PingViewModel(FakePingApi, FakePingSyncService) {
init {
uiState = state
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Previews
// ─────────────────────────────────────────────────────────────────────────────
@ComponentPreview
@Composable
fun PreviewPingScreen_Empty() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(PingUiState()),
onBack = {}
)
}
}
@ComponentPreview
@Composable
fun PreviewPingScreen_WithData() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(
PingUiState(
simplePingResponse = fakePingResponse,
healthResponse = fakeHealthResponse,
logs = listOf(
LogEntry("12:00:01", "SimplePing", "Success: OK from ping-service"),
LogEntry("12:00:00", "HealthCheck", "Status: UP, Healthy: true"),
)
)
),
onBack = {}
)
}
}
@ComponentPreview
@Composable
fun PreviewPingScreen_Loading() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(PingUiState(isLoading = true, isSyncing = true)),
onBack = {}
)
}
}
@ComponentPreview
@Composable
fun PreviewPingScreen_Error() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(
PingUiState(errorMessage = "Connection refused: Backend nicht erreichbar")
),
onBack = {}
)
}
}