/*
* This file is part of ETL-Processor
*
* Copyright (c) 2023 Comprehensive Cancer Center Mainfranken
* Copyright (c) 2024-2026 Paul-Christian Volkmer, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.Fingerprint
import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.Tan
import dev.dnpm.etl.processor.config.AppConfiguration
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
import dev.dnpm.etl.processor.monitoring.CountedState
import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType
import dev.dnpm.etl.processor.monitoring.SubmissionType
import dev.dnpm.etl.processor.randomRequestId
import dev.dnpm.etl.processor.services.RequestService
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.hasSize
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.http.MediaType.TEXT_EVENT_STREAM
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.reactive.server.WebTestClient
import org.springframework.test.web.reactive.server.returnResult
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.client.MockMvcWebTestClient
import org.springframework.test.web.servlet.get
import org.springframework.web.context.WebApplicationContext
import reactor.core.publisher.Sinks
import reactor.test.StepVerifier
import java.time.Instant
import java.time.ZoneId
import java.time.temporal.ChronoUnit
@WebMvcTest(controllers = [StatisticsRestController::class])
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
@ContextConfiguration(
classes =
[StatisticsRestController::class, AppConfiguration::class, AppSecurityConfiguration::class],
)
@TestPropertySource(
properties =
[
"app.pseudonymize.generator=BUILDIN",
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret",
],
)
@MockitoBean(types = [RequestService::class])
class StatisticsRestControllerTest {
private lateinit var mockMvc: MockMvc
private lateinit var statisticsUpdateProducer: Sinks.Many
private lateinit var requestService: RequestService
@BeforeEach
fun setup(
@Autowired mockMvc: MockMvc,
@Autowired statisticsUpdateProducer: Sinks.Many,
@Autowired requestService: RequestService,
) {
this.mockMvc = mockMvc
this.statisticsUpdateProducer = statisticsUpdateProducer
this.requestService = requestService
}
@Nested
inner class RequestStatesTest {
@Test
fun testShouldRequestStatesForMtbFiles() {
doAnswer { _ ->
listOf(CountedState(42, RequestStatus.WARNING), CountedState(1, RequestStatus.UNKNOWN))
}.whenever(requestService)
.countStates()
mockMvc.get("/statistics/requeststates").andExpect {
status { isOk() }
.also {
jsonPath("$", hasSize(2))
jsonPath("$[0].name", equalTo(RequestStatus.WARNING.name))
jsonPath("$[0].value", equalTo(42))
jsonPath("$[1].name", equalTo(RequestStatus.UNKNOWN.name))
jsonPath("$[1].value", equalTo(1))
}
}
}
@Test
fun testShouldRequestStatesForDeletes() {
doAnswer { _ ->
listOf(CountedState(42, RequestStatus.SUCCESS), CountedState(1, RequestStatus.ERROR))
}.whenever(requestService)
.countDeleteStates()
mockMvc.get("/statistics/requeststates?delete=true").andExpect {
status { isOk() }
.also {
jsonPath("$", hasSize(2))
jsonPath("$[0].name", equalTo(RequestStatus.SUCCESS.name))
jsonPath("$[0].value", equalTo(42))
jsonPath("$[1].name", equalTo(RequestStatus.ERROR.name))
jsonPath("$[1].value", equalTo(1))
}
}
}
}
@Nested
inner class PatientRequestStatesTest {
@Test
fun testShouldRequestPatientStatesForMtbFiles() {
doAnswer { _ ->
listOf(CountedState(42, RequestStatus.WARNING), CountedState(1, RequestStatus.UNKNOWN))
}.whenever(requestService)
.findPatientUniqueStates()
mockMvc.get("/statistics/requestpatientstates").andExpect {
status { isOk() }
.also {
jsonPath("$", hasSize(2))
jsonPath("$[0].name", equalTo(RequestStatus.WARNING.name))
jsonPath("$[0].value", equalTo(42))
jsonPath("$[1].name", equalTo(RequestStatus.UNKNOWN.name))
jsonPath("$[1].value", equalTo(1))
}
}
}
@Test
fun testShouldRequestPatientStatesForDeletes() {
doAnswer { _ ->
listOf(CountedState(42, RequestStatus.SUCCESS), CountedState(1, RequestStatus.ERROR))
}.whenever(requestService)
.findPatientUniqueDeleteStates()
mockMvc.get("/statistics/requestpatientstates?delete=true").andExpect {
status { isOk() }
.also {
jsonPath("$", hasSize(2))
jsonPath("$[0].name", equalTo(RequestStatus.SUCCESS.name))
jsonPath("$[0].value", equalTo(42))
jsonPath("$[1].name", equalTo(RequestStatus.ERROR.name))
jsonPath("$[1].value", equalTo(1))
}
}
}
}
@Nested
inner class LastMonthStatesTest {
@BeforeEach
fun setup() {
val zoneId = ZoneId.of("Europe/Berlin")
doAnswer { _ ->
listOf(
Request(
1,
randomRequestId(),
PatientPseudonym("TEST_12345678901"),
PatientId("P1"),
Fingerprint("0123456789abcdef1"),
RequestType.MTB_FILE,
SubmissionType.TEST,
RequestStatus.SUCCESS,
Tan.empty(),
Instant
.now()
.atZone(zoneId)
.truncatedTo(ChronoUnit.DAYS)
.minus(2, ChronoUnit.DAYS)
.toInstant(),
),
Request(
2,
randomRequestId(),
PatientPseudonym("TEST_12345678902"),
PatientId("P2"),
Fingerprint("0123456789abcdef2"),
RequestType.MTB_FILE,
SubmissionType.TEST,
RequestStatus.WARNING,
Tan.empty(),
Instant
.now()
.atZone(zoneId)
.truncatedTo(ChronoUnit.DAYS)
.minus(2, ChronoUnit.DAYS)
.toInstant(),
),
Request(
3,
randomRequestId(),
PatientPseudonym("TEST_12345678901"),
PatientId("P2"),
Fingerprint("0123456789abcdee1"),
RequestType.DELETE,
SubmissionType.TEST,
RequestStatus.ERROR,
Tan.empty(),
Instant
.now()
.atZone(zoneId)
.truncatedTo(ChronoUnit.DAYS)
.minus(1, ChronoUnit.DAYS)
.toInstant(),
),
Request(
4,
randomRequestId(),
PatientPseudonym("TEST_12345678902"),
PatientId("P2"),
Fingerprint("0123456789abcdef2"),
RequestType.MTB_FILE,
SubmissionType.TEST,
RequestStatus.DUPLICATION,
Tan.empty(),
Instant
.now()
.atZone(zoneId)
.truncatedTo(ChronoUnit.DAYS)
.minus(1, ChronoUnit.DAYS)
.toInstant(),
),
Request(
5,
randomRequestId(),
PatientPseudonym("TEST_12345678902"),
PatientId("P2"),
Fingerprint("0123456789abcdef2"),
RequestType.DELETE,
SubmissionType.TEST,
RequestStatus.UNKNOWN,
Tan.empty(),
Instant
.now()
.atZone(zoneId)
.truncatedTo(ChronoUnit.DAYS)
.toInstant(),
),
)
}.whenever(requestService)
.findAll()
}
@Test
fun testShouldRequestLastMonthForMtbFiles() {
mockMvc.get("/statistics/requestslastmonth").andExpect {
status { isOk() }
.also { jsonPath("$", hasSize(31)) }
.also {
jsonPath("$[28].nameValues.error", equalTo(0))
jsonPath("$[28].nameValues.warning", equalTo(1))
jsonPath("$[28].nameValues.success", equalTo(1))
jsonPath("$[28].nameValues.duplication", equalTo(0))
jsonPath("$[28].nameValues.unknown", equalTo(0))
jsonPath("$[29].nameValues.error", equalTo(0))
jsonPath("$[29].nameValues.warning", equalTo(0))
jsonPath("$[29].nameValues.success", equalTo(0))
jsonPath("$[29].nameValues.duplication", equalTo(1))
jsonPath("$[29].nameValues.unknown", equalTo(0))
}
}
}
@Test
fun testShouldRequestLastMonthForDeletes() {
mockMvc.get("/statistics/requestslastmonth?delete=true").andExpect {
status { isOk() }
.also { jsonPath("$", hasSize(31)) }
.also {
jsonPath("$[29].nameValues.error", equalTo(1))
jsonPath("$[29].nameValues.warning", equalTo(0))
jsonPath("$[29].nameValues.success", equalTo(0))
jsonPath("$[29].nameValues.duplication", equalTo(0))
jsonPath("$[29].nameValues.unknown", equalTo(0))
jsonPath("$[30].nameValues.error", equalTo(0))
jsonPath("$[30].nameValues.warning", equalTo(0))
jsonPath("$[30].nameValues.success", equalTo(0))
jsonPath("$[30].nameValues.duplication", equalTo(0))
jsonPath("$[30].nameValues.unknown", equalTo(1))
}
}
}
}
@Nested
inner class SseTest {
private lateinit var webClient: WebTestClient
@BeforeEach
fun setup(applicationContext: WebApplicationContext) {
this.webClient = MockMvcWebTestClient.bindToApplicationContext(applicationContext).build()
}
@Test
fun testShouldRequestSSE() {
statisticsUpdateProducer.emitComplete { _, _ -> true }
val result =
webClient
.get()
.uri("http://localhost/statistics/events")
.accept(TEXT_EVENT_STREAM)
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.contentType(TEXT_EVENT_STREAM)
.returnResult()
StepVerifier.create(result.responseBody).expectComplete().verify()
}
}
}