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:
|
on:
|
||||||
workflow_dispatch:
|
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:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
@ -24,7 +43,7 @@ jobs:
|
||||||
- name: Build debug APK
|
- name: Build debug APK
|
||||||
run: ./gradlew assembleDebug
|
run: ./gradlew assembleDebug
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests (app + shared + server)
|
||||||
run: ./gradlew test
|
run: ./gradlew test
|
||||||
|
|
||||||
- name: Upload debug APK
|
- name: Upload debug APK
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.mindrot.jbcrypt.BCrypt
|
import org.mindrot.jbcrypt.BCrypt
|
||||||
|
|
||||||
|
|
@ -140,4 +141,35 @@ class AuthenticationTest {
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
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")
|
.withClaim("type", "refresh")
|
||||||
.withExpiresAt(Date(System.currentTimeMillis() + 2_592_000_000))
|
.withExpiresAt(Date(System.currentTimeMillis() + 2_592_000_000))
|
||||||
.sign(Algorithm.HMAC256(TEST_JWT_SECRET))
|
.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