diff --git a/app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatScreen.kt index 7e813e0..34d52e7 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/messaging/ChatScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.outlined.Face import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -32,6 +33,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -153,16 +157,32 @@ private fun MessageInputBar( onSend: () -> Unit, isSending: Boolean ) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + Row( modifier = Modifier .fillMaxWidth() .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { + IconButton( + onClick = { + focusRequester.requestFocus() + keyboardController?.show() + } + ) { + Icon( + imageVector = Icons.Outlined.Face, + contentDescription = "Emoji-Tastatur" + ) + } OutlinedTextField( value = text, onValueChange = onTextChange, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), placeholder = { Text("Nachricht...") }, maxLines = 4, enabled = !isSending diff --git a/app/src/test/java/de/krisenvorrat/app/ui/messaging/ChatViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/messaging/ChatViewModelTest.kt index 2c4607e..2fa0617 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/messaging/ChatViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/messaging/ChatViewModelTest.kt @@ -101,4 +101,72 @@ class ChatViewModelTest { // Then assertFalse(fakeRepo.sendMessageCalled) } + + @Test + fun test_sendMessage_withUmlauts_sendsCorrectBody() = runTest(testDispatcher) { + // Given + val fakeRepo = FakeChatMessageRepository() + val viewModel = createViewModel(repo = fakeRepo) + advanceUntilIdle() + val umlautText = "Schöne Grüße aus München – äöüÄÖÜß" + viewModel.onInputChanged(umlautText) + + // When + viewModel.sendMessage() + advanceUntilIdle() + + // Then + assertTrue(fakeRepo.sendMessageCalled) + assertEquals(umlautText, fakeRepo.lastSentBody) + } + + @Test + fun test_sendMessage_withEmojis_sendsCorrectBody() = runTest(testDispatcher) { + // Given + val fakeRepo = FakeChatMessageRepository() + val viewModel = createViewModel(repo = fakeRepo) + advanceUntilIdle() + val emojiText = "Guten Morgen! 😀🎉❤️👍" + viewModel.onInputChanged(emojiText) + + // When + viewModel.sendMessage() + advanceUntilIdle() + + // Then + assertTrue(fakeRepo.sendMessageCalled) + assertEquals(emojiText, fakeRepo.lastSentBody) + } + + @Test + fun test_sendMessage_withMixedUmlautsAndEmojis_sendsCorrectBody() = runTest(testDispatcher) { + // Given + val fakeRepo = FakeChatMessageRepository() + val viewModel = createViewModel(repo = fakeRepo) + advanceUntilIdle() + val mixedText = "Schöner Tag 😀! Grüße & Küsse 💋" + viewModel.onInputChanged(mixedText) + + // When + viewModel.sendMessage() + advanceUntilIdle() + + // Then + assertTrue(fakeRepo.sendMessageCalled) + assertEquals(mixedText, fakeRepo.lastSentBody) + } + + @Test + fun test_onInputChanged_withEmojis_updatesUiState() = runTest(testDispatcher) { + // Given + val viewModel = createViewModel() + advanceUntilIdle() + + // When + val emojiText = "👨‍👩‍👧‍👦 Familie 🇩🇪" + viewModel.onInputChanged(emojiText) + + // Then + assertEquals(emojiText, viewModel.uiState.value.inputText) + } } diff --git a/run-integration-tests.ps1 b/run-integration-tests.ps1 index 82c8579..ee1143d 100644 --- a/run-integration-tests.ps1 +++ b/run-integration-tests.ps1 @@ -398,6 +398,109 @@ if ($aliceTokens -and $bobTokens) { # --------------------------------------------------------------------------- Section "Szenario 6: Bob-Generaltest" +# --------------------------------------------------------------------------- +# Szenario 6a: UTF-8-Unterstuetzung (Umlaute und Emojis im Chat) +# --------------------------------------------------------------------------- +Section "Szenario 6a: UTF-8 & Emoji im Chat" + +if ($aliceTokens -and $bobTokens) { + $bobId = $bobTokens.userId + $aliceId = $aliceTokens.userId + $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + + # ---- Testfall 1: Nachricht mit Umlauten senden und empfangen ---- + try { + $umlautBody = "Schöne Grüße aus München – äöüÄÖÜß" + $umlautMsg = Invoke-Api -Method POST -Path "/api/messages" -Token $aliceTokens.accessToken -Body @{ + receiverId = $bobId + body = $umlautBody + sentAt = $now + 1000 + } + if ($umlautMsg.body -eq $umlautBody) { + Pass "UTF-8: Umlaut-Nachricht korrekt gespeichert und zurueckgegeben (äöüÄÖÜß)" + } + else { + Fail "UTF-8: Umlaut-Nachricht veraendert: erwartet='$umlautBody', erhalten='$($umlautMsg.body)'" + } + } + catch { Fail "UTF-8: Umlaut-Nachricht senden - $_" } + + # ---- Testfall 2: Nachricht mit Emojis senden und empfangen ---- + try { + $emojiBody = "Guten Morgen! 😀🎉❤️👍" + $emojiMsg = Invoke-Api -Method POST -Path "/api/messages" -Token $aliceTokens.accessToken -Body @{ + receiverId = $bobId + body = $emojiBody + sentAt = $now + 2000 + } + if ($emojiMsg.body -eq $emojiBody) { + Pass "UTF-8: Emoji-Nachricht korrekt gespeichert und zurueckgegeben (😀🎉❤️👍)" + } + else { + Fail "UTF-8: Emoji-Nachricht veraendert: erwartet='$emojiBody', erhalten='$($emojiMsg.body)'" + } + } + catch { Fail "UTF-8: Emoji-Nachricht senden - $_" } + + # ---- Testfall 3: Gemischte Nachricht (Umlaut + Emoji + ASCII) ---- + try { + $mixedBody = "Schöner Tag 😀! Grüße & Küsse 💋" + $mixedMsg = Invoke-Api -Method POST -Path "/api/messages" -Token $aliceTokens.accessToken -Body @{ + receiverId = $bobId + body = $mixedBody + sentAt = $now + 3000 + } + if ($mixedMsg.body -eq $mixedBody) { + Pass "UTF-8: Gemischte Nachricht (Umlaut+Emoji+ASCII) korrekt" + } + else { + Fail "UTF-8: Gemischte Nachricht veraendert: erwartet='$mixedBody', erhalten='$($mixedMsg.body)'" + } + } + catch { Fail "UTF-8: Gemischte Nachricht senden - $_" } + + # ---- Testfall 4: Konversation mit Umlauten/Emojis abrufen ---- + try { + $conv = Invoke-Api -Method GET -Path "/api/messages/$bobId" -Token $aliceTokens.accessToken + $umlautFound = $conv | Where-Object { $_.body -like "*äöüÄÖÜß*" } + $emojiFound = $conv | Where-Object { $_.body -like "*😀*" } + if ($umlautFound -and $emojiFound) { + Pass "UTF-8: Konversationsverlauf enthaelt Umlaute und Emojis korrekt" + } + else { + Fail "UTF-8: Konversationsverlauf: umlaut=$([bool]$umlautFound), emoji=$([bool]$emojiFound)" + } + } + catch { Fail "UTF-8: Konversation abrufen - $_" } + + # ---- Testfall 5: WebSocket-Delivery mit UTF-8 ---- + try { + $bobWsUtf8 = Open-WebSocket -token $bobTokens.accessToken + Start-Sleep -Milliseconds 500 + + $wsBody = "Hallo Böb! 🚀 Schöne Grüße" + Invoke-Api -Method POST -Path "/api/messages" -Token $aliceTokens.accessToken -Body @{ + receiverId = $bobId + body = $wsBody + sentAt = $now + 4000 + } | Out-Null + + Start-Sleep -Milliseconds 500 + $wsReceived = Receive-WsMessages -ws $bobWsUtf8 -waitSeconds 4 + Close-WebSocket -ws $bobWsUtf8 + + $wsMsg = $wsReceived | Where-Object { $_.type -eq "new_message" -and $_.body -eq $wsBody } + if ($wsMsg) { + Pass "UTF-8: WebSocket-Delivery mit Umlauten und Emojis korrekt" + } + else { + $bodies = ($wsReceived | Where-Object { $_.type -eq "new_message" } | Select-Object -ExpandProperty body) -join "; " + Fail "UTF-8: WebSocket-Body veraendert. Bodies: $bodies" + } + } + catch { Fail "UTF-8: WebSocket UTF-8 Delivery - $_" } +} + if ($bobTokens) { # Artikel-IDs fuer das Haupt-Szenario diff --git a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Serialization.kt b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Serialization.kt index e839293..13a200c 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Serialization.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Serialization.kt @@ -1,5 +1,6 @@ package de.krisenvorrat.server.plugins +import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.plugins.contentnegotiation.* @@ -7,10 +8,13 @@ import kotlinx.serialization.json.Json internal fun Application.configureSerialization() { install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = false - ignoreUnknownKeys = true - }) + json( + json = Json { + prettyPrint = true + isLenient = false + ignoreUnknownKeys = true + }, + contentType = ContentType.Application.Json.withCharset(Charsets.UTF_8) + ) } } diff --git a/server/src/test/kotlin/de/krisenvorrat/server/Utf8MessagingTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/Utf8MessagingTest.kt new file mode 100644 index 0000000..67f8ba0 --- /dev/null +++ b/server/src/test/kotlin/de/krisenvorrat/server/Utf8MessagingTest.kt @@ -0,0 +1,195 @@ +package de.krisenvorrat.server + +import de.krisenvorrat.server.db.DatabaseFactory +import de.krisenvorrat.shared.model.MessageDto +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.config.* +import io.ktor.server.testing.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests für UTF-8-Unterstützung im Chat (Umlaute, Sonderzeichen, Emojis). + */ +class Utf8MessagingTest { + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val adminToken = createTestAccessToken( + userId = TEST_ADMIN_ID, + username = TEST_ADMIN_USERNAME, + isAdmin = true + ) + + private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { + environment { + config = MapApplicationConfig(*testMapConfig().toTypedArray()) + } + application { + DatabaseFactory.init( + jdbcUrl = "jdbc:h2:mem:utf8_test_${System.nanoTime()};DB_CLOSE_DELAY=-1", + driver = "org.h2.Driver", + adminPassword = "test-admin-pw" + ) + configurePlugins() + } + block() + } + + private suspend fun ApplicationTestBuilder.createUser(username: String, password: String): String { + client.post("/api/admin/users") { + bearerAuth(adminToken) + contentType(ContentType.Application.Json) + setBody("""{"username":"$username","password":"$password"}""") + } + // Get user ID by listing all users + val listResponse = client.get("/api/admin/users") { + bearerAuth(adminToken) + } + val users = json.decodeFromString(listResponse.bodyAsText()) + return users.first { it.jsonObject["username"]?.jsonPrimitive?.content == username } + .jsonObject["id"]!!.jsonPrimitive.content + } + + @Test + fun test_sendMessage_withUmlauts_preservesCharacters() = testApp { + // Given + val bobId = createUser("bob", "bob123") + val aliceId = createUser("alice", "alice123") + val aliceToken = createTestAccessToken(userId = aliceId, username = "alice") + val bodyText = "Schöne Grüße aus München – äöüÄÖÜß" + + // When + val response = client.post("/api/messages") { + bearerAuth(aliceToken) + contentType(ContentType.Application.Json) + setBody("""{"receiverId":"$bobId","body":"$bodyText","sentAt":1700000000000}""") + } + + // Then + assertEquals(HttpStatusCode.Created, response.status) + val responseBody = response.bodyAsText() + assertTrue("Response should contain umlauts", responseBody.contains("Schöne Grüße aus München")) + assertTrue("Response should contain äöüÄÖÜß", responseBody.contains("äöüÄÖÜß")) + } + + @Test + fun test_sendMessage_withEmojis_preservesCharacters() = testApp { + // Given + val bobId = createUser("bob", "bob123") + val aliceId = createUser("alice", "alice123") + val aliceToken = createTestAccessToken(userId = aliceId, username = "alice") + val bodyText = "Guten Morgen! \uD83D\uDE00\uD83C\uDF89❤\uFE0F\uD83D\uDC4D" + + // When + val response = client.post("/api/messages") { + bearerAuth(aliceToken) + contentType(ContentType.Application.Json) + setBody("""{"receiverId":"$bobId","body":"$bodyText","sentAt":1700000001000}""") + } + + // Then + assertEquals(HttpStatusCode.Created, response.status) + val msg = json.decodeFromString(response.bodyAsText()) + assertEquals(bodyText, msg.body) + } + + @Test + fun test_sendMessage_withMixedContent_preservesAll() = testApp { + // Given + val bobId = createUser("bob", "bob123") + val aliceId = createUser("alice", "alice123") + val aliceToken = createTestAccessToken(userId = aliceId, username = "alice") + val bodyText = "Schöner Tag \uD83D\uDE00! Grüße & Küsse \uD83D\uDC8B" + + // When + val response = client.post("/api/messages") { + bearerAuth(aliceToken) + contentType(ContentType.Application.Json) + setBody("""{"receiverId":"$bobId","body":"$bodyText","sentAt":1700000002000}""") + } + + // Then + assertEquals(HttpStatusCode.Created, response.status) + val msg = json.decodeFromString(response.bodyAsText()) + assertEquals(bodyText, msg.body) + } + + @Test + fun test_getConversation_withUmlauts_returnsCorrectCharacters() = testApp { + // Given + val bobId = createUser("bob", "bob123") + val aliceId = createUser("alice", "alice123") + val aliceToken = createTestAccessToken(userId = aliceId, username = "alice") + val bodyText = "Tschüss und auf Wiedersehen! \uD83C\uDDE9\uD83C\uDDEA" + client.post("/api/messages") { + bearerAuth(aliceToken) + contentType(ContentType.Application.Json) + setBody("""{"receiverId":"$bobId","body":"$bodyText","sentAt":1700000003000}""") + } + + // When + val response = client.get("/api/messages/$bobId") { + bearerAuth(aliceToken) + } + + // Then + assertEquals(HttpStatusCode.OK, response.status) + val responseBody = response.bodyAsText() + assertTrue("Conversation should contain umlauts", responseBody.contains("Tschüss")) + assertTrue("Conversation should contain flag emoji", responseBody.contains("\uD83C\uDDE9\uD83C\uDDEA")) + } + + @Test + fun test_messageResponse_hasUtf8ContentType() = testApp { + // Given + val bobId = createUser("bob", "bob123") + val aliceId = createUser("alice", "alice123") + val aliceToken = createTestAccessToken(userId = aliceId, username = "alice") + + // When + val response = client.post("/api/messages") { + bearerAuth(aliceToken) + contentType(ContentType.Application.Json) + setBody("""{"receiverId":"$bobId","body":"Test","sentAt":1700000004000}""") + } + + // Then + assertEquals(HttpStatusCode.Created, response.status) + assertEquals( + ContentType.Application.Json.withCharset(Charsets.UTF_8), + response.contentType() + ) + } + + @Test + fun test_sendMessage_withMultiCodepointEmoji_preservesIntact() = testApp { + // Given – Multi-Codepoint emoji: family, skin tone modifiers + val bobId = createUser("bob", "bob123") + val aliceId = createUser("alice", "alice123") + val aliceToken = createTestAccessToken(userId = aliceId, username = "alice") + val bodyText = "Familie: \uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66 und \uD83D\uDC4B\uD83C\uDFFD" + + // When + val response = client.post("/api/messages") { + bearerAuth(aliceToken) + contentType(ContentType.Application.Json) + setBody("""{"receiverId":"$bobId","body":"$bodyText","sentAt":1700000005000}""") + } + + // Then + assertEquals(HttpStatusCode.Created, response.status) + val msg = json.decodeFromString(response.bodyAsText()) + assertEquals(bodyText, msg.body) + } +}