From 61ef56425d2cecc9b85f456011115dc63fe9b2e5 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sun, 17 May 2026 04:02:34 +0200 Subject: [PATCH] =?UTF-8?q?test(server):=20Server-Integrationstests=20verv?= =?UTF-8?q?ollst=C3=A4ndigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/android-ci.yml | 21 +- .../krisenvorrat/server/AuthenticationTest.kt | 32 +++ .../de/krisenvorrat/server/MessageApiTest.kt | 246 ++++++++++++++++++ .../de/krisenvorrat/server/TestHelpers.kt | 21 ++ .../de/krisenvorrat/server/WebSocketTest.kt | 236 +++++++++++++++++ 5 files changed, 555 insertions(+), 1 deletion(-) create mode 100644 server/src/test/kotlin/de/krisenvorrat/server/MessageApiTest.kt create mode 100644 server/src/test/kotlin/de/krisenvorrat/server/WebSocketTest.kt diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 11fe417..1337e4b 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -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 diff --git a/server/src/test/kotlin/de/krisenvorrat/server/AuthenticationTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/AuthenticationTest.kt index 4c9ddd4..852dcff 100644 --- a/server/src/test/kotlin/de/krisenvorrat/server/AuthenticationTest.kt +++ b/server/src/test/kotlin/de/krisenvorrat/server/AuthenticationTest.kt @@ -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 + ) + } } diff --git a/server/src/test/kotlin/de/krisenvorrat/server/MessageApiTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/MessageApiTest.kt new file mode 100644 index 0000000..50a270c --- /dev/null +++ b/server/src/test/kotlin/de/krisenvorrat/server/MessageApiTest.kt @@ -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(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(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(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>(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>(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(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(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) + } +} diff --git a/server/src/test/kotlin/de/krisenvorrat/server/TestHelpers.kt b/server/src/test/kotlin/de/krisenvorrat/server/TestHelpers.kt index d1e9d47..97eb313 100644 --- a/server/src/test/kotlin/de/krisenvorrat/server/TestHelpers.kt +++ b/server/src/test/kotlin/de/krisenvorrat/server/TestHelpers.kt @@ -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)) diff --git a/server/src/test/kotlin/de/krisenvorrat/server/WebSocketTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/WebSocketTest.kt new file mode 100644 index 0000000..70fc7f2 --- /dev/null +++ b/server/src/test/kotlin/de/krisenvorrat/server/WebSocketTest.kt @@ -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(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(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(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(payload) + assertEquals("new_message", event["type"]?.jsonPrimitive?.content) + assertEquals("Offline message", event["body"]?.jsonPrimitive?.content) + + close(CloseReason(CloseReason.Codes.NORMAL, "Test done")) + } + } +}