diff --git a/services/notification-service/src/main/kotlin/com/otterworks/notification/routes/Routes.kt b/services/notification-service/src/main/kotlin/com/otterworks/notification/routes/Routes.kt index 2d265b602..947fc590f 100644 --- a/services/notification-service/src/main/kotlin/com/otterworks/notification/routes/Routes.kt +++ b/services/notification-service/src/main/kotlin/com/otterworks/notification/routes/Routes.kt @@ -6,6 +6,7 @@ import com.otterworks.notification.model.UnreadCountResponse import com.otterworks.notification.service.NotificationService import com.otterworks.notification.websocket.WebSocketManager import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall import io.ktor.server.application.Application import io.ktor.server.application.call import io.ktor.server.request.receive @@ -14,8 +15,10 @@ import io.ktor.server.response.respondText import io.ktor.server.routing.delete import io.ktor.server.routing.get import io.ktor.server.routing.put +import io.ktor.server.routing.Route import io.ktor.server.routing.route import io.ktor.server.routing.routing +import io.ktor.server.websocket.DefaultWebSocketServerSession import io.ktor.server.websocket.webSocket import io.ktor.websocket.CloseReason import io.ktor.websocket.Frame @@ -34,6 +37,23 @@ data class ErrorResponse(val error: String) @Serializable data class MarkAllReadResponse(val markedCount: Int) +private fun ApplicationCall.requestedUserId(): String? = + (request.headers["X-User-ID"] ?: request.queryParameters["user_id"])?.takeUnless { it.isBlank() } + +private suspend fun ApplicationCall.respondUserIdRequired() = + respond(HttpStatusCode.BadRequest, ErrorResponse("user_id is required (via X-User-ID header or query parameter)")) + +private suspend fun ApplicationCall.respondNotificationIdRequired() = + respond(HttpStatusCode.BadRequest, ErrorResponse("Notification ID is required")) + +private suspend fun ApplicationCall.respondNoContentOrNotFound(success: Boolean) { + if (success) { + respond(HttpStatusCode.NoContent) + } else { + respond(HttpStatusCode.NotFound, ErrorResponse("Notification not found")) + } +} + fun Application.configureRouting(prometheusRegistry: PrometheusMeterRegistry) { val notificationService by inject() val webSocketManager by inject() @@ -50,144 +70,151 @@ fun Application.configureRouting(prometheusRegistry: PrometheusMeterRegistry) { ) } - route("/api/v1/notifications") { - get { - val userId = call.request.headers["X-User-ID"] ?: call.request.queryParameters["user_id"] - if (userId.isNullOrBlank()) { - call.respond(HttpStatusCode.BadRequest, ErrorResponse("user_id is required (via X-User-ID header or query parameter)")) - return@get - } + notificationRoutes(notificationService) + preferenceRoutes(notificationService) + notificationWebSocket(webSocketManager) + } +} - val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1 - val pageSize = call.request.queryParameters["page_size"]?.toIntOrNull() ?: 20 +private fun Route.notificationRoutes(notificationService: NotificationService) { + route("/api/v1/notifications") { + get { + val userId = call.requestedUserId() + if (userId == null) { + call.respondUserIdRequired() + return@get + } + + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1 + val pageSize = call.request.queryParameters["page_size"]?.toIntOrNull() ?: 20 - val (notifications, total) = notificationService.getNotifications(userId, page, pageSize) + val (notifications, total) = notificationService.getNotifications(userId, page, pageSize) - call.respond( - PaginatedResponse( - data = notifications, - total = total, - page = page, - pageSize = pageSize, - hasMore = (page * pageSize) < total, - ) + call.respond( + PaginatedResponse( + data = notifications, + total = total, + page = page, + pageSize = pageSize, + hasMore = (page * pageSize) < total, ) + ) + } + + get("/unread-count") { + val userId = call.requestedUserId() + if (userId == null) { + call.respondUserIdRequired() + return@get } - get("/unread-count") { - val userId = call.request.headers["X-User-ID"] ?: call.request.queryParameters["user_id"] - if (userId.isNullOrBlank()) { - call.respond(HttpStatusCode.BadRequest, ErrorResponse("user_id is required (via X-User-ID header or query parameter)")) - return@get - } + val count = notificationService.getUnreadCount(userId) + call.respond(UnreadCountResponse(userId = userId, unreadCount = count)) + } - val count = notificationService.getUnreadCount(userId) - call.respond(UnreadCountResponse(userId = userId, unreadCount = count)) + get("/{id}") { + val id = call.parameters["id"] + if (id == null) { + call.respondNotificationIdRequired() + return@get } - get("/{id}") { - val id = call.parameters["id"] ?: return@get call.respond( - HttpStatusCode.BadRequest, - ErrorResponse("Notification ID is required"), - ) + val notification = notificationService.getNotificationById(id) + if (notification != null) { + call.respond(notification) + } else { + call.respond(HttpStatusCode.NotFound, ErrorResponse("Notification not found")) + } + } - val notification = notificationService.getNotificationById(id) - if (notification != null) { - call.respond(notification) - } else { - call.respond(HttpStatusCode.NotFound, ErrorResponse("Notification not found")) - } + put("/{id}/read") { + val id = call.parameters["id"] + if (id == null) { + call.respondNotificationIdRequired() + return@put } - put("/{id}/read") { - val id = call.parameters["id"] ?: return@put call.respond( - HttpStatusCode.BadRequest, - ErrorResponse("Notification ID is required"), - ) + val success = notificationService.markAsRead(id) + call.respondNoContentOrNotFound(success) + } - val success = notificationService.markAsRead(id) - if (success) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(HttpStatusCode.NotFound, ErrorResponse("Notification not found")) - } + put("/read-all") { + val userId = call.requestedUserId() + if (userId == null) { + call.respondUserIdRequired() + return@put } - put("/read-all") { - val userId = call.request.headers["X-User-ID"] ?: call.request.queryParameters["user_id"] - if (userId.isNullOrBlank()) { - call.respond(HttpStatusCode.BadRequest, ErrorResponse("user_id is required (via X-User-ID header or query parameter)")) - return@put - } + val count = notificationService.markAllAsRead(userId) + call.respond(MarkAllReadResponse(markedCount = count)) + } - val count = notificationService.markAllAsRead(userId) - call.respond(MarkAllReadResponse(markedCount = count)) + delete("/{id}") { + val id = call.parameters["id"] + if (id == null) { + call.respondNotificationIdRequired() + return@delete } - delete("/{id}") { - val id = call.parameters["id"] ?: return@delete call.respond( - HttpStatusCode.BadRequest, - ErrorResponse("Notification ID is required"), - ) + val success = notificationService.deleteNotification(id) + call.respondNoContentOrNotFound(success) + } + } +} - val success = notificationService.deleteNotification(id) - if (success) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(HttpStatusCode.NotFound, ErrorResponse("Notification not found")) - } +private fun Route.preferenceRoutes(notificationService: NotificationService) { + route("/api/v1/preferences") { + get { + val userId = call.requestedUserId() + if (userId == null) { + call.respondUserIdRequired() + return@get } - } - route("/api/v1/preferences") { - get { - val userId = call.request.headers["X-User-ID"] ?: call.request.queryParameters["user_id"] - if (userId.isNullOrBlank()) { - call.respond(HttpStatusCode.BadRequest, ErrorResponse("user_id is required (via X-User-ID header or query parameter)")) - return@get - } + val preferences = notificationService.getPreferences(userId) + call.respond(preferences) + } - val preferences = notificationService.getPreferences(userId) - call.respond(preferences) - } + put { + val request = call.receive() + notificationService.updatePreferences( + userId = request.userId, + eventType = request.eventType, + channels = request.channels, + ) + call.respond(HttpStatusCode.NoContent) + } + } +} - put { - val request = call.receive() - notificationService.updatePreferences( - userId = request.userId, - eventType = request.eventType, - channels = request.channels, - ) - call.respond(HttpStatusCode.NoContent) - } +private fun Route.notificationWebSocket(webSocketManager: WebSocketManager) { + webSocket("/ws/notifications/{userId}") { + val userId = call.parameters["userId"] + if (userId.isNullOrBlank()) { + close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "userId is required")) + return@webSocket } - webSocket("/ws/notifications/{userId}") { - val userId = call.parameters["userId"] - if (userId.isNullOrBlank()) { - close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "userId is required")) - return@webSocket - } + webSocketManager.addConnection(userId, this) + + try { + handleIncomingFrames() + } finally { + webSocketManager.removeConnection(userId, this) + } + } +} - webSocketManager.addConnection(userId, this) - - try { - for (frame in incoming) { - when (frame) { - is Frame.Text -> { - val text = frame.readText() - // Handle ping/pong or client messages if needed - if (text == "ping") { - send(Frame.Text("pong")) - } - } - is Frame.Close -> break - else -> { /* ignore other frame types */ } - } +private suspend fun DefaultWebSocketServerSession.handleIncomingFrames() { + for (frame in incoming) { + when (frame) { + is Frame.Text -> { + if (frame.readText() == "ping") { + send(Frame.Text("pong")) } - } finally { - webSocketManager.removeConnection(userId, this) } + is Frame.Close -> break + else -> { } } } } diff --git a/services/notification-service/src/test/kotlin/com/otterworks/notification/routes/RoutesTest.kt b/services/notification-service/src/test/kotlin/com/otterworks/notification/routes/RoutesTest.kt new file mode 100644 index 000000000..8da4e9e46 --- /dev/null +++ b/services/notification-service/src/test/kotlin/com/otterworks/notification/routes/RoutesTest.kt @@ -0,0 +1,111 @@ +package com.otterworks.notification.routes + +import com.otterworks.notification.service.NotificationService +import com.otterworks.notification.websocket.WebSocketManager +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.put +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.testing.ApplicationTestBuilder +import io.ktor.server.testing.testApplication +import io.ktor.server.websocket.WebSockets +import io.micrometer.prometheus.PrometheusConfig +import io.micrometer.prometheus.PrometheusMeterRegistry +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.koin.core.context.stopKoin as stopKoinContext +import org.koin.dsl.module +import org.koin.ktor.plugin.Koin +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class RoutesTest { + + @AfterTest + fun stopKoin() { + stopKoinContext() + } + + @Test + fun `GET notifications without user id returns bad request`() = testApplication { + installRoutes() + + val response = client.get("/api/v1/notifications") + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals( + """{"error":"user_id is required (via X-User-ID header or query parameter)"}""", + response.bodyAsText(), + ) + } + + @Test + fun `GET unread count with user id returns count and calls service`() = testApplication { + val service = mockk(relaxed = true) + coEvery { service.getUnreadCount("user-1") } returns 3 + installRoutes(service) + + val response = client.get("/api/v1/notifications/unread-count") { + header("X-User-ID", "user-1") + } + + assertEquals(HttpStatusCode.OK, response.status) + coVerify { service.getUnreadCount("user-1") } + } + + @Test + fun `PUT mark as read returns not found when service returns false`() = testApplication { + val service = mockk(relaxed = true) + coEvery { service.markAsRead("notification-1") } returns false + installRoutes(service) + + val response = client.put("/api/v1/notifications/notification-1/read") + + assertEquals(HttpStatusCode.NotFound, response.status) + } + + @Test + fun `PUT mark as read returns no content when service returns true`() = testApplication { + val service = mockk(relaxed = true) + coEvery { service.markAsRead("notification-1") } returns true + installRoutes(service) + + val response = client.put("/api/v1/notifications/notification-1/read") + + assertEquals(HttpStatusCode.NoContent, response.status) + } + + @Test + fun `GET health returns ok`() = testApplication { + installRoutes() + + val response = client.get("/health") + + assertEquals(HttpStatusCode.OK, response.status) + } + + private fun ApplicationTestBuilder.installRoutes( + service: NotificationService = mockk(relaxed = true), + webSocketManager: WebSocketManager = mockk(relaxed = true), + ) { + application { + install(ContentNegotiation) { + json() + } + install(WebSockets) + install(Koin) { + modules(module { + single { service } + single { webSocketManager } + }) + } + configureRouting(PrometheusMeterRegistry(PrometheusConfig.DEFAULT)) + } + } +}