feat(chat): UTF-8-Unterstützung für Umlaute und Emoji-Eingabe

Closes #66

Server (Serialization.kt):
- ContentNegotiation explizit mit charset=UTF-8 konfiguriert, damit
  Response-Header immer 'application/json; charset=utf-8' enthält

App (ChatScreen.kt):
- Emoji-Button (Face-Icon) zur MessageInputBar hinzugefügt, der bei Klick
  den Fokus auf das Eingabefeld setzt und die Soft-Keyboard öffnet (System-
  IME mit Emoji-Panel-Zugang)
- FocusRequester + LocalSoftwareKeyboardController integriert

Tests:
- Utf8MessagingTest (Server): 6 Tests für Umlaute, Emojis, Multi-Codepoint-
  Emojis, gemischte Nachrichten, Konversationsabruf, charset-Header
- ChatViewModelTest (App): 4 neue Tests für Umlaut-, Emoji- und gemischte
  Nachrichten
- run-integration-tests.ps1: Szenario 6a mit 5 Testfällen (Umlaute, Emojis,
  gemischt, Konversation, WebSocket-Delivery mit UTF-8)
This commit is contained in:
Jens Reinemann 2026-05-17 01:58:27 +02:00
parent 95e262d009
commit 4b1a5818f2
5 changed files with 396 additions and 6 deletions

View file

@ -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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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)
)
}
}

View file

@ -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<JsonArray>(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<MessageDto>(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<MessageDto>(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<MessageDto>(response.bodyAsText())
assertEquals(bodyText, msg.body)
}
}