summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorArchTest.kt73
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt4
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/helpers.kt30
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt76
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt75
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt136
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt63
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt273
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt287
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt88
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt73
-rw-r--r--src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt312
-rw-r--r--src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java122
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt3
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt120
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppJdbcConfiguration.kt25
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt14
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt6
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt11
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt6
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt124
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt3
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt48
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt7
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt6
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt49
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt53
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt37
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt8
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt3
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/security/TokenService.kt (renamed from src/main/kotlin/dev/dnpm/etl/processor/services/TokenService.kt)2
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/security/UserRoleService.kt (renamed from src/main/kotlin/dev/dnpm/etl/processor/services/UserRoleService.kt)5
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt63
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt35
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/services/ResponseProcessor.kt10
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt3
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/types.kt49
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt16
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt19
-rw-r--r--src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt14
-rw-r--r--src/main/resources/application-dev.yml35
-rw-r--r--src/main/resources/db/migration/mariadb/V0_4_0__RenamePatientPseudonym.sql1
-rw-r--r--src/main/resources/db/migration/postgresql/V0_4_0__RenamePatientPseudonym.sql1
-rw-r--r--src/main/resources/static/style.css15
-rw-r--r--src/main/resources/templates/configs/gPasConnectionAvailable.html15
-rw-r--r--src/main/resources/templates/configs/outputConnectionAvailable.html43
-rw-r--r--src/main/resources/templates/index.html32
-rw-r--r--src/main/resources/templates/report.html2
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/helpers.kt20
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt8
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt11
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt23
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt (renamed from src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt)85
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt262
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt10
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/security/TokenServiceTest.kt (renamed from src/test/kotlin/dev/dnpm/etl/processor/services/TokenServiceTest.kt)15
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/security/UserRoleServiceTest.kt202
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt126
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt178
-rw-r--r--src/test/kotlin/dev/dnpm/etl/processor/services/ResponseProcessorTest.kt37
-rw-r--r--src/test/resources/fake_MTBFile.json2
61 files changed, 2865 insertions, 609 deletions
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorArchTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorArchTest.kt
new file mode 100644
index 0000000..308d0cc
--- /dev/null
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorArchTest.kt
@@ -0,0 +1,73 @@
+package dev.dnpm.etl.processor
+
+import com.tngtech.archunit.core.domain.JavaClasses
+import com.tngtech.archunit.core.importer.ClassFileImporter
+import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes
+import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.springframework.data.repository.Repository
+
+class EtlProcessorArchTest {
+
+ private lateinit var noTestClasses: JavaClasses
+
+ @BeforeEach
+ fun setUp() {
+ this.noTestClasses = ClassFileImporter()
+ .withImportOption { !(it.contains("/test/") || it.contains("/integrationTest/")) }
+ .importPackages("dev.dnpm.etl.processor")
+ }
+
+ @Test
+ fun noClassesInInputPackageShouldDependOnMonitoringPackage() {
+ val rule = noClasses()
+ .that()
+ .resideInAPackage("..input")
+ .should().dependOnClassesThat()
+ .resideInAnyPackage("..monitoring")
+
+ rule.check(noTestClasses)
+ }
+
+ @Test
+ fun noClassesInInputPackageShouldDependOnRepositories() {
+ val rule = noClasses()
+ .that()
+ .resideInAPackage("..input")
+ .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
+
+ rule.check(noTestClasses)
+ }
+
+ @Test
+ fun noClassesInOutputPackageShouldDependOnRepositories() {
+ val rule = noClasses()
+ .that()
+ .resideInAPackage("..output")
+ .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
+
+ rule.check(noTestClasses)
+ }
+
+ @Test
+ fun noClassesInWebPackageShouldDependOnRepositories() {
+ val rule = noClasses()
+ .that()
+ .resideInAPackage("..web")
+ .should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
+
+ rule.check(noTestClasses)
+ }
+
+ @Test
+ fun repositoryClassNamesShouldEndWithRepository() {
+ val rule = classes()
+ .that()
+ .areInterfaces().and().areAssignableTo(Repository::class.java)
+ .should().haveSimpleNameEndingWith("Repository")
+
+ rule.check(noTestClasses)
+ }
+
+} \ No newline at end of file
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt
index 262aca0..c7454ed 100644
--- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt
@@ -27,8 +27,8 @@ import dev.dnpm.etl.processor.output.RestMtbFileSender
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
import dev.dnpm.etl.processor.services.RequestProcessor
-import dev.dnpm.etl.processor.services.TokenRepository
-import dev.dnpm.etl.processor.services.TokenService
+import dev.dnpm.etl.processor.security.TokenRepository
+import dev.dnpm.etl.processor.security.TokenService
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/helpers.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/helpers.kt
new file mode 100644
index 0000000..6ca420f
--- /dev/null
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/helpers.kt
@@ -0,0 +1,30 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor
+
+import org.mockito.ArgumentMatchers
+
+@Suppress("UNCHECKED_CAST")
+inline fun <reified T> anyValueClass(): T {
+ val unboxedClass = T::class.java.declaredFields.first().type
+ return ArgumentMatchers.any(unboxedClass as Class<T>)
+ ?: T::class.java.getDeclaredMethod("box-impl", unboxedClass)
+ .invoke(null, null) as T
+} \ No newline at end of file
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt
index f1586d0..670020f 100644
--- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt
@@ -21,13 +21,15 @@ package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.*
+import dev.dnpm.etl.processor.anyValueClass
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
+import dev.dnpm.etl.processor.security.TokenRepository
+import dev.dnpm.etl.processor.security.UserRoleRepository
import dev.dnpm.etl.processor.services.RequestProcessor
-import dev.dnpm.etl.processor.services.TokenRepository
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.ArgumentMatchers.anyString
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
import org.mockito.kotlin.never
@@ -37,6 +39,7 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
@@ -92,6 +95,19 @@ class MtbFileRestControllerTest {
}
@Test
+ fun testShouldGrantPermissionToSendMtbFileToAdminUser() {
+ mockMvc.post("/mtbfile") {
+ with(user("onkostarserver").roles("ADMIN"))
+ contentType = MediaType.APPLICATION_JSON
+ content = ObjectMapper().writeValueAsString(mtbFile)
+ }.andExpect {
+ status { isAccepted() }
+ }
+
+ verify(requestProcessor, times(1)).processMtbFile(any())
+ }
+
+ @Test
fun testShouldDenyPermissionToSendMtbFile() {
mockMvc.post("/mtbfile") {
with(anonymous())
@@ -105,6 +121,19 @@ class MtbFileRestControllerTest {
}
@Test
+ fun testShouldDenyPermissionToSendMtbFileForUser() {
+ mockMvc.post("/mtbfile") {
+ with(user("fakeuser").roles("USER"))
+ contentType = MediaType.APPLICATION_JSON
+ content = ObjectMapper().writeValueAsString(mtbFile)
+ }.andExpect {
+ status { isForbidden() }
+ }
+
+ verify(requestProcessor, never()).processMtbFile(any())
+ }
+
+ @Test
fun testShouldGrantPermissionToDeletePatientData() {
mockMvc.delete("/mtbfile/12345678") {
with(user("onkostarserver").roles("MTBFILE"))
@@ -112,7 +141,7 @@ class MtbFileRestControllerTest {
status { isAccepted() }
}
- verify(requestProcessor, times(1)).processDeletion(anyString())
+ verify(requestProcessor, times(1)).processDeletion(anyValueClass())
}
@Test
@@ -123,7 +152,46 @@ class MtbFileRestControllerTest {
status { isUnauthorized() }
}
- verify(requestProcessor, never()).processDeletion(anyString())
+ verify(requestProcessor, never()).processDeletion(anyValueClass())
+ }
+
+ @Nested
+ @MockBean(UserRoleRepository::class, ClientRegistrationRepository::class)
+ @TestPropertySource(
+ properties = [
+ "app.pseudonymize.generator=BUILDIN",
+ "app.security.admin-user=admin",
+ "app.security.admin-password={noop}very-secret",
+ "app.security.enable-tokens=true",
+ "app.security.enable-oidc=true"
+ ]
+ )
+ inner class WithOidcEnabled {
+ @Test
+ fun testShouldGrantPermissionToSendMtbFileToAdminUser() {
+ mockMvc.post("/mtbfile") {
+ with(user("onkostarserver").roles("ADMIN"))
+ contentType = MediaType.APPLICATION_JSON
+ content = ObjectMapper().writeValueAsString(mtbFile)
+ }.andExpect {
+ status { isAccepted() }
+ }
+
+ verify(requestProcessor, times(1)).processMtbFile(any())
+ }
+
+ @Test
+ fun testShouldGrantPermissionToSendMtbFileToUser() {
+ mockMvc.post("/mtbfile") {
+ with(user("onkostarserver").roles("USER"))
+ contentType = MediaType.APPLICATION_JSON
+ content = ObjectMapper().writeValueAsString(mtbFile)
+ }.andExpect {
+ status { isAccepted() }
+ }
+
+ verify(requestProcessor, times(1)).processMtbFile(any())
+ }
}
companion object {
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt
new file mode 100644
index 0000000..bef124c
--- /dev/null
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt
@@ -0,0 +1,75 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.monitoring
+
+import dev.dnpm.etl.processor.*
+import dev.dnpm.etl.processor.output.MtbFileSender
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest
+import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
+import org.springframework.boot.test.mock.mockito.MockBean
+import org.springframework.test.context.TestPropertySource
+import org.springframework.test.context.junit.jupiter.SpringExtension
+import org.springframework.transaction.annotation.Transactional
+import org.testcontainers.junit.jupiter.Testcontainers
+import java.time.Instant
+
+@Testcontainers
+@ExtendWith(SpringExtension::class)
+@DataJdbcTest
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Transactional
+@MockBean(MtbFileSender::class)
+@TestPropertySource(
+ properties = [
+ "app.pseudonymize.generator=buildin",
+ "app.rest.uri=http://example.com"
+ ]
+)
+class RequestRepositoryTest : AbstractTestcontainerTest() {
+
+ private lateinit var requestRepository: RequestRepository
+
+ @BeforeEach
+ fun setUp(
+ @Autowired requestRepository: RequestRepository
+ ) {
+ this.requestRepository = requestRepository
+ }
+
+ @Test
+ fun shouldSaveRequest() {
+ val request = Request(
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdef1"),
+ RequestType.MTB_FILE,
+ RequestStatus.WARNING,
+ Instant.parse("2023-07-07T00:00:00Z")
+ )
+
+ requestRepository.save(request)
+ }
+
+} \ No newline at end of file
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt
new file mode 100644
index 0000000..da0c55c
--- /dev/null
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt
@@ -0,0 +1,136 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.pseudonym
+
+import dev.dnpm.etl.processor.config.GPasConfigProperties
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.springframework.http.HttpHeaders
+import org.springframework.http.HttpMethod
+import org.springframework.http.HttpStatus
+import org.springframework.retry.policy.SimpleRetryPolicy
+import org.springframework.retry.support.RetryTemplateBuilder
+import org.springframework.test.web.client.MockRestServiceServer
+import org.springframework.test.web.client.match.MockRestRequestMatchers.method
+import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
+import org.springframework.test.web.client.response.MockRestResponseCreators.withException
+import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
+import org.springframework.web.client.RestTemplate
+import java.io.IOException
+
+class GpasPseudonymGeneratorTest {
+
+ private lateinit var mockRestServiceServer: MockRestServiceServer
+ private lateinit var generator: GpasPseudonymGenerator
+ private lateinit var restTemplate: RestTemplate
+
+ @BeforeEach
+ fun setup() {
+ val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
+ val gPasConfigProperties = GPasConfigProperties(
+ "http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate",
+ "test",
+ null,
+ null,
+ null
+ )
+
+ this.restTemplate = RestTemplate()
+ this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
+ this.generator = GpasPseudonymGenerator(gPasConfigProperties, retryTemplate, restTemplate)
+ }
+
+ @Test
+ fun shouldReturnExpectedPseudonym() {
+ this.mockRestServiceServer.expect {
+ method(HttpMethod.POST)
+ requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
+ }.andRespond {
+ withStatus(HttpStatus.OK).body(getDummyResponseBody("1234", "test", "test1234ABCDEF567890"))
+ .createResponse(it)
+ }
+
+ assertThat(this.generator.generate("ID1234")).isEqualTo("test1234ABCDEF567890")
+ }
+
+ @Test
+ fun shouldThrowExceptionIfGpasNotAvailable() {
+ this.mockRestServiceServer.expect {
+ method(HttpMethod.POST)
+ requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
+ }.andRespond {
+ withException(IOException("Simulated IO error")).createResponse(it)
+ }
+
+ assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") }
+ }
+
+ @Test
+ fun shouldThrowExceptionIfGpasDoesNotReturn2xxResponse() {
+ this.mockRestServiceServer.expect {
+ method(HttpMethod.POST)
+ requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
+ }.andRespond {
+ withStatus(HttpStatus.FOUND)
+ .header(HttpHeaders.LOCATION, "https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
+ .createResponse(it)
+ }
+
+ assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") }
+ }
+
+ companion object {
+
+ fun getDummyResponseBody(original: String, target: String, pseudonym: String) = """{
+ "resourceType": "Parameters",
+ "parameter": [
+ {
+ "name": "pseudonym",
+ "part": [
+ {
+ "name": "original",
+ "valueIdentifier": {
+ "system": "https://ths-greifswald.de/gpas",
+ "value": "$original"
+ }
+ },
+ {
+ "name": "target",
+ "valueIdentifier": {
+ "system": "https://ths-greifswald.de/gpas",
+ "value": "$target"
+ }
+ },
+ {
+ "name": "pseudonym",
+ "valueIdentifier": {
+ "system": "https://ths-greifswald.de/gpas",
+ "value": "$pseudonym"
+ }
+ }
+ ]
+ }
+ ]
+ }""".trimIndent()
+
+ }
+} \ No newline at end of file
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt
index 88a3a08..47ac301 100644
--- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt
@@ -19,7 +19,7 @@
package dev.dnpm.etl.processor.services
-import dev.dnpm.etl.processor.AbstractTestcontainerTest
+import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus
@@ -37,7 +37,6 @@ import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional
import org.testcontainers.junit.jupiter.Testcontainers
import java.time.Instant
-import java.util.*
@Testcontainers
@ExtendWith(SpringExtension::class)
@@ -66,7 +65,7 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
@Test
fun shouldResultInEmptyRequestList() {
- val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901")
+ val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM)
assertThat(actual).isEmpty()
}
@@ -76,33 +75,33 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
this.requestRepository.saveAll(
listOf(
Request(
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "0123456789abcdef1",
- type = RequestType.MTB_FILE,
- status = RequestStatus.SUCCESS,
- processedAt = Instant.parse("2023-07-07T02:00:00Z")
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdef1"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS,
+ Instant.parse("2023-07-07T02:00:00Z")
),
// Should be ignored - wrong patient ID -->
Request(
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678902",
- pid = "P2",
- fingerprint = "0123456789abcdef2",
- type = RequestType.MTB_FILE,
- status = RequestStatus.WARNING,
- processedAt = Instant.parse("2023-08-08T00:00:00Z")
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678902"),
+ PatientId("P2"),
+ Fingerprint("0123456789abcdef2"),
+ RequestType.MTB_FILE,
+ RequestStatus.WARNING,
+ Instant.parse("2023-08-08T00:00:00Z")
),
// <--
Request(
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P2",
- fingerprint = "0123456789abcdee1",
- type = RequestType.DELETE,
- status = RequestStatus.SUCCESS,
- processedAt = Instant.parse("2023-08-08T02:00:00Z")
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P2"),
+ Fingerprint("0123456789abcdee1"),
+ RequestType.DELETE,
+ RequestStatus.SUCCESS,
+ Instant.parse("2023-08-08T02:00:00Z")
)
)
)
@@ -112,18 +111,18 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
fun shouldResultInSortedRequestList() {
setupTestData()
- val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901")
+ val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM)
assertThat(actual).hasSize(2)
- assertThat(actual[0].fingerprint).isEqualTo("0123456789abcdee1")
- assertThat(actual[1].fingerprint).isEqualTo("0123456789abcdef1")
+ assertThat(actual[0].fingerprint).isEqualTo(Fingerprint("0123456789abcdee1"))
+ assertThat(actual[1].fingerprint).isEqualTo(Fingerprint("0123456789abcdef1"))
}
@Test
fun shouldReturnDeleteRequestAsLastRequest() {
setupTestData()
- val actual = requestService.isLastRequestWithKnownStatusDeletion("TEST_12345678901")
+ val actual = requestService.isLastRequestWithKnownStatusDeletion(TEST_PATIENT_PSEUDONYM)
assertThat(actual).isTrue()
}
@@ -132,10 +131,14 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
fun shouldReturnLastMtbFileRequest() {
setupTestData()
- val actual = requestService.lastMtbFileRequestForPatientPseudonym("TEST_12345678901")
+ val actual = requestService.lastMtbFileRequestForPatientPseudonym(TEST_PATIENT_PSEUDONYM)
assertThat(actual).isNotNull
- assertThat(actual?.fingerprint).isEqualTo("0123456789abcdef1")
+ assertThat(actual?.fingerprint).isEqualTo(Fingerprint("0123456789abcdef1"))
+ }
+
+ companion object {
+ val TEST_PATIENT_PSEUDONYM = PatientPseudonym("TEST_12345678901")
}
} \ No newline at end of file
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt
index 7fc0121..af4650d 100644
--- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt
@@ -19,32 +19,51 @@
package dev.dnpm.etl.processor.web
+import com.gargoylesoftware.htmlunit.WebClient
+import com.gargoylesoftware.htmlunit.html.HtmlPage
import dev.dnpm.etl.processor.config.AppConfiguration
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
-import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
+import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
+import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.pseudonym.Generator
+import dev.dnpm.etl.processor.security.Role
import dev.dnpm.etl.processor.services.RequestProcessor
-import dev.dnpm.etl.processor.services.TokenRepository
+import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.services.TransformationService
+import dev.dnpm.etl.processor.security.UserRoleService
+import org.assertj.core.api.Assertions.assertThat
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.ArgumentMatchers.anyString
import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
+import org.springframework.http.MediaType.TEXT_EVENT_STREAM
+import org.springframework.security.test.context.support.WithMockUser
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.junit.jupiter.SpringExtension
-import org.springframework.test.web.servlet.MockMvc
-import org.springframework.test.web.servlet.get
+import org.springframework.test.web.reactive.server.WebTestClient
+import org.springframework.test.web.servlet.*
+import org.springframework.test.web.servlet.client.MockMvcWebTestClient
+import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
+import org.springframework.web.context.WebApplicationContext
import reactor.core.publisher.Sinks
+import reactor.test.StepVerifier
+import java.time.Instant
abstract class MockSink : Sinks.Many<Boolean>
@@ -59,44 +78,48 @@ abstract class MockSink : Sinks.Many<Boolean>
)
@TestPropertySource(
properties = [
- "app.pseudonymize.generator=BUILDIN",
- "app.security.admin-user=admin",
- "app.security.admin-password={noop}very-secret",
- "app.security.enable-tokens=true"
+ "app.pseudonymize.generator=BUILDIN"
]
)
@MockBean(name = "configsUpdateProducer", classes = [MockSink::class])
@MockBean(
Generator::class,
MtbFileSender::class,
- ConnectionCheckService::class,
RequestProcessor::class,
TransformationService::class,
- TokenRepository::class,
- RestConnectionCheckService::class
+ GPasConnectionCheckService::class,
+ RestConnectionCheckService::class,
)
class ConfigControllerTest {
private lateinit var mockMvc: MockMvc
+ private lateinit var webClient: WebClient
private lateinit var requestProcessor: RequestProcessor
+ private lateinit var connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
@BeforeEach
fun setup(
@Autowired mockMvc: MockMvc,
- @Autowired requestProcessor: RequestProcessor
+ @Autowired requestProcessor: RequestProcessor,
+ @Autowired connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) {
this.mockMvc = mockMvc
+ this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
this.requestProcessor = requestProcessor
+ this.connectionCheckUpdateProducer = connectionCheckUpdateProducer
+
+ webClient.options.isThrowExceptionOnScriptError = false
}
@Test
- fun testShouldShowConfigPageIfLoggedIn() {
+ fun testShouldRequestConfigPageIfLoggedIn() {
mockMvc.get("/configs") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
}.andExpect {
status { isOk() }
+ view { name("configs") }
}
}
@@ -113,4 +136,228 @@ class ConfigControllerTest {
}
}
+ @Nested
+ @TestPropertySource(
+ properties = [
+ "app.security.enable-tokens=true",
+ "app.security.admin-user=admin"
+ ]
+ )
+ @MockBean(
+ TokenService::class
+ )
+ inner class WithTokensEnabled {
+ private lateinit var tokenService: TokenService
+
+ @BeforeEach
+ fun setup(
+ @Autowired tokenService: TokenService
+ ) {
+ webClient.options.isThrowExceptionOnScriptError = false
+
+ this.tokenService = tokenService
+ }
+
+ @Test
+ fun testShouldSaveNewToken() {
+ mockMvc.post("/configs/tokens") {
+ with(user("admin").roles("ADMIN"))
+ accept(MediaType.TEXT_HTML)
+ contentType = MediaType.APPLICATION_FORM_URLENCODED
+ content = "name=Testtoken"
+ }.andExpect {
+ status { is2xxSuccessful() }
+ view { name("configs/tokens") }
+ }
+
+ val captor = argumentCaptor<String>()
+ verify(tokenService, times(1)).addToken(captor.capture())
+
+ assertThat(captor.firstValue).isEqualTo("Testtoken")
+ }
+
+ @Test
+ fun testShouldNotSaveTokenWithExstingName() {
+ whenever(tokenService.addToken(anyString())).thenReturn(Result.failure(RuntimeException("Testfailure")))
+
+ mockMvc.post("/configs/tokens") {
+ with(user("admin").roles("ADMIN"))
+ accept(MediaType.TEXT_HTML)
+ contentType = MediaType.APPLICATION_FORM_URLENCODED
+ content = "name=Testtoken"
+ }.andExpect {
+ status { is2xxSuccessful() }
+ view { name("configs/tokens") }
+ }
+
+ val captor = argumentCaptor<String>()
+ verify(tokenService, times(1)).addToken(captor.capture())
+
+ assertThat(captor.firstValue).isEqualTo("Testtoken")
+ }
+
+ @Test
+ fun testShouldDeleteToken() {
+ mockMvc.delete("/configs/tokens/42") {
+ with(user("admin").roles("ADMIN"))
+ accept(MediaType.TEXT_HTML)
+ }.andExpect {
+ status { is2xxSuccessful() }
+ view { name("configs/tokens") }
+ }
+
+ val captor = argumentCaptor<Long>()
+ verify(tokenService, times(1)).deleteToken(captor.capture())
+
+ assertThat(captor.firstValue).isEqualTo(42)
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = ["ADMIN"])
+ fun testShouldRenderConfigPageWithTokens() {
+ val page = webClient.getPage<HtmlPage>("http://localhost/configs")
+ assertThat(
+ page.getElementById("tokens")
+ ).isNotNull
+ }
+ }
+
+ @Nested
+ @TestPropertySource(
+ properties = [
+ "app.security.enable-tokens=false"
+ ]
+ )
+ inner class WithTokensDisabled {
+ @BeforeEach
+ fun setup() {
+ webClient.options.isThrowExceptionOnScriptError = false
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = ["ADMIN"])
+ fun testShouldRenderConfigPageWithoutTokens() {
+ val page = webClient.getPage<HtmlPage>("http://localhost/configs")
+ assertThat(
+ page.getElementById("tokens")
+ ).isNull()
+ }
+ }
+
+ @Nested
+ @TestPropertySource(
+ properties = [
+ "app.security.enable-tokens=false",
+ "app.security.admin-user=admin",
+ "app.security.admin-password={noop}very-secret"
+ ]
+ )
+ @MockBean(
+ UserRoleService::class
+ )
+ inner class WithUserRolesEnabled {
+ private lateinit var userRoleService: UserRoleService
+
+ @BeforeEach
+ fun setup(
+ @Autowired userRoleService: UserRoleService
+ ) {
+ webClient.options.isThrowExceptionOnScriptError = false
+
+ this.userRoleService = userRoleService
+ }
+
+ @Test
+ fun testShouldDeleteUserRole() {
+ mockMvc.delete("/configs/userroles/42") {
+ with(user("admin").roles("ADMIN"))
+ accept(MediaType.TEXT_HTML)
+ }.andExpect {
+ status { is2xxSuccessful() }
+ view { name("configs/userroles") }
+ }
+
+ val captor = argumentCaptor<Long>()
+ verify(userRoleService, times(1)).deleteUserRole(captor.capture())
+
+ assertThat(captor.firstValue).isEqualTo(42)
+ }
+
+ @Test
+ fun testShouldUpdateUserRole() {
+ mockMvc.put("/configs/userroles/42") {
+ with(user("admin").roles("ADMIN"))
+ accept(MediaType.TEXT_HTML)
+ contentType = MediaType.APPLICATION_FORM_URLENCODED
+ content = "role=ADMIN"
+ }.andExpect {
+ status { is2xxSuccessful() }
+ view { name("configs/userroles") }
+ }
+
+ val idCaptor = argumentCaptor<Long>()
+ val roleCaptor = argumentCaptor<Role>()
+ verify(userRoleService, times(1)).updateUserRole(idCaptor.capture(), roleCaptor.capture())
+
+ assertThat(idCaptor.firstValue).isEqualTo(42)
+ assertThat(roleCaptor.firstValue).isEqualTo(Role.ADMIN)
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = ["ADMIN"])
+ fun testShouldRenderConfigPageWithUserRoles() {
+ val page = webClient.getPage<HtmlPage>("http://localhost/configs")
+ assertThat(
+ page.getElementById("userroles")
+ ).isNotNull
+ }
+ }
+
+ @Nested
+ inner class WithUserRolesDisabled {
+ @BeforeEach
+ fun setup() {
+ webClient.options.isThrowExceptionOnScriptError = false
+ }
+
+ @Test
+ fun testShouldRenderConfigPageWithoutUserRoles() {
+ val page = webClient.getPage<HtmlPage>("http://localhost/configs")
+ assertThat(
+ page.getElementById("userroles")
+ ).isNull()
+ }
+ }
+
+ @Nested
+ inner class SseTest {
+ private lateinit var webClient: WebTestClient
+
+ @BeforeEach
+ fun setup(
+ applicationContext: WebApplicationContext,
+ ) {
+ this.webClient = MockMvcWebTestClient
+ .bindToApplicationContext(applicationContext).build()
+ }
+
+ @Test
+ fun testShouldRequestSSE() {
+ val expectedEvent = ConnectionCheckResult.GPasConnectionCheckResult(true, Instant.now(), Instant.now())
+
+ connectionCheckUpdateProducer.tryEmitNext(expectedEvent)
+ connectionCheckUpdateProducer.emitComplete { _, _ -> true }
+
+ val result = webClient.get().uri("http://localhost/configs/events").accept(TEXT_EVENT_STREAM).exchange()
+ .expectStatus().isOk()
+ .expectHeader().contentType(TEXT_EVENT_STREAM)
+ .returnResult(ConnectionCheckResult.GPasConnectionCheckResult::class.java)
+
+ StepVerifier.create(result.responseBody)
+ .expectNext(expectedEvent)
+ .expectComplete()
+ .verify()
+ }
+ }
+
}
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt
new file mode 100644
index 0000000..82835b4
--- /dev/null
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt
@@ -0,0 +1,287 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.web
+
+import com.gargoylesoftware.htmlunit.WebClient
+import com.gargoylesoftware.htmlunit.html.HtmlPage
+import dev.dnpm.etl.processor.*
+import dev.dnpm.etl.processor.config.AppConfiguration
+import dev.dnpm.etl.processor.config.AppSecurityConfiguration
+import dev.dnpm.etl.processor.monitoring.Report
+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.services.RequestService
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Nested
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.kotlin.any
+import org.mockito.kotlin.whenever
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
+import org.springframework.boot.test.mock.mockito.MockBean
+import org.springframework.data.domain.Page
+import org.springframework.data.domain.PageImpl
+import org.springframework.data.domain.Pageable
+import org.springframework.security.test.context.support.WithMockUser
+import org.springframework.test.context.ContextConfiguration
+import org.springframework.test.context.TestPropertySource
+import org.springframework.test.context.junit.jupiter.SpringExtension
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.get
+import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
+import java.io.IOException
+import java.time.Instant
+import java.util.*
+
+@WebMvcTest(controllers = [HomeController::class])
+@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
+@ContextConfiguration(
+ classes = [
+ HomeController::class,
+ AppConfiguration::class,
+ AppSecurityConfiguration::class
+ ]
+)
+@TestPropertySource(
+ properties = [
+ "app.pseudonymize.generator=BUILDIN",
+ "app.security.admin-user=admin",
+ "app.security.admin-password={noop}very-secret"
+ ]
+)
+@MockBean(
+ RequestService::class
+)
+class HomeControllerTest {
+
+ private lateinit var mockMvc: MockMvc
+ private lateinit var webClient: WebClient
+
+ @BeforeEach
+ fun setup(
+ @Autowired mockMvc: MockMvc,
+ @Autowired requestService: RequestService
+ ) {
+ this.mockMvc = mockMvc
+ this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
+
+ whenever(requestService.findAll(any<Pageable>())).thenReturn(Page.empty())
+ }
+
+ @Test
+ fun testShouldRequestHomePage() {
+ mockMvc.get("/").andExpect {
+ status { isOk() }
+ view { name("index") }
+ }
+ }
+
+ @Nested
+ inner class WithRequests {
+
+ private lateinit var requestService: RequestService
+
+ @BeforeEach
+ fun setup(
+ @Autowired requestService: RequestService
+ ) {
+ this.requestService = requestService
+ }
+
+ @Test
+ fun testShouldShowHomePage() {
+ whenever(requestService.findAll(any<Pageable>())).thenReturn(
+ PageImpl(
+ listOf(
+ Request(
+ 2L,
+ randomRequestId(),
+ PatientPseudonym("PSEUDO1"),
+ PatientId("PATIENT1"),
+ Fingerprint("ashdkasdh"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS
+ ),
+ Request(
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("PSEUDO1"),
+ PatientId("PATIENT1"),
+ Fingerprint("asdasdasd"),
+ RequestType.MTB_FILE,
+ RequestStatus.ERROR
+ )
+ )
+ )
+ )
+
+ val page = webClient.getPage<HtmlPage>("http://localhost/")
+ assertThat(page.querySelectorAll("tbody tr")).hasSize(2)
+ assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = ["ADMIN"])
+ fun testShouldShowRequestDetails() {
+ val requestId = randomRequestId()
+
+ whenever(requestService.findByUuid(anyValueClass())).thenReturn(
+ Optional.of(
+ Request(
+ 2L,
+ requestId,
+ PatientPseudonym("PSEUDO1"),
+ PatientId("PATIENT1"),
+ Fingerprint("ashdkasdh"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS,
+ Instant.now(),
+ Report("Test")
+ )
+ )
+ )
+
+ val page = webClient.getPage<HtmlPage>("http://localhost/report/${requestId.value}")
+ assertThat(page.querySelectorAll("tbody tr")).hasSize(1)
+ assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = ["ADMIN"])
+ fun testShouldShowPatientDetails() {
+ whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(
+ PageImpl(
+ listOf(
+ Request(
+ 2L,
+ randomRequestId(),
+ PatientPseudonym("PSEUDO1"),
+ PatientId("PATIENT1"),
+ Fingerprint("ashdkasdh"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS
+ ),
+ Request(
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("PSEUDO1"),
+ PatientId("PATIENT1"),
+ Fingerprint("asdasdasd"),
+ RequestType.MTB_FILE,
+ RequestStatus.ERROR
+ )
+ )
+ )
+ )
+
+ val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
+ assertThat(page.querySelectorAll("tbody tr")).hasSize(2)
+ assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = ["ADMIN"])
+ fun testShouldShowPatientPseudonym() {
+ whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(
+ PageImpl(
+ listOf(
+ Request(
+ 2L,
+ randomRequestId(),
+ PatientPseudonym("PSEUDO1"),
+ PatientId("PATIENT1"),
+ Fingerprint("ashdkasdh"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS
+ ),
+ Request(
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("PSEUDO1"),
+ PatientId("PATIENT1"),
+ Fingerprint("asdasdasd"),
+ RequestType.MTB_FILE,
+ RequestStatus.ERROR
+ )
+ )
+ )
+ )
+
+ val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
+ assertThat(page.querySelectorAll("h2 > span")).hasSize(1)
+ assertThat(page.querySelectorAll("h2 > span").first().textContent).isEqualTo("PSEUDO1")
+ }
+
+ }
+
+ @Nested
+ inner class WithoutRequests {
+
+ private lateinit var requestService: RequestService
+
+ @BeforeEach
+ fun setup(
+ @Autowired requestService: RequestService
+ ) {
+ this.requestService = requestService
+
+ whenever(requestService.findAll(any<Pageable>())).thenReturn(Page.empty())
+ }
+
+ @Test
+ fun testShouldShowHomePage() {
+ val page = webClient.getPage<HtmlPage>("http://localhost/")
+ assertThat(page.querySelectorAll("tbody tr")).isEmpty()
+ assertThat(page.querySelectorAll("div.notification.info")).hasSize(1)
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = ["ADMIN"])
+ fun testShouldThrowNotFoundExceptionForUnknownReport() {
+ val requestId = randomRequestId()
+
+ whenever(requestService.findByUuid(anyValueClass())).thenReturn(
+ Optional.empty()
+ )
+
+ assertThrows<IOException> {
+ webClient.getPage<HtmlPage>("http://localhost/report/${requestId.value}")
+ }.also {
+ assertThat(it).hasRootCauseInstanceOf(NotFoundException::class.java)
+ }
+ }
+
+ @Test
+ @WithMockUser(username = "admin", roles = ["ADMIN"])
+ fun testShouldShowEmptyPatientDetails() {
+ whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(Page.empty())
+
+ val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
+ assertThat(page.querySelectorAll("tbody tr")).isEmpty()
+ assertThat(page.querySelectorAll("div.notification.info")).hasSize(1)
+ }
+ }
+
+}
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt
new file mode 100644
index 0000000..0471543
--- /dev/null
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt
@@ -0,0 +1,88 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.web
+
+import com.gargoylesoftware.htmlunit.WebClient
+import com.gargoylesoftware.htmlunit.html.HtmlPage
+import dev.dnpm.etl.processor.config.AppConfiguration
+import dev.dnpm.etl.processor.config.AppSecurityConfiguration
+import dev.dnpm.etl.processor.security.TokenService
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.junit.jupiter.MockitoExtension
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
+import org.springframework.boot.test.mock.mockito.MockBean
+import org.springframework.test.context.ContextConfiguration
+import org.springframework.test.context.TestPropertySource
+import org.springframework.test.context.junit.jupiter.SpringExtension
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.get
+import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
+
+@WebMvcTest(controllers = [LoginController::class])
+@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
+@ContextConfiguration(
+ classes = [
+ LoginController::class,
+ AppConfiguration::class,
+ AppSecurityConfiguration::class
+ ]
+)
+@TestPropertySource(
+ properties = [
+ "app.pseudonymize.generator=BUILDIN",
+ "app.security.admin-user=admin",
+ "app.security.admin-password={noop}very-secret",
+ "app.security.enable-tokens=true"
+ ]
+)
+@MockBean(
+ TokenService::class,
+)
+class LoginControllerTest {
+
+ private lateinit var mockMvc: MockMvc
+ private lateinit var webClient: WebClient
+
+ @BeforeEach
+ fun setup(@Autowired mockMvc: MockMvc) {
+ this.mockMvc = mockMvc
+ this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
+ }
+
+ @Test
+ fun testShouldRequestLoginPage() {
+ mockMvc.get("/login").andExpect {
+ status { isOk() }
+ view { name("login") }
+ }
+ }
+
+ @Test
+ fun testShouldShowLoginForm() {
+ val page = webClient.getPage<HtmlPage>("http://localhost/login")
+ assertThat(
+ page.getElementsByTagName("main").first().firstElementChild.getAttribute("class")
+ ).isEqualTo("login-form")
+ }
+}
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt
new file mode 100644
index 0000000..424a0e3
--- /dev/null
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt
@@ -0,0 +1,73 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.web
+
+import com.gargoylesoftware.htmlunit.WebClient
+import dev.dnpm.etl.processor.config.AppConfiguration
+import dev.dnpm.etl.processor.config.AppSecurityConfiguration
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.mockito.junit.jupiter.MockitoExtension
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
+import org.springframework.test.context.ContextConfiguration
+import org.springframework.test.context.TestPropertySource
+import org.springframework.test.context.junit.jupiter.SpringExtension
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.get
+import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
+
+@WebMvcTest(controllers = [StatisticsController::class])
+@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
+@ContextConfiguration(
+ classes = [
+ StatisticsController::class,
+ AppConfiguration::class,
+ AppSecurityConfiguration::class
+ ]
+)
+@TestPropertySource(
+ properties = [
+ "app.pseudonymize.generator=BUILDIN",
+ "app.security.admin-user=admin",
+ "app.security.admin-password={noop}very-secret"
+ ]
+)
+class StatisticsControllerTest {
+
+ private lateinit var mockMvc: MockMvc
+ private lateinit var webClient: WebClient
+
+ @BeforeEach
+ fun setup(@Autowired mockMvc: MockMvc) {
+ this.mockMvc = mockMvc
+ this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
+ }
+
+ @Test
+ fun testShouldRequestLoginPage() {
+ mockMvc.get("/statistics").andExpect {
+ status { isOk() }
+ view { name("statistics") }
+ }
+ }
+
+}
diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt
new file mode 100644
index 0000000..b9a1338
--- /dev/null
+++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt
@@ -0,0 +1,312 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+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.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.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.test.autoconfigure.web.servlet.WebMvcTest
+import org.springframework.boot.test.mock.mockito.MockBean
+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.junit.jupiter.SpringExtension
+import org.springframework.test.web.reactive.server.WebTestClient
+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.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"
+ ]
+)
+@MockBean(
+ RequestService::class
+)
+class StatisticsRestControllerTest {
+
+ private lateinit var mockMvc: MockMvc
+
+ private lateinit var statisticsUpdateProducer: Sinks.Many<Any>
+ private lateinit var requestService: RequestService
+
+ @BeforeEach
+ fun setup(
+ @Autowired mockMvc: MockMvc,
+ @Autowired statisticsUpdateProducer: Sinks.Many<Any>,
+ @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<Int>(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<Int>(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<Int>(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<Int>(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() {
+ doAnswer { _ ->
+ listOf(
+ Request(
+ 1,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdef1"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS,
+ Instant.now().truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS)
+ ),
+ Request(
+ 2,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678902"),
+ PatientId("P2"),
+ Fingerprint("0123456789abcdef2"),
+ RequestType.MTB_FILE,
+ RequestStatus.WARNING,
+ Instant.now().truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS)
+ ),
+ Request(
+ 3,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P2"),
+ Fingerprint("0123456789abcdee1"),
+ RequestType.DELETE,
+ RequestStatus.ERROR,
+ Instant.now().truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS)
+ ),
+ Request(
+ 4,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678902"),
+ PatientId("P2"),
+ Fingerprint("0123456789abcdef2"),
+ RequestType.MTB_FILE,
+ RequestStatus.DUPLICATION,
+ Instant.now().truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS)
+ ),
+ Request(
+ 5,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678902"),
+ PatientId("P2"),
+ Fingerprint("0123456789abcdef2"),
+ RequestType.DELETE,
+ RequestStatus.UNKNOWN,
+ Instant.now().truncatedTo(ChronoUnit.DAYS)
+ ),
+ )
+ }.whenever(requestService).findAll()
+ }
+
+ @Test
+ fun testShouldRequestLastMonthForMtbFiles() {
+ mockMvc.get("/statistics/requestslastmonth").andExpect {
+ status { isOk() }.also {
+ jsonPath("$", hasSize<Int>(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<Int>(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(String::class.java)
+
+ StepVerifier.create(result.responseBody)
+ .expectComplete()
+ .verify()
+ }
+ }
+
+}
diff --git a/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java b/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java
index 446bd16..77caa77 100644
--- a/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java
+++ b/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java
@@ -23,41 +23,17 @@ import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.parser.IParser;
import dev.dnpm.etl.processor.config.GPasConfigProperties;
import org.apache.commons.lang3.StringUtils;
-import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
-import org.apache.hc.client5.http.impl.classic.HttpClients;
-import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager;
-import org.apache.hc.client5.http.socket.ConnectionSocketFactory;
-import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory;
-import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
-import org.apache.hc.core5.http.config.Registry;
-import org.apache.hc.core5.http.config.RegistryBuilder;
import org.hl7.fhir.r4.model.Identifier;
import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
import org.hl7.fhir.r4.model.StringType;
import org.jetbrains.annotations.NotNull;
-import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.*;
-import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.RestTemplate;
-import javax.net.ssl.SSLContext;
-import javax.net.ssl.TrustManagerFactory;
-import java.io.BufferedInputStream;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.security.KeyManagementException;
-import java.security.KeyStore;
-import java.security.KeyStoreException;
-import java.security.NoSuchAlgorithmException;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.util.Base64;
-
public class GpasPseudonymGenerator implements Generator {
private final static FhirContext r4Context = FhirContext.forR4();
@@ -69,27 +45,13 @@ public class GpasPseudonymGenerator implements Generator {
private final RestTemplate restTemplate;
- private SSLContext customSslContext;
-
- public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate) {
+ public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, RestTemplate restTemplate) {
this.retryTemplate = retryTemplate;
- this.restTemplate = getRestTemplete();
-
+ this.restTemplate = restTemplate;
this.gPasUrl = gpasCfg.getUri();
this.psnTargetDomain = gpasCfg.getTarget();
httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword());
- try {
- if (StringUtils.isNotBlank(gpasCfg.getSslCaLocation())) {
- customSslContext = getSslContext(gpasCfg.getSslCaLocation());
- log.warn(String.format("%s has been initialized with SSL certificate %s. This is deprecated in favor of including Root CA.",
- this.getClass().getName(), gpasCfg.getSslCaLocation()));
- }
- } catch (IOException | KeyManagementException | KeyStoreException | CertificateException |
- NoSuchAlgorithmException e) {
- throw new RuntimeException(e);
- }
-
log.debug(String.format("%s has been initialized", this.getClass().getName()));
}
@@ -99,7 +61,7 @@ public class GpasPseudonymGenerator implements Generator {
var gPasRequestBody = getGpasRequestBody(id);
var responseEntity = getGpasPseudonym(gPasRequestBody);
var gPasPseudonymResult = (Parameters) r4Context.newJsonParser()
- .parseResource(responseEntity.getBody());
+ .parseResource(responseEntity.getBody());
return unwrapPseudonym(gPasPseudonymResult);
}
@@ -113,9 +75,9 @@ public class GpasPseudonymGenerator implements Generator {
}
final var identifier = (Identifier) parameters.get().getPart().stream()
- .filter(a -> a.getName().equals("pseudonym"))
- .findFirst()
- .orElseGet(ParametersParameterComponent::new).getValue();
+ .filter(a -> a.getName().equals("pseudonym"))
+ .findFirst()
+ .orElseGet(ParametersParameterComponent::new).getValue();
// pseudonym
return sanitizeValue(identifier.getValue());
@@ -144,8 +106,8 @@ public class GpasPseudonymGenerator implements Generator {
try {
responseEntity = retryTemplate.execute(
- ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity,
- String.class));
+ ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity,
+ String.class));
if (responseEntity.getStatusCode().is2xxSuccessful()) {
log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode());
@@ -157,16 +119,16 @@ public class GpasPseudonymGenerator implements Generator {
return responseEntity;
} catch (Exception unexpected) {
throw new PseudonymRequestFailed(
- "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected);
+ "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected);
}
}
protected String getGpasRequestBody(String id) {
var requestParameters = new Parameters();
requestParameters.addParameter().setName("target")
- .setValue(new StringType().setValue(psnTargetDomain));
+ .setValue(new StringType().setValue(psnTargetDomain));
requestParameters.addParameter().setName("original")
- .setValue(new StringType().setValue(id));
+ .setValue(new StringType().setValue(id));
final IParser iParser = r4Context.newJsonParser();
return iParser.encodeResourceToString(requestParameters);
}
@@ -180,67 +142,7 @@ public class GpasPseudonymGenerator implements Generator {
return headers;
}
- String authHeader = gPasUserName + ":" + gPasPassword;
- byte[] authHeaderBytes = authHeader.getBytes();
- byte[] encodedAuthHeaderBytes = Base64.getEncoder().encode(authHeaderBytes);
- String encodedAuthHeader = new String(encodedAuthHeaderBytes);
-
- if (StringUtils.isNotBlank(gPasUserName) && StringUtils.isNotBlank(gPasPassword)) {
- headers.set("Authorization", "Basic " + encodedAuthHeader);
- }
-
+ headers.setBasicAuth(gPasUserName, gPasPassword);
return headers;
}
-
- /**
- * Read SSL root certificate and return SSLContext
- *
- * @param certificateLocation file location to root certificate (PEM)
- * @return initialized SSLContext
- * @throws IOException file cannot be read
- * @throws CertificateException in case we have an invalid certificate of type X.509
- * @throws KeyStoreException keystore cannot be initialized
- * @throws NoSuchAlgorithmException missing trust manager algorithmus
- * @throws KeyManagementException key management failed at init SSLContext
- */
- @Nullable
- protected SSLContext getSslContext(String certificateLocation)
- throws IOException, CertificateException, KeyStoreException, KeyManagementException, NoSuchAlgorithmException {
-
- KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
-
- FileInputStream fis = new FileInputStream(certificateLocation);
- X509Certificate ca = (X509Certificate) CertificateFactory.getInstance("X.509")
- .generateCertificate(new BufferedInputStream(fis));
-
- ks.load(null, null);
- ks.setCertificateEntry(Integer.toString(1), ca);
-
- TrustManagerFactory tmf = TrustManagerFactory.getInstance(
- TrustManagerFactory.getDefaultAlgorithm());
- tmf.init(ks);
-
- SSLContext sslContext = SSLContext.getInstance("TLS");
- sslContext.init(null, tmf.getTrustManagers(), null);
-
- return sslContext;
- }
-
- protected RestTemplate getRestTemplete() {
- if (customSslContext == null) {
- return new RestTemplate();
- }
- final var sslsf = new SSLConnectionSocketFactory(customSslContext);
- final Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
- .register("https", sslsf).register("http", new PlainConnectionSocketFactory()).build();
-
- final BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager(
- socketFactoryRegistry);
- final CloseableHttpClient httpClient = HttpClients.custom()
- .setConnectionManager(connectionManager).build();
-
- final HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(
- httpClient);
- return new RestTemplate(requestFactory);
- }
}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
index d951c60..7c192c8 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt
@@ -69,6 +69,9 @@ data class GPasConfigProperties(
@ConfigurationProperties(RestTargetProperties.NAME)
data class RestTargetProperties(
val uri: String?,
+ val username: String?,
+ val password: String?,
+ val isBwhc: Boolean = false,
) {
companion object {
const val NAME = "app.rest"
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt
index 0ae2c2f..5fc1120 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt
@@ -20,21 +20,32 @@
package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.databind.ObjectMapper
-import dev.dnpm.etl.processor.monitoring.*
+import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
+import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
+import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
+import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
-import dev.dnpm.etl.processor.services.TokenRepository
-import dev.dnpm.etl.processor.services.TokenService
+import dev.dnpm.etl.processor.security.TokenRepository
+import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.services.Transformation
import dev.dnpm.etl.processor.services.TransformationService
+import org.apache.hc.client5.http.impl.classic.HttpClients
+import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager
+import org.apache.hc.client5.http.socket.ConnectionSocketFactory
+import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory
+import org.apache.hc.core5.http.config.RegistryBuilder
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
+import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
+import org.springframework.http.client.HttpComponentsClientHttpRequestFactory
import org.springframework.retry.RetryCallback
import org.springframework.retry.RetryContext
import org.springframework.retry.RetryListener
@@ -46,6 +57,13 @@ import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.web.client.RestTemplate
import reactor.core.publisher.Sinks
+import java.io.BufferedInputStream
+import java.io.FileInputStream
+import java.security.KeyStore
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import javax.net.ssl.SSLContext
+import javax.net.ssl.TrustManagerFactory
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@@ -70,8 +88,20 @@ class AppConfiguration {
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
@Bean
- fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate): Generator {
- return GpasPseudonymGenerator(configProperties, retryTemplate)
+ fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator {
+ try {
+ if (!configProperties.sslCaLocation.isNullOrBlank()) {
+ return GpasPseudonymGenerator(
+ configProperties,
+ retryTemplate,
+ createCustomGpasRestTemplate(configProperties)
+ )
+ }
+ } catch (e: Exception) {
+ throw RuntimeException(e)
+ }
+
+ return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate)
}
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "BUILDIN", matchIfMissing = true)
@@ -83,8 +113,80 @@ class AppConfiguration {
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
@ConditionalOnMissingBean
@Bean
- fun gpasPseudonymGeneratorOnDeprecatedProperty(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate): Generator {
- return GpasPseudonymGenerator(configProperties, retryTemplate)
+ fun gpasPseudonymGeneratorOnDeprecatedProperty(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator {
+ try {
+ if (!configProperties.sslCaLocation.isNullOrBlank()) {
+ return GpasPseudonymGenerator(
+ configProperties,
+ retryTemplate,
+ createCustomGpasRestTemplate(configProperties)
+ )
+ }
+ } catch (e: Exception) {
+ throw RuntimeException(e)
+ }
+
+ return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate)
+ }
+
+ private fun createCustomGpasRestTemplate(configProperties: GPasConfigProperties): RestTemplate {
+ fun getSslContext(certificateLocation: String): SSLContext? {
+ val ks = KeyStore.getInstance(KeyStore.getDefaultType())
+
+ val fis = FileInputStream(certificateLocation)
+ val ca = CertificateFactory.getInstance("X.509")
+ .generateCertificate(BufferedInputStream(fis)) as X509Certificate
+
+ ks.load(null, null)
+ ks.setCertificateEntry(1.toString(), ca)
+
+ val tmf = TrustManagerFactory.getInstance(
+ TrustManagerFactory.getDefaultAlgorithm()
+ )
+ tmf.init(ks)
+
+ val sslContext = SSLContext.getInstance("TLS")
+ sslContext.init(null, tmf.trustManagers, null)
+
+ return sslContext
+ }
+
+ fun getCustomRestTemplate(customSslContext: SSLContext): RestTemplate {
+ val sslsf = SSLConnectionSocketFactory(customSslContext)
+ val socketFactoryRegistry = RegistryBuilder.create<ConnectionSocketFactory>()
+ .register("https", sslsf).register("http", PlainConnectionSocketFactory()).build()
+
+ val connectionManager = BasicHttpClientConnectionManager(
+ socketFactoryRegistry
+ )
+ val httpClient = HttpClients.custom()
+ .setConnectionManager(connectionManager).build()
+
+ val requestFactory = HttpComponentsClientHttpRequestFactory(
+ httpClient
+ )
+ return RestTemplate(requestFactory)
+ }
+
+ try {
+ if (!configProperties.sslCaLocation.isNullOrBlank()) {
+ val customSslContext = getSslContext(configProperties.sslCaLocation)
+ logger.warn(
+ String.format(
+ "%s has been initialized with SSL certificate %s. This is deprecated in favor of including Root CA.",
+ this.javaClass.name, configProperties.sslCaLocation
+ )
+ )
+
+ if (customSslContext != null) {
+ return getCustomRestTemplate(customSslContext)
+ }
+ }
+ } catch (e: Exception) {
+ throw RuntimeException(e)
+ }
+
+ throw RuntimeException("Custom SSL configuration for gPAS not usable")
}
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "BUILDIN")
@@ -173,5 +275,9 @@ class AppConfiguration {
return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer)
}
+ @Bean
+ fun jdbcConfiguration(): AbstractJdbcConfiguration {
+ return AppJdbcConfiguration()
+ }
}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppJdbcConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppJdbcConfiguration.kt
new file mode 100644
index 0000000..898982c
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppJdbcConfiguration.kt
@@ -0,0 +1,25 @@
+package dev.dnpm.etl.processor.config
+
+import dev.dnpm.etl.processor.Fingerprint
+import org.springframework.context.annotation.Configuration
+import org.springframework.core.convert.converter.Converter
+import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
+
+@Configuration
+class AppJdbcConfiguration : AbstractJdbcConfiguration() {
+ override fun userConverters(): MutableList<*> {
+ return mutableListOf(StringToFingerprintConverter(), FingerprintToStringConverter())
+ }
+}
+
+class StringToFingerprintConverter : Converter<String, Fingerprint> {
+ override fun convert(source: String): Fingerprint {
+ return Fingerprint(source)
+ }
+}
+
+class FingerprintToStringConverter : Converter<Fingerprint, String> {
+ override fun convert(source: Fingerprint): String {
+ return source.value
+ }
+} \ No newline at end of file
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt
index fc2676b..a393267 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppRestConfiguration.kt
@@ -1,7 +1,7 @@
/*
* This file is part of ETL-Processor
*
- * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
+ * Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, 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
@@ -23,7 +23,8 @@ import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
import dev.dnpm.etl.processor.output.MtbFileSender
-import dev.dnpm.etl.processor.output.RestMtbFileSender
+import dev.dnpm.etl.processor.output.RestBwhcMtbFileSender
+import dev.dnpm.etl.processor.output.RestDipMtbFileSender
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
@@ -54,8 +55,13 @@ class AppRestConfiguration {
restTargetProperties: RestTargetProperties,
retryTemplate: RetryTemplate
): MtbFileSender {
- logger.info("Selected 'RestMtbFileSender'")
- return RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
+ if (restTargetProperties.isBwhc) {
+ logger.info("Selected 'RestBwhcMtbFileSender'")
+ return RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
+ }
+
+ logger.info("Selected 'RestDipMtbFileSender'")
+ return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
}
@Bean
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt
index c377555..6b063bd 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt
@@ -21,7 +21,7 @@ package dev.dnpm.etl.processor.config
import dev.dnpm.etl.processor.security.UserRole
import dev.dnpm.etl.processor.security.UserRoleRepository
-import dev.dnpm.etl.processor.services.UserRoleService
+import dev.dnpm.etl.processor.security.UserRoleService
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties
@@ -89,7 +89,7 @@ class AppSecurityConfiguration(
http {
authorizeRequests {
authorize("/configs/**", hasRole("ADMIN"))
- authorize("/mtbfile/**", hasAnyRole("MTBFILE"))
+ authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN", "USER"))
authorize("/report/**", hasAnyRole("ADMIN", "USER"))
authorize("*.css", permitAll)
authorize("*.ico", permitAll)
@@ -147,7 +147,7 @@ class AppSecurityConfiguration(
http {
authorizeRequests {
authorize("/configs/**", hasRole("ADMIN"))
- authorize("/mtbfile/**", hasAnyRole("MTBFILE"))
+ authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN"))
authorize("/report/**", hasRole("ADMIN"))
authorize(anyRequest, permitAll)
}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt b/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt
index de901ce..2aff8cb 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt
@@ -22,6 +22,8 @@ package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile
+import dev.dnpm.etl.processor.PatientId
+import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.services.RequestProcessor
import org.apache.kafka.clients.consumer.ConsumerRecord
import org.slf4j.LoggerFactory
@@ -35,11 +37,12 @@ class KafkaInputListener(
override fun onMessage(data: ConsumerRecord<String, String>) {
val mtbFile = objectMapper.readValue(data.value(), MtbFile::class.java)
+ val patientId = PatientId(mtbFile.patient.id)
val firstRequestIdHeader = data.headers().headers("requestId")?.firstOrNull()
val requestId = if (null != firstRequestIdHeader) {
- String(firstRequestIdHeader.value())
+ RequestId(String(firstRequestIdHeader.value()))
} else {
- ""
+ RequestId("")
}
if (mtbFile.consent.status == Consent.Status.ACTIVE) {
@@ -52,9 +55,9 @@ class KafkaInputListener(
} else {
logger.debug("Accepted MTB File and process deletion")
if (requestId.isBlank()) {
- requestProcessor.processDeletion(mtbFile.patient.id)
+ requestProcessor.processDeletion(patientId)
} else {
- requestProcessor.processDeletion(mtbFile.patient.id, requestId)
+ requestProcessor.processDeletion(patientId, requestId)
}
}
}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt
index 8259288..9e282c2 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt
@@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.input
import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile
+import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.services.RequestProcessor
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity
@@ -46,7 +47,8 @@ class MtbFileRestController(
requestProcessor.processMtbFile(mtbFile)
} else {
logger.debug("Accepted MTB File and process deletion")
- requestProcessor.processDeletion(mtbFile.patient.id)
+ val patientId = PatientId(mtbFile.patient.id)
+ requestProcessor.processDeletion(patientId)
}
return ResponseEntity.accepted().build()
}
@@ -54,7 +56,7 @@ class MtbFileRestController(
@DeleteMapping(path = ["{patientId}"])
fun deleteData(@PathVariable patientId: String): ResponseEntity<Void> {
logger.debug("Accepted patient ID to process deletion")
- requestProcessor.processDeletion(patientId)
+ requestProcessor.processDeletion(PatientId(patientId))
return ResponseEntity.accepted().build()
}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt
index 81ad922..9d96654 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt
@@ -26,22 +26,18 @@ import jakarta.annotation.PostConstruct
import org.apache.kafka.clients.consumer.Consumer
import org.apache.kafka.common.errors.TimeoutException
import org.springframework.beans.factory.annotation.Qualifier
-import org.springframework.http.HttpEntity
-import org.springframework.http.HttpHeaders
-import org.springframework.http.HttpMethod
-import org.springframework.http.HttpStatus
-import org.springframework.http.MediaType
-import org.springframework.http.RequestEntity
+import org.springframework.http.*
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder
import reactor.core.publisher.Sinks
+import java.time.Instant
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
interface ConnectionCheckService {
- fun connectionAvailable(): Boolean
+ fun connectionAvailable(): ConnectionCheckResult
}
@@ -51,9 +47,27 @@ sealed class ConnectionCheckResult {
abstract val available: Boolean
- data class KafkaConnectionCheckResult(override val available: Boolean) : ConnectionCheckResult()
- data class RestConnectionCheckResult(override val available: Boolean) : ConnectionCheckResult()
- data class GPasConnectionCheckResult(override val available: Boolean) : ConnectionCheckResult()
+ abstract val timestamp: Instant
+
+ abstract val lastChange: Instant
+
+ data class KafkaConnectionCheckResult(
+ override val available: Boolean,
+ override val timestamp: Instant,
+ override val lastChange: Instant
+ ) : ConnectionCheckResult()
+
+ data class RestConnectionCheckResult(
+ override val available: Boolean,
+ override val timestamp: Instant,
+ override val lastChange: Instant
+ ) : ConnectionCheckResult()
+
+ data class GPasConnectionCheckResult(
+ override val available: Boolean,
+ override val timestamp: Instant,
+ override val lastChange: Instant
+ ) : ConnectionCheckResult()
}
class KafkaConnectionCheckService(
@@ -62,25 +76,33 @@ class KafkaConnectionCheckService(
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) : OutputConnectionCheckService {
- private var connectionAvailable: Boolean = false
-
+ private var result = ConnectionCheckResult.KafkaConnectionCheckResult(false, Instant.now(), Instant.now())
@PostConstruct
@Scheduled(cron = "0 * * * * *")
fun check() {
- connectionAvailable = try {
- null != consumer.listTopics(5.seconds.toJavaDuration())
+ result = try {
+ val available = null != consumer.listTopics(5.seconds.toJavaDuration())
+ ConnectionCheckResult.KafkaConnectionCheckResult(
+ available,
+ Instant.now(),
+ if (result.available == available) { result.lastChange } else { Instant.now() }
+ )
} catch (e: TimeoutException) {
- false
+ ConnectionCheckResult.KafkaConnectionCheckResult(
+ false,
+ Instant.now(),
+ if (!result.available) { result.lastChange } else { Instant.now() }
+ )
}
connectionCheckUpdateProducer.emitNext(
- ConnectionCheckResult.KafkaConnectionCheckResult(connectionAvailable),
+ result,
Sinks.EmitFailureHandler.FAIL_FAST
)
}
- override fun connectionAvailable(): Boolean {
- return this.connectionAvailable
+ override fun connectionAvailable(): ConnectionCheckResult.KafkaConnectionCheckResult {
+ return this.result
}
}
@@ -92,27 +114,45 @@ class RestConnectionCheckService(
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) : OutputConnectionCheckService {
- private var connectionAvailable: Boolean = false
+ private var result = ConnectionCheckResult.RestConnectionCheckResult(false, Instant.now(), Instant.now())
@PostConstruct
@Scheduled(cron = "0 * * * * *")
fun check() {
- connectionAvailable = try {
- restTemplate.getForEntity(
- restTargetProperties.uri?.replace("/etl/api", "").toString(),
+ result = try {
+ val available = restTemplate.getForEntity(
+ if (restTargetProperties.isBwhc) {
+ UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString()).path("").toUriString()
+ } else {
+ UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString())
+ .pathSegment("mtb")
+ .pathSegment("kaplan-meier")
+ .pathSegment("config")
+ .toUriString()
+ },
String::class.java
).statusCode == HttpStatus.OK
+
+ ConnectionCheckResult.RestConnectionCheckResult(
+ available,
+ Instant.now(),
+ if (result.available == available) { result.lastChange } else { Instant.now() }
+ )
} catch (e: Exception) {
- false
+ ConnectionCheckResult.RestConnectionCheckResult(
+ false,
+ Instant.now(),
+ if (!result.available) { result.lastChange } else { Instant.now() }
+ )
}
connectionCheckUpdateProducer.emitNext(
- ConnectionCheckResult.RestConnectionCheckResult(connectionAvailable),
+ result,
Sinks.EmitFailureHandler.FAIL_FAST
)
}
- override fun connectionAvailable(): Boolean {
- return this.connectionAvailable
+ override fun connectionAvailable(): ConnectionCheckResult.RestConnectionCheckResult {
+ return this.result
}
}
@@ -123,40 +163,48 @@ class GPasConnectionCheckService(
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) : ConnectionCheckService {
- private var connectionAvailable: Boolean = false
+ private var result = ConnectionCheckResult.GPasConnectionCheckResult(false, Instant.now(), Instant.now())
@PostConstruct
@Scheduled(cron = "0 * * * * *")
fun check() {
- connectionAvailable = try {
+ result = try {
val uri = UriComponentsBuilder.fromUriString(
- gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/\$pseudonymize").toString()
- )
- .queryParam("target", gPasConfigProperties.target)
- .queryParam("original", "???")
- .build().toUri()
+ gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/metadata").toString()
+ ).build().toUri()
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
if (!gPasConfigProperties.username.isNullOrBlank() && !gPasConfigProperties.password.isNullOrBlank()) {
headers.setBasicAuth(gPasConfigProperties.username, gPasConfigProperties.password)
}
- restTemplate.exchange(
+
+ val available = restTemplate.exchange(
uri,
HttpMethod.GET,
HttpEntity<Void>(headers),
Void::class.java
).statusCode == HttpStatus.OK
+
+ ConnectionCheckResult.GPasConnectionCheckResult(
+ available,
+ Instant.now(),
+ if (result.available == available) { result.lastChange } else { Instant.now() }
+ )
} catch (e: Exception) {
- false
+ ConnectionCheckResult.GPasConnectionCheckResult(
+ false,
+ Instant.now(),
+ if (!result.available) { result.lastChange } else { Instant.now() }
+ )
}
connectionCheckUpdateProducer.emitNext(
- ConnectionCheckResult.GPasConnectionCheckResult(connectionAvailable),
+ result,
Sinks.EmitFailureHandler.FAIL_FAST
)
}
- override fun connectionAvailable(): Boolean {
- return this.connectionAvailable
+ override fun connectionAvailable(): ConnectionCheckResult.GPasConnectionCheckResult {
+ return this.result
}
} \ No newline at end of file
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt
index 97ecd05..062f749 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt
@@ -19,6 +19,7 @@
package dev.dnpm.etl.processor.monitoring
+import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonValue
import com.fasterxml.jackson.core.JsonParseException
@@ -54,7 +55,7 @@ class ReportService(
private data class DataQualityReport(val issues: List<Issue>)
@JsonIgnoreProperties(ignoreUnknown = true)
- data class Issue(val severity: Severity, val message: String)
+ data class Issue(val severity: Severity, @JsonAlias("details") val message: String)
enum class Severity(@JsonValue val value: String) {
FATAL("fatal"),
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt
index 028b4a3..36c9705 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/Request.kt
@@ -19,10 +19,12 @@
package dev.dnpm.etl.processor.monitoring
+import dev.dnpm.etl.processor.*
import org.springframework.data.annotation.Id
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jdbc.repository.query.Query
+import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Embedded
import org.springframework.data.relational.core.mapping.Table
import org.springframework.data.repository.CrudRepository
@@ -30,26 +32,48 @@ import org.springframework.data.repository.PagingAndSortingRepository
import java.time.Instant
import java.util.*
-typealias RequestId = UUID
-
@Table("request")
data class Request(
@Id val id: Long? = null,
- val uuid: String = RequestId.randomUUID().toString(),
- val patientId: String,
- val pid: String,
- val fingerprint: String,
+ val uuid: RequestId = randomRequestId(),
+ val patientPseudonym: PatientPseudonym,
+ val pid: PatientId,
+ @Column("fingerprint")
+ val fingerprint: Fingerprint,
val type: RequestType,
var status: RequestStatus,
var processedAt: Instant = Instant.now(),
@Embedded.Nullable var report: Report? = null
-)
+) {
+ constructor(
+ uuid: RequestId,
+ patientPseudonym: PatientPseudonym,
+ pid: PatientId,
+ fingerprint: Fingerprint,
+ type: RequestType,
+ status: RequestStatus
+ ) :
+ this(null, uuid, patientPseudonym, pid, fingerprint, type, status, Instant.now())
+
+ constructor(
+ uuid: RequestId,
+ patientPseudonym: PatientPseudonym,
+ pid: PatientId,
+ fingerprint: Fingerprint,
+ type: RequestType,
+ status: RequestStatus,
+ processedAt: Instant
+ ) :
+ this(null, uuid, patientPseudonym, pid, fingerprint, type, status, processedAt)
+}
+@JvmRecord
data class Report(
val description: String,
val dataQualityReport: String = ""
)
+@JvmRecord
data class CountedState(
val count: Int,
val status: RequestStatus,
@@ -57,17 +81,17 @@ data class CountedState(
interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRepository<Request, Long> {
- fun findAllByPatientIdOrderByProcessedAtDesc(patientId: String): List<Request>
+ fun findAllByPatientPseudonymOrderByProcessedAtDesc(patientId: PatientPseudonym): List<Request>
- fun findByUuidEquals(uuid: String): Optional<Request>
+ fun findByUuidEquals(uuid: RequestId): Optional<Request>
- fun findRequestByPatientId(patientId: String, pageable: Pageable): Page<Request>
+ fun findRequestByPatientPseudonym(patientPseudonym: PatientPseudonym, pageable: Pageable): Page<Request>
@Query("SELECT count(*) AS count, status FROM request WHERE type = 'MTB_FILE' GROUP BY status ORDER BY status, count DESC;")
fun countStates(): List<CountedState>
@Query("SELECT count(*) AS count, status FROM (" +
- "SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " +
+ "SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " +
"WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " +
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
fun findPatientUniqueStates(): List<CountedState>
@@ -76,7 +100,7 @@ interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRep
fun countDeleteStates(): List<CountedState>
@Query("SELECT count(*) AS count, status FROM (" +
- "SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " +
+ "SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " +
"WHERE type = 'DELETE'" +
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
fun findPatientUniqueDeleteStates(): List<CountedState>
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt
index fc5d617..4838689 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt
@@ -22,6 +22,7 @@ package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile
+import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.config.KafkaProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.slf4j.LoggerFactory
@@ -62,7 +63,7 @@ class KafkaMtbFileSender(
val dummyMtbFile = MtbFile.builder()
.withConsent(
Consent.builder()
- .withPatient(request.patientId)
+ .withPatient(request.patientId.value)
.withStatus(Consent.Status.REJECTED)
.build()
)
@@ -98,8 +99,8 @@ class KafkaMtbFileSender(
}
private fun key(request: MtbFileSender.DeleteRequest): String {
- return "{\"pid\": \"${request.patientId}\"}"
+ return "{\"pid\": \"${request.patientId.value}\"}"
}
- data class Data(val requestId: String, val content: MtbFile)
+ data class Data(val requestId: RequestId, val content: MtbFile)
} \ No newline at end of file
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt
index aca972b..8d994c5 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt
@@ -20,6 +20,8 @@
package dev.dnpm.etl.processor.output
import de.ukw.ccc.bwhc.dto.MtbFile
+import dev.dnpm.etl.processor.PatientPseudonym
+import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.springframework.http.HttpStatusCode
@@ -32,9 +34,9 @@ interface MtbFileSender {
data class Response(val status: RequestStatus, val body: String = "")
- data class MtbFileRequest(val requestId: String, val mtbFile: MtbFile)
+ data class MtbFileRequest(val requestId: RequestId, val mtbFile: MtbFile)
- data class DeleteRequest(val requestId: String, val patientId: String)
+ data class DeleteRequest(val requestId: RequestId, val patientId: PatientPseudonym)
}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt
new file mode 100644
index 0000000..f4a58e8
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt
@@ -0,0 +1,49 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.output
+
+import dev.dnpm.etl.processor.PatientPseudonym
+import dev.dnpm.etl.processor.config.RestTargetProperties
+import org.springframework.retry.support.RetryTemplate
+import org.springframework.web.client.RestTemplate
+import org.springframework.web.util.UriComponentsBuilder
+
+class RestBwhcMtbFileSender(
+ restTemplate: RestTemplate,
+ private val restTargetProperties: RestTargetProperties,
+ retryTemplate: RetryTemplate
+) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) {
+
+ override fun sendUrl(): String {
+ return UriComponentsBuilder
+ .fromUriString(restTargetProperties.uri.toString())
+ .pathSegment("MTBFile")
+ .toUriString()
+ }
+
+ override fun deleteUrl(patientId: PatientPseudonym): String {
+ return UriComponentsBuilder
+ .fromUriString(restTargetProperties.uri.toString())
+ .pathSegment("Patient")
+ .pathSegment(patientId.value)
+ .toUriString()
+ }
+
+} \ No newline at end of file
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt
new file mode 100644
index 0000000..42dbb30
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt
@@ -0,0 +1,53 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.output
+
+import dev.dnpm.etl.processor.PatientPseudonym
+import dev.dnpm.etl.processor.config.RestTargetProperties
+import org.springframework.retry.support.RetryTemplate
+import org.springframework.web.client.RestTemplate
+import org.springframework.web.util.UriComponentsBuilder
+
+class RestDipMtbFileSender(
+ restTemplate: RestTemplate,
+ private val restTargetProperties: RestTargetProperties,
+ retryTemplate: RetryTemplate
+) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) {
+
+ override fun sendUrl(): String {
+ return UriComponentsBuilder
+ .fromUriString(restTargetProperties.uri.toString())
+ .pathSegment("mtb")
+ .pathSegment("etl")
+ .pathSegment("patient-record")
+ .toUriString()
+ }
+
+ override fun deleteUrl(patientId: PatientPseudonym): String {
+ return UriComponentsBuilder
+ .fromUriString(restTargetProperties.uri.toString())
+ .pathSegment("mtb")
+ .pathSegment("etl")
+ .pathSegment("patient")
+ .pathSegment(patientId.value)
+ .toUriString()
+ }
+
+} \ No newline at end of file
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt
index e1aecb7..5ea42e3 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt
@@ -21,15 +21,17 @@ package dev.dnpm.etl.processor.output
import dev.dnpm.etl.processor.config.RestTargetProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus
+import dev.dnpm.etl.processor.PatientPseudonym
import org.slf4j.LoggerFactory
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.retry.support.RetryTemplate
import org.springframework.web.client.RestClientException
+import org.springframework.web.client.RestClientResponseException
import org.springframework.web.client.RestTemplate
-class RestMtbFileSender(
+abstract class RestMtbFileSender(
private val restTemplate: RestTemplate,
private val restTargetProperties: RestTargetProperties,
private val retryTemplate: RetryTemplate
@@ -37,14 +39,17 @@ class RestMtbFileSender(
private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java)
+ abstract fun sendUrl(): String
+
+ abstract fun deleteUrl(patientId: PatientPseudonym): String
+
override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response {
try {
return retryTemplate.execute<MtbFileSender.Response, Exception> {
- val headers = HttpHeaders()
- headers.contentType = MediaType.APPLICATION_JSON
+ val headers = getHttpHeaders()
val entityReq = HttpEntity(request.mtbFile, headers)
val response = restTemplate.postForEntity(
- "${restTargetProperties.uri}/MTBFile",
+ sendUrl(),
entityReq,
String::class.java
)
@@ -60,9 +65,10 @@ class RestMtbFileSender(
}
} catch (e: IllegalArgumentException) {
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
- } catch (e: RestClientException) {
+ } catch (e: RestClientResponseException) {
logger.info(restTargetProperties.uri!!.toString())
- logger.error("Cannot send data to remote system", e)
+ logger.error("Request data not accepted by remote system", e)
+ return MtbFileSender.Response(e.statusCode.asRequestStatus(), e.responseBodyAsString)
}
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
}
@@ -70,11 +76,10 @@ class RestMtbFileSender(
override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response {
try {
return retryTemplate.execute<MtbFileSender.Response, Exception> {
- val headers = HttpHeaders()
- headers.contentType = MediaType.APPLICATION_JSON
+ val headers = getHttpHeaders()
val entityReq = HttpEntity(null, headers)
restTemplate.delete(
- "${restTargetProperties.uri}/Patient/${request.patientId}",
+ deleteUrl(request.patientId),
entityReq,
String::class.java
)
@@ -94,4 +99,18 @@ class RestMtbFileSender(
return this.restTargetProperties.uri.orEmpty()
}
+ private fun getHttpHeaders(): HttpHeaders {
+ val username = restTargetProperties.username
+ val password = restTargetProperties.password
+ val headers = HttpHeaders()
+ headers.setContentType(MediaType.APPLICATION_JSON)
+
+ if (username.isNullOrBlank() || password.isNullOrBlank()) {
+ return headers
+ }
+
+ headers.setBasicAuth(username, password)
+ return headers
+ }
+
} \ No newline at end of file
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt
index d18cd2c..e80f6ec 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt
@@ -19,6 +19,8 @@
package dev.dnpm.etl.processor.pseudonym
+import dev.dnpm.etl.processor.PatientId
+import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties
class PseudonymizeService(
@@ -26,10 +28,10 @@ class PseudonymizeService(
private val configProperties: PseudonymizeConfigProperties
) {
- fun patientPseudonym(patientId: String): String {
+ fun patientPseudonym(patientId: PatientId): PatientPseudonym {
return when (generator) {
- is GpasPseudonymGenerator -> generator.generate(patientId)
- else -> "${configProperties.prefix}_${generator.generate(patientId)}"
+ is GpasPseudonymGenerator -> PatientPseudonym(generator.generate(patientId.value))
+ else -> PatientPseudonym("${configProperties.prefix}_${generator.generate(patientId.value)}")
}
}
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt
index ef25787..bf645f6 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt
@@ -20,6 +20,7 @@
package dev.dnpm.etl.processor.pseudonym
import de.ukw.ccc.bwhc.dto.MtbFile
+import dev.dnpm.etl.processor.PatientId
import org.apache.commons.codec.digest.DigestUtils
/** Replaces patient ID with generated patient pseudonym
@@ -29,7 +30,7 @@ import org.apache.commons.codec.digest.DigestUtils
* @return The MTB file containing patient pseudonymes
*/
infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
- val patientPseudonym = pseudonymizeService.patientPseudonym(this.patient.id)
+ val patientPseudonym = pseudonymizeService.patientPseudonym(PatientId(this.patient.id)).value
this.episode?.patient = patientPseudonym
this.carePlans?.forEach { it.patient = patientPseudonym }
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/TokenService.kt b/src/main/kotlin/dev/dnpm/etl/processor/security/TokenService.kt
index f084408..44b04e8 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/services/TokenService.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/security/TokenService.kt
@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-package dev.dnpm.etl.processor.services
+package dev.dnpm.etl.processor.security
import jakarta.annotation.PostConstruct
import org.springframework.data.annotation.Id
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/UserRoleService.kt b/src/main/kotlin/dev/dnpm/etl/processor/security/UserRoleService.kt
index 6649f7d..174f8a9 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/services/UserRoleService.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/security/UserRoleService.kt
@@ -17,11 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-package dev.dnpm.etl.processor.services
+package dev.dnpm.etl.processor.security
-import dev.dnpm.etl.processor.security.Role
-import dev.dnpm.etl.processor.security.UserRole
-import dev.dnpm.etl.processor.security.UserRoleRepository
import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.oauth2.core.oidc.user.OidcUser
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt
index bdf07cb..5b2c42a 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt
@@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.services
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.MtbFile
+import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.config.AppConfigProperties
import dev.dnpm.etl.processor.monitoring.Report
import dev.dnpm.etl.processor.monitoring.Request
@@ -49,25 +50,27 @@ class RequestProcessor(
) {
fun processMtbFile(mtbFile: MtbFile) {
- processMtbFile(mtbFile, UUID.randomUUID().toString())
+ processMtbFile(mtbFile, randomRequestId())
}
- fun processMtbFile(mtbFile: MtbFile, requestId: String) {
- val pid = mtbFile.patient.id
+ fun processMtbFile(mtbFile: MtbFile, requestId: RequestId) {
+ val pid = PatientId(mtbFile.patient.id)
mtbFile pseudonymizeWith pseudonymizeService
mtbFile anonymizeContentWith pseudonymizeService
val request = MtbFileSender.MtbFileRequest(requestId, transformationService.transform(mtbFile))
+ val patientPseudonym = PatientPseudonym(request.mtbFile.patient.id)
+
requestService.save(
Request(
- uuid = requestId,
- patientId = request.mtbFile.patient.id,
- pid = pid,
- fingerprint = fingerprint(request.mtbFile),
- status = RequestStatus.UNKNOWN,
- type = RequestType.MTB_FILE
+ requestId,
+ patientPseudonym,
+ pid,
+ fingerprint(request.mtbFile),
+ RequestType.MTB_FILE,
+ RequestStatus.UNKNOWN
)
)
@@ -90,7 +93,7 @@ class RequestProcessor(
Instant.now(),
responseStatus.status,
when (responseStatus.status) {
- RequestStatus.WARNING -> Optional.of(responseStatus.body)
+ RequestStatus.ERROR, RequestStatus.WARNING -> Optional.of(responseStatus.body)
else -> Optional.empty()
}
)
@@ -98,31 +101,33 @@ class RequestProcessor(
}
private fun isDuplication(pseudonymizedMtbFile: MtbFile): Boolean {
+ val patientPseudonym = PatientPseudonym(pseudonymizedMtbFile.patient.id)
+
val lastMtbFileRequestForPatient =
- requestService.lastMtbFileRequestForPatientPseudonym(pseudonymizedMtbFile.patient.id)
- val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(pseudonymizedMtbFile.patient.id)
+ requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym)
+ val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym)
return null != lastMtbFileRequestForPatient
&& !isLastRequestDeletion
&& lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFile)
}
- fun processDeletion(patientId: String) {
- processDeletion(patientId, UUID.randomUUID().toString())
+ fun processDeletion(patientId: PatientId) {
+ processDeletion(patientId, randomRequestId())
}
- fun processDeletion(patientId: String, requestId: String) {
+ fun processDeletion(patientId: PatientId, requestId: RequestId) {
try {
val patientPseudonym = pseudonymizeService.patientPseudonym(patientId)
requestService.save(
Request(
- uuid = requestId,
- patientId = patientPseudonym,
- pid = patientId,
- fingerprint = fingerprint(patientPseudonym),
- status = RequestStatus.UNKNOWN,
- type = RequestType.DELETE
+ requestId,
+ patientPseudonym,
+ patientId,
+ fingerprint(patientPseudonym.value),
+ RequestType.DELETE,
+ RequestStatus.UNKNOWN
)
)
@@ -144,9 +149,9 @@ class RequestProcessor(
requestService.save(
Request(
uuid = requestId,
- patientId = "???",
+ patientPseudonym = emptyPatientPseudonym(),
pid = patientId,
- fingerprint = "",
+ fingerprint = Fingerprint.empty(),
status = RequestStatus.ERROR,
type = RequestType.DELETE,
report = Report("Fehler bei der Pseudonymisierung")
@@ -155,14 +160,16 @@ class RequestProcessor(
}
}
- private fun fingerprint(mtbFile: MtbFile): String {
+ private fun fingerprint(mtbFile: MtbFile): Fingerprint {
return fingerprint(objectMapper.writeValueAsString(mtbFile))
}
- private fun fingerprint(s: String): String {
- return Base32().encodeAsString(DigestUtils.sha256(s))
- .replace("=", "")
- .lowercase()
+ private fun fingerprint(s: String): Fingerprint {
+ return Fingerprint(
+ Base32().encodeAsString(DigestUtils.sha256(s))
+ .replace("=", "")
+ .lowercase()
+ )
}
} \ No newline at end of file
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt
index e0043d2..757b353 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestService.kt
@@ -19,11 +19,13 @@
package dev.dnpm.etl.processor.services
-import dev.dnpm.etl.processor.monitoring.Request
-import dev.dnpm.etl.processor.monitoring.RequestRepository
-import dev.dnpm.etl.processor.monitoring.RequestStatus
-import dev.dnpm.etl.processor.monitoring.RequestType
+import dev.dnpm.etl.processor.PatientPseudonym
+import dev.dnpm.etl.processor.RequestId
+import dev.dnpm.etl.processor.monitoring.*
+import org.springframework.data.domain.Page
+import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
+import java.util.*
@Service
class RequestService(
@@ -32,15 +34,32 @@ class RequestService(
fun save(request: Request) = requestRepository.save(request)
- fun allRequestsByPatientPseudonym(patientPseudonym: String) = requestRepository
- .findAllByPatientIdOrderByProcessedAtDesc(patientPseudonym)
+ fun findAll(): Iterable<Request> = requestRepository.findAll()
- fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: String) =
+ fun findAll(pageable: Pageable): Page<Request> = requestRepository.findAll(pageable)
+
+ fun findByUuid(uuid: RequestId): Optional<Request> =
+ requestRepository.findByUuidEquals(uuid)
+
+ fun findRequestByPatientId(patientPseudonym: PatientPseudonym, pageable: Pageable): Page<Request> = requestRepository.findRequestByPatientPseudonym(patientPseudonym, pageable)
+
+ fun allRequestsByPatientPseudonym(patientPseudonym: PatientPseudonym) = requestRepository
+ .findAllByPatientPseudonymOrderByProcessedAtDesc(patientPseudonym)
+
+ fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: PatientPseudonym) =
Companion.lastMtbFileRequestForPatientPseudonym(allRequestsByPatientPseudonym(patientPseudonym))
- fun isLastRequestWithKnownStatusDeletion(patientPseudonym: String) =
+ fun isLastRequestWithKnownStatusDeletion(patientPseudonym: PatientPseudonym) =
Companion.isLastRequestWithKnownStatusDeletion(allRequestsByPatientPseudonym(patientPseudonym))
+ fun countStates(): Iterable<CountedState> = requestRepository.countStates()
+
+ fun countDeleteStates(): Iterable<CountedState> = requestRepository.countDeleteStates()
+
+ fun findPatientUniqueStates(): List<CountedState> = requestRepository.findPatientUniqueStates()
+
+ fun findPatientUniqueDeleteStates(): List<CountedState> = requestRepository.findPatientUniqueDeleteStates()
+
companion object {
fun lastMtbFileRequestForPatientPseudonym(allRequests: List<Request>) = allRequests
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/ResponseProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/ResponseProcessor.kt
index 4048348..ecb2ec7 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/services/ResponseProcessor.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/services/ResponseProcessor.kt
@@ -19,8 +19,8 @@
package dev.dnpm.etl.processor.services
+import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.Report
-import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.slf4j.LoggerFactory
import org.springframework.context.event.EventListener
@@ -31,7 +31,7 @@ import java.util.*
@Service
class ResponseProcessor(
- private val requestRepository: RequestRepository,
+ private val requestService: RequestService,
private val statisticsUpdateProducer: Sinks.Many<Any>
) {
@@ -39,7 +39,7 @@ class ResponseProcessor(
@EventListener(classes = [ResponseEvent::class])
fun handleResponseEvent(event: ResponseEvent) {
- requestRepository.findByUuidEquals(event.requestUuid).ifPresentOrElse({
+ requestService.findByUuid(event.requestUuid).ifPresentOrElse({
it.processedAt = event.timestamp
it.status = event.status
@@ -76,7 +76,7 @@ class ResponseProcessor(
}
}
- requestRepository.save(it)
+ requestService.save(it)
statisticsUpdateProducer.emitNext("", Sinks.EmitFailureHandler.FAIL_FAST)
}, {
@@ -87,7 +87,7 @@ class ResponseProcessor(
}
data class ResponseEvent(
- val requestUuid: String,
+ val requestUuid: RequestId,
val timestamp: Instant,
val status: RequestStatus,
val body: Optional<String> = Optional.empty()
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt
index a29010f..12e824d 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/services/kafka/KafkaResponseProcessor.kt
@@ -22,6 +22,7 @@ package dev.dnpm.etl.processor.services.kafka
import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.ObjectMapper
+import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.output.asRequestStatus
import dev.dnpm.etl.processor.services.ResponseEvent
@@ -47,7 +48,7 @@ class KafkaResponseProcessor(
Optional.empty()
}.ifPresentOrElse({ responseBody ->
val event = ResponseEvent(
- responseBody.requestId,
+ RequestId(responseBody.requestId),
Instant.ofEpochMilli(data.timestamp()),
responseBody.statusCode.asRequestStatus(),
when (responseBody.statusCode.asRequestStatus()) {
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/types.kt b/src/main/kotlin/dev/dnpm/etl/processor/types.kt
new file mode 100644
index 0000000..b2f13ef
--- /dev/null
+++ b/src/main/kotlin/dev/dnpm/etl/processor/types.kt
@@ -0,0 +1,49 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor
+
+import java.util.*
+
+class Fingerprint(val value: String) {
+ override fun hashCode() = value.hashCode()
+
+ override fun equals(other: Any?) = other is Fingerprint && other.value == value
+
+ companion object {
+ fun empty() = Fingerprint("")
+ }
+}
+
+@JvmInline
+value class RequestId(val value: String) {
+
+ fun isBlank() = value.isBlank()
+
+}
+
+fun randomRequestId() = RequestId(UUID.randomUUID().toString())
+
+@JvmInline
+value class PatientId(val value: String)
+
+@JvmInline
+value class PatientPseudonym(val value: String)
+
+fun emptyPatientPseudonym() = PatientPseudonym("") \ No newline at end of file
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt
index eb9d541..25ec7cc 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/web/ConfigController.kt
@@ -27,10 +27,10 @@ import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.security.Role
import dev.dnpm.etl.processor.security.UserRole
-import dev.dnpm.etl.processor.services.Token
-import dev.dnpm.etl.processor.services.TokenService
+import dev.dnpm.etl.processor.security.Token
+import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.services.TransformationService
-import dev.dnpm.etl.processor.services.UserRoleService
+import dev.dnpm.etl.processor.security.UserRoleService
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.MediaType
import org.springframework.http.codec.ServerSentEvent
@@ -56,7 +56,7 @@ class ConfigController(
@GetMapping
fun index(model: Model): String {
val outputConnectionAvailable =
- connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().first().connectionAvailable()
+ connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().firstOrNull()?.connectionAvailable()
val gPasConnectionAvailable =
connectionCheckServices.filterIsInstance<GPasConnectionCheckService>().firstOrNull()?.connectionAvailable()
@@ -127,10 +127,11 @@ class ConfigController(
} else {
model.addAttribute("tokensEnabled", true)
val result = tokenService.addToken(name)
- if (result.isSuccess) {
- model.addAttribute("newTokenValue", result.getOrDefault(""))
+ result.onSuccess {
+ model.addAttribute("newTokenValue", it)
model.addAttribute("success", true)
- } else {
+ }
+ result.onFailure {
model.addAttribute("success", false)
}
model.addAttribute("tokens", tokenService.findAll())
@@ -182,6 +183,7 @@ class ConfigController(
}
@GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
+ @ResponseBody
fun events(): Flux<ServerSentEvent<Any>> {
return connectionCheckUpdateProducer.asFlux().map {
val event = when (it) {
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt
index 6a256aa..54920b1 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/web/HomeController.kt
@@ -20,9 +20,10 @@
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.NotFoundException
+import dev.dnpm.etl.processor.PatientPseudonym
+import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.ReportService
-import dev.dnpm.etl.processor.monitoring.RequestId
-import dev.dnpm.etl.processor.monitoring.RequestRepository
+import dev.dnpm.etl.processor.services.RequestService
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault
@@ -35,7 +36,7 @@ import org.springframework.web.bind.annotation.RequestMapping
@Controller
@RequestMapping(path = ["/"])
class HomeController(
- private val requestRepository: RequestRepository,
+ private val requestService: RequestService,
private val reportService: ReportService
) {
@@ -44,20 +45,20 @@ class HomeController(
@PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable,
model: Model
): String {
- val requests = requestRepository.findAll(pageable)
+ val requests = requestService.findAll(pageable)
model.addAttribute("requests", requests)
return "index"
}
- @GetMapping(path = ["patient/{patientId}"])
+ @GetMapping(path = ["patient/{patientPseudonym}"])
fun byPatient(
- @PathVariable patientId: String,
+ @PathVariable patientPseudonym: PatientPseudonym,
@PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable,
model: Model
): String {
- val requests = requestRepository.findRequestByPatientId(patientId, pageable)
- model.addAttribute("patientId", patientId)
+ val requests = requestService.findRequestByPatientId(patientPseudonym, pageable)
+ model.addAttribute("patientPseudonym", patientPseudonym.value)
model.addAttribute("requests", requests)
return "index"
@@ -65,7 +66,7 @@ class HomeController(
@GetMapping(path = ["/report/{id}"])
fun report(@PathVariable id: RequestId, model: Model): String {
- val request = requestRepository.findByUuidEquals(id.toString()).orElse(null) ?: throw NotFoundException()
+ val request = requestService.findByUuid(id).orElse(null) ?: throw NotFoundException()
model.addAttribute("request", request)
model.addAttribute("issues", reportService.deserialize(request.report?.dataQualityReport))
diff --git a/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt b/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt
index daa6af3..c034cb4 100644
--- a/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt
+++ b/src/main/kotlin/dev/dnpm/etl/processor/web/StatisticsRestController.kt
@@ -19,9 +19,9 @@
package dev.dnpm.etl.processor.web
-import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType
+import dev.dnpm.etl.processor.services.RequestService
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.MediaType
import org.springframework.http.codec.ServerSentEvent
@@ -41,15 +41,15 @@ import java.time.temporal.ChronoUnit
class StatisticsRestController(
@Qualifier("statisticsUpdateProducer")
private val statisticsUpdateProducer: Sinks.Many<Any>,
- private val requestRepository: RequestRepository
+ private val requestService: RequestService
) {
@GetMapping(path = ["requeststates"])
fun requestStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
val states = if (delete) {
- requestRepository.countDeleteStates()
+ requestService.countDeleteStates()
} else {
- requestRepository.countStates()
+ requestService.countStates()
}
return states
@@ -79,7 +79,7 @@ class StatisticsRestController(
}
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin"))
- val data = requestRepository.findAll()
+ val data = requestService.findAll()
.filter { it.type == requestType }
.filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) }
.groupBy { formatter.format(it.processedAt) }
@@ -115,9 +115,9 @@ class StatisticsRestController(
@GetMapping(path = ["requestpatientstates"])
fun requestPatientStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
val states = if (delete) {
- requestRepository.findPatientUniqueDeleteStates()
+ requestService.findPatientUniqueDeleteStates()
} else {
- requestRepository.findPatientUniqueStates()
+ requestService.findPatientUniqueStates()
}
return states.map {
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index 3d4827c..895f026 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -3,17 +3,34 @@ spring:
compose:
file: ./dev-compose.yml
+ security:
+ oauth2:
+ client:
+ registration:
+ custom:
+ client-name: App-Dev
+ client-id: app-dev
+ client-secret: very-secret-ae3f7a-5a9f-1190
+ scope:
+ - openid
+ provider:
+ custom:
+ issuer-uri: https://dnpm.dev/auth/realms/intern
+ user-name-attribute: name
+
app:
- #rest:
- # uri: http://localhost:9000/bwhc/etl/api
- kafka:
- topic: test
- response-topic: test_response
- servers: localhost:9094
- #security:
- # admin-user: admin
- # admin-password: "{noop}very-secret"
+ rest:
+ uri: http://localhost:9000/bwhc/etl/api
+ #kafka:
+ # topic: test
+ # response-topic: test_response
+ # servers: localhost:9094
+ security:
+ admin-user: admin
+ admin-password: "{noop}very-secret"
+ enable-oidc: "true"
server:
port: 8000
+
diff --git a/src/main/resources/db/migration/mariadb/V0_4_0__RenamePatientPseudonym.sql b/src/main/resources/db/migration/mariadb/V0_4_0__RenamePatientPseudonym.sql
new file mode 100644
index 0000000..bb2b0cc
--- /dev/null
+++ b/src/main/resources/db/migration/mariadb/V0_4_0__RenamePatientPseudonym.sql
@@ -0,0 +1 @@
+ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym; \ No newline at end of file
diff --git a/src/main/resources/db/migration/postgresql/V0_4_0__RenamePatientPseudonym.sql b/src/main/resources/db/migration/postgresql/V0_4_0__RenamePatientPseudonym.sql
new file mode 100644
index 0000000..bb2b0cc
--- /dev/null
+++ b/src/main/resources/db/migration/postgresql/V0_4_0__RenamePatientPseudonym.sql
@@ -0,0 +1 @@
+ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym; \ No newline at end of file
diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css
index 1dd68ed..c6a8c33 100644
--- a/src/main/resources/static/style.css
+++ b/src/main/resources/static/style.css
@@ -22,6 +22,10 @@
--bg-gray-op: rgba(112, 128, 144, .35);
}
+* {
+ font-family: sans-serif;
+}
+
html {
background: linear-gradient(-5deg, var(--bg-blue-op), transparent 10em);
min-height: 100vh;
@@ -30,7 +34,6 @@ html {
body {
margin: 0 0 5em 0;
- font-family: sans-serif;
font-size: .8rem;
color: var(--text);
@@ -619,6 +622,10 @@ input.inline:focus-visible {
text-align: center;
}
+.notification.info {
+ color: var(--bg-blue);
+}
+
.notification.success {
color: var(--bg-green);
}
@@ -643,14 +650,16 @@ input.inline:focus-visible {
.tab:hover,
.tab.active {
- background: var(--table-border);
+ background: var(--bg-gray);
+ color: white;
}
.tabcontent {
- border: 1px solid var(--table-border);
+ border: 2px solid var(--bg-gray);
border-radius: 0 .5em .5em .5em;
display: none;
padding: 1em;
+ background: white;
}
.tabcontent.active {
diff --git a/src/main/resources/templates/configs/gPasConnectionAvailable.html b/src/main/resources/templates/configs/gPasConnectionAvailable.html
index 6dccc60..a9a8517 100644
--- a/src/main/resources/templates/configs/gPasConnectionAvailable.html
+++ b/src/main/resources/templates/configs/gPasConnectionAvailable.html
@@ -2,15 +2,20 @@
<h2><span>🟦</span> gPAS nicht konfiguriert - Patienten-IDs werden intern anonymisiert</h2>
</th:block>
<th:block th:if="${gPasConnectionAvailable != null}">
- <h2><span th:if="${gPasConnectionAvailable}">✅</span><span th:if="${not(gPasConnectionAvailable)}">⚡</span> Verbindung zu gPAS</h2>
+ <h2><span th:if="${gPasConnectionAvailable.available}">✅</span><span th:if="${not(gPasConnectionAvailable.available)}">⚡</span> Verbindung zu gPAS</h2>
<div>
- Die Verbindung ist aktuell
- <strong th:if="${gPasConnectionAvailable}" style="color: green">verfügbar.</strong>
- <strong th:if="${not(gPasConnectionAvailable)}" style="color: red">nicht verfügbar.</strong>
+ Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gPasConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(gPasConnectionAvailable.timestamp)}"></time>
+ &nbsp;|&nbsp;
+ Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gPasConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(gPasConnectionAvailable.lastChange)}"></time>
+ </div>
+ <div>
+ <span>Die Verbindung ist aktuell</span>
+ <strong th:if="${gPasConnectionAvailable.available}" style="color: green">verfügbar.</strong>
+ <strong th:if="${not(gPasConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong>
</div>
<div class="connection-display border">
<img th:src="@{/server.png}" alt="ETL-Processor" />
- <span class="connection" th:classappend="${gPasConnectionAvailable ? 'available' : ''}"></span>
+ <span class="connection" th:classappend="${gPasConnectionAvailable.available ? 'available' : ''}"></span>
<img th:src="@{/server.png}" alt="gPAS" />
<span>ETL-Processor</span>
<span></span>
diff --git a/src/main/resources/templates/configs/outputConnectionAvailable.html b/src/main/resources/templates/configs/outputConnectionAvailable.html
index 2b18b75..93ad549 100644
--- a/src/main/resources/templates/configs/outputConnectionAvailable.html
+++ b/src/main/resources/templates/configs/outputConnectionAvailable.html
@@ -1,16 +1,27 @@
-<h2><span th:if="${outputConnectionAvailable}">✅</span><span th:if="${not(outputConnectionAvailable)}">⚡</span> MTB-File Verbindung</h2>
-<div>
- Verbindung über <code>[[ ${mtbFileSender} ]]</code>. Die Verbindung ist aktuell
- <strong th:if="${outputConnectionAvailable}" style="color: green">verfügbar.</strong>
- <strong th:if="${not(outputConnectionAvailable)}" style="color: red">nicht verfügbar.</strong>
-</div>
-<div class="connection-display border">
- <img th:src="@{/server.png}" alt="ETL-Processor" />
- <span class="connection" th:classappend="${outputConnectionAvailable ? 'available' : ''}"></span>
- <img th:if="${mtbFileSender.startsWith('Rest')}" th:src="@{/server.png}" alt="bwHC-Backend" />
- <img th:if="${mtbFileSender.startsWith('Kafka')}" th:src="@{/kafka.png}" alt="Kafka-Broker" />
- <span>ETL-Processor</span>
- <span></span>
- <span th:if="${mtbFileSender.startsWith('Rest')}">bwHC-Backend</span>
- <span th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker</span>
-</div> \ No newline at end of file
+<th:block th:if="${outputConnectionAvailable == null}">
+ <h2><span>🟦</span> Keine Ausgabenkonfiguration</h2>
+</th:block>
+<th:block th:if="${outputConnectionAvailable != null}">
+ <h2><span th:if="${outputConnectionAvailable.available}">✅</span><span th:if="${not(outputConnectionAvailable.available)}">⚡</span> MTB-File Verbindung</h2>
+ <div>
+ Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(outputConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(outputConnectionAvailable.timestamp)}"></time>
+ &nbsp;|&nbsp;
+ Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(outputConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(outputConnectionAvailable.lastChange)}"></time>
+ </div>
+ <div>
+ Verbindung über <code>[[ ${mtbFileSender} ]]</code>. Die Verbindung ist aktuell
+ <strong th:if="${outputConnectionAvailable.available}" style="color: green">verfügbar.</strong>
+ <strong th:if="${not(outputConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong>
+ </div>
+ <div class="connection-display border">
+ <img th:src="@{/server.png}" alt="ETL-Processor" />
+ <span class="connection" th:classappend="${outputConnectionAvailable.available ? 'available' : ''}"></span>
+ <img th:if="${mtbFileSender.startsWith('Rest')}" th:src="@{/server.png}" alt="bwHC-Backend" />
+ <img th:if="${mtbFileSender.startsWith('Kafka')}" th:src="@{/kafka.png}" alt="Kafka-Broker" />
+ <span>ETL-Processor</span>
+ <span></span>
+ <span th:if="${mtbFileSender.startsWith('RestBwhc')}">bwHC-Backend</span>
+ <span th:if="${mtbFileSender.startsWith('RestDip')}">DNPM:DIP-Backend</span>
+ <span th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker</span>
+ </div>
+</th:block> \ No newline at end of file
diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html
index be3123b..7ca0b67 100644
--- a/src/main/resources/templates/index.html
+++ b/src/main/resources/templates/index.html
@@ -12,26 +12,30 @@
<h1>Alle Anfragen<a id="reload-notify" class="reload" title="Neue Anfragen" th:href="@{/}">⟳</a></h1>
<div>
- <h2 th:if="${patientId != null}">
- Betreffend Patienten-Pseudonym <span class="monospace" th:text="${patientId}">***</span>
- <a class="btn btn-blue" th:if="${patientId != null}" th:href="@{/}">Alle anzeigen</a>
+ <h2 th:if="${patientPseudonym != null}">
+ Betreffend Patienten-Pseudonym <span class="monospace" th:text="${patientPseudonym}">***</span>
+ <a class="btn btn-blue" th:if="${patientPseudonym != null}" th:href="@{/}">Alle anzeigen</a>
</h2>
</div>
- <div class="border">
- <div th:if="${patientId == null}" class="page-control">
+ <div class="border" th:if="${requests.totalElements == 0}">
+ <div class="notification info">Noch keine Anfragen eingegangen</div>
+ </div>
+
+ <div class="border" th:if="${requests.totalElements > 0}">
+ <div th:if="${patientPseudonym == null}" class="page-control">
<a id="first-page-link" th:href="@{/(page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</a>
<a id="prev-page-link" th:href="@{/(page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</a>
<span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span>
<a id="next-page-link" th:href="@{/(page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a>
<a id="last-page-link" th:href="@{/(page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</a>
</div>
- <div th:if="${patientId != null}" class="page-control">
- <a id="first-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</a>
- <a id="prev-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</a>
+ <div th:if="${patientPseudonym != null}" class="page-control">
+ <a id="first-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</a>
+ <a id="prev-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</a>
<span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span>
- <a id="next-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a>
- <a id="last-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</a>
+ <a id="next-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a>
+ <a id="last-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</a>
</div>
<table class="paged">
<thead>
@@ -57,11 +61,11 @@
<th:block sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">[[ ${request.uuid} ]]</th:block>
</td>
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
- <td class="patient-id" th:if="${patientId != null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
- [[ ${request.patientId} ]]
+ <td class="patient-id" th:if="${patientPseudonym != null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
+ [[ ${request.patientPseudonym} ]]
</td>
- <td class="patient-id" th:if="${patientId == null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
- <a th:href="@{/patient/{pid}(pid=${request.patientId})}">[[ ${request.patientId} ]]</a>
+ <td class="patient-id" th:if="${patientPseudonym == null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
+ <a th:href="@{/patient/{pid}(pid=${request.patientPseudonym})}">[[ ${request.patientPseudonym} ]]</a>
</td>
<td class="patient-id" sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">***</td>
</tr>
diff --git a/src/main/resources/templates/report.html b/src/main/resources/templates/report.html
index 07f987c..21d1b48 100644
--- a/src/main/resources/templates/report.html
+++ b/src/main/resources/templates/report.html
@@ -31,7 +31,7 @@
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td>
<td>[[ ${request.uuid} ]]</td>
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
- <td class="patient-id" sec:authorize="authenticated">[[ ${request.patientId} ]]</td>
+ <td class="patient-id" sec:authorize="authenticated">[[ ${request.patientPseudonym} ]]</td>
<td class="patient-id" sec:authorize="not authenticated">***</td>
</tr>
</tbody>
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt b/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt
new file mode 100644
index 0000000..8caa908
--- /dev/null
+++ b/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt
@@ -0,0 +1,20 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor \ No newline at end of file
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt
index 1157644..b54a02e 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt
@@ -31,10 +31,10 @@ import org.apache.kafka.common.record.TimestampType
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
-import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
+import org.mockito.kotlin.anyValueClass
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import java.util.*
@@ -77,7 +77,7 @@ class KafkaInputListenerTest {
kafkaInputListener.onMessage(ConsumerRecord("testtopic", 0, 0, "", this.objectMapper.writeValueAsString(mtbFile)))
- verify(requestProcessor, times(1)).processDeletion(anyString())
+ verify(requestProcessor, times(1)).processDeletion(anyValueClass())
}
@Test
@@ -92,7 +92,7 @@ class KafkaInputListenerTest {
ConsumerRecord("testtopic", 0, 0, -1L, TimestampType.NO_TIMESTAMP_TYPE, -1, -1, "", this.objectMapper.writeValueAsString(mtbFile), headers, Optional.empty())
)
- verify(requestProcessor, times(1)).processMtbFile(any(), anyString())
+ verify(requestProcessor, times(1)).processMtbFile(any(), anyValueClass())
}
@Test
@@ -106,7 +106,7 @@ class KafkaInputListenerTest {
kafkaInputListener.onMessage(
ConsumerRecord("testtopic", 0, 0, -1L, TimestampType.NO_TIMESTAMP_TYPE, -1, -1, "", this.objectMapper.writeValueAsString(mtbFile), headers, Optional.empty())
)
- verify(requestProcessor, times(1)).processDeletion(anyString(), anyString())
+ verify(requestProcessor, times(1)).processDeletion(anyValueClass(), anyValueClass())
}
} \ No newline at end of file
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt
index 0b076a1..3e5b53a 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt
@@ -22,7 +22,6 @@ package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.services.RequestProcessor
-import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
@@ -31,7 +30,7 @@ import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
-import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.anyValueClass
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
@@ -129,9 +128,7 @@ class MtbFileRestControllerTest {
}
}
- val captor = argumentCaptor<String>()
- verify(requestProcessor, times(1)).processDeletion(captor.capture())
- assertThat(captor.firstValue).isEqualTo("TEST_12345678")
+ verify(requestProcessor, times(1)).processDeletion(anyValueClass())
}
@Test
@@ -142,9 +139,7 @@ class MtbFileRestControllerTest {
}
}
- val captor = argumentCaptor<String>()
- verify(requestProcessor, times(1)).processDeletion(captor.capture())
- assertThat(captor.firstValue).isEqualTo("TEST_12345678")
+ verify(requestProcessor, times(1)).processDeletion(anyValueClass())
}
} \ No newline at end of file
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt
index 411c51e..655e29e 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt
@@ -21,6 +21,8 @@ package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.*
+import dev.dnpm.etl.processor.PatientPseudonym
+import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.config.KafkaProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.assertj.core.api.Assertions.assertThat
@@ -72,7 +74,7 @@ class KafkaMtbFileSenderTest {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
- val response = kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile(Consent.Status.ACTIVE)))
+ val response = kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE)))
assertThat(response.status).isEqualTo(testData.requestStatus)
}
@@ -86,7 +88,7 @@ class KafkaMtbFileSenderTest {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
- val response = kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
+ val response = kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(testData.requestStatus)
}
@@ -96,14 +98,14 @@ class KafkaMtbFileSenderTest {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
- kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile(Consent.Status.ACTIVE)))
+ kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE)))
val captor = argumentCaptor<String>()
verify(kafkaTemplate, times(1)).send(anyString(), captor.capture(), captor.capture())
assertThat(captor.firstValue).isNotNull
assertThat(captor.firstValue).isEqualTo("{\"pid\": \"PID\"}")
assertThat(captor.secondValue).isNotNull
- assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData("TestID", Consent.Status.ACTIVE)))
+ assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData(TEST_REQUEST_ID, Consent.Status.ACTIVE)))
}
@Test
@@ -112,14 +114,14 @@ class KafkaMtbFileSenderTest {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
- kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
+ kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
val captor = argumentCaptor<String>()
verify(kafkaTemplate, times(1)).send(anyString(), captor.capture(), captor.capture())
assertThat(captor.firstValue).isNotNull
assertThat(captor.firstValue).isEqualTo("{\"pid\": \"PID\"}")
assertThat(captor.secondValue).isNotNull
- assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData("TestID", Consent.Status.REJECTED)))
+ assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData(TEST_REQUEST_ID, Consent.Status.REJECTED)))
}
@ParameterizedTest
@@ -136,7 +138,7 @@ class KafkaMtbFileSenderTest {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
- kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile(Consent.Status.ACTIVE)))
+ kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE)))
val expectedCount = when (testData.exception) {
// OK - No Retry
@@ -162,7 +164,7 @@ class KafkaMtbFileSenderTest {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
- kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
+ kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
val expectedCount = when (testData.exception) {
// OK - No Retry
@@ -175,6 +177,9 @@ class KafkaMtbFileSenderTest {
}
companion object {
+ val TEST_REQUEST_ID = RequestId("TestId")
+ val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
+
fun mtbFile(consentStatus: Consent.Status): MtbFile {
return if (consentStatus == Consent.Status.ACTIVE) {
MtbFile.builder()
@@ -210,7 +215,7 @@ class KafkaMtbFileSenderTest {
}.build()
}
- fun kafkaRecordData(requestId: String, consentStatus: Consent.Status): KafkaMtbFileSender.Data {
+ fun kafkaRecordData(requestId: RequestId, consentStatus: Consent.Status): KafkaMtbFileSender.Data {
return KafkaMtbFileSender.Data(requestId, mtbFile(consentStatus))
}
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt
index df19ddb..5063a97 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt
@@ -1,7 +1,7 @@
/*
* This file is part of ETL-Processor
*
- * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
+ * Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, 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
@@ -20,6 +20,8 @@
package dev.dnpm.etl.processor.output
import de.ukw.ccc.bwhc.dto.*
+import dev.dnpm.etl.processor.PatientPseudonym
+import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.config.RestTargetProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.assertj.core.api.Assertions.assertThat
@@ -37,7 +39,7 @@ import org.springframework.test.web.client.match.MockRestRequestMatchers.request
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.web.client.RestTemplate
-class RestMtbFileSenderTest {
+class RestBwhcMtbFileSenderTest {
private lateinit var mockRestServiceServer: MockRestServiceServer
@@ -46,25 +48,25 @@ class RestMtbFileSenderTest {
@BeforeEach
fun setup() {
val restTemplate = RestTemplate()
- val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile")
+ val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null)
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
- this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
+ this.restMtbFileSender = RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
}
@ParameterizedTest
@MethodSource("deleteRequestWithResponseSource")
fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) {
- this.mockRestServiceServer.expect {
- method(HttpMethod.DELETE)
- requestTo("/mtbfile")
- }.andRespond {
- withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
- }
+ this.mockRestServiceServer
+ .expect(method(HttpMethod.DELETE))
+ .andExpect(requestTo("http://localhost:9000/mtbfile/Patient/${TEST_PATIENT_PSEUDONYM.value}"))
+ .andRespond {
+ withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
+ }
- val response = restMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
+ val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@@ -72,14 +74,14 @@ class RestMtbFileSenderTest {
@ParameterizedTest
@MethodSource("mtbFileRequestWithResponseSource")
fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) {
- this.mockRestServiceServer.expect {
- method(HttpMethod.POST)
- requestTo("/mtbfile")
- }.andRespond {
- withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
- }
+ this.mockRestServiceServer
+ .expect(method(HttpMethod.POST))
+ .andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile"))
+ .andRespond {
+ withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
+ }
- val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile))
+ val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@@ -88,11 +90,11 @@ class RestMtbFileSenderTest {
@MethodSource("mtbFileRequestWithResponseSource")
fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) {
val restTemplate = RestTemplate()
- val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile")
+ val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null)
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
- this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
+ this.restMtbFileSender = RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
val expectedCount = when (requestWithResponse.httpStatus) {
// OK - No Retry
@@ -101,14 +103,14 @@ class RestMtbFileSenderTest {
else -> ExpectedCount.max(3)
}
- this.mockRestServiceServer.expect(expectedCount) {
- method(HttpMethod.POST)
- requestTo("/mtbfile")
- }.andRespond {
- withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
- }
+ this.mockRestServiceServer
+ .expect(expectedCount, method(HttpMethod.POST))
+ .andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile"))
+ .andRespond {
+ withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
+ }
- val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile))
+ val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@@ -117,11 +119,11 @@ class RestMtbFileSenderTest {
@MethodSource("deleteRequestWithResponseSource")
fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) {
val restTemplate = RestTemplate()
- val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile")
+ val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null)
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
- this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
+ this.restMtbFileSender = RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
val expectedCount = when (requestWithResponse.httpStatus) {
// OK - No Retry
@@ -130,14 +132,14 @@ class RestMtbFileSenderTest {
else -> ExpectedCount.max(3)
}
- this.mockRestServiceServer.expect(expectedCount) {
- method(HttpMethod.DELETE)
- requestTo("/mtbfile")
- }.andRespond {
- withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
- }
+ this.mockRestServiceServer
+ .expect(expectedCount, method(HttpMethod.DELETE))
+ .andExpect(requestTo("http://localhost:9000/mtbfile/Patient/${TEST_PATIENT_PSEUDONYM.value}"))
+ .andRespond {
+ withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
+ }
- val response = restMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
+ val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body)
}
@@ -149,6 +151,9 @@ class RestMtbFileSenderTest {
val response: MtbFileSender.Response
)
+ val TEST_REQUEST_ID = RequestId("TestId")
+ val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
+
private val warningBody = """
{
"patient_id": "PID",
@@ -208,23 +213,23 @@ class RestMtbFileSenderTest {
),
RequestWithResponse(
HttpStatus.BAD_REQUEST,
- "??",
+ ERROR_RESPONSE_BODY,
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
RequestWithResponse(
HttpStatus.UNPROCESSABLE_ENTITY,
errorBody,
- MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
+ MtbFileSender.Response(RequestStatus.ERROR, errorBody)
),
// Some more errors not mentioned in documentation
RequestWithResponse(
HttpStatus.NOT_FOUND,
- "what????",
+ ERROR_RESPONSE_BODY,
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
RequestWithResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
- "what????",
+ ERROR_RESPONSE_BODY,
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
)
)
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt
new file mode 100644
index 0000000..dac6496
--- /dev/null
+++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt
@@ -0,0 +1,262 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.output
+
+import de.ukw.ccc.bwhc.dto.*
+import dev.dnpm.etl.processor.PatientPseudonym
+import dev.dnpm.etl.processor.RequestId
+import dev.dnpm.etl.processor.config.RestTargetProperties
+import dev.dnpm.etl.processor.monitoring.RequestStatus
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
+import org.springframework.http.HttpMethod
+import org.springframework.http.HttpStatus
+import org.springframework.retry.policy.SimpleRetryPolicy
+import org.springframework.retry.support.RetryTemplateBuilder
+import org.springframework.test.web.client.ExpectedCount
+import org.springframework.test.web.client.MockRestServiceServer
+import org.springframework.test.web.client.match.MockRestRequestMatchers.method
+import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
+import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
+import org.springframework.web.client.RestTemplate
+
+class RestDipMtbFileSenderTest {
+
+ private lateinit var mockRestServiceServer: MockRestServiceServer
+
+ private lateinit var restMtbFileSender: RestMtbFileSender
+
+ @BeforeEach
+ fun setup() {
+ val restTemplate = RestTemplate()
+ val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
+ val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
+
+ this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
+
+ this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
+ }
+
+ @ParameterizedTest
+ @MethodSource("deleteRequestWithResponseSource")
+ fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) {
+ this.mockRestServiceServer
+ .expect(method(HttpMethod.DELETE))
+ .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}"))
+ .andRespond {
+ withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
+ }
+
+ val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
+ assertThat(response.status).isEqualTo(requestWithResponse.response.status)
+ assertThat(response.body).isEqualTo(requestWithResponse.response.body)
+ }
+
+ @ParameterizedTest
+ @MethodSource("mtbFileRequestWithResponseSource")
+ fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) {
+ this.mockRestServiceServer
+ .expect(method(HttpMethod.POST))
+ .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record"))
+ .andRespond {
+ withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
+ }
+
+ val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile))
+ assertThat(response.status).isEqualTo(requestWithResponse.response.status)
+ assertThat(response.body).isEqualTo(requestWithResponse.response.body)
+ }
+
+ @ParameterizedTest
+ @MethodSource("mtbFileRequestWithResponseSource")
+ fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) {
+ val restTemplate = RestTemplate()
+ val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
+ val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
+
+ this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
+ this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
+
+ val expectedCount = when (requestWithResponse.httpStatus) {
+ // OK - No Retry
+ HttpStatus.OK, HttpStatus.CREATED -> ExpectedCount.max(1)
+ // Request failed - Retry max 3 times
+ else -> ExpectedCount.max(3)
+ }
+
+ this.mockRestServiceServer
+ .expect(expectedCount, method(HttpMethod.POST))
+ .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record"))
+ .andRespond {
+ withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
+ }
+
+ val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile))
+ assertThat(response.status).isEqualTo(requestWithResponse.response.status)
+ assertThat(response.body).isEqualTo(requestWithResponse.response.body)
+ }
+
+ @ParameterizedTest
+ @MethodSource("deleteRequestWithResponseSource")
+ fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) {
+ val restTemplate = RestTemplate()
+ val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false)
+ val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
+
+ this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
+ this.restMtbFileSender = RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
+
+ val expectedCount = when (requestWithResponse.httpStatus) {
+ // OK - No Retry
+ HttpStatus.OK, HttpStatus.CREATED -> ExpectedCount.max(1)
+ // Request failed - Retry max 3 times
+ else -> ExpectedCount.max(3)
+ }
+
+ this.mockRestServiceServer
+ .expect(expectedCount, method(HttpMethod.DELETE))
+ .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient/${TEST_PATIENT_PSEUDONYM.value}"))
+ .andRespond {
+ withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
+ }
+
+ val response = restMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
+ assertThat(response.status).isEqualTo(requestWithResponse.response.status)
+ assertThat(response.body).isEqualTo(requestWithResponse.response.body)
+ }
+
+ companion object {
+ data class RequestWithResponse(
+ val httpStatus: HttpStatus,
+ val body: String,
+ val response: MtbFileSender.Response
+ )
+
+ val TEST_REQUEST_ID = RequestId("TestId")
+ val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
+
+ private val warningBody = """
+ {
+ "patient_id": "PID",
+ "issues": [
+ { "severity": "warning", "message": "Something is not right" }
+ ]
+ }
+ """.trimIndent()
+
+ private val errorBody = """
+ {
+ "patient_id": "PID",
+ "issues": [
+ { "severity": "error", "message": "Something is very bad" }
+ ]
+ }
+ """.trimIndent()
+
+ val mtbFile: MtbFile = MtbFile.builder()
+ .withPatient(
+ Patient.builder()
+ .withId("PID")
+ .withBirthDate("2000-08-08")
+ .withGender(Patient.Gender.MALE)
+ .build()
+ )
+ .withConsent(
+ Consent.builder()
+ .withId("1")
+ .withStatus(Consent.Status.ACTIVE)
+ .withPatient("PID")
+ .build()
+ )
+ .withEpisode(
+ Episode.builder()
+ .withId("1")
+ .withPatient("PID")
+ .withPeriod(PeriodStart("2023-08-08"))
+ .build()
+ )
+ .build()
+
+ private const val ERROR_RESPONSE_BODY = "Sonstiger Fehler bei der Übertragung"
+
+ /**
+ * Synthetic http responses with related request status
+ * Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
+ */
+ @JvmStatic
+ fun mtbFileRequestWithResponseSource(): Set<RequestWithResponse> {
+ return setOf(
+ RequestWithResponse(HttpStatus.OK, "{}", MtbFileSender.Response(RequestStatus.SUCCESS, "{}")),
+ RequestWithResponse(
+ HttpStatus.CREATED,
+ warningBody,
+ MtbFileSender.Response(RequestStatus.WARNING, warningBody)
+ ),
+ RequestWithResponse(
+ HttpStatus.BAD_REQUEST,
+ ERROR_RESPONSE_BODY,
+ MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
+ ),
+ RequestWithResponse(
+ HttpStatus.UNPROCESSABLE_ENTITY,
+ errorBody,
+ MtbFileSender.Response(RequestStatus.ERROR, errorBody)
+ ),
+ // Some more errors not mentioned in documentation
+ RequestWithResponse(
+ HttpStatus.NOT_FOUND,
+ ERROR_RESPONSE_BODY,
+ MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
+ ),
+ RequestWithResponse(
+ HttpStatus.INTERNAL_SERVER_ERROR,
+ ERROR_RESPONSE_BODY,
+ MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
+ )
+ )
+ }
+
+ /**
+ * Synthetic http responses with related request status
+ * Also see: https://ibmi-intra.cs.uni-tuebingen.de/display/ZPM/bwHC+REST+API
+ */
+ @JvmStatic
+ fun deleteRequestWithResponseSource(): Set<RequestWithResponse> {
+ return setOf(
+ RequestWithResponse(HttpStatus.OK, "", MtbFileSender.Response(RequestStatus.SUCCESS)),
+ // Some more errors not mentioned in documentation
+ RequestWithResponse(
+ HttpStatus.NOT_FOUND,
+ "what????",
+ MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
+ ),
+ RequestWithResponse(
+ HttpStatus.INTERNAL_SERVER_ERROR,
+ "what????",
+ MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
+ )
+ )
+ }
+ }
+
+
+} \ No newline at end of file
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt
index d8c7813..0acf7db 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt
@@ -25,9 +25,9 @@ import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith
-import org.mockito.ArgumentMatchers
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.kotlin.anyValueClass
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.whenever
import org.springframework.core.io.ClassPathResource
@@ -52,7 +52,7 @@ class ExtensionsTest {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
- }.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
val mtbFile = fakeMtbFile()
@@ -67,7 +67,7 @@ class ExtensionsTest {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
- }.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
"TESTDOMAIN"
@@ -95,7 +95,7 @@ class ExtensionsTest {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
- }.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
"TESTDOMAIN"
@@ -139,7 +139,7 @@ class ExtensionsTest {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
- }.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
"TESTDOMAIN"
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/TokenServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/security/TokenServiceTest.kt
index 1fdc3d9..b93e9f5 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/services/TokenServiceTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/security/TokenServiceTest.kt
@@ -17,13 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-package dev.dnpm.etl.processor.services
+package dev.dnpm.etl.processor.security
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
-import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
@@ -96,11 +95,11 @@ class TokenServiceTest {
val actual = this.tokenService.addToken("Test Token")
- val captor = ArgumentCaptor.forClass(Token::class.java)
+ val captor = argumentCaptor<Token>()
verify(tokenRepository, times(1)).save(captor.capture())
assertThat(actual).satisfies(Consumer { assertThat(it.isSuccess).isTrue() })
- assertThat(captor.value).satisfies(
+ assertThat(captor.firstValue).satisfies(
Consumer { assertThat(it.name).isEqualTo("Test Token") },
Consumer { assertThat(it.username).isEqualTo("testtoken") },
Consumer { assertThat(it.password).isEqualTo("{test}verysecret") }
@@ -116,13 +115,13 @@ class TokenServiceTest {
this.tokenService.deleteToken(42)
- val stringCaptor = ArgumentCaptor.forClass(String::class.java)
+ val stringCaptor = argumentCaptor<String>()
verify(userDetailsManager, times(1)).deleteUser(stringCaptor.capture())
- assertThat(stringCaptor.value).isEqualTo("testtoken")
+ assertThat(stringCaptor.firstValue).isEqualTo("testtoken")
- val tokenCaptor = ArgumentCaptor.forClass(Token::class.java)
+ val tokenCaptor = argumentCaptor<Token>()
verify(tokenRepository, times(1)).delete(tokenCaptor.capture())
- assertThat(tokenCaptor.value.id).isEqualTo(42)
+ assertThat(tokenCaptor.firstValue.id).isEqualTo(42)
}
@Test
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/security/UserRoleServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/security/UserRoleServiceTest.kt
new file mode 100644
index 0000000..39ba7c0
--- /dev/null
+++ b/src/test/kotlin/dev/dnpm/etl/processor/security/UserRoleServiceTest.kt
@@ -0,0 +1,202 @@
+/*
+ * This file is part of ETL-Processor
+ *
+ * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, 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 <https://www.gnu.org/licenses/>.
+ */
+
+package dev.dnpm.etl.processor.security
+
+import org.assertj.core.api.Assertions.assertThat
+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.Mock
+import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.kotlin.*
+import org.springframework.security.core.session.SessionInformation
+import org.springframework.security.core.session.SessionRegistry
+import org.springframework.security.oauth2.core.oidc.OidcIdToken
+import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser
+import org.springframework.security.oauth2.core.oidc.user.OidcUser
+import java.time.Instant
+import java.util.*
+
+@ExtendWith(MockitoExtension::class)
+class UserRoleServiceTest {
+
+ private lateinit var userRoleRepository: UserRoleRepository
+ private lateinit var sessionRegistry: SessionRegistry
+
+ private lateinit var userRoleService: UserRoleService
+
+ @BeforeEach
+ fun setup(
+ @Mock userRoleRepository: UserRoleRepository,
+ @Mock sessionRegistry: SessionRegistry
+ ) {
+ this.userRoleRepository = userRoleRepository
+ this.sessionRegistry = sessionRegistry
+
+ this.userRoleService = UserRoleService(userRoleRepository, sessionRegistry)
+ }
+
+ @Test
+ fun shouldDelegateFindAllToRepository() {
+ userRoleService.findAll()
+
+ verify(userRoleRepository, times(1)).findAll()
+ }
+
+ @Nested
+ inner class WithExistingUserRole {
+
+ @BeforeEach
+ fun setup() {
+ doAnswer { invocation ->
+ Optional.of(
+ UserRole(invocation.getArgument(0), "patrick.tester", Role.USER)
+ )
+ }.whenever(userRoleRepository).findById(any<Long>())
+
+ doAnswer { _ ->
+ listOf(
+ dummyPrincipal()
+ )
+ }.whenever(sessionRegistry).allPrincipals
+ }
+
+ @Test
+ fun shouldUpdateUserRole() {
+ userRoleService.updateUserRole(1, Role.ADMIN)
+
+ val userRoleCaptor = argumentCaptor<UserRole>()
+ verify(userRoleRepository, times(1)).save(userRoleCaptor.capture())
+
+ assertThat(userRoleCaptor.firstValue.id).isEqualTo(1L)
+ assertThat(userRoleCaptor.firstValue.role).isEqualTo(Role.ADMIN)
+ }
+
+ @Test
+ fun shouldExpireSessionOnUpdate() {
+ val dummySessions = dummySessions()
+ whenever(sessionRegistry.getAllSessions(any(), any<Boolean>())).thenReturn(
+ dummySessions
+ )
+
+ assertThat(dummySessions.filter { it.isExpired }).hasSize(0)
+
+ userRoleService.updateUserRole(1, Role.ADMIN)
+
+ verify(sessionRegistry, times(1)).getAllSessions(any<OidcUser>(), any<Boolean>())
+
+ assertThat(dummySessions.filter { it.isExpired }).hasSize(2)
+ }
+
+ @Test
+ fun shouldDeleteUserRole() {
+ userRoleService.deleteUserRole(1)
+
+ val userRoleCaptor = argumentCaptor<UserRole>()
+ verify(userRoleRepository, times(1)).delete(userRoleCaptor.capture())
+
+ assertThat(userRoleCaptor.firstValue.id).isEqualTo(1L)
+ assertThat(userRoleCaptor.firstValue.role).isEqualTo(Role.USER)
+ }
+
+ @Test
+ fun shouldExpireSessionOnDelete() {
+ val dummySessions = dummySessions()
+ whenever(sessionRegistry.getAllSessions(any(), any<Boolean>())).thenReturn(
+ dummySessions
+ )
+
+ assertThat(dummySessions.filter { it.isExpired }).hasSize(0)
+
+ userRoleService.deleteUserRole(1)
+
+ verify(sessionRegistry, times(1)).getAllSessions(any<OidcUser>(), any<Boolean>())
+
+ assertThat(dummySessions.filter { it.isExpired }).hasSize(2)
+ }
+ }
+
+ @Nested
+ inner class WithoutExistingUserRole {
+
+ @BeforeEach
+ fun setup() {
+ doAnswer { _ ->
+ Optional.empty<UserRole>()
+ }.whenever(userRoleRepository).findById(any<Long>())
+ }
+
+ @Test
+ fun shouldNotUpdateUserRole() {
+ userRoleService.updateUserRole(1, Role.ADMIN)
+
+ verify(userRoleRepository, never()).save(any<UserRole>())
+ }
+
+ @Test
+ fun shouldNotExpireSessionOnUpdate() {
+ userRoleService.updateUserRole(1, Role.ADMIN)
+
+ verify(sessionRegistry, never()).getAllSessions(any<OidcUser>(), any<Boolean>())
+ }
+
+ @Test
+ fun shouldNotDeleteUserRole() {
+ userRoleService.deleteUserRole(1)
+
+ verify(userRoleRepository, never()).delete(any<UserRole>())
+ }
+
+ @Test
+ fun shouldNotExpireSessionOnDelete() {
+ userRoleService.deleteUserRole(1)
+
+ verify(sessionRegistry, never()).getAllSessions(any<OidcUser>(), any<Boolean>())
+ }
+
+ }
+
+
+ companion object {
+ private fun dummyPrincipal() = DefaultOidcUser(
+ listOf(),
+ OidcIdToken(
+ "anytokenvalue",
+ Instant.now(),
+ Instant.now().plusSeconds(10),
+ mapOf("sub" to "testsub", "preferred_username" to "patrick.tester")
+ )
+ )
+
+ private fun dummySessions() = listOf(
+ SessionInformation(
+ dummyPrincipal(),
+ "SESSIONID1",
+ Date.from(Instant.now()),
+ ),
+ SessionInformation(
+ dummyPrincipal(),
+ "SESSIONID2",
+ Date.from(Instant.now()),
+ )
+ )
+ }
+} \ No newline at end of file
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt
index 611c0ff..5578c7b 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt
@@ -21,6 +21,7 @@ package dev.dnpm.etl.processor.services
import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.*
+import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.config.AppConfigProperties
import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestStatus
@@ -32,16 +33,15 @@ import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
-import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
import org.mockito.Mockito.*
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
+import org.mockito.kotlin.anyValueClass
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.whenever
import org.springframework.context.ApplicationEventPublisher
import java.time.Instant
-import java.util.*
@ExtendWith(MockitoExtension::class)
@@ -88,24 +88,24 @@ class RequestProcessorTest {
fun testShouldSendMtbFileDuplicationAndSaveUnknownRequestStatusAtFirst() {
doAnswer {
Request(
- id = 1L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga",
- type = RequestType.MTB_FILE,
- status = RequestStatus.SUCCESS,
- processedAt = Instant.parse("2023-08-08T02:00:00Z")
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS,
+ Instant.parse("2023-08-08T02:00:00Z")
)
- }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString())
+ }.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass())
doAnswer {
false
- }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString())
+ }.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass())
doAnswer {
it.arguments[0] as String
- }.`when`(pseudonymizeService).patientPseudonym(any())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
it.arguments[0]
@@ -147,24 +147,24 @@ class RequestProcessorTest {
fun testShouldDetectMtbFileDuplicationAndSendDuplicationEvent() {
doAnswer {
Request(
- id = 1L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga",
- type = RequestType.MTB_FILE,
- status = RequestStatus.SUCCESS,
- processedAt = Instant.parse("2023-08-08T02:00:00Z")
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS,
+ Instant.parse("2023-08-08T02:00:00Z")
)
- }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString())
+ }.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass())
doAnswer {
false
- }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString())
+ }.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass())
doAnswer {
it.arguments[0] as String
- }.`when`(pseudonymizeService).patientPseudonym(any())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
it.arguments[0]
@@ -206,28 +206,28 @@ class RequestProcessorTest {
fun testShouldSendMtbFileAndSendSuccessEvent() {
doAnswer {
Request(
- id = 1L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "different",
- type = RequestType.MTB_FILE,
- status = RequestStatus.SUCCESS,
- processedAt = Instant.parse("2023-08-08T02:00:00Z")
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("different"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS,
+ Instant.parse("2023-08-08T02:00:00Z")
)
- }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString())
+ }.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass())
doAnswer {
false
- }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString())
+ }.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass())
doAnswer {
MtbFileSender.Response(status = RequestStatus.SUCCESS)
- }.`when`(sender).send(any<MtbFileSender.MtbFileRequest>())
+ }.whenever(sender).send(any<MtbFileSender.MtbFileRequest>())
doAnswer {
it.arguments[0] as String
- }.`when`(pseudonymizeService).patientPseudonym(any())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
it.arguments[0]
@@ -269,28 +269,28 @@ class RequestProcessorTest {
fun testShouldSendMtbFileAndSendErrorEvent() {
doAnswer {
Request(
- id = 1L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "different",
- type = RequestType.MTB_FILE,
- status = RequestStatus.SUCCESS,
- processedAt = Instant.parse("2023-08-08T02:00:00Z")
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("different"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS,
+ Instant.parse("2023-08-08T02:00:00Z")
)
- }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString())
+ }.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass())
doAnswer {
false
- }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString())
+ }.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass())
doAnswer {
MtbFileSender.Response(status = RequestStatus.ERROR)
- }.`when`(sender).send(any<MtbFileSender.MtbFileRequest>())
+ }.whenever(sender).send(any<MtbFileSender.MtbFileRequest>())
doAnswer {
it.arguments[0] as String
- }.`when`(pseudonymizeService).patientPseudonym(any())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
it.arguments[0]
@@ -332,13 +332,13 @@ class RequestProcessorTest {
fun testShouldSendDeleteRequestAndSaveUnknownRequestStatusAtFirst() {
doAnswer {
"PSEUDONYM"
- }.`when`(pseudonymizeService).patientPseudonym(anyString())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
MtbFileSender.Response(status = RequestStatus.UNKNOWN)
- }.`when`(sender).send(any<MtbFileSender.DeleteRequest>())
+ }.whenever(sender).send(any<MtbFileSender.DeleteRequest>())
- this.requestProcessor.processDeletion("TEST_12345678901")
+ this.requestProcessor.processDeletion(TEST_PATIENT_ID)
val requestCaptor = argumentCaptor<Request>()
verify(requestService, times(1)).save(requestCaptor.capture())
@@ -350,13 +350,13 @@ class RequestProcessorTest {
fun testShouldSendDeleteRequestAndSendSuccessEvent() {
doAnswer {
"PSEUDONYM"
- }.`when`(pseudonymizeService).patientPseudonym(anyString())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
MtbFileSender.Response(status = RequestStatus.SUCCESS)
- }.`when`(sender).send(any<MtbFileSender.DeleteRequest>())
+ }.whenever(sender).send(any<MtbFileSender.DeleteRequest>())
- this.requestProcessor.processDeletion("TEST_12345678901")
+ this.requestProcessor.processDeletion(TEST_PATIENT_ID)
val eventCaptor = argumentCaptor<ResponseEvent>()
verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture())
@@ -368,13 +368,13 @@ class RequestProcessorTest {
fun testShouldSendDeleteRequestAndSendErrorEvent() {
doAnswer {
"PSEUDONYM"
- }.`when`(pseudonymizeService).patientPseudonym(anyString())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
MtbFileSender.Response(status = RequestStatus.ERROR)
- }.`when`(sender).send(any<MtbFileSender.DeleteRequest>())
+ }.whenever(sender).send(any<MtbFileSender.DeleteRequest>())
- this.requestProcessor.processDeletion("TEST_12345678901")
+ this.requestProcessor.processDeletion(TEST_PATIENT_ID)
val eventCaptor = argumentCaptor<ResponseEvent>()
verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture())
@@ -384,9 +384,9 @@ class RequestProcessorTest {
@Test
fun testShouldSendDeleteRequestWithPseudonymErrorAndSaveErrorRequestStatus() {
- doThrow(RuntimeException()).`when`(pseudonymizeService).patientPseudonym(anyString())
+ doThrow(RuntimeException()).whenever(pseudonymizeService).patientPseudonym(anyValueClass())
- this.requestProcessor.processDeletion("TEST_12345678901")
+ this.requestProcessor.processDeletion(TEST_PATIENT_ID)
val requestCaptor = argumentCaptor<Request>()
verify(requestService, times(1)).save(requestCaptor.capture())
@@ -400,7 +400,7 @@ class RequestProcessorTest {
doAnswer {
it.arguments[0] as String
- }.`when`(pseudonymizeService).patientPseudonym(any())
+ }.whenever(pseudonymizeService).patientPseudonym(anyValueClass())
doAnswer {
it.arguments[0]
@@ -408,7 +408,7 @@ class RequestProcessorTest {
doAnswer {
MtbFileSender.Response(status = RequestStatus.SUCCESS)
- }.`when`(sender).send(any<MtbFileSender.MtbFileRequest>())
+ }.whenever(sender).send(any<MtbFileSender.MtbFileRequest>())
val mtbFile = MtbFile.builder()
.withPatient(
@@ -442,4 +442,8 @@ class RequestProcessorTest {
assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS)
}
+ companion object {
+ val TEST_PATIENT_ID = PatientId("TEST_12345678901")
+ }
+
} \ No newline at end of file
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt
index 3cf8804..c0e4400 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt
@@ -19,6 +19,7 @@
package dev.dnpm.etl.processor.services
+import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus
@@ -30,8 +31,9 @@ import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mock
import org.mockito.Mockito.*
import org.mockito.junit.jupiter.MockitoExtension
+import org.mockito.kotlin.anyValueClass
+import org.mockito.kotlin.whenever
import java.time.Instant
-import java.util.*
@ExtendWith(MockitoExtension::class)
class RequestServiceTest {
@@ -41,14 +43,14 @@ class RequestServiceTest {
private lateinit var requestService: RequestService
private fun anyRequest() = any(Request::class.java) ?: Request(
- id = 0L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_dummy",
- pid = "PX",
- fingerprint = "dummy",
- type = RequestType.MTB_FILE,
- status = RequestStatus.SUCCESS,
- processedAt = Instant.parse("2023-08-08T02:00:00Z")
+ 0L,
+ randomRequestId(),
+ PatientPseudonym("TEST_dummy"),
+ PatientId("PX"),
+ Fingerprint("dummy"),
+ RequestType.MTB_FILE,
+ RequestStatus.SUCCESS,
+ Instant.parse("2023-08-08T02:00:00Z")
)
@BeforeEach
@@ -63,34 +65,34 @@ class RequestServiceTest {
fun shouldIndicateLastRequestIsDeleteRequest() {
val requests = listOf(
Request(
- id = 1L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "0123456789abcdef1",
- type = RequestType.MTB_FILE,
- status = RequestStatus.WARNING,
- processedAt = Instant.parse("2023-07-07T00:00:00Z")
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdef1"),
+ RequestType.MTB_FILE,
+ RequestStatus.WARNING,
+ Instant.parse("2023-07-07T00:00:00Z")
),
Request(
- id = 2L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "0123456789abcdefd",
- type = RequestType.DELETE,
- status = RequestStatus.WARNING,
- processedAt = Instant.parse("2023-07-07T02:00:00Z")
+ 2L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdefd"),
+ RequestType.DELETE,
+ RequestStatus.WARNING,
+ Instant.parse("2023-07-07T02:00:00Z")
),
Request(
- id = 3L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "0123456789abcdef1",
- type = RequestType.MTB_FILE,
- status = RequestStatus.UNKNOWN,
- processedAt = Instant.parse("2023-08-11T00:00:00Z")
+ 3L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdef1"),
+ RequestType.MTB_FILE,
+ RequestStatus.UNKNOWN,
+ Instant.parse("2023-08-11T00:00:00Z")
)
)
@@ -103,34 +105,34 @@ class RequestServiceTest {
fun shouldIndicateLastRequestIsNotDeleteRequest() {
val requests = listOf(
Request(
- id = 1L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "0123456789abcdef1",
- type = RequestType.MTB_FILE,
- status = RequestStatus.WARNING,
- processedAt = Instant.parse("2023-07-07T00:00:00Z")
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdef1"),
+ RequestType.MTB_FILE,
+ RequestStatus.WARNING,
+ Instant.parse("2023-07-07T00:00:00Z")
),
Request(
- id = 2L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "0123456789abcdef1",
- type = RequestType.MTB_FILE,
- status = RequestStatus.WARNING,
- processedAt = Instant.parse("2023-07-07T02:00:00Z")
+ 2L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdef1"),
+ RequestType.MTB_FILE,
+ RequestStatus.WARNING,
+ Instant.parse("2023-07-07T02:00:00Z")
),
Request(
- id = 3L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "0123456789abcdef1",
- type = RequestType.MTB_FILE,
- status = RequestStatus.UNKNOWN,
- processedAt = Instant.parse("2023-08-11T00:00:00Z")
+ 3L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdef1"),
+ RequestType.MTB_FILE,
+ RequestStatus.UNKNOWN,
+ Instant.parse("2023-08-11T00:00:00Z")
)
)
@@ -143,31 +145,31 @@ class RequestServiceTest {
fun shouldReturnPatientsLastRequest() {
val requests = listOf(
Request(
- id = 1L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "0123456789abcdef1",
- type = RequestType.DELETE,
- status = RequestStatus.SUCCESS,
- processedAt = Instant.parse("2023-07-07T02:00:00Z")
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdef1"),
+ RequestType.DELETE,
+ RequestStatus.SUCCESS,
+ Instant.parse("2023-07-07T02:00:00Z")
),
Request(
- id = 1L,
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678902",
- pid = "P2",
- fingerprint = "0123456789abcdef2",
- type = RequestType.MTB_FILE,
- status = RequestStatus.WARNING,
- processedAt = Instant.parse("2023-08-08T00:00:00Z")
+ 1L,
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678902"),
+ PatientId("P2"),
+ Fingerprint("0123456789abcdef2"),
+ RequestType.MTB_FILE,
+ RequestStatus.WARNING,
+ Instant.parse("2023-08-08T00:00:00Z")
)
)
val actual = RequestService.lastMtbFileRequestForPatientPseudonym(requests)
assertThat(actual).isInstanceOf(Request::class.java)
- assertThat(actual?.fingerprint).isEqualTo("0123456789abcdef2")
+ assertThat(actual?.fingerprint).isEqualTo(Fingerprint("0123456789abcdef2"))
}
@Test
@@ -184,16 +186,16 @@ class RequestServiceTest {
doAnswer {
val obj = it.arguments[0] as Request
obj.copy(id = 1L)
- }.`when`(requestRepository).save(anyRequest())
+ }.whenever(requestRepository).save(anyRequest())
val request = Request(
- uuid = UUID.randomUUID().toString(),
- patientId = "TEST_12345678901",
- pid = "P1",
- fingerprint = "0123456789abcdef1",
- type = RequestType.DELETE,
- status = RequestStatus.SUCCESS,
- processedAt = Instant.parse("2023-07-07T02:00:00Z")
+ randomRequestId(),
+ PatientPseudonym("TEST_12345678901"),
+ PatientId("P1"),
+ Fingerprint("0123456789abcdef1"),
+ RequestType.DELETE,
+ RequestStatus.SUCCESS,
+ Instant.parse("2023-07-07T02:00:00Z")
)
requestService.save(request)
@@ -203,23 +205,23 @@ class RequestServiceTest {
@Test
fun allRequestsByPatientPseudonymShouldRequestAllRequestsForPatientPseudonym() {
- requestService.allRequestsByPatientPseudonym("TEST_12345678901")
+ requestService.allRequestsByPatientPseudonym(PatientPseudonym("TEST_12345678901"))
- verify(requestRepository, times(1)).findAllByPatientIdOrderByProcessedAtDesc(anyString())
+ verify(requestRepository, times(1)).findAllByPatientPseudonymOrderByProcessedAtDesc(anyValueClass())
}
@Test
fun lastMtbFileRequestForPatientPseudonymShouldRequestAllRequestsForPatientPseudonym() {
- requestService.lastMtbFileRequestForPatientPseudonym("TEST_12345678901")
+ requestService.lastMtbFileRequestForPatientPseudonym(PatientPseudonym("TEST_12345678901"))
- verify(requestRepository, times(1)).findAllByPatientIdOrderByProcessedAtDesc(anyString())
+ verify(requestRepository, times(1)).findAllByPatientPseudonymOrderByProcessedAtDesc(anyValueClass())
}
@Test
fun isLastRequestDeletionShouldRequestAllRequestsForPatientPseudonym() {
- requestService.isLastRequestWithKnownStatusDeletion("TEST_12345678901")
+ requestService.isLastRequestWithKnownStatusDeletion(PatientPseudonym("TEST_12345678901"))
- verify(requestRepository, times(1)).findAllByPatientIdOrderByProcessedAtDesc(anyString())
+ verify(requestRepository, times(1)).findAllByPatientPseudonymOrderByProcessedAtDesc(anyValueClass())
}
} \ No newline at end of file
diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/ResponseProcessorTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/ResponseProcessorTest.kt
index b9e4b7f..465d8b8 100644
--- a/src/test/kotlin/dev/dnpm/etl/processor/services/ResponseProcessorTest.kt
+++ b/src/test/kotlin/dev/dnpm/etl/processor/services/ResponseProcessorTest.kt
@@ -19,8 +19,8 @@
package dev.dnpm.etl.processor.services
+import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.monitoring.Request
-import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType
import org.assertj.core.api.Assertions.assertThat
@@ -29,7 +29,6 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
-import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.*
@@ -40,64 +39,64 @@ import java.util.*
@ExtendWith(MockitoExtension::class)
class ResponseProcessorTest {
- private lateinit var requestRepository: RequestRepository
+ private lateinit var requestService: RequestService
private lateinit var statisticsUpdateProducer: Sinks.Many<Any>
private lateinit var responseProcessor: ResponseProcessor
private val testRequest = Request(
1L,
- "TestID1234",
- "PSEUDONYM-A",
- "1",
- "dummyfingerprint",
+ RequestId("TestID1234"),
+ PatientPseudonym("PSEUDONYM-A"),
+ PatientId("1"),
+ Fingerprint("dummyfingerprint"),
RequestType.MTB_FILE,
RequestStatus.UNKNOWN
)
@BeforeEach
fun setup(
- @Mock requestRepository: RequestRepository,
+ @Mock requestService: RequestService,
@Mock statisticsUpdateProducer: Sinks.Many<Any>
) {
- this.requestRepository = requestRepository
+ this.requestService = requestService
this.statisticsUpdateProducer = statisticsUpdateProducer
- this.responseProcessor = ResponseProcessor(requestRepository, statisticsUpdateProducer)
+ this.responseProcessor = ResponseProcessor(requestService, statisticsUpdateProducer)
}
@Test
fun shouldNotSaveStatusForUnknownRequest() {
doAnswer {
Optional.empty<Request>()
- }.whenever(requestRepository).findByUuidEquals(anyString())
+ }.whenever(requestService).findByUuid(anyValueClass())
val event = ResponseEvent(
- "TestID1234",
+ RequestId("TestID1234"),
Instant.parse("2023-09-09T00:00:00Z"),
RequestStatus.SUCCESS
)
this.responseProcessor.handleResponseEvent(event)
- verify(requestRepository, never()).save(any())
+ verify(requestService, never()).save(any())
}
@Test
fun shouldNotSaveStatusWithUnknownState() {
doAnswer {
Optional.of(testRequest)
- }.whenever(requestRepository).findByUuidEquals(anyString())
+ }.whenever(requestService).findByUuid(anyValueClass())
val event = ResponseEvent(
- "TestID1234",
+ RequestId("TestID1234"),
Instant.parse("2023-09-09T00:00:00Z"),
RequestStatus.UNKNOWN
)
this.responseProcessor.handleResponseEvent(event)
- verify(requestRepository, never()).save(any())
+ verify(requestService, never()).save(any<Request>())
}
@ParameterizedTest
@@ -105,10 +104,10 @@ class ResponseProcessorTest {
fun shouldSaveStatusForKnownRequest(requestStatus: RequestStatus) {
doAnswer {
Optional.of(testRequest)
- }.whenever(requestRepository).findByUuidEquals(anyString())
+ }.whenever(requestService).findByUuid(anyValueClass())
val event = ResponseEvent(
- "TestID1234",
+ RequestId("TestID1234"),
Instant.parse("2023-09-09T00:00:00Z"),
requestStatus
)
@@ -116,7 +115,7 @@ class ResponseProcessorTest {
this.responseProcessor.handleResponseEvent(event)
val captor = argumentCaptor<Request>()
- verify(requestRepository, times(1)).save(captor.capture())
+ verify(requestService, times(1)).save(captor.capture())
assertThat(captor.firstValue).isNotNull
assertThat(captor.firstValue.status).isEqualTo(requestStatus)
}
diff --git a/src/test/resources/fake_MTBFile.json b/src/test/resources/fake_MTBFile.json
index 3f4e8a3..cdf8d75 100644
--- a/src/test/resources/fake_MTBFile.json
+++ b/src/test/resources/fake_MTBFile.json
@@ -1 +1 @@
-{"patient":{"id":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","gender":"female","birthDate":"1971-03","insurance":"Barmer"},"consent":{"id":"b93e4717-7b0e-4ca5-a5d6-cf8d0f7b14cf","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","status":"active"},"episode":{"id":"8ddb893f-0d55-412f-a257-9bc8bb054549","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","period":{"start":"2021-12-29"}},"diagnoses":[{"id":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","recordedOn":"2023-12-14","icd10":{"code":"C16.0","display":"Bösartige Neubildung: Kardia","version":"2023","system":"ICD-10-GM"},"whoGrade":{"code":"III","system":"WHO-Grading-CNS-Tumors"},"histologyResults":["385926d9-51f2-4a3a-96b6-57c5effefd84"],"statusHistory":[{"status":"local","date":"2023-12-14"},{"status":"unknown","date":"2023-12-14"}],"guidelineTreatmentStatus":"no-guidelines-available"}],"familyMemberDiagnoses":[{"id":"c434f063-76d9-4a7f-8ff2-34cd55fc56b2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","relationship":{"code":"EXT","system":"http://terminology.hl7.org/ValueSet/v3-FamilyMember"}},{"id":"b07a73a8-70ed-48f8-a745-e82e7a5907a8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","relationship":{"code":"EXT","system":"http://terminology.hl7.org/ValueSet/v3-FamilyMember"}}],"previousGuidelineTherapies":[{"id":"6435d684-18e3-45ad-b063-cdc303f61aa2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":4,"medication":[{"code":"L01XC18","system":"ATC","display":"Pembrolizumab","version":"2020"},{"code":"L01XE21","system":"ATC","display":"Regorafenib","version":"2020"},{"code":"L01XC17","system":"ATC","display":"Nivolumab","version":"2020"}]},{"id":"e35731db-7447-4c4b-896a-0e90f1e68c67","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":4,"medication":[{"code":"L01XX46","system":"ATC","display":"Olaparib","version":"2020"},{"code":"L01XX32","system":"ATC","display":"Bortezomib","version":"2020"},{"code":"L01XE02","system":"ATC","display":"Gefitinib","version":"2020"}]},{"id":"d8a46cf9-cdc9-4f0d-b106-5aa57e08c4d0","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":1,"medication":[{"code":"L01XC28","system":"ATC","display":"Durvalumab","version":"2020"},{"code":"L01XC06","system":"ATC","display":"Cetuximab","version":"2020"},{"code":"L01XE47","system":"ATC","display":"Dacomitinib","version":"2020"}]}],"lastGuidelineTherapies":[{"id":"9152b20d-ac04-406c-b18b-5b6f7f4d1911","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":1,"period":{"start":"2023-12-14","end":"2024-01-04"},"medication":[{"code":"L01DB01","system":"ATC","display":"Doxorubicin","version":"2020"}],"reasonStopped":{"code":"toxicity","system":"MTB-CDS:GuidelineTherapy-StopReason"}}],"ecogStatus":[{"id":"51589dd2-f48d-4c41-8740-292b88d63b30","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","effectiveDate":"2023-12-14","value":{"code":"2","system":"ECOG-Performance-Status"}},{"id":"30caf153-a30a-4a1c-9056-fd4eae2a55da","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","effectiveDate":"2023-12-14","value":{"code":"4","system":"ECOG-Performance-Status"}}],"specimens":[{"id":"40043ae5-d4cb-48e4-85c6-b34266b7693f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","icd10":{"code":"C16.0","display":"Bösartige Neubildung: Kardia","version":"2023","system":"ICD-10-GM"},"type":"liquid-biopsy","collection":{"date":"2023-12-14","localization":"unknown","method":"liquid-biopsy"}}],"molecularPathologyFindings":[{"id":"c7622342-3297-489e-850a-26aaf1225b36","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","performingInstitute":"TESTINSTITUTE","issuedOn":"2023-12-14","note":"MolecularPathologyFinding notes..."}],"histologyReports":[{"id":"385926d9-51f2-4a3a-96b6-57c5effefd84","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14","tumorMorphology":{"id":"592b13c7-9507-4f31-a544-5cff90e35581","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","value":{"code":"8851/3","display":"Gut differenziertes Liposarkom","version":"Zweite Revision","system":"ICD-O-3-M"},"note":"Histology finding notes..."},"tumorCellContent":{"id":"f6af339c-415c-4682-b700-499e392b4558","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","method":"histologic","value":0.38164}}],"ngsReports":[{"id":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issueDate":"2023-12-14","sequencingType":"WGS","metadata":[{"kitType":"Agilent ExomV6","kitManufacturer":"Agilent","sequencer":"Sequencer-XYZ","referenceGenome":"HG19","pipeline":"dummy/uri/to/pipeline"}],"tumorCellContent":{"id":"e865f20a-1307-4ca3-b2ef-3a863b8afde0","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","method":"bioinformatic","value":0.74991},"brcaness":0.39,"msi":0.35,"tmb":594349.91,"simpleVariants":[{"id":"SNV_599c38d4-af9f-416c-ad38-eb2fee8159e1","chromosome":"chr6","gene":{"ensemblId":"ENSENSG00000242908","hgncId":"HGNC:50301","symbol":"AADACL2-AS1","name":"AADACL2 antisense RNA 1"},"startEnd":{"start":6736035388467870105},"refAllele":"A","altAllele":"G","dnaChange":{"code":"A>G","system":"HGVS.c"},"aminoAcidChange":{"code":"Amino acid change code...","system":"HGVS.p"},"readDepth":19,"allelicFrequency":0.75,"cosmicId":"COSMICf106c745-dbaa-453f-8dca-2f584bc1e6cb","dbSNPId":"DBSNPIDc3f51fb2-31f3-4b27-bbcc-aac52736986f","interpretation":{"code":"Probably Inactivating","system":"ClinVAR"}},{"id":"SNV_963a3ea1-a1d5-4a52-b8d4-2e969be74b7b","chromosome":"chr2","gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},"startEnd":{"start":1672464855319477743},"refAllele":"T","altAllele":"C","dnaChange":{"code":"T>C","system":"HGVS.c"},"aminoAcidChange":{"code":"Amino acid change code...","system":"HGVS.p"},"readDepth":23,"allelicFrequency":0.36,"cosmicId":"COSMICbcbc96bb-0428-48c9-8c64-7d2fd884528d","dbSNPId":"DBSNPIDc4904618-819c-4af1-b793-ca9d820371dc","interpretation":{"code":"Ambiguous","system":"ClinVAR"}},{"id":"SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546","chromosome":"chr5","gene":{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"},"startEnd":{"start":4251323878559029469},"refAllele":"A","altAllele":"C","dnaChange":{"code":"A>C","system":"HGVS.c"},"aminoAcidChange":{"code":"Amino acid change code...","system":"HGVS.p"},"readDepth":37,"allelicFrequency":0.48,"cosmicId":"COSMICc3c67469-2303-4cba-9b45-424d62a0d3db","dbSNPId":"DBSNPIDd94cb5ce-250a-471f-a571-015fe8a711c9","interpretation":{"code":"Function Changed","system":"ClinVAR"}},{"id":"SNV_22f943fd-ad99-48af-a61e-42679e851b71","chromosome":"chr2","gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"},"startEnd":{"start":7454627449124699972},"refAllele":"A","altAllele":"C","dnaChange":{"code":"A>C","system":"HGVS.c"},"aminoAcidChange":{"code":"Amino acid change code...","system":"HGVS.p"},"readDepth":22,"allelicFrequency":0.37,"cosmicId":"COSMICa6074760-ce24-4075-ab11-f5ab4cd6c497","dbSNPId":"DBSNPID3578fa1b-eb03-423c-929d-6705cd8e805c","interpretation":{"code":"Function Changed","system":"ClinVAR"}},{"id":"SNV_fe346bed-74ac-4b84-843b-7490a5823364","chromosome":"chr14","gene":{"ensemblId":"ENSENSG00000125257","hgncId":"HGNC:55","symbol":"ABCC4","name":"ATP binding cassette subfamily C member 4"},"startEnd":{"start":6478613836523717707},"refAllele":"G","altAllele":"T","dnaChange":{"code":"G>T","system":"HGVS.c"},"aminoAcidChange":{"code":"Amino acid change code...","system":"HGVS.p"},"readDepth":30,"allelicFrequency":0.47,"cosmicId":"COSMIC14d8842e-6af9-434f-a993-32fae87a84be","dbSNPId":"DBSNPID1a97211e-a6f6-44be-a909-3620f34b01e2","interpretation":{"code":"Ambiguous","system":"ClinVAR"}}],"copyNumberVariants":[{"id":"CNV_ABALON_ABCA17P_high-level-gain","chromosome":"chr8","startRange":{"start":969911792064545275,"end":969911792064546084},"endRange":{"start":4404138220928659257,"end":4404138220928659925},"totalCopyNumber":2,"relativeCopyNumber":0.18,"cnA":0.87,"cnB":0.49,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000281376","hgncId":"HGNC:49667","symbol":"ABALON","name":"apoptotic BCL2L1-antisense long non-coding RNA"},{"ensemblId":"ENSENSG00000238098","hgncId":"HGNC:32972","symbol":"ABCA17P","name":"ATP binding cassette subfamily A member 17, pseudogene"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000115657","hgncId":"HGNC:47","symbol":"ABCB6","name":"ATP binding cassette subfamily B member 6 (Langereis blood group)"},{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"}]},{"id":"CNV_AAGAB_ABCA6_high-level-gain","chromosome":"chr3","startRange":{"start":6843968935032545040,"end":6843968935032545924},"endRange":{"start":3583631517115538627,"end":3583631517115539281},"totalCopyNumber":4,"relativeCopyNumber":0.11,"cnA":0.15,"cnB":0.29,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000103591","hgncId":"HGNC:25662","symbol":"AAGAB","name":"alpha and gamma adaptin binding protein"},{"ensemblId":"ENSENSG00000154262","hgncId":"HGNC:36","symbol":"ABCA6","name":"ATP binding cassette subfamily A member 6"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000231749","hgncId":"HGNC:39983","symbol":"ABCA9-AS1","name":"ABCA9 antisense RNA 1"},{"ensemblId":"ENSENSG00000154265","hgncId":"HGNC:35","symbol":"ABCA5","name":"ATP binding cassette subfamily A member 5"},{"ensemblId":"ENSENSG00000081760","hgncId":"HGNC:21298","symbol":"AACS","name":"acetoacetyl-CoA synthetase"}]},{"id":"CNV_ABCA12_AASDHPPT_ABCC2_AARS1P1_high-level-gain","chromosome":"chr5","startRange":{"start":8487172994898555456,"end":8487172994898556127},"endRange":{"start":2329045896118581347,"end":2329045896118581894},"totalCopyNumber":3,"relativeCopyNumber":0.67,"cnA":0.47,"cnB":0.46,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000144452","hgncId":"HGNC:14637","symbol":"ABCA12","name":"ATP binding cassette subfamily A member 12"},{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"},{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},{"ensemblId":"ENSENSG00000129673","hgncId":"HGNC:19","symbol":"AANAT","name":"aralkylamine N-acetyltransferase"},{"ensemblId":"ENSENSG00000242908","hgncId":"HGNC:50301","symbol":"AADACL2-AS1","name":"AADACL2 antisense RNA 1"}]},{"id":"CNV_AAAS_AADAT_high-level-gain","chromosome":"chr10","startRange":{"start":1954565432038993495,"end":1954565432038994133},"endRange":{"start":442085989067090995,"end":442085989067091164},"totalCopyNumber":4,"relativeCopyNumber":0.37,"cnA":0.98,"cnB":0.18,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000094914","hgncId":"HGNC:13666","symbol":"AAAS","name":"aladin WD repeat nucleoporin"},{"ensemblId":"ENSENSG00000109576","hgncId":"HGNC:17929","symbol":"AADAT","name":"aminoadipate aminotransferase"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000150967","hgncId":"HGNC:50","symbol":"ABCB9","name":"ATP binding cassette subfamily B member 9"},{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"}]},{"id":"CNV_A3GALT2_ABCB10P4_low-level-gain","chromosome":"chr8","startRange":{"start":1779205446909981075,"end":1779205446909981845},"endRange":{"start":3151805846500148631,"end":3151805846500149455},"totalCopyNumber":4,"relativeCopyNumber":0.94,"cnA":0.3,"cnB":0.66,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"}],"reportedFocality":"reported-focality...","type":"low-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"},{"ensemblId":"ENSENSG00000231749","hgncId":"HGNC:39983","symbol":"ABCA9-AS1","name":"ABCA9 antisense RNA 1"},{"ensemblId":"ENSENSG00000108846","hgncId":"HGNC:54","symbol":"ABCC3","name":"ATP binding cassette subfamily C member 3"},{"ensemblId":"ENSENSG00000197953","hgncId":"HGNC:24427","symbol":"AADACL2","name":"arylacetamide deacetylase like 2"}]},{"id":"CNV_ABCA10_AADACL2_high-level-gain","chromosome":"chrX","startRange":{"start":165156786091954061,"end":165156786091954176},"endRange":{"start":5591033364020511004,"end":5591033364020511607},"totalCopyNumber":4,"relativeCopyNumber":0.69,"cnA":0.56,"cnB":0.12,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000154263","hgncId":"HGNC:30","symbol":"ABCA10","name":"ATP binding cassette subfamily A member 10"},{"ensemblId":"ENSENSG00000197953","hgncId":"HGNC:24427","symbol":"AADACL2","name":"arylacetamide deacetylase like 2"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000215458","hgncId":"HGNC:51526","symbol":"AATBC","name":"apoptosis associated transcript in bladder cancer"},{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"},{"ensemblId":"ENSENSG00000261524","hgncId":"HGNC:31129","symbol":"ABCB10P3","name":"ABCB10 pseudogene 3"}]},{"id":"CNV_AAMP_AADACL3_low-level-gain","chromosome":"chrX","startRange":{"start":7552444878262806955,"end":7552444878262807187},"endRange":{"start":140089034030783731,"end":140089034030784407},"totalCopyNumber":2,"relativeCopyNumber":0.57,"cnA":0.26,"cnB":0.82,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"},{"ensemblId":"ENSENSG00000188984","hgncId":"HGNC:32037","symbol":"AADACL3","name":"arylacetamide deacetylase like 3"}],"reportedFocality":"reported-focality...","type":"low-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000121410","hgncId":"HGNC:5","symbol":"A1BG","name":"alpha-1-B glycoprotein"},{"ensemblId":"ENSENSG00000175899","hgncId":"HGNC:7","symbol":"A2M","name":"alpha-2-macroglobulin"},{"ensemblId":"ENSENSG00000257408","hgncId":"HGNC:55707","symbol":"ABCA3P1","name":"ABCA3 pseudogene 1"},{"ensemblId":"ENSENSG00000005471","hgncId":"HGNC:45","symbol":"ABCB4","name":"ATP binding cassette subfamily B member 4"}]}],"dnaFusions":[{"id":"DNAFusion_A3GALT2_AAGAB","fusionPartner5prime":{"chromosome":"chr16","position":569568638299166051,"gene":{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"}},"fusionPartner3prime":{"chromosome":"chr1","position":1728963273084125905,"gene":{"ensemblId":"ENSENSG00000103591","hgncId":"HGNC:25662","symbol":"AAGAB","name":"alpha and gamma adaptin binding protein"}},"reportedNumReads":25},{"id":"DNAFusion_ABCA3_AASDHPPT","fusionPartner5prime":{"chromosome":"chr12","position":4142955940382701892,"gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"}},"fusionPartner3prime":{"chromosome":"chr13","position":2530447494476677762,"gene":{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"}},"reportedNumReads":29},{"id":"DNAFusion_ABCB10P3_AAAS","fusionPartner5prime":{"chromosome":"chr19","position":216619303235013143,"gene":{"ensemblId":"ENSENSG00000261524","hgncId":"HGNC:31129","symbol":"ABCB10P3","name":"ABCB10 pseudogene 3"}},"fusionPartner3prime":{"chromosome":"chr20","position":7983660439294503113,"gene":{"ensemblId":"ENSENSG00000094914","hgncId":"HGNC:13666","symbol":"AAAS","name":"aladin WD repeat nucleoporin"}},"reportedNumReads":30},{"id":"DNAFusion_A1BG_AADACL4","fusionPartner5prime":{"chromosome":"chr19","position":2761782759714541191,"gene":{"ensemblId":"ENSENSG00000121410","hgncId":"HGNC:5","symbol":"A1BG","name":"alpha-1-B glycoprotein"}},"fusionPartner3prime":{"chromosome":"chr15","position":2381966877469433813,"gene":{"ensemblId":"ENSENSG00000204518","hgncId":"HGNC:32038","symbol":"AADACL4","name":"arylacetamide deacetylase like 4"}},"reportedNumReads":22},{"id":"DNAFusion_AAMP_ABCB9","fusionPartner5prime":{"chromosome":"chr12","position":2738282492147015127,"gene":{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"}},"fusionPartner3prime":{"chromosome":"chr18","position":4689414126579295665,"gene":{"ensemblId":"ENSENSG00000150967","hgncId":"HGNC:50","symbol":"ABCB9","name":"ATP binding cassette subfamily B member 9"}},"reportedNumReads":44},{"id":"DNAFusion_AACS_ABCB10","fusionPartner5prime":{"chromosome":"chr19","position":5162788528310959454,"gene":{"ensemblId":"ENSENSG00000081760","hgncId":"HGNC:21298","symbol":"AACS","name":"acetoacetyl-CoA synthetase"}},"fusionPartner3prime":{"chromosome":"chr2","position":281250493569316672,"gene":{"ensemblId":"ENSENSG00000135776","hgncId":"HGNC:41","symbol":"ABCB10","name":"ATP binding cassette subfamily B member 10"}},"reportedNumReads":28},{"id":"DNAFusion_ABCA3_ABCA8","fusionPartner5prime":{"chromosome":"chr17","position":7239027143174816791,"gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"}},"fusionPartner3prime":{"chromosome":"chr20","position":3415200056745807403,"gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"}},"reportedNumReads":47},{"id":"DNAFusion_AATF_AASS","fusionPartner5prime":{"chromosome":"chr14","position":6207520478306983467,"gene":{"ensemblId":"ENSENSG00000275700","hgncId":"HGNC:19235","symbol":"AATF","name":"apoptosis antagonizing transcription factor"}},"fusionPartner3prime":{"chromosome":"chr7","position":966733822586135931,"gene":{"ensemblId":"ENSENSG00000008311","hgncId":"HGNC:17366","symbol":"AASS","name":"aminoadipate-semialdehyde synthase"}},"reportedNumReads":32},{"id":"DNAFusion_AATBC_AARD","fusionPartner5prime":{"chromosome":"chr9","position":6948858453904558539,"gene":{"ensemblId":"ENSENSG00000215458","hgncId":"HGNC:51526","symbol":"AATBC","name":"apoptosis associated transcript in bladder cancer"}},"fusionPartner3prime":{"chromosome":"chr21","position":5188489683141511746,"gene":{"ensemblId":"ENSENSG00000205002","hgncId":"HGNC:33842","symbol":"AARD","name":"alanine and arginine rich domain containing protein"}},"reportedNumReads":21}],"rnaFusions":[{"id":"RNAFusion_A3GALT2_ABCB6","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},"transcriptId":"TIDf283c026-41bf-41c6-afd5-1980bd408a06","exon":"EXONc9f963b3-4e55-4f1f-9ad1-e79b86e6e751","position":1009212469473862062,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000115657","hgncId":"HGNC:47","symbol":"ABCB6","name":"ATP binding cassette subfamily B member 6 (Langereis blood group)"},"transcriptId":"TID97eabd37-cf0e-44f3-a1f1-d21543eb3b5d","exon":"EXONc9077d54-f82f-42bb-8bc8-e79e46204085","position":6271859040431005877,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC28f0a2f7-a923-4622-a9e4-52ecd5806066","reportedNumReads":29},{"id":"RNAFusion_ABCB10P4_A2ML1-AS1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"},"transcriptId":"TID2cd74611-6960-455f-9946-3962aadbbd56","exon":"EXONf7f8f171-248f-44de-9408-37a3bfa75b4f","position":9124790507143722597,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000256661","hgncId":"HGNC:41022","symbol":"A2ML1-AS1","name":"A2ML1 antisense RNA 1"},"transcriptId":"TIDb16fc5d0-e8ed-40e4-9798-216ad6d4aade","exon":"EXON1bd1a002-966b-4c1d-bb0a-7f331038e5ae","position":461028552708332541,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC89d4c113-ca01-4804-ad46-ac20a4762389","reportedNumReads":22},{"id":"RNAFusion_A2ML1-AS2_AARS1P1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000256904","hgncId":"HGNC:41523","symbol":"A2ML1-AS2","name":"A2ML1 antisense RNA 2"},"transcriptId":"TID40d495d1-f498-4b7d-8e71-e3c90af68e58","exon":"EXON23c9ccaa-ea43-444c-8340-f18365c32e4e","position":6743012027657999130,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"},"transcriptId":"TIDb4337aef-0a69-483c-a603-9e3d6beefff6","exon":"EXON6980165c-6f85-4fd3-8c66-4349187f6382","position":4014336725221699201,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMICb82c4810-5629-4ea3-b569-3fd9c7167752","reportedNumReads":25},{"id":"RNAFusion_ABCA9_ABCB10P1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000154258","hgncId":"HGNC:39","symbol":"ABCA9","name":"ATP binding cassette subfamily A member 9"},"transcriptId":"TIDfbbc5ac0-1d3f-476a-a1ac-4ed1456528ba","exon":"EXONf97da06b-9d7d-48c0-b202-d58a92d57e22","position":7451485235432246024,"strand":"-"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000274099","hgncId":"HGNC:14114","symbol":"ABCB10P1","name":"ABCB10 pseudogene 1"},"transcriptId":"TID3154eb7b-4364-4394-a128-75d7eb7174a0","exon":"EXON22661f53-bfb7-4b8a-9e5f-ca0b05cda801","position":2144193097707360397,"strand":"+"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC42bebec2-a56e-4b55-9dc6-986d1b421404","reportedNumReads":36},{"id":"RNAFusion_A2M-AS1_AAR2","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000245105","hgncId":"HGNC:27057","symbol":"A2M-AS1","name":"A2M antisense RNA 1"},"transcriptId":"TID9a631927-221f-455b-b3af-b7ed8204f8ce","exon":"EXON1ae4eead-6101-4f79-b09e-2289476ec75e","position":8438088205780109333,"strand":"-"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000131043","hgncId":"HGNC:15886","symbol":"AAR2","name":"AAR2 splicing factor"},"transcriptId":"TIDe66203bb-2b54-432c-8eb9-41b81f712602","exon":"EXONfe1ed045-ddfa-4a7d-b0c8-6f08a2bc7249","position":4758763569629035168,"strand":"+"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC6f5c8a08-fa74-41da-9a3e-c5965a8d6978","reportedNumReads":32},{"id":"RNAFusion_ABCB7_AADAT","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000131269","hgncId":"HGNC:48","symbol":"ABCB7","name":"ATP binding cassette subfamily B member 7"},"transcriptId":"TID78736fa2-71a0-4dc3-9675-e497da53a019","exon":"EXON6cd359fc-87e1-41b8-90be-9ca397aff1af","position":7631148909251407357,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000109576","hgncId":"HGNC:17929","symbol":"AADAT","name":"aminoadipate aminotransferase"},"transcriptId":"TIDf79fa95a-c199-4568-959f-14f3baaea274","exon":"EXON98a11577-0325-4199-8b6f-1c8207b4d3d4","position":5619672559603971,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC8a43fd3b-3df4-414d-8520-571c3ad2cf77","reportedNumReads":49}],"rnaSeqs":[{"id":"RNASeq_8a6e6d50-bfd8-4c29-9003-1937ba3cc9b3","entrezId":"EID8a6e6d50-bfd8-4c29-9003-1937ba3cc9b3","ensemblId":"ENS0fcf6c3b-6389-42a8-a8d3-3e724feb302c","gene":{"ensemblId":"ENSENSG00000129673","hgncId":"HGNC:19","symbol":"AANAT","name":"aralkylamine N-acetyltransferase"},"transcriptId":"TID16c9f024-d10b-48fc-b3eb-fe21455f2016","fragmentsPerKilobaseMillion":0.52,"fromNGS":false,"tissueCorrectedExpression":true,"rawCounts":393,"librarySize":97,"cohortRanking":2},{"id":"RNASeq_0ae924be-d9c2-42e2-b9eb-c946bf768da4","entrezId":"EID0ae924be-d9c2-42e2-b9eb-c946bf768da4","ensemblId":"ENS347f84b9-6147-4336-bba8-bb5e8f32091d","gene":{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"},"transcriptId":"TID68d3f08f-68c5-4c4e-a868-2a91bf6a74ef","fragmentsPerKilobaseMillion":0.5,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":410,"librarySize":82,"cohortRanking":5},{"id":"RNASeq_4d00e0d0-76be-4071-ab10-2d1b6fd2bf32","entrezId":"EID4d00e0d0-76be-4071-ab10-2d1b6fd2bf32","ensemblId":"ENS7b768481-e129-4f87-8bff-815dc5449f58","gene":{"ensemblId":"ENSENSG00000256661","hgncId":"HGNC:41022","symbol":"A2ML1-AS1","name":"A2ML1 antisense RNA 1"},"transcriptId":"TID488b86f2-668b-4d75-a784-8267757b5cdd","fragmentsPerKilobaseMillion":0.55,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":968,"librarySize":47,"cohortRanking":2},{"id":"RNASeq_ceeb241f-6dbd-4e55-9c34-666c44e46405","entrezId":"EIDceeb241f-6dbd-4e55-9c34-666c44e46405","ensemblId":"ENS5a0783cb-48ff-4cde-98b1-e2b8ea31a9f4","gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},"transcriptId":"TID972fda4e-a47b-43b0-b574-9dc688d668f5","fragmentsPerKilobaseMillion":0.71,"fromNGS":true,"tissueCorrectedExpression":true,"rawCounts":294,"librarySize":35,"cohortRanking":3},{"id":"RNASeq_475d891e-030f-490e-b741-030b965877c0","entrezId":"EID475d891e-030f-490e-b741-030b965877c0","ensemblId":"ENS66d9d63f-8b6e-4de7-baa3-e6be55497c77","gene":{"ensemblId":"ENSENSG00000197150","hgncId":"HGNC:49","symbol":"ABCB8","name":"ATP binding cassette subfamily B member 8"},"transcriptId":"TID7af2745c-811f-481a-a288-81456117a9fa","fragmentsPerKilobaseMillion":0.04,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":371,"librarySize":68,"cohortRanking":8}]}],"carePlans":[{"id":"6a3601ea-8fba-437d-8add-bf4f4cce469e","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","description":"MTB conference protocol...","recommendations":["df376556-df45-41c3-8bae-af1fe3fb7418","08234e1e-105c-4362-87e8-4f20bf87ed0b"],"geneticCounsellingRequest":"65344fca-0028-4129-a530-dd36fd984bd3","rebiopsyRequests":["5f54fb43-92a5-4f62-ae48-081d428ff2e8"],"studyInclusionRequests":["d49b41ff-e2df-499f-938f-8fb7136366b2"]}],"recommendations":[{"id":"df376556-df45-41c3-8bae-af1fe3fb7418","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","medication":[{"code":"L01XE39","system":"ATC","display":"Midostaurin","version":"2020"},{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"}],"priority":"4","levelOfEvidence":{"grading":{"code":"m1B","system":"MTB-CDS:Level-of-Evidence:Grading"},"addendums":[{"code":"R","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"is","system":"MTB-CDS:Level-of-Evidence:Addendum"}]},"ngsReport":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","supportingVariants":["CNV_AAGAB_ABCA6_high-level-gain","CNV_AAMP_AADACL3_low-level-gain","SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546"]},{"id":"08234e1e-105c-4362-87e8-4f20bf87ed0b","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","medication":[{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"},{"code":"L01XE12","system":"ATC","display":"Vandetanib","version":"2020"}],"priority":"4","levelOfEvidence":{"grading":{"code":"m1A","system":"MTB-CDS:Level-of-Evidence:Grading"},"addendums":[{"code":"R","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"is","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"iv","system":"MTB-CDS:Level-of-Evidence:Addendum"}]},"ngsReport":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","supportingVariants":["CNV_AAGAB_ABCA6_high-level-gain","CNV_ABCA12_AASDHPPT_ABCC2_AARS1P1_high-level-gain","SNV_963a3ea1-a1d5-4a52-b8d4-2e969be74b7b","SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546","CNV_AAAS_AADAT_high-level-gain","CNV_ABALON_ABCA17P_high-level-gain","CNV_AAMP_AADACL3_low-level-gain","SNV_22f943fd-ad99-48af-a61e-42679e851b71","SNV_599c38d4-af9f-416c-ad38-eb2fee8159e1"]}],"geneticCounsellingRequests":[{"id":"65344fca-0028-4129-a530-dd36fd984bd3","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","reason":"Some reason for genetic counselling..."}],"rebiopsyRequests":[{"id":"5f54fb43-92a5-4f62-ae48-081d428ff2e8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14"}],"histologyReevaluationRequests":[{"id":"b76dfb95-13e8-4acb-9ab8-364bc5215d63","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14"}],"studyInclusionRequests":[{"id":"d49b41ff-e2df-499f-938f-8fb7136366b2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","reason":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","nctNumber":"NCT84044685","issuedOn":"2023-12-14"}],"claims":[{"id":"2280a457-c5b8-4541-b6ca-86de385d789f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","therapy":"df376556-df45-41c3-8bae-af1fe3fb7418"},{"id":"061fde0a-935c-4e2f-a54a-531679b2f8d5","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","therapy":"08234e1e-105c-4362-87e8-4f20bf87ed0b"}],"claimResponses":[{"id":"1177f670-cf44-4886-b9b3-a4dd25271dcb","claim":"2280a457-c5b8-4541-b6ca-86de385d789f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","status":"accepted","reason":"standard-therapy-not-exhausted"},{"id":"c85d365d-e3c1-474b-aaa3-0e3e051d4223","claim":"061fde0a-935c-4e2f-a54a-531679b2f8d5","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","status":"rejected","reason":"other"}],"molecularTherapies":[{"history":[{"id":"88801f10-9f77-4a5d-adc1-47dd97b7e9ea","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","recordedOn":"2023-12-14","basedOn":"df376556-df45-41c3-8bae-af1fe3fb7418","period":{"start":"2023-12-14","end":"2023-12-14"},"medication":[{"code":"L01XE39","system":"ATC","display":"Midostaurin","version":"2020"},{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"}],"dosage":">=50%","reasonStopped":{"code":"medical-reason","system":"MTB-CDS:MolecularTherapy:StopReason"},"note":"Notes on the Therapy...","status":"stopped"}]},{"history":[{"id":"660813fd-a42d-492a-8522-9c4aa3b3e162","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","recordedOn":"2023-12-14","basedOn":"08234e1e-105c-4362-87e8-4f20bf87ed0b","period":{"start":"2023-12-14","end":"2023-12-14"},"medication":[{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"},{"code":"L01XE12","system":"ATC","display":"Vandetanib","version":"2020"}],"dosage":"<50%","note":"Notes on the Therapy...","status":"completed"}]}],"responses":[{"id":"267cddc7-50fd-43e6-90e6-a7f2806c7da2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","therapy":"88801f10-9f77-4a5d-adc1-47dd97b7e9ea","effectiveDate":"2023-12-14","value":{"code":"SD","system":"RECIST"}},{"id":"8b2af33e-afee-450b-b947-3370d89603f8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","therapy":"660813fd-a42d-492a-8522-9c4aa3b3e162","effectiveDate":"2023-12-14","value":{"code":"CR","system":"RECIST"}}]} \ No newline at end of file
+{"patient":{"id":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","gender":"female","birthDate":"1971-03","insurance":"Barmer"},"consent":{"id":"b93e4717-7b0e-4ca5-a5d6-cf8d0f7b14cf","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","status":"active"},"episode":{"id":"8ddb893f-0d55-412f-a257-9bc8bb054549","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","period":{"start":"2021-12-29"}},"diagnoses":[{"id":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","recordedOn":"2023-12-14","icd10":{"code":"C16.0","display":"Bösartige Neubildung: Kardia","version":"2023","system":"ICD-10-GM"},"whoGrade":{"code":"3", "version":"2021", "system":"WHO-Grading-CNS-Tumors"},"histologyResults":["385926d9-51f2-4a3a-96b6-57c5effefd84"],"statusHistory":[{"status":"local","date":"2023-12-14"},{"status":"unknown","date":"2023-12-14"}],"guidelineTreatmentStatus":"no-guidelines-available"}],"familyMemberDiagnoses":[{"id":"c434f063-76d9-4a7f-8ff2-34cd55fc56b2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","relationship":{"code":"EXT","system":"http://terminology.hl7.org/ValueSet/v3-FamilyMember"}},{"id":"b07a73a8-70ed-48f8-a745-e82e7a5907a8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","relationship":{"code":"EXT","system":"http://terminology.hl7.org/ValueSet/v3-FamilyMember"}}],"previousGuidelineTherapies":[{"id":"6435d684-18e3-45ad-b063-cdc303f61aa2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":4,"medication":[{"code":"L01XC18","system":"ATC","display":"Pembrolizumab","version":"2020"},{"code":"L01XE21","system":"ATC","display":"Regorafenib","version":"2020"},{"code":"L01XC17","system":"ATC","display":"Nivolumab","version":"2020"}]},{"id":"e35731db-7447-4c4b-896a-0e90f1e68c67","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":4,"medication":[{"code":"L01XX46","system":"ATC","display":"Olaparib","version":"2020"},{"code":"L01XX32","system":"ATC","display":"Bortezomib","version":"2020"},{"code":"L01XE02","system":"ATC","display":"Gefitinib","version":"2020"}]},{"id":"d8a46cf9-cdc9-4f0d-b106-5aa57e08c4d0","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":1,"medication":[{"code":"L01XC28","system":"ATC","display":"Durvalumab","version":"2020"},{"code":"L01XC06","system":"ATC","display":"Cetuximab","version":"2020"},{"code":"L01XE47","system":"ATC","display":"Dacomitinib","version":"2020"}]}],"lastGuidelineTherapies":[{"id":"9152b20d-ac04-406c-b18b-5b6f7f4d1911","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","therapyLine":1,"period":{"start":"2023-12-14","end":"2024-01-04"},"medication":[{"code":"L01DB01","system":"ATC","display":"Doxorubicin","version":"2020"}],"reasonStopped":{"code":"toxicity","system":"MTB-CDS:GuidelineTherapy-StopReason"}}],"ecogStatus":[{"id":"51589dd2-f48d-4c41-8740-292b88d63b30","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","effectiveDate":"2023-12-14","value":{"code":"2","system":"ECOG-Performance-Status"}},{"id":"30caf153-a30a-4a1c-9056-fd4eae2a55da","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","effectiveDate":"2023-12-14","value":{"code":"4","system":"ECOG-Performance-Status"}}],"specimens":[{"id":"40043ae5-d4cb-48e4-85c6-b34266b7693f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","icd10":{"code":"C16.0","display":"Bösartige Neubildung: Kardia","version":"2023","system":"ICD-10-GM"},"type":"liquid-biopsy","collection":{"date":"2023-12-14","localization":"unknown","method":"liquid-biopsy"}}],"molecularPathologyFindings":[{"id":"c7622342-3297-489e-850a-26aaf1225b36","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","performingInstitute":"TESTINSTITUTE","issuedOn":"2023-12-14","note":"MolecularPathologyFinding notes..."}],"histologyReports":[{"id":"385926d9-51f2-4a3a-96b6-57c5effefd84","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14","tumorMorphology":{"id":"592b13c7-9507-4f31-a544-5cff90e35581","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","value":{"code":"8851/3","display":"Gut differenziertes Liposarkom","version":"Zweite Revision","system":"ICD-O-3-M"},"note":"Histology finding notes..."},"tumorCellContent":{"id":"f6af339c-415c-4682-b700-499e392b4558","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","method":"histologic","value":0.38164}}],"ngsReports":[{"id":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issueDate":"2023-12-14","sequencingType":"WGS","metadata":[{"kitType":"Agilent ExomV6","kitManufacturer":"Agilent","sequencer":"Sequencer-XYZ","referenceGenome":"HG19","pipeline":"dummy/uri/to/pipeline"}],"tumorCellContent":{"id":"e865f20a-1307-4ca3-b2ef-3a863b8afde0","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","method":"bioinformatic","value":0.74991},"brcaness":0.39,"msi":0.35,"tmb":594349.91,"simpleVariants":[{"id":"SNV_599c38d4-af9f-416c-ad38-eb2fee8159e1","chromosome":"chr6","gene":{"ensemblId":"ENSENSG00000242908","hgncId":"HGNC:50301","symbol":"AADACL2-AS1","name":"AADACL2 antisense RNA 1"},"startEnd":{"start":6736035388467870105},"refAllele":"A","altAllele":"G","dnaChange":{"code":"A>G","system":"HGVS.c"},"aminoAcidChange":{"code":"Tyr","system":"HGVS.p"},"readDepth":19,"allelicFrequency":0.75,"cosmicId":"COSMICf106c745-dbaa-453f-8dca-2f584bc1e6cb","dbSNPId":"DBSNPIDc3f51fb2-31f3-4b27-bbcc-aac52736986f","interpretation":{"code":"Probably Inactivating","system":"ClinVAR"}},{"id":"SNV_963a3ea1-a1d5-4a52-b8d4-2e969be74b7b","chromosome":"chr2","gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},"startEnd":{"start":1672464855319477743},"refAllele":"T","altAllele":"C","dnaChange":{"code":"Tyr","system":"HGVS.c"},"aminoAcidChange":{"code":"Tyr","system":"HGVS.p"},"readDepth":23,"allelicFrequency":0.36,"cosmicId":"COSMICbcbc96bb-0428-48c9-8c64-7d2fd884528d","dbSNPId":"DBSNPIDc4904618-819c-4af1-b793-ca9d820371dc","interpretation":{"code":"Ambiguous","system":"ClinVAR"}},{"id":"SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546","chromosome":"chr5","gene":{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"},"startEnd":{"start":4251323878559029469},"refAllele":"A","altAllele":"C","dnaChange":{"code":"A>C","system":"HGVS.c"},"aminoAcidChange":{"code":"Tyr","system":"HGVS.p"},"readDepth":37,"allelicFrequency":0.48,"cosmicId":"COSMICc3c67469-2303-4cba-9b45-424d62a0d3db","dbSNPId":"DBSNPIDd94cb5ce-250a-471f-a571-015fe8a711c9","interpretation":{"code":"Function Changed","system":"ClinVAR"}},{"id":"SNV_22f943fd-ad99-48af-a61e-42679e851b71","chromosome":"chr2","gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"},"startEnd":{"start":7454627449124699972},"refAllele":"A","altAllele":"C","dnaChange":{"code":"A>C","system":"HGVS.c"},"aminoAcidChange":{"code":"Tyr","system":"HGVS.p"},"readDepth":22,"allelicFrequency":0.37,"cosmicId":"COSMICa6074760-ce24-4075-ab11-f5ab4cd6c497","dbSNPId":"DBSNPID3578fa1b-eb03-423c-929d-6705cd8e805c","interpretation":{"code":"Function Changed","system":"ClinVAR"}},{"id":"SNV_fe346bed-74ac-4b84-843b-7490a5823364","chromosome":"chr14","gene":{"ensemblId":"ENSENSG00000125257","hgncId":"HGNC:55","symbol":"ABCC4","name":"ATP binding cassette subfamily C member 4"},"startEnd":{"start":6478613836523717707},"refAllele":"G","altAllele":"T","dnaChange":{"code":"G>T","system":"HGVS.c"},"aminoAcidChange":{"code":"Tyr","system":"HGVS.p"},"readDepth":30,"allelicFrequency":0.47,"cosmicId":"COSMIC14d8842e-6af9-434f-a993-32fae87a84be","dbSNPId":"DBSNPID1a97211e-a6f6-44be-a909-3620f34b01e2","interpretation":{"code":"Ambiguous","system":"ClinVAR"}}],"copyNumberVariants":[{"id":"CNV_ABALON_ABCA17P_high-level-gain","chromosome":"chr8","startRange":{"start":969911792064545275,"end":969911792064546084},"endRange":{"start":4404138220928659257,"end":4404138220928659925},"totalCopyNumber":2,"relativeCopyNumber":0.18,"cnA":0.87,"cnB":0.49,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000281376","hgncId":"HGNC:49667","symbol":"ABALON","name":"apoptotic BCL2L1-antisense long non-coding RNA"},{"ensemblId":"ENSENSG00000238098","hgncId":"HGNC:32972","symbol":"ABCA17P","name":"ATP binding cassette subfamily A member 17, pseudogene"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000115657","hgncId":"HGNC:47","symbol":"ABCB6","name":"ATP binding cassette subfamily B member 6 (Langereis blood group)"},{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"}]},{"id":"CNV_AAGAB_ABCA6_high-level-gain","chromosome":"chr3","startRange":{"start":6843968935032545040,"end":6843968935032545924},"endRange":{"start":3583631517115538627,"end":3583631517115539281},"totalCopyNumber":4,"relativeCopyNumber":0.11,"cnA":0.15,"cnB":0.29,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000103591","hgncId":"HGNC:25662","symbol":"AAGAB","name":"alpha and gamma adaptin binding protein"},{"ensemblId":"ENSENSG00000154262","hgncId":"HGNC:36","symbol":"ABCA6","name":"ATP binding cassette subfamily A member 6"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000231749","hgncId":"HGNC:39983","symbol":"ABCA9-AS1","name":"ABCA9 antisense RNA 1"},{"ensemblId":"ENSENSG00000154265","hgncId":"HGNC:35","symbol":"ABCA5","name":"ATP binding cassette subfamily A member 5"},{"ensemblId":"ENSENSG00000081760","hgncId":"HGNC:21298","symbol":"AACS","name":"acetoacetyl-CoA synthetase"}]},{"id":"CNV_ABCA12_AASDHPPT_ABCC2_AARS1P1_high-level-gain","chromosome":"chr5","startRange":{"start":8487172994898555456,"end":8487172994898556127},"endRange":{"start":2329045896118581347,"end":2329045896118581894},"totalCopyNumber":3,"relativeCopyNumber":0.67,"cnA":0.47,"cnB":0.46,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000144452","hgncId":"HGNC:14637","symbol":"ABCA12","name":"ATP binding cassette subfamily A member 12"},{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"},{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},{"ensemblId":"ENSENSG00000129673","hgncId":"HGNC:19","symbol":"AANAT","name":"aralkylamine N-acetyltransferase"},{"ensemblId":"ENSENSG00000242908","hgncId":"HGNC:50301","symbol":"AADACL2-AS1","name":"AADACL2 antisense RNA 1"}]},{"id":"CNV_AAAS_AADAT_high-level-gain","chromosome":"chr10","startRange":{"start":1954565432038993495,"end":1954565432038994133},"endRange":{"start":442085989067090995,"end":442085989067091164},"totalCopyNumber":4,"relativeCopyNumber":0.37,"cnA":0.98,"cnB":0.18,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000094914","hgncId":"HGNC:13666","symbol":"AAAS","name":"aladin WD repeat nucleoporin"},{"ensemblId":"ENSENSG00000109576","hgncId":"HGNC:17929","symbol":"AADAT","name":"aminoadipate aminotransferase"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000023839","hgncId":"HGNC:53","symbol":"ABCC2","name":"ATP binding cassette subfamily C member 2"},{"ensemblId":"ENSENSG00000150967","hgncId":"HGNC:50","symbol":"ABCB9","name":"ATP binding cassette subfamily B member 9"},{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"}]},{"id":"CNV_A3GALT2_ABCB10P4_low-level-gain","chromosome":"chr8","startRange":{"start":1779205446909981075,"end":1779205446909981845},"endRange":{"start":3151805846500148631,"end":3151805846500149455},"totalCopyNumber":4,"relativeCopyNumber":0.94,"cnA":0.3,"cnB":0.66,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"}],"reportedFocality":"reported-focality...","type":"low-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"},{"ensemblId":"ENSENSG00000231749","hgncId":"HGNC:39983","symbol":"ABCA9-AS1","name":"ABCA9 antisense RNA 1"},{"ensemblId":"ENSENSG00000108846","hgncId":"HGNC:54","symbol":"ABCC3","name":"ATP binding cassette subfamily C member 3"},{"ensemblId":"ENSENSG00000197953","hgncId":"HGNC:24427","symbol":"AADACL2","name":"arylacetamide deacetylase like 2"}]},{"id":"CNV_ABCA10_AADACL2_high-level-gain","chromosome":"chrX","startRange":{"start":165156786091954061,"end":165156786091954176},"endRange":{"start":5591033364020511004,"end":5591033364020511607},"totalCopyNumber":4,"relativeCopyNumber":0.69,"cnA":0.56,"cnB":0.12,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000154263","hgncId":"HGNC:30","symbol":"ABCA10","name":"ATP binding cassette subfamily A member 10"},{"ensemblId":"ENSENSG00000197953","hgncId":"HGNC:24427","symbol":"AADACL2","name":"arylacetamide deacetylase like 2"}],"reportedFocality":"reported-focality...","type":"high-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000215458","hgncId":"HGNC:51526","symbol":"AATBC","name":"apoptosis associated transcript in bladder cancer"},{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"},{"ensemblId":"ENSENSG00000261524","hgncId":"HGNC:31129","symbol":"ABCB10P3","name":"ABCB10 pseudogene 3"}]},{"id":"CNV_AAMP_AADACL3_low-level-gain","chromosome":"chrX","startRange":{"start":7552444878262806955,"end":7552444878262807187},"endRange":{"start":140089034030783731,"end":140089034030784407},"totalCopyNumber":2,"relativeCopyNumber":0.57,"cnA":0.26,"cnB":0.82,"reportedAffectedGenes":[{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"},{"ensemblId":"ENSENSG00000188984","hgncId":"HGNC:32037","symbol":"AADACL3","name":"arylacetamide deacetylase like 3"}],"reportedFocality":"reported-focality...","type":"low-level-gain","copyNumberNeutralLoH":[{"ensemblId":"ENSENSG00000121410","hgncId":"HGNC:5","symbol":"A1BG","name":"alpha-1-B glycoprotein"},{"ensemblId":"ENSENSG00000175899","hgncId":"HGNC:7","symbol":"A2M","name":"alpha-2-macroglobulin"},{"ensemblId":"ENSENSG00000257408","hgncId":"HGNC:55707","symbol":"ABCA3P1","name":"ABCA3 pseudogene 1"},{"ensemblId":"ENSENSG00000005471","hgncId":"HGNC:45","symbol":"ABCB4","name":"ATP binding cassette subfamily B member 4"}]}],"dnaFusions":[{"id":"DNAFusion_A3GALT2_AAGAB","fusionPartner5prime":{"chromosome":"chr16","position":569568638299166051,"gene":{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"}},"fusionPartner3prime":{"chromosome":"chr1","position":1728963273084125905,"gene":{"ensemblId":"ENSENSG00000103591","hgncId":"HGNC:25662","symbol":"AAGAB","name":"alpha and gamma adaptin binding protein"}},"reportedNumReads":25},{"id":"DNAFusion_ABCA3_AASDHPPT","fusionPartner5prime":{"chromosome":"chr12","position":4142955940382701892,"gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"}},"fusionPartner3prime":{"chromosome":"chr13","position":2530447494476677762,"gene":{"ensemblId":"ENSENSG00000149313","hgncId":"HGNC:14235","symbol":"AASDHPPT","name":"aminoadipate-semialdehyde dehydrogenase-phosphopantetheinyl transferase"}},"reportedNumReads":29},{"id":"DNAFusion_ABCB10P3_AAAS","fusionPartner5prime":{"chromosome":"chr19","position":216619303235013143,"gene":{"ensemblId":"ENSENSG00000261524","hgncId":"HGNC:31129","symbol":"ABCB10P3","name":"ABCB10 pseudogene 3"}},"fusionPartner3prime":{"chromosome":"chr20","position":7983660439294503113,"gene":{"ensemblId":"ENSENSG00000094914","hgncId":"HGNC:13666","symbol":"AAAS","name":"aladin WD repeat nucleoporin"}},"reportedNumReads":30},{"id":"DNAFusion_A1BG_AADACL4","fusionPartner5prime":{"chromosome":"chr19","position":2761782759714541191,"gene":{"ensemblId":"ENSENSG00000121410","hgncId":"HGNC:5","symbol":"A1BG","name":"alpha-1-B glycoprotein"}},"fusionPartner3prime":{"chromosome":"chr15","position":2381966877469433813,"gene":{"ensemblId":"ENSENSG00000204518","hgncId":"HGNC:32038","symbol":"AADACL4","name":"arylacetamide deacetylase like 4"}},"reportedNumReads":22},{"id":"DNAFusion_AAMP_ABCB9","fusionPartner5prime":{"chromosome":"chr12","position":2738282492147015127,"gene":{"ensemblId":"ENSENSG00000127837","hgncId":"HGNC:18","symbol":"AAMP","name":"angio associated migratory cell protein"}},"fusionPartner3prime":{"chromosome":"chr18","position":4689414126579295665,"gene":{"ensemblId":"ENSENSG00000150967","hgncId":"HGNC:50","symbol":"ABCB9","name":"ATP binding cassette subfamily B member 9"}},"reportedNumReads":44},{"id":"DNAFusion_AACS_ABCB10","fusionPartner5prime":{"chromosome":"chr19","position":5162788528310959454,"gene":{"ensemblId":"ENSENSG00000081760","hgncId":"HGNC:21298","symbol":"AACS","name":"acetoacetyl-CoA synthetase"}},"fusionPartner3prime":{"chromosome":"chr2","position":281250493569316672,"gene":{"ensemblId":"ENSENSG00000135776","hgncId":"HGNC:41","symbol":"ABCB10","name":"ATP binding cassette subfamily B member 10"}},"reportedNumReads":28},{"id":"DNAFusion_ABCA3_ABCA8","fusionPartner5prime":{"chromosome":"chr17","position":7239027143174816791,"gene":{"ensemblId":"ENSENSG00000167972","hgncId":"HGNC:33","symbol":"ABCA3","name":"ATP binding cassette subfamily A member 3"}},"fusionPartner3prime":{"chromosome":"chr20","position":3415200056745807403,"gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"}},"reportedNumReads":47},{"id":"DNAFusion_AATF_AASS","fusionPartner5prime":{"chromosome":"chr14","position":6207520478306983467,"gene":{"ensemblId":"ENSENSG00000275700","hgncId":"HGNC:19235","symbol":"AATF","name":"apoptosis antagonizing transcription factor"}},"fusionPartner3prime":{"chromosome":"chr7","position":966733822586135931,"gene":{"ensemblId":"ENSENSG00000008311","hgncId":"HGNC:17366","symbol":"AASS","name":"aminoadipate-semialdehyde synthase"}},"reportedNumReads":32},{"id":"DNAFusion_AATBC_AARD","fusionPartner5prime":{"chromosome":"chr9","position":6948858453904558539,"gene":{"ensemblId":"ENSENSG00000215458","hgncId":"HGNC:51526","symbol":"AATBC","name":"apoptosis associated transcript in bladder cancer"}},"fusionPartner3prime":{"chromosome":"chr21","position":5188489683141511746,"gene":{"ensemblId":"ENSENSG00000205002","hgncId":"HGNC:33842","symbol":"AARD","name":"alanine and arginine rich domain containing protein"}},"reportedNumReads":21}],"rnaFusions":[{"id":"RNAFusion_A3GALT2_ABCB6","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000184389","hgncId":"HGNC:30005","symbol":"A3GALT2","name":"alpha 1,3-galactosyltransferase 2"},"transcriptId":"TIDf283c026-41bf-41c6-afd5-1980bd408a06","exon":"EXONc9f963b3-4e55-4f1f-9ad1-e79b86e6e751","position":1009212469473862062,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000115657","hgncId":"HGNC:47","symbol":"ABCB6","name":"ATP binding cassette subfamily B member 6 (Langereis blood group)"},"transcriptId":"TID97eabd37-cf0e-44f3-a1f1-d21543eb3b5d","exon":"EXONc9077d54-f82f-42bb-8bc8-e79e46204085","position":6271859040431005877,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC28f0a2f7-a923-4622-a9e4-52ecd5806066","reportedNumReads":29},{"id":"RNAFusion_ABCB10P4_A2ML1-AS1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"},"transcriptId":"TID2cd74611-6960-455f-9946-3962aadbbd56","exon":"EXONf7f8f171-248f-44de-9408-37a3bfa75b4f","position":9124790507143722597,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000256661","hgncId":"HGNC:41022","symbol":"A2ML1-AS1","name":"A2ML1 antisense RNA 1"},"transcriptId":"TIDb16fc5d0-e8ed-40e4-9798-216ad6d4aade","exon":"EXON1bd1a002-966b-4c1d-bb0a-7f331038e5ae","position":461028552708332541,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC89d4c113-ca01-4804-ad46-ac20a4762389","reportedNumReads":22},{"id":"RNAFusion_A2ML1-AS2_AARS1P1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000256904","hgncId":"HGNC:41523","symbol":"A2ML1-AS2","name":"A2ML1 antisense RNA 2"},"transcriptId":"TID40d495d1-f498-4b7d-8e71-e3c90af68e58","exon":"EXON23c9ccaa-ea43-444c-8340-f18365c32e4e","position":6743012027657999130,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000249038","hgncId":"HGNC:49894","symbol":"AARS1P1","name":"alanyl-tRNA synthetase 1 pseudogene 1"},"transcriptId":"TIDb4337aef-0a69-483c-a603-9e3d6beefff6","exon":"EXON6980165c-6f85-4fd3-8c66-4349187f6382","position":4014336725221699201,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMICb82c4810-5629-4ea3-b569-3fd9c7167752","reportedNumReads":25},{"id":"RNAFusion_ABCA9_ABCB10P1","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000154258","hgncId":"HGNC:39","symbol":"ABCA9","name":"ATP binding cassette subfamily A member 9"},"transcriptId":"TIDfbbc5ac0-1d3f-476a-a1ac-4ed1456528ba","exon":"EXONf97da06b-9d7d-48c0-b202-d58a92d57e22","position":7451485235432246024,"strand":"-"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000274099","hgncId":"HGNC:14114","symbol":"ABCB10P1","name":"ABCB10 pseudogene 1"},"transcriptId":"TID3154eb7b-4364-4394-a128-75d7eb7174a0","exon":"EXON22661f53-bfb7-4b8a-9e5f-ca0b05cda801","position":2144193097707360397,"strand":"+"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC42bebec2-a56e-4b55-9dc6-986d1b421404","reportedNumReads":36},{"id":"RNAFusion_A2M-AS1_AAR2","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000245105","hgncId":"HGNC:27057","symbol":"A2M-AS1","name":"A2M antisense RNA 1"},"transcriptId":"TID9a631927-221f-455b-b3af-b7ed8204f8ce","exon":"EXON1ae4eead-6101-4f79-b09e-2289476ec75e","position":8438088205780109333,"strand":"-"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000131043","hgncId":"HGNC:15886","symbol":"AAR2","name":"AAR2 splicing factor"},"transcriptId":"TIDe66203bb-2b54-432c-8eb9-41b81f712602","exon":"EXONfe1ed045-ddfa-4a7d-b0c8-6f08a2bc7249","position":4758763569629035168,"strand":"+"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC6f5c8a08-fa74-41da-9a3e-c5965a8d6978","reportedNumReads":32},{"id":"RNAFusion_ABCB7_AADAT","fusionPartner5prime":{"gene":{"ensemblId":"ENSENSG00000131269","hgncId":"HGNC:48","symbol":"ABCB7","name":"ATP binding cassette subfamily B member 7"},"transcriptId":"TID78736fa2-71a0-4dc3-9675-e497da53a019","exon":"EXON6cd359fc-87e1-41b8-90be-9ca397aff1af","position":7631148909251407357,"strand":"+"},"fusionPartner3prime":{"gene":{"ensemblId":"ENSENSG00000109576","hgncId":"HGNC:17929","symbol":"AADAT","name":"aminoadipate aminotransferase"},"transcriptId":"TIDf79fa95a-c199-4568-959f-14f3baaea274","exon":"EXON98a11577-0325-4199-8b6f-1c8207b4d3d4","position":5619672559603971,"strand":"-"},"effect":"RNA Fusion effect...","cosmicId":"COSMIC8a43fd3b-3df4-414d-8520-571c3ad2cf77","reportedNumReads":49}],"rnaSeqs":[{"id":"RNASeq_8a6e6d50-bfd8-4c29-9003-1937ba3cc9b3","entrezId":"EID8a6e6d50-bfd8-4c29-9003-1937ba3cc9b3","ensemblId":"ENS0fcf6c3b-6389-42a8-a8d3-3e724feb302c","gene":{"ensemblId":"ENSENSG00000129673","hgncId":"HGNC:19","symbol":"AANAT","name":"aralkylamine N-acetyltransferase"},"transcriptId":"TID16c9f024-d10b-48fc-b3eb-fe21455f2016","fragmentsPerKilobaseMillion":0.52,"fromNGS":false,"tissueCorrectedExpression":true,"rawCounts":393,"librarySize":97,"cohortRanking":2},{"id":"RNASeq_0ae924be-d9c2-42e2-b9eb-c946bf768da4","entrezId":"EID0ae924be-d9c2-42e2-b9eb-c946bf768da4","ensemblId":"ENS347f84b9-6147-4336-bba8-bb5e8f32091d","gene":{"ensemblId":"ENSENSG00000260053","hgncId":"HGNC:31130","symbol":"ABCB10P4","name":"ABCB10 pseudogene 4"},"transcriptId":"TID68d3f08f-68c5-4c4e-a868-2a91bf6a74ef","fragmentsPerKilobaseMillion":0.5,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":410,"librarySize":82,"cohortRanking":5},{"id":"RNASeq_4d00e0d0-76be-4071-ab10-2d1b6fd2bf32","entrezId":"EID4d00e0d0-76be-4071-ab10-2d1b6fd2bf32","ensemblId":"ENS7b768481-e129-4f87-8bff-815dc5449f58","gene":{"ensemblId":"ENSENSG00000256661","hgncId":"HGNC:41022","symbol":"A2ML1-AS1","name":"A2ML1 antisense RNA 1"},"transcriptId":"TID488b86f2-668b-4d75-a784-8267757b5cdd","fragmentsPerKilobaseMillion":0.55,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":968,"librarySize":47,"cohortRanking":2},{"id":"RNASeq_ceeb241f-6dbd-4e55-9c34-666c44e46405","entrezId":"EIDceeb241f-6dbd-4e55-9c34-666c44e46405","ensemblId":"ENS5a0783cb-48ff-4cde-98b1-e2b8ea31a9f4","gene":{"ensemblId":"ENSENSG00000141338","hgncId":"HGNC:38","symbol":"ABCA8","name":"ATP binding cassette subfamily A member 8"},"transcriptId":"TID972fda4e-a47b-43b0-b574-9dc688d668f5","fragmentsPerKilobaseMillion":0.71,"fromNGS":true,"tissueCorrectedExpression":true,"rawCounts":294,"librarySize":35,"cohortRanking":3},{"id":"RNASeq_475d891e-030f-490e-b741-030b965877c0","entrezId":"EID475d891e-030f-490e-b741-030b965877c0","ensemblId":"ENS66d9d63f-8b6e-4de7-baa3-e6be55497c77","gene":{"ensemblId":"ENSENSG00000197150","hgncId":"HGNC:49","symbol":"ABCB8","name":"ATP binding cassette subfamily B member 8"},"transcriptId":"TID7af2745c-811f-481a-a288-81456117a9fa","fragmentsPerKilobaseMillion":0.04,"fromNGS":true,"tissueCorrectedExpression":false,"rawCounts":371,"librarySize":68,"cohortRanking":8}]}],"carePlans":[{"id":"6a3601ea-8fba-437d-8add-bf4f4cce469e","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","description":"MTB conference protocol...","recommendations":["df376556-df45-41c3-8bae-af1fe3fb7418","08234e1e-105c-4362-87e8-4f20bf87ed0b"],"geneticCounsellingRequest":"65344fca-0028-4129-a530-dd36fd984bd3","rebiopsyRequests":["5f54fb43-92a5-4f62-ae48-081d428ff2e8"],"studyInclusionRequests":["d49b41ff-e2df-499f-938f-8fb7136366b2"]}],"recommendations":[{"id":"df376556-df45-41c3-8bae-af1fe3fb7418","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","medication":[{"code":"L01XE39","system":"ATC","display":"Midostaurin","version":"2020"},{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"}],"priority":"4","levelOfEvidence":{"grading":{"code":"m1B","system":"MTB-CDS:Level-of-Evidence:Grading"},"addendums":[{"code":"R","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"is","system":"MTB-CDS:Level-of-Evidence:Addendum"}]},"ngsReport":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","supportingVariants":["CNV_AAGAB_ABCA6_high-level-gain","CNV_AAMP_AADACL3_low-level-gain","SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546"]},{"id":"08234e1e-105c-4362-87e8-4f20bf87ed0b","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","diagnosis":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","issuedOn":"2023-12-14","medication":[{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"},{"code":"L01XE12","system":"ATC","display":"Vandetanib","version":"2020"}],"priority":"4","levelOfEvidence":{"grading":{"code":"m1A","system":"MTB-CDS:Level-of-Evidence:Grading"},"addendums":[{"code":"R","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"is","system":"MTB-CDS:Level-of-Evidence:Addendum"},{"code":"iv","system":"MTB-CDS:Level-of-Evidence:Addendum"}]},"ngsReport":"223e3e6e-d99b-415e-83c0-18bcfc5729ca","supportingVariants":["CNV_AAGAB_ABCA6_high-level-gain","CNV_ABCA12_AASDHPPT_ABCC2_AARS1P1_high-level-gain","SNV_963a3ea1-a1d5-4a52-b8d4-2e969be74b7b","SNV_7f35df9d-d8b1-4421-9ec7-35cf8d3c4546","CNV_AAAS_AADAT_high-level-gain","CNV_ABALON_ABCA17P_high-level-gain","CNV_AAMP_AADACL3_low-level-gain","SNV_22f943fd-ad99-48af-a61e-42679e851b71","SNV_599c38d4-af9f-416c-ad38-eb2fee8159e1"]}],"geneticCounsellingRequests":[{"id":"65344fca-0028-4129-a530-dd36fd984bd3","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","reason":"Some reason for genetic counselling..."}],"rebiopsyRequests":[{"id":"5f54fb43-92a5-4f62-ae48-081d428ff2e8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14"}],"histologyReevaluationRequests":[{"id":"b76dfb95-13e8-4acb-9ab8-364bc5215d63","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","specimen":"40043ae5-d4cb-48e4-85c6-b34266b7693f","issuedOn":"2023-12-14"}],"studyInclusionRequests":[{"id":"d49b41ff-e2df-499f-938f-8fb7136366b2","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","reason":"ed1f151c-0707-4bbf-bb02-9654310ac1f8","nctNumber":"NCT84044685","issuedOn":"2023-12-14"}],"claims":[{"id":"2280a457-c5b8-4541-b6ca-86de385d789f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","therapy":"df376556-df45-41c3-8bae-af1fe3fb7418"},{"id":"061fde0a-935c-4e2f-a54a-531679b2f8d5","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","therapy":"08234e1e-105c-4362-87e8-4f20bf87ed0b"}],"claimResponses":[{"id":"1177f670-cf44-4886-b9b3-a4dd25271dcb","claim":"2280a457-c5b8-4541-b6ca-86de385d789f","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","status":"accepted","reason":"standard-therapy-not-exhausted"},{"id":"c85d365d-e3c1-474b-aaa3-0e3e051d4223","claim":"061fde0a-935c-4e2f-a54a-531679b2f8d5","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","issuedOn":"2023-12-14","status":"rejected","reason":"other"}],"molecularTherapies":[{"history":[{"id":"660813fd-a42d-492a-8522-9c4aa3b3e162","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","recordedOn":"2023-12-31","basedOn":"08234e1e-105c-4362-87e8-4f20bf87ed0b","period":{"start":"2023-12-16","end":"2023-12-31"},"medication":[{"code":"L01XX42","system":"ATC","display":"Panobinostat","version":"2020"},{"code":"L01XE12","system":"ATC","display":"Vandetanib","version":"2020"}],"dosage":"<50%","note":"Notes on the Therapy...","status":"completed"}]}],"responses":[{"id":"8b2af33e-afee-450b-b947-3370d89603f8","patient":"5dad2f0b-49c6-47d8-a952-7b9e9e0f7549","therapy":"660813fd-a42d-492a-8522-9c4aa3b3e162","effectiveDate":"2023-12-16","value":{"code":"CR","system":"RECIST"}}]} \ No newline at end of file