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:
parent
95e262d009
commit
4b1a5818f2
5 changed files with 396 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue