test(server): Server-Integrationstests vervollständigen
Auth: Expired-Token-Tests (Access + Refresh), fehlende Felder Message-API: Send, Get Conversation, Blank Body, Receiver Not Found, Custom ID, Response-Format (9 Tests) WebSocket: Connect mit gültigem/ungültigem/fehlendem/abgelaufenem Token, inventoryUpdated-Event, new_message-Event, Disconnect, undelivered Messages bei Connect (8 Tests) CI-Pipeline: Auto-Trigger für push/PR auf app/shared/server-Pfade, Step-Label verdeutlicht (app + shared + server) Server-Tests: 130 gesamt, 0 Failures Closes #80
This commit is contained in:
parent
eb9ab6aa54
commit
61ef56425d
5 changed files with 555 additions and 1 deletions
21
.github/workflows/android-ci.yml
vendored
21
.github/workflows/android-ci.yml
vendored
|
|
@ -2,6 +2,25 @@ name: Android CI
|
|||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "shared/**"
|
||||
- "server/**"
|
||||
- "build.gradle.kts"
|
||||
- "settings.gradle.kts"
|
||||
- "gradle/**"
|
||||
- ".github/workflows/android-ci.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "app/**"
|
||||
- "shared/**"
|
||||
- "server/**"
|
||||
- "build.gradle.kts"
|
||||
- "settings.gradle.kts"
|
||||
- "gradle/**"
|
||||
- ".github/workflows/android-ci.yml"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -24,7 +43,7 @@ jobs:
|
|||
- name: Build debug APK
|
||||
run: ./gradlew assembleDebug
|
||||
|
||||
- name: Run unit tests
|
||||
- name: Run unit tests (app + shared + server)
|
||||
run: ./gradlew test
|
||||
|
||||
- name: Upload debug APK
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
|
|||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
|
|
@ -140,4 +141,35 @@ class AuthenticationTest {
|
|||
}
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_inventoryGet_expiredToken_returns401() = testApp {
|
||||
val expiredToken = createExpiredAccessToken()
|
||||
val response = client.get("/api/inventory") { bearerAuth(expiredToken) }
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_refresh_expiredRefreshToken_returns401() = testApp {
|
||||
val expiredRefresh = createExpiredRefreshToken()
|
||||
val refreshBody = json.encodeToString(RefreshRequest(expiredRefresh))
|
||||
val response = client.post("/api/auth/refresh") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(refreshBody)
|
||||
}
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_login_missingFields_returns400or401() = testApp {
|
||||
val response = client.post("/api/auth/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("{}")
|
||||
}
|
||||
// Server should reject missing fields
|
||||
assertTrue(
|
||||
"Expected 400 or 401, got ${response.status}",
|
||||
response.status == HttpStatusCode.BadRequest || response.status == HttpStatusCode.Unauthorized
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
246
server/src/test/kotlin/de/krisenvorrat/server/MessageApiTest.kt
Normal file
246
server/src/test/kotlin/de/krisenvorrat/server/MessageApiTest.kt
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
package de.krisenvorrat.server
|
||||
|
||||
import de.krisenvorrat.server.db.DatabaseFactory
|
||||
import de.krisenvorrat.server.model.ErrorResponse
|
||||
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.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MessageApiTest {
|
||||
|
||||
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:msg_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 = "pass123"): String {
|
||||
client.post("/api/admin/users") {
|
||||
bearerAuth(adminToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"username":"$username","password":"$password"}""")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ── Send Message ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun test_sendMessage_validRequest_returns201() = testApp {
|
||||
// Given
|
||||
val bobId = createUser("bob")
|
||||
val aliceId = createUser("alice")
|
||||
val aliceToken = createTestAccessToken(userId = aliceId, username = "alice")
|
||||
|
||||
// When
|
||||
val response = client.post("/api/messages") {
|
||||
bearerAuth(aliceToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"receiverId":"$bobId","body":"Hallo Bob!","sentAt":1700000000000}""")
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.Created, response.status)
|
||||
val msg = json.decodeFromString<MessageDto>(response.bodyAsText())
|
||||
assertEquals("Hallo Bob!", msg.body)
|
||||
assertEquals(aliceId, msg.senderId)
|
||||
assertEquals(bobId, msg.receiverId)
|
||||
assertNotNull(msg.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_sendMessage_blankBody_returns400() = testApp {
|
||||
// Given
|
||||
val bobId = createUser("bob")
|
||||
val aliceId = createUser("alice")
|
||||
val aliceToken = createTestAccessToken(userId = aliceId, username = "alice")
|
||||
|
||||
// When
|
||||
val response = client.post("/api/messages") {
|
||||
bearerAuth(aliceToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"receiverId":"$bobId","body":" ","sentAt":1700000000000}""")
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_sendMessage_receiverNotFound_returns404() = testApp {
|
||||
// Given
|
||||
val aliceId = createUser("alice")
|
||||
val aliceToken = createTestAccessToken(userId = aliceId, username = "alice")
|
||||
|
||||
// When
|
||||
val response = client.post("/api/messages") {
|
||||
bearerAuth(aliceToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"receiverId":"non-existent-id","body":"Hello","sentAt":1700000000000}""")
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
|
||||
assertEquals(404, error.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_sendMessage_noAuth_returns401() = testApp {
|
||||
// When
|
||||
val response = client.post("/api/messages") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"receiverId":"some-id","body":"Hello","sentAt":1700000000000}""")
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
// ── Get Conversation ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun test_getConversation_returnsMessages() = testApp {
|
||||
// Given
|
||||
val bobId = createUser("bob")
|
||||
val aliceId = createUser("alice")
|
||||
val aliceToken = createTestAccessToken(userId = aliceId, username = "alice")
|
||||
val bobToken = createTestAccessToken(userId = bobId, username = "bob")
|
||||
|
||||
// Send a message from Alice to Bob
|
||||
client.post("/api/messages") {
|
||||
bearerAuth(aliceToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"receiverId":"$bobId","body":"Hi Bob!","sentAt":1700000001000}""")
|
||||
}
|
||||
|
||||
// Send a reply from Bob to Alice
|
||||
client.post("/api/messages") {
|
||||
bearerAuth(bobToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"receiverId":"$aliceId","body":"Hi Alice!","sentAt":1700000002000}""")
|
||||
}
|
||||
|
||||
// When – Alice gets conversation with Bob
|
||||
val response = client.get("/api/messages/$bobId") {
|
||||
bearerAuth(aliceToken)
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val messages = json.decodeFromString<List<MessageDto>>(response.bodyAsText())
|
||||
assertEquals(2, messages.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getConversation_emptyConversation_returnsEmptyList() = testApp {
|
||||
// Given
|
||||
val bobId = createUser("bob")
|
||||
val aliceId = createUser("alice")
|
||||
val aliceToken = createTestAccessToken(userId = aliceId, username = "alice")
|
||||
|
||||
// When
|
||||
val response = client.get("/api/messages/$bobId") {
|
||||
bearerAuth(aliceToken)
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val messages = json.decodeFromString<List<MessageDto>>(response.bodyAsText())
|
||||
assertTrue(messages.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getConversation_noAuth_returns401() = testApp {
|
||||
// When
|
||||
val response = client.get("/api/messages/some-user-id")
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
// ── Message with custom ID ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun test_sendMessage_withCustomId_usesProvidedId() = testApp {
|
||||
// Given
|
||||
val bobId = createUser("bob")
|
||||
val aliceId = createUser("alice")
|
||||
val aliceToken = createTestAccessToken(userId = aliceId, username = "alice")
|
||||
val customId = "custom-msg-id-001"
|
||||
|
||||
// When
|
||||
val response = client.post("/api/messages") {
|
||||
bearerAuth(aliceToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"id":"$customId","receiverId":"$bobId","body":"Test","sentAt":1700000000000}""")
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.Created, response.status)
|
||||
val msg = json.decodeFromString<MessageDto>(response.bodyAsText())
|
||||
assertEquals(customId, msg.id)
|
||||
}
|
||||
|
||||
// ── Response format ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun test_sendMessage_responseContainsAllFields() = testApp {
|
||||
// Given
|
||||
val bobId = createUser("bob")
|
||||
val aliceId = createUser("alice")
|
||||
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 message","sentAt":1700000000000}""")
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.Created, response.status)
|
||||
val msg = json.decodeFromString<MessageDto>(response.bodyAsText())
|
||||
assertFalse(msg.id.isBlank())
|
||||
assertEquals(aliceId, msg.senderId)
|
||||
assertEquals("alice", msg.senderUsername)
|
||||
assertEquals(bobId, msg.receiverId)
|
||||
assertEquals("Test message", msg.body)
|
||||
assertEquals(1700000000000L, msg.sentAt)
|
||||
}
|
||||
}
|
||||
|
|
@ -41,3 +41,24 @@ internal fun createTestRefreshToken(userId: String = TEST_USER_ID): String = JWT
|
|||
.withClaim("type", "refresh")
|
||||
.withExpiresAt(Date(System.currentTimeMillis() + 2_592_000_000))
|
||||
.sign(Algorithm.HMAC256(TEST_JWT_SECRET))
|
||||
|
||||
internal fun createExpiredAccessToken(
|
||||
userId: String = TEST_USER_ID,
|
||||
username: String = TEST_USERNAME
|
||||
): String = JWT.create()
|
||||
.withAudience(TEST_JWT_AUDIENCE)
|
||||
.withIssuer(TEST_JWT_ISSUER)
|
||||
.withClaim("userId", userId)
|
||||
.withClaim("username", username)
|
||||
.withClaim("isAdmin", false)
|
||||
.withClaim("type", "access")
|
||||
.withExpiresAt(Date(System.currentTimeMillis() - 1_000))
|
||||
.sign(Algorithm.HMAC256(TEST_JWT_SECRET))
|
||||
|
||||
internal fun createExpiredRefreshToken(userId: String = TEST_USER_ID): String = JWT.create()
|
||||
.withAudience(TEST_JWT_AUDIENCE)
|
||||
.withIssuer(TEST_JWT_ISSUER)
|
||||
.withClaim("userId", userId)
|
||||
.withClaim("type", "refresh")
|
||||
.withExpiresAt(Date(System.currentTimeMillis() - 1_000))
|
||||
.sign(Algorithm.HMAC256(TEST_JWT_SECRET))
|
||||
|
|
|
|||
236
server/src/test/kotlin/de/krisenvorrat/server/WebSocketTest.kt
Normal file
236
server/src/test/kotlin/de/krisenvorrat/server/WebSocketTest.kt
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package de.krisenvorrat.server
|
||||
|
||||
import de.krisenvorrat.server.db.DatabaseFactory
|
||||
import io.ktor.client.plugins.websocket.WebSockets as ClientWebSockets
|
||||
import io.ktor.client.plugins.websocket.*
|
||||
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 io.ktor.websocket.*
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WebSocketTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = 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:ws_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 = "pass123"): String {
|
||||
client.post("/api/admin/users") {
|
||||
bearerAuth(adminToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"username":"$username","password":"$password"}""")
|
||||
}
|
||||
val listResponse = client.get("/api/admin/users") { bearerAuth(adminToken) }
|
||||
val users = json.decodeFromString<kotlinx.serialization.json.JsonArray>(listResponse.bodyAsText())
|
||||
return users.first {
|
||||
it as JsonObject
|
||||
it["username"]?.jsonPrimitive?.content == username
|
||||
}.let { (it as JsonObject)["id"]!!.jsonPrimitive.content }
|
||||
}
|
||||
|
||||
// ── Connect ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun test_webSocket_validToken_connectsSuccessfully() = testApp {
|
||||
val token = createTestAccessToken()
|
||||
val wsClient = createClient {
|
||||
install(ClientWebSockets)
|
||||
}
|
||||
|
||||
wsClient.webSocket("/ws/sync?token=$token") {
|
||||
// Connection established – send close
|
||||
close(CloseReason(CloseReason.Codes.NORMAL, "Test done"))
|
||||
}
|
||||
// If we reach here without exception, the connection was successful
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_webSocket_missingToken_closesWithViolatedPolicy() = testApp {
|
||||
val wsClient = createClient {
|
||||
install(ClientWebSockets)
|
||||
}
|
||||
|
||||
wsClient.webSocket("/ws/sync") {
|
||||
// Server should close the connection
|
||||
val reason = closeReason.await()
|
||||
assertNotNull(reason)
|
||||
assertEquals(CloseReason.Codes.VIOLATED_POLICY.code, reason!!.code)
|
||||
assertTrue(reason.message.contains("Missing token"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_webSocket_invalidToken_closesWithViolatedPolicy() = testApp {
|
||||
val wsClient = createClient {
|
||||
install(ClientWebSockets)
|
||||
}
|
||||
|
||||
wsClient.webSocket("/ws/sync?token=invalid-jwt-token") {
|
||||
val reason = closeReason.await()
|
||||
assertNotNull(reason)
|
||||
assertEquals(CloseReason.Codes.VIOLATED_POLICY.code, reason!!.code)
|
||||
assertTrue(reason.message.contains("Invalid or expired token"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_webSocket_expiredToken_closesWithViolatedPolicy() = testApp {
|
||||
val expiredToken = createExpiredAccessToken()
|
||||
val wsClient = createClient {
|
||||
install(ClientWebSockets)
|
||||
}
|
||||
|
||||
wsClient.webSocket("/ws/sync?token=$expiredToken") {
|
||||
val reason = closeReason.await()
|
||||
assertNotNull(reason)
|
||||
assertEquals(CloseReason.Codes.VIOLATED_POLICY.code, reason!!.code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun test_webSocket_inventoryUpdate_receivesEvent() = testApp {
|
||||
val token = createTestAccessToken()
|
||||
val wsClient = createClient {
|
||||
install(ClientWebSockets)
|
||||
}
|
||||
|
||||
wsClient.webSocket("/ws/sync?token=$token") {
|
||||
// Trigger an inventory update from outside via PUT
|
||||
val httpClient = this@testApp.client
|
||||
val inventory = """{"categories":[],"locations":[],"items":[],"settings":[]}"""
|
||||
httpClient.put("/api/inventory") {
|
||||
bearerAuth(token)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(inventory)
|
||||
}
|
||||
|
||||
// Expect a fullSyncRequired event
|
||||
val frame = withTimeout(5_000) { incoming.receive() }
|
||||
assertTrue(frame is Frame.Text)
|
||||
val payload = (frame as Frame.Text).readText()
|
||||
val event = json.decodeFromString<JsonObject>(payload)
|
||||
assertEquals("fullSyncRequired", event["type"]?.jsonPrimitive?.content)
|
||||
assertNotNull(event["timestamp"])
|
||||
|
||||
close(CloseReason(CloseReason.Codes.NORMAL, "Test done"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_webSocket_newMessage_receivesEvent() = testApp {
|
||||
// Given – create bob and alice, connect bob's WebSocket
|
||||
val bobId = createUser("bob")
|
||||
val aliceId = createUser("alice")
|
||||
val bobToken = createTestAccessToken(userId = bobId, username = "bob")
|
||||
val aliceToken = createTestAccessToken(userId = aliceId, username = "alice")
|
||||
|
||||
val wsClient = createClient {
|
||||
install(ClientWebSockets)
|
||||
}
|
||||
|
||||
// Bob connects WebSocket
|
||||
wsClient.webSocket("/ws/sync?token=$bobToken") {
|
||||
// Alice sends a message to Bob via HTTP
|
||||
val httpClient = this@testApp.client
|
||||
httpClient.post("/api/messages") {
|
||||
bearerAuth(aliceToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"receiverId":"$bobId","body":"Hello Bob!","sentAt":1700000000000}""")
|
||||
}
|
||||
|
||||
// Bob should receive new_message event
|
||||
val frame = withTimeout(5_000) { incoming.receive() }
|
||||
assertTrue(frame is Frame.Text)
|
||||
val payload = (frame as Frame.Text).readText()
|
||||
val event = json.decodeFromString<JsonObject>(payload)
|
||||
assertEquals("new_message", event["type"]?.jsonPrimitive?.content)
|
||||
assertEquals("Hello Bob!", event["body"]?.jsonPrimitive?.content)
|
||||
assertEquals(aliceId, event["senderId"]?.jsonPrimitive?.content)
|
||||
|
||||
close(CloseReason(CloseReason.Codes.NORMAL, "Test done"))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Disconnect ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun test_webSocket_clientDisconnect_serverHandlesGracefully() = testApp {
|
||||
val token = createTestAccessToken()
|
||||
val wsClient = createClient {
|
||||
install(ClientWebSockets)
|
||||
}
|
||||
|
||||
// Connect and immediately disconnect
|
||||
wsClient.webSocket("/ws/sync?token=$token") {
|
||||
close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting"))
|
||||
}
|
||||
|
||||
// Verify the server is still healthy after disconnect
|
||||
val healthResponse = client.get("/api/health")
|
||||
assertEquals(HttpStatusCode.OK, healthResponse.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_webSocket_undeliveredMessages_sentOnConnect() = testApp {
|
||||
// Given – create users and send a message while bob is offline
|
||||
val bobId = createUser("bob")
|
||||
val aliceId = createUser("alice")
|
||||
val aliceToken = createTestAccessToken(userId = aliceId, username = "alice")
|
||||
val bobToken = createTestAccessToken(userId = bobId, username = "bob")
|
||||
|
||||
// Alice sends a message while Bob is offline
|
||||
client.post("/api/messages") {
|
||||
bearerAuth(aliceToken)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"receiverId":"$bobId","body":"Offline message","sentAt":1700000000000}""")
|
||||
}
|
||||
|
||||
// When – Bob connects via WebSocket
|
||||
val wsClient = createClient {
|
||||
install(ClientWebSockets)
|
||||
}
|
||||
|
||||
wsClient.webSocket("/ws/sync?token=$bobToken") {
|
||||
// Then – Bob should receive the pending message on connect
|
||||
val frame = withTimeout(5_000) { incoming.receive() }
|
||||
assertTrue(frame is Frame.Text)
|
||||
val payload = (frame as Frame.Text).readText()
|
||||
val event = json.decodeFromString<JsonObject>(payload)
|
||||
assertEquals("new_message", event["type"]?.jsonPrimitive?.content)
|
||||
assertEquals("Offline message", event["body"]?.jsonPrimitive?.content)
|
||||
|
||||
close(CloseReason(CloseReason.Codes.NORMAL, "Test done"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue