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:
Jens Reinemann 2026-05-17 04:02:34 +02:00
parent eb9ab6aa54
commit 61ef56425d
5 changed files with 555 additions and 1 deletions

View file

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

View file

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

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

View file

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

View 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"))
}
}
}