diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b703b73..65b5e78 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,6 @@ jobs: - name: Execute image build and push run: | ./gradlew bootBuildImage - docker tag ghcr.io/ccc-mf/etl-processor ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }} - docker push ghcr.io/ccc-mf/etl-processor - docker push ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }} \ No newline at end of file + docker tag ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}:${{ github.ref_name }} + docker push ghcr.io/${{ github.repository }} + docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }} \ No newline at end of file diff --git a/README.md b/README.md index 258c13b..dd465c1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ETL-Processor for bwHC data [![Run Tests](https://github.com/CCC-MF/etl-processor/actions/workflows/test.yml/badge.svg)](https://github.com/CCC-MF/etl-processor/actions/workflows/test.yml) +# ETL-Processor for DNPM:DIP [![Run Tests](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml/badge.svg)](https://github.com/pcvolkmer/etl-processor/actions/workflows/test.yml) -Diese Anwendung versendet ein bwHC-MTB-File an das bwHC-Backend und pseudonymisiert die Patienten-ID. +Diese Anwendung versendet ein bwHC-MTB-File im bwHC-Datenmodell 1.0 an DNPM:DIP und pseudonymisiert die Patienten-ID. ## Einordnung innerhalb einer DNPM-ETL-Strecke @@ -9,7 +9,7 @@ Diese Anwendung erlaubt das Entgegennehmen von HTTP/REST-Anfragen aus dem Onkost Der Inhalt einer Anfrage, wenn ein bwHC-MTBFile, wird pseudonymisiert und auf Duplikate geprüft. Duplikate werden verworfen, Änderungen werden weitergeleitet. -Löschanfragen werden immer als Löschanfrage an das bwHC-backend weitergeleitet. +Löschanfragen werden immer als Löschanfrage an DNPM:DIP weitergeleitet. Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand der Anwendung gewährt. @@ -22,7 +22,17 @@ Die Erkennung von Duplikaten ist normalerweise immer aktiv, kann jedoch über de ### Datenübermittlung über HTTP/REST -Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an das bwHC-Backend gesendet. +Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an DNPM:DIP gesendet. + +Ein HTTP Request kann, angenommen die Installation erfolgte auf dem Host `dnpm.example.com` an nachfolgende URLs gesendet werden: + +| HTTP-Request | URL | Consent-Status im Datensatz | Bemerkung | +|--------------|-----------------------------------------|-----------------------------|---------------------------------------------------------------------------------| +| `POST` | `https://dnpm.example.com/mtb` | `ACTIVE` | Die Anwendung verarbeitet den eingehenden Datensatz | +| `POST` | `https://dnpm.example.com/mtb` | `REJECT` | Die Anwendung sendet einen Lösch-Request für die im Datensatz angegebene Pat-ID | +| `DELETE` | `https://dnpm.example.com/mtb/12345678` | - | Die Anwendung sendet einen Lösch-Request für Pat-ID `12345678` | + +Anstelle des Pfads `/mtb` kann auch, wie in Version 0.9 und älter üblich, `/mtbfile` verwendet werden. ### Datenübermittlung mit Apache Kafka @@ -33,15 +43,21 @@ Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc ## Konfiguration -### Breaking Changes nach Version 0.10 +### 🔥 Wichtige Änderungen in Version 0.10 + +Ab Version 0.10 wird [DNPM:DIP](https://github.com/dnpm-dip) unterstützt und als Standardendpunkt verwendet. +Soll noch das alte bwHC-Backend verwendet werden, so ist die Umgebungsvariable `APP_REST_IS_BWHC` auf `true` zu setzen. + +### 🔥 Breaking Changes nach Version 0.10 In Versionen des ETL-Processors **nach Version 0.10** werden die folgenden Konfigurationsoptionen entfernt: -* `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`: Nutzen Sie hier, wie unter [_Integration eines eigenen Root CA - Zertifikats_](#integration-eines-eigenen-root-ca-zertifikats) beschrieben, das Einbinden eigener Zertifikate. * `APP_KAFKA_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_TOPIC` * `APP_KAFKA_RESPONSE_TOPIC`: Nutzen Sie nun die Konfigurationsoption `APP_KAFKA_OUTPUT_RESPONSE_TOPIC` +Der Pfad zum Versenden von MTB-Daten ist nun offiziell `/mtb`. +In Versionen **nach Version 0.10** wird die Unterstützung des Pfads `/mtbfile` entfernt. + ### Pseudonymisierung der Patienten-ID Wenn eine URI zu einer gPAS-Instanz (Version >= 2023.1.0) angegeben ist, wird diese verwendet. @@ -50,13 +66,11 @@ Ist diese nicht gesetzt. wird intern eine Anonymisierung der Patienten-ID vorgen * `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Präfix - `UNKNOWN`, wenn nicht gesetzt * `APP_PSEUDONYMIZE_GENERATOR`: `BUILDIN` oder `GPAS` - `BUILDIN`, wenn nicht gesetzt -**Hinweise**: +**Hinweis** -* Der alte Konfigurationsparameter `APP_PSEUDONYMIZER` mit den Werten `GPAS` oder `BUILDIN` sollte nicht mehr verwendet - werden. -* Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID. - Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den aktuellen Kontext nicht - vergleichbare IDs bereitzustellen. +Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID. +Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den aktuellen Kontext nicht +vergleichbare IDs bereitzustellen. #### Eingebaute Anonymisierung @@ -72,13 +86,6 @@ Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfiguri * `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname * `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername * `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort -* ~~`APP_PSEUDONYMIZE_GPAS_SSLCALOCATION`~~: **Veraltet** - Root Zertifikat für gPas, falls es dediziert hinzugefügt werden muss. - **Wird in nach Version 0.10 entfernt** - -Der Konfigurationsparameter `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION` sollte nicht mehr verwendet werden und wird nach -Version 0.10 entfernt. -Stattdessen sollte das Root Zertifikat wie unter [_Integration eines eigenen Root CA -Zertifikats_](#integration-eines-eigenen-root-ca-zertifikats) beschrieben eingebunden werden. ### Anmeldung mit einem Passwort @@ -145,7 +152,7 @@ Sie bekommen dabei wieder die Standardrolle zugewiesen. #### Auswirkungen auf den dargestellten Inhalt Nur Administratoren haben Zugriff auf den Konfigurationsbereich, nur angemeldete Benutzer können die anonymisierte oder -pseudonymisierte Patienten-ID sowie den Qualitätsbericht des bwHC-Backends einsehen. +pseudonymisierte Patienten-ID sowie den Qualitätsbericht von DNPM:DIP einsehen. Wurde kein Administrator-Account konfiguriert, sind diese Inhalte generell nicht verfügbar. @@ -161,7 +168,7 @@ zur Nutzung des MTB-File-Endpunkts eine HTTP-Basic-Authentifizierung voraussetze ![Tokenverwaltung](docs/tokens.png) -In diesem Fall können den Endpunkt für das Onkostar-Plugin **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)** wie folgt konfigurieren: +In diesem Fall kann der Endpunkt für das Onkostar-Plugin **[onkostar-plugin-dnpmexport](https://github.com/CCC-MF/onkostar-plugin-dnpmexport)** wie folgt konfiguriert werden: ``` https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile @@ -174,7 +181,7 @@ Alternativ kann eine Authentifizierung über Benutzername/Passwort oder OIDC erf ### Transformation von Werten In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht, -der vom bwHC-Backend akzeptiert wird. +der von DNPM:DIP akzeptiert wird. Diese Anwendung bietet daher die Möglichkeit, eine Transformation vorzunehmen. Hierzu muss der "Pfad" innerhalb des JSON-MTB-Files angegeben werden und welcher Wert wie ersetzt werden soll. @@ -194,18 +201,21 @@ Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpu #### REST -Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an das bwHC-Backend gesendet wird: +Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an DNPM:DIP gesendet wird: -* `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. z.B.: `http://localhost:9000/bwhc/etl/api` +* `APP_REST_URI`: URI der zu benutzenden API der Backend-Instanz. Zum Beispiel: + * `http://localhost:9000/bwhc/etl/api` für **bwHC Backend** + * `http://localhost:9000/api` für **dnpm:dip** +* `APP_REST_USERNAME`: Basic-Auth-Benutzername für den REST-Endpunkt +* `APP_REST_PASSWORD`: Basic-Auth-Passwort für den REST-Endpunkt +* `APP_REST_IS_BWHC`: `true` für **bwHC Backend**, weglassen oder `false` für **dnpm:dip** #### Kafka-Topics Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic übermittelt wird: * `APP_KAFKA_OUTPUT_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen. - Ersetzt ~~`APP_KAFKA_TOPIC`~~, **welches nach Version 0.10 entfernt wird**. * `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`: Topic mit Antworten über den Erfolg des Versendens. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_response". - Ersetzt ~~`APP_KAFKA_RESPONSE_TOPIC`~~, **welches nach Version 0.10 entfernt wird**. * `APP_KAFKA_GROUP_ID`: Kafka GroupID des Consumers. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_group". * `APP_KAFKA_SERVERS`: Zu verwendende Kafka-Bootstrap-Server als kommagetrennte Liste @@ -213,7 +223,7 @@ Wird keine Rückantwort über Apache Kafka empfangen und es gibt keine weitere M Weitere Einstellungen können über die Parameter von Spring Kafka konfiguriert werden. -Lässt sich keine Verbindung zu dem bwHC-Backend aufbauen, wird eine Rückantwort mit Status-Code `900` erwartet, welchen es +Lässt sich keine Verbindung zu dem Backend aufbauen, wird eine Rückantwort mit Status-Code `900` erwartet, welchen es für HTTP nicht gibt. Wird die Umgebungsvariable `APP_KAFKA_INPUT_TOPIC` gesetzt, kann eine Nachricht auch über dieses Kafka-Topic an den ETL-Prozessor übermittelt werden. @@ -250,7 +260,7 @@ kafka-configs.sh --bootstrap-server localhost:9092 --alter --topic test --add-co Da als Key eines Records die (pseudonymisierte) Patienten-ID verwendet wird, stehen mit obiger Konfiguration der Kafka-Topics nach 10 Sekunden nur noch der jeweils letzte Eintrag für den entsprechenden Key zur Verfügung. -Da der Key sowohl für die Records in Richtung bwHC-Backend für die Rückantwort identisch aufgebaut ist, lassen sich so +Da der Key sowohl für die Records in Richtung DNPM:DIP, als auch für die Rückantwort identisch aufgebaut ist, lassen sich so auch im Falle eines Consent-Widerspruchs die enthaltenen Daten als auch die Offenlegung durch Verifikationsdaten in der Antwort effektiv verhindern, da diese nach 10 Sekunden gelöscht werden. @@ -259,9 +269,30 @@ ein Consent-Widerspruch erfolgte. Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für andere Auswertungen verwenden möchten. +### Antworten und Statusauswertung + +Anfragen an das bwHC-Backend aus Versionen bis 0.9.x wurden wie folgt behandelt: + +| HTTP-Response | Status | +|----------------|-----------| +| `HTTP 200` | `SUCCESS` | +| `HTTP 201` | `WARNING` | +| `HTTP 400-...` | `ERROR` | + +Dies konnte dazu führen, dass zwar mit einem `HTTP 201` geantwortet wurde, aber dennoch in der Issue-Liste die +Severity `error` aufgetaucht ist. + +Ab Version 0.10 wird die Issue-Liste der Antwort verwendet und die darion enthaltene höchste Severity-Stufe als Ergebnis verwendet. + +| Höchste Severity | Status | +|------------------|-----------| +| `info` | `SUCCESS` | +| `warning` | `WARNING` | +| `error`, `fatal` | `ERROR` | + ## Docker-Images -Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/CCC-MF/etl-processor/pkgs/container/etl-processor +Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/pcvolkmer/etl-processor/pkgs/container/etl-processor ### Images lokal bauen @@ -374,3 +405,5 @@ Die Datei `application-dev.yml` enthält hierzu die Konfiguration für das Profi Beim Ausführen der Integrationstests wird eine Testdatenbank in einem Docker-Container gestartet. Siehe hier auch die Klasse `AbstractTestcontainerTest` unter `src/integrationTest`. + +Ein einfaches Entwickler-Setup inklusive DNPM:DIP ist mit Hilfe von https://github.com/pcvolkmer/dnpmdip-devenv realisierbar. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 0711328..c62029c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,34 +2,35 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.springframework.boot.gradle.tasks.bundling.BootBuildImage -import java.text.SimpleDateFormat -import java.util.* plugins { war - id("org.springframework.boot") version "3.3.1" - id("io.spring.dependency-management") version "1.1.5" - kotlin("jvm") version "1.9.24" - kotlin("plugin.spring") version "1.9.24" + id("org.springframework.boot") version "3.4.5" + id("io.spring.dependency-management") version "1.1.7" + kotlin("jvm") version "1.9.25" + kotlin("plugin.spring") version "1.9.25" jacoco } -group = "de.ukw.ccc" -version = "0.10.0-SNAPSHOT" +group = "dev.dnpm" +version = "0.11.0-SNAPSHOT" var versions = mapOf( - "bwhc-dto-java" to "0.3.0", - "hapi-fhir" to "6.10.5", - "commons-compress" to "1.26.2", - "mockito-kotlin" to "5.3.1", + "bwhc-dto-java" to "0.4.0", + "mtb-dto" to "0.1.0-SNAPSHOT", + "hapi-fhir" to "7.6.0", + "mockito-kotlin" to "5.4.0", "archunit" to "1.3.0", // Webjars + "webjars-locator" to "0.52", "echarts" to "5.4.3", "htmx.org" to "1.9.12" ) java { - sourceCompatibility = JavaVersion.VERSION_21 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } sourceSets { @@ -48,9 +49,18 @@ configurations { compileOnly { extendsFrom(configurations.annotationProcessor.get()) } + + all { + resolutionStrategy { + cacheChangingModulesFor(5, "minutes") + } + } } repositories { + maven { + url = uri("https://git.dnpm.dev/api/packages/public-snapshots/maven") + } maven { url = uri("https://git.dnpm.dev/api/packages/public/maven") } @@ -72,30 +82,35 @@ dependencies { implementation("commons-codec:commons-codec") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("de.ukw.ccc:bwhc-dto-java:${versions["bwhc-dto-java"]}") + implementation("dev.pcvolkmer.mv64e:mtb-dto:${versions["mtb-dto"]}") { isChanging = true } implementation("ca.uhn.hapi.fhir:hapi-fhir-base:${versions["hapi-fhir"]}") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}") implementation("org.apache.httpcomponents.client5:httpclient5") implementation("com.jayway.jsonpath:json-path") - implementation("org.webjars:webjars-locator:0.52") + implementation("org.webjars:webjars-locator:${versions["webjars-locator"]}") implementation("org.webjars.npm:echarts:${versions["echarts"]}") implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}") + runtimeOnly("org.mariadb.jdbc:mariadb-java-client") runtimeOnly("org.postgresql:postgresql") + developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-docker-compose") + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + providedRuntime("org.springframework.boot:spring-boot-starter-tomcat") + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.security:spring-security-test") testImplementation("io.projectreactor:reactor-test") testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}") + integrationTestImplementation("org.testcontainers:junit-jupiter") integrationTestImplementation("org.testcontainers:postgresql") integrationTestImplementation("com.tngtech.archunit:archunit:${versions["archunit"]}") - integrationTestImplementation("net.sourceforge.htmlunit:htmlunit") + integrationTestImplementation("org.htmlunit:htmlunit") integrationTestImplementation("org.springframework:spring-webflux") - // Override dependency version from org.testcontainers:junit-jupiter - CVE-2024-26308, CVE-2024-25710 - integrationTestImplementation("org.apache.commons:commons-compress:${versions["commons-compress"]}") } tasks.withType { @@ -112,8 +127,9 @@ tasks.withType { } } -task("integrationTest") { +tasks.register("integrationTest") { description = "Runs integration tests" + group = "verification" testClassesDirs = sourceSets["integrationTest"].output.classesDirs classpath = sourceSets["integrationTest"].runtimeClasspath @@ -122,6 +138,8 @@ task("integrationTest") { } tasks.register("allTests") { + description = "Run all tests" + group = JavaBasePlugin.VERIFICATION_GROUP dependsOn(tasks.withType()) } @@ -135,9 +153,8 @@ tasks.jacocoTestReport { } } - tasks.named("bootBuildImage") { - imageName.set("ghcr.io/ccc-mf/etl-processor") + imageName.set("ghcr.io/pcvolkmer/etl-processor") // Binding for CA Certs bindings.set(listOf( @@ -146,9 +163,8 @@ tasks.named("bootBuildImage") { environment.set(environment.get() + mapOf( // Enable this line to embed CA Certs into image on build time - "BP_EMBED_CERTS" to "true", - "BP_OCI_CREATED" to SimpleDateFormat("MM-dd-yyyy_hh-mm").format(Date()), - "BP_OCI_SOURCE" to "https://github.com/CCC-MF/etl-processor", + //"BP_EMBED_CERTS" to "true", + "BP_OCI_SOURCE" to "https://github.com/pcvolkmer/etl-processor", "BP_OCI_LICENSES" to "AGPLv3", "BP_OCI_DESCRIPTION" to "ETL Processor for bwHC MTB files" )) diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index 4641ca6..754bb23 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -18,6 +18,9 @@ services: APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID} APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC} APP_REST_URI: ${DNPM_BWHC_REST_URI} + APP_REST_USERNAME: ${DNPM_BWHC_REST_USERNAME} + APP_REST_PASSWORD: ${DNPM_BWHC_REST_PASSWORD} + APP_REST_IS_BWHC: ${DNPM_BWHC_REST_IS_BWHC} APP_SECURITY_ADMIN_USER: ${DNPM_ADMIN_USER} APP_SECURITY_ADMIN_PASSWORD: ${DNPM_ADMIN_PASSWORD} SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL} diff --git a/deploy/env-sample.env b/deploy/env-sample.env index 04a3f8f..4888474 100644 --- a/deploy/env-sample.env +++ b/deploy/env-sample.env @@ -28,6 +28,9 @@ DNPM_DATASOURCE_URL=jdbc:mariadb://dnpm-monitor-db:3306/$DNPM_MARIADB_DB ## TARGET SYSTEMS CONFIG # in case of direct access to bwhc enter endpoint url here DNPM_BWHC_REST_URI= +DNPM_BWHC_REST_USERNAME= +DNPM_BWHC_REST_PASSWORD= +DNPM_BWHC_REST_IS_BWHC=false # produce mtb files to this topic - values 'false' disabling kafka processing DNPM_KAFKA_TOPIC=false diff --git a/docs/etl.png b/docs/etl.png index 360e780..bd9f379 100644 Binary files a/docs/etl.png and b/docs/etl.png differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 20db9ad..c6f0030 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt index 67d2d05..8984e60 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/EtlProcessorApplicationTests.kt @@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.dnpm.etl.processor.output.BwhcV1MtbFileRequest import dev.dnpm.etl.processor.output.MtbFileSender import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach @@ -33,10 +34,10 @@ import org.mockito.kotlin.* import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.ApplicationContext import org.springframework.http.MediaType import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.post @@ -45,7 +46,7 @@ import org.testcontainers.junit.jupiter.Testcontainers @Testcontainers @ExtendWith(SpringExtension::class) @SpringBootTest -@MockBean(MtbFileSender::class) +@MockitoBean(types = [MtbFileSender::class]) @TestPropertySource( properties = [ "app.rest.uri=http://example.com", @@ -73,7 +74,7 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() { ) inner class TransformationTest { - @MockBean + @MockitoBean private lateinit var mtbFileSender: MtbFileSender @Autowired @@ -91,7 +92,7 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() { fun mtbFileIsTransformed() { doAnswer { MtbFileSender.Response(RequestStatus.SUCCESS) - }.whenever(mtbFileSender).send(any()) + }.whenever(mtbFileSender).send(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -134,9 +135,9 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() { } } - val captor = argumentCaptor() + val captor = argumentCaptor() verify(mtbFileSender).send(captor.capture()) - assertThat(captor.firstValue.mtbFile.diagnoses).hasSize(1).allMatch { diagnosis -> + assertThat(captor.firstValue.content.diagnoses).hasSize(1).allMatch { diagnosis -> diagnosis.icd10.version == "2014" } } 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 c7454ed..39a0997 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/config/AppConfigurationTest.kt @@ -26,9 +26,9 @@ import dev.dnpm.etl.processor.output.KafkaMtbFileSender 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.security.TokenRepository import dev.dnpm.etl.processor.security.TokenService +import dev.dnpm.etl.processor.services.RequestProcessor import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -36,24 +36,25 @@ import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.NoSuchBeanDefinitionException import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.boot.test.mock.mockito.MockBeans import org.springframework.context.ApplicationContext import org.springframework.retry.support.RetryTemplate import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean @SpringBootTest -@ContextConfiguration(classes = [ - AppConfiguration::class, - AppSecurityConfiguration::class, - KafkaAutoConfiguration::class, - AppKafkaConfiguration::class, - AppRestConfiguration::class -]) -@MockBean(ObjectMapper::class) +@ContextConfiguration( + classes = [ + AppConfiguration::class, + AppSecurityConfiguration::class, + KafkaAutoConfiguration::class, + AppKafkaConfiguration::class, + AppRestConfiguration::class + ] +) +@MockitoBean(types = [ObjectMapper::class]) @TestPropertySource( properties = [ "app.pseudonymize.generator=BUILDIN", @@ -86,7 +87,7 @@ class AppConfigurationTest { "app.kafka.group-id=test" ] ) - @MockBean(RequestRepository::class) + @MockitoBean(types = [RequestRepository::class]) inner class AppConfigurationKafkaTest(private val context: ApplicationContext) { @Test @@ -145,7 +146,7 @@ class AppConfigurationTest { "app.kafka.group-id=test" ] ) - @MockBean(RequestProcessor::class) + @MockitoBean(types = [RequestProcessor::class]) inner class AppConfigurationUsingKafkaInputTest(private val context: ApplicationContext) { @Test @@ -181,40 +182,7 @@ class AppConfigurationTest { @Nested @TestPropertySource( properties = [ - "app.pseudonymize.generator=", - "app.pseudonymizer=buildin", - ] - ) - inner class AppConfigurationPseudonymizerBuildinTest(private val context: ApplicationContext) { - - @Test - fun shouldUseConfiguredGenerator() { - assertThat(context.getBean(AnonymizingGenerator::class.java)).isNotNull - } - - } - - @Nested - @TestPropertySource( - properties = [ - "app.pseudonymize.generator=", - "app.pseudonymizer=gpas", - ] - ) - inner class AppConfigurationPseudonymizerGpasTest(private val context: ApplicationContext) { - - @Test - fun shouldUseConfiguredGenerator() { - assertThat(context.getBean(GpasPseudonymGenerator::class.java)).isNotNull - } - - } - - @Nested - @TestPropertySource( - properties = [ - "app.pseudonymize.generator=buildin", - "app.pseudonymizer=", + "app.pseudonymize.generator=buildin" ] ) inner class AppConfigurationPseudonymizeGeneratorBuildinTest(private val context: ApplicationContext) { @@ -229,8 +197,7 @@ class AppConfigurationTest { @Nested @TestPropertySource( properties = [ - "app.pseudonymize.generator=gpas", - "app.pseudonymizer=", + "app.pseudonymize.generator=gpas" ] ) inner class AppConfigurationPseudonymizeGeneratorGpasTest(private val context: ApplicationContext) { @@ -248,11 +215,13 @@ class AppConfigurationTest { "app.security.enable-tokens=true" ] ) - @MockBeans(value = [ - MockBean(InMemoryUserDetailsManager::class), - MockBean(PasswordEncoder::class), - MockBean(TokenRepository::class) - ]) + @MockitoBean( + types = [ + InMemoryUserDetailsManager::class, + PasswordEncoder::class, + TokenRepository::class + ] + ) inner class AppConfigurationTokenEnabledTest(private val context: ApplicationContext) { @Test @@ -263,11 +232,13 @@ class AppConfigurationTest { } @Nested - @MockBeans(value = [ - MockBean(InMemoryUserDetailsManager::class), - MockBean(PasswordEncoder::class), - MockBean(TokenRepository::class) - ]) + @MockitoBean( + types = [ + InMemoryUserDetailsManager::class, + PasswordEncoder::class, + TokenRepository::class + ] + ) inner class AppConfigurationTokenDisabledTest(private val context: ApplicationContext) { @Test 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 276c35f..410fac8 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt @@ -39,13 +39,13 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.* 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 import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.delete @@ -60,7 +60,7 @@ import org.springframework.test.web.servlet.post ConsentCheckedIgnored::class, ICheckConsent::class ] ) -@MockBean(TokenRepository::class, RequestProcessor::class) +@MockitoBean(types = [TokenRepository::class, RequestProcessor::class]) @TestPropertySource( properties = [ "app.pseudonymize.generator=BUILDIN", @@ -95,7 +95,7 @@ class MtbFileRestControllerTest { status { isAccepted() } } - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } @Test @@ -108,7 +108,7 @@ class MtbFileRestControllerTest { status { isAccepted() } } - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } @Test @@ -121,7 +121,7 @@ class MtbFileRestControllerTest { status { isUnauthorized() } } - verify(requestProcessor, never()).processMtbFile(any()) + verify(requestProcessor, never()).processMtbFile(any()) } @Test @@ -134,7 +134,7 @@ class MtbFileRestControllerTest { status { isForbidden() } } - verify(requestProcessor, never()).processMtbFile(any()) + verify(requestProcessor, never()).processMtbFile(any()) } @Test @@ -160,7 +160,7 @@ class MtbFileRestControllerTest { } @Nested - @MockBean(UserRoleRepository::class, ClientRegistrationRepository::class) + @MockitoBean(types = [UserRoleRepository::class, ClientRegistrationRepository::class]) @TestPropertySource( properties = [ "app.pseudonymize.generator=BUILDIN", @@ -182,7 +182,7 @@ class MtbFileRestControllerTest { status { isAccepted() } } - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } @Test @@ -195,7 +195,7 @@ class MtbFileRestControllerTest { status { isAccepted() } } - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } } diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt index bef124c..428bca9 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/monitoring/RequestRepositoryTest.kt @@ -27,8 +27,8 @@ 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.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.transaction.annotation.Transactional import org.testcontainers.junit.jupiter.Testcontainers @@ -39,7 +39,7 @@ import java.time.Instant @DataJdbcTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @Transactional -@MockBean(MtbFileSender::class) +@MockitoBean(types = [MtbFileSender::class]) @TestPropertySource( properties = [ "app.pseudonymize.generator=buildin", diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt index dc5a725..1275239 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.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 @@ -49,10 +49,9 @@ class GpasPseudonymGeneratorTest { fun setup() { val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() val gPasConfigProperties = GPasConfigProperties( - "http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate", + "https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate", "test", null, - null, null ) @@ -66,7 +65,7 @@ class GpasPseudonymGeneratorTest { fun shouldReturnExpectedPseudonym() { this.mockRestServiceServer.expect { method(HttpMethod.POST) - requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") + requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") }.andRespond { withStatus(HttpStatus.OK).body( getDummyResponseBody( @@ -85,7 +84,7 @@ class GpasPseudonymGeneratorTest { fun shouldThrowExceptionIfGpasNotAvailable() { this.mockRestServiceServer.expect { method(HttpMethod.POST) - requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") + requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") }.andRespond { withException(IOException("Simulated IO error")).createResponse(it) } @@ -97,7 +96,7 @@ class GpasPseudonymGeneratorTest { fun shouldThrowExceptionIfGpasDoesNotReturn2xxResponse() { this.mockRestServiceServer.expect { method(HttpMethod.POST) - requestTo("http://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") + requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate") }.andRespond { withStatus(HttpStatus.FOUND) .header( 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 47ac301..9fcdc16 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/services/RequestServiceIntegrationTest.kt @@ -31,8 +31,8 @@ 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.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.transaction.annotation.Transactional import org.testcontainers.junit.jupiter.Testcontainers @@ -42,7 +42,7 @@ import java.time.Instant @ExtendWith(SpringExtension::class) @SpringBootTest @Transactional -@MockBean(MtbFileSender::class) +@MockitoBean(types = [MtbFileSender::class]) @TestPropertySource( properties = [ "app.pseudonymize.generator=buildin", 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 af4650d..9f3ae62 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/ConfigControllerTest.kt @@ -19,8 +19,6 @@ 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.ConnectionCheckResult @@ -29,11 +27,13 @@ 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.security.TokenService -import dev.dnpm.etl.processor.services.TransformationService import dev.dnpm.etl.processor.security.UserRoleService +import dev.dnpm.etl.processor.services.RequestProcessor +import dev.dnpm.etl.processor.services.TransformationService import org.assertj.core.api.Assertions.assertThat +import org.htmlunit.WebClient +import org.htmlunit.html.HtmlPage import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -46,7 +46,6 @@ 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 @@ -55,6 +54,7 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ 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.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.servlet.* @@ -81,14 +81,16 @@ abstract class MockSink : Sinks.Many "app.pseudonymize.generator=BUILDIN" ] ) -@MockBean(name = "configsUpdateProducer", classes = [MockSink::class]) -@MockBean( - Generator::class, - MtbFileSender::class, - RequestProcessor::class, - TransformationService::class, - GPasConnectionCheckService::class, - RestConnectionCheckService::class, +@MockitoBean(name = "configsUpdateProducer", types = [MockSink::class]) +@MockitoBean( + types = [ + Generator::class, + MtbFileSender::class, + RequestProcessor::class, + TransformationService::class, + GPasConnectionCheckService::class, + RestConnectionCheckService::class + ] ) class ConfigControllerTest { @@ -143,8 +145,10 @@ class ConfigControllerTest { "app.security.admin-user=admin" ] ) - @MockBean( - TokenService::class + @MockitoBean( + types = [ + TokenService::class + ] ) inner class WithTokensEnabled { private lateinit var tokenService: TokenService @@ -252,8 +256,10 @@ class ConfigControllerTest { "app.security.admin-password={noop}very-secret" ] ) - @MockBean( - UserRoleService::class + @MockitoBean( + types = [ + UserRoleService::class + ] ) inner class WithUserRolesEnabled { private lateinit var userRoleService: UserRoleService diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt index 82835b4..829f9a1 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/HomeControllerTest.kt @@ -19,8 +19,6 @@ 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 @@ -30,6 +28,8 @@ 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.htmlunit.WebClient +import org.htmlunit.html.HtmlPage import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -40,13 +40,13 @@ 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.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get @@ -71,8 +71,8 @@ import java.util.* "app.security.admin-password={noop}very-secret" ] ) -@MockBean( - RequestService::class +@MockitoBean( + types = [RequestService::class] ) class HomeControllerTest { diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt index 0471543..54ad6e8 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/LoginControllerTest.kt @@ -19,21 +19,21 @@ 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.htmlunit.WebClient +import org.htmlunit.html.HtmlPage 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.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get @@ -56,8 +56,8 @@ import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder "app.security.enable-tokens=true" ] ) -@MockBean( - TokenService::class, +@MockitoBean( + types = [TokenService::class] ) class LoginControllerTest { diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt index 424a0e3..b55a702 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsControllerTest.kt @@ -19,9 +19,9 @@ 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.htmlunit.WebClient import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt index b9a1338..f0c3b63 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/web/StatisticsRestControllerTest.kt @@ -41,10 +41,10 @@ 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.bean.override.mockito.MockitoBean import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.servlet.MockMvc @@ -54,6 +54,7 @@ import org.springframework.web.context.WebApplicationContext import reactor.core.publisher.Sinks import reactor.test.StepVerifier import java.time.Instant +import java.time.ZoneId import java.time.temporal.ChronoUnit @@ -73,8 +74,8 @@ import java.time.temporal.ChronoUnit "app.security.admin-password={noop}very-secret" ] ) -@MockBean( - RequestService::class +@MockitoBean( + types = [RequestService::class] ) class StatisticsRestControllerTest { @@ -185,6 +186,7 @@ class StatisticsRestControllerTest { @BeforeEach fun setup() { + val zoneId = ZoneId.of("Europe/Berlin") doAnswer { _ -> listOf( Request( @@ -195,7 +197,7 @@ class StatisticsRestControllerTest { Fingerprint("0123456789abcdef1"), RequestType.MTB_FILE, RequestStatus.SUCCESS, - Instant.now().truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS) + Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS).toInstant() ), Request( 2, @@ -205,7 +207,7 @@ class StatisticsRestControllerTest { Fingerprint("0123456789abcdef2"), RequestType.MTB_FILE, RequestStatus.WARNING, - Instant.now().truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS) + Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(2, ChronoUnit.DAYS).toInstant() ), Request( 3, @@ -215,7 +217,7 @@ class StatisticsRestControllerTest { Fingerprint("0123456789abcdee1"), RequestType.DELETE, RequestStatus.ERROR, - Instant.now().truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS) + Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS).toInstant() ), Request( 4, @@ -225,7 +227,7 @@ class StatisticsRestControllerTest { Fingerprint("0123456789abcdef2"), RequestType.MTB_FILE, RequestStatus.DUPLICATION, - Instant.now().truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS) + Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).minus(1, ChronoUnit.DAYS).toInstant() ), Request( 5, @@ -235,7 +237,7 @@ class StatisticsRestControllerTest { Fingerprint("0123456789abcdef2"), RequestType.DELETE, RequestStatus.UNKNOWN, - Instant.now().truncatedTo(ChronoUnit.DAYS) + Instant.now().atZone(zoneId).truncatedTo(ChronoUnit.DAYS).toInstant() ), ) }.whenever(requestService).findAll() 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 f04d30f..6f127c6 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.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 @@ -21,16 +21,10 @@ package dev.dnpm.etl.processor.config import dev.dnpm.etl.processor.security.Role import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.boot.context.properties.DeprecatedConfigurationProperty @ConfigurationProperties(AppConfigProperties.NAME) data class AppConfigProperties( var bwhcUri: String?, - @get:DeprecatedConfigurationProperty( - reason = "Deprecated in favor of 'app.pseudonymize.generator'", - replacement = "app.pseudonymize.generator" - ) - var pseudonymizer: PseudonymGenerator = PseudonymGenerator.BUILDIN, var transformations: List = listOf(), var maxRetryAttempts: Int = 3, var duplicationDetection: Boolean = true @@ -56,10 +50,6 @@ data class GPasConfigProperties( val target: String = "etl-processor", val username: String?, val password: String?, - @get:DeprecatedConfigurationProperty( - reason = "Deprecated in favor of including Root CA" - ) - val sslCaLocation: String? ) { companion object { const val NAME = "app.pseudonymize.gpas" @@ -113,6 +103,9 @@ data class GIcsConfigProperties( @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" @@ -123,18 +116,8 @@ data class RestTargetProperties( data class KafkaProperties( val inputTopic: String?, val outputTopic: String = "etl-processor", - @get:DeprecatedConfigurationProperty( - reason = "Deprecated", - replacement = "outputTopic" - ) - val topic: String = outputTopic, val outputResponseTopic: String = "${outputTopic}_response", - @get:DeprecatedConfigurationProperty( - reason = "Deprecated", - replacement = "outputResponseTopic" - ) - val responseTopic: String = outputResponseTopic, - val groupId: String = "${topic}_group", + val groupId: String = "${outputTopic}_group", val servers: String = "" ) { companion object { 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 56cca9a..485b644 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfiguration.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 @@ -35,12 +35,6 @@ 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 @@ -48,7 +42,6 @@ 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 @@ -58,15 +51,9 @@ import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.web.client.HttpClientErrorException 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 @@ -97,20 +84,8 @@ class AppConfiguration { @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS") @Bean - fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate, appFhirConfig: AppFhirConfig): Generator { - try { - if (!configProperties.sslCaLocation.isNullOrBlank()) { - return GpasPseudonymGenerator( - configProperties, - retryTemplate, - createCustomGpasRestTemplate(configProperties),appFhirConfig - ) - } - } catch (e: Exception) { - throw RuntimeException(e) - } - - return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate,appFhirConfig) + fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator { + return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate) } @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "BUILDIN", matchIfMissing = true) @@ -119,92 +94,6 @@ class AppConfiguration { return AnonymizingGenerator() } - @ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS") - @ConditionalOnMissingBean - @Bean - fun gpasPseudonymGeneratorOnDeprecatedProperty(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate, appFhirConfig: AppFhirConfig): Generator { - try { - if (!configProperties.sslCaLocation.isNullOrBlank()) { - return GpasPseudonymGenerator( - configProperties, - retryTemplate, - createCustomGpasRestTemplate(configProperties),appFhirConfig - ) - } - } catch (e: Exception) { - throw RuntimeException(e) - } - - return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate,appFhirConfig) - } - - 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() - .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") - @ConditionalOnMissingBean - @Bean - fun buildinPseudonymGeneratorOnDeprecatedProperty(): Generator { - return AnonymizingGenerator() - } - @Bean fun pseudonymizeService( generator: Generator, @@ -233,6 +122,8 @@ class AppConfiguration { fun retryTemplate(configProperties: AppConfigProperties): RetryTemplate { return RetryTemplateBuilder() .notRetryOn(IllegalArgumentException::class.java) + .notRetryOn(HttpClientErrorException.BadRequest::class.java) + .notRetryOn(HttpClientErrorException.UnprocessableEntity::class.java) .exponentialBackoff(2.seconds.toJavaDuration(), 1.25, 5.seconds.toJavaDuration()) .customPolicy(SimpleRetryPolicy(configProperties.maxRetryAttempts)) .withListener(object : RetryListener { diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppKafkaConfiguration.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppKafkaConfiguration.kt index 80c66d2..de11cbb 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppKafkaConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppKafkaConfiguration.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 @@ -71,7 +71,7 @@ class AppKafkaConfiguration { kafkaProperties: KafkaProperties, kafkaResponseProcessor: KafkaResponseProcessor ): KafkaMessageListenerContainer { - val containerProperties = ContainerProperties(kafkaProperties.responseTopic) + val containerProperties = ContainerProperties(kafkaProperties.outputResponseTopic) containerProperties.messageListener = kafkaResponseProcessor return KafkaMessageListenerContainer(consumerFactory, containerProperties) } 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..1a18924 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 @@ -21,9 +21,11 @@ package dev.dnpm.etl.processor.config import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult import dev.dnpm.etl.processor.monitoring.ConnectionCheckService +import dev.dnpm.etl.processor.monitoring.ReportService 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 @@ -52,10 +54,16 @@ class AppRestConfiguration { fun restMtbFileSender( restTemplate: RestTemplate, restTargetProperties: RestTargetProperties, - retryTemplate: RetryTemplate + retryTemplate: RetryTemplate, + reportService: ReportService, ): MtbFileSender { - logger.info("Selected 'RestMtbFileSender'") - return RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate) + if (restTargetProperties.isBwhc) { + logger.info("Selected 'RestBwhcMtbFileSender'") + return RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) + } + + logger.info("Selected 'RestDipMtbFileSender'") + return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) } @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 6b063bd..762c7d8 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppSecurityConfiguration.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2023 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 @@ -44,6 +44,8 @@ import org.springframework.security.web.SecurityFilterChain import java.util.* +private const val LOGIN_PATH = "/login" + @Configuration @EnableConfigurationProperties( value = [ @@ -85,9 +87,14 @@ class AppSecurityConfiguration( @Bean @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true") - fun filterChainOidc(http: HttpSecurity, passwordEncoder: PasswordEncoder, userRoleRepository: UserRoleRepository, sessionRegistry: SessionRegistry): SecurityFilterChain { + fun filterChainOidc( + http: HttpSecurity, + passwordEncoder: PasswordEncoder, + userRoleRepository: UserRoleRepository, + sessionRegistry: SessionRegistry + ): SecurityFilterChain { http { - authorizeRequests { + authorizeHttpRequests { authorize("/configs/**", hasRole("ADMIN")) authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN", "USER")) authorize("/report/**", hasAnyRole("ADMIN", "USER")) @@ -104,15 +111,15 @@ class AppSecurityConfiguration( realmName = "ETL-Processor" } formLogin { - loginPage = "/login" + loginPage = LOGIN_PATH } oauth2Login { - loginPage = "/login" + loginPage = LOGIN_PATH } sessionManagement { sessionConcurrency { maximumSessions = 1 - expiredUrl = "/login?expired" + expiredUrl = "$LOGIN_PATH?expired" } sessionFixation { newSession() @@ -125,13 +132,22 @@ class AppSecurityConfiguration( @Bean @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true") - fun grantedAuthoritiesMapper(userRoleRepository: UserRoleRepository, appSecurityConfigProperties: SecurityConfigProperties): GrantedAuthoritiesMapper { + fun grantedAuthoritiesMapper( + userRoleRepository: UserRoleRepository, + appSecurityConfigProperties: SecurityConfigProperties + ): GrantedAuthoritiesMapper { return GrantedAuthoritiesMapper { grantedAuthority -> grantedAuthority.filterIsInstance() .onEach { val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername) if (userRole.isEmpty) { - userRoleRepository.save(UserRole(null, it.userInfo.preferredUsername, appSecurityConfigProperties.defaultNewUserRole)) + userRoleRepository.save( + UserRole( + null, + it.userInfo.preferredUsername, + appSecurityConfigProperties.defaultNewUserRole + ) + ) } } .map { @@ -145,7 +161,7 @@ class AppSecurityConfiguration( @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true) fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain { http { - authorizeRequests { + authorizeHttpRequests { authorize("/configs/**", hasRole("ADMIN")) authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN")) authorize("/report/**", hasRole("ADMIN")) @@ -155,7 +171,7 @@ class AppSecurityConfiguration( realmName = "ETL-Processor" } formLogin { - loginPage = "/login" + loginPage = LOGIN_PATH } csrf { disable() } } 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 af97b72..61a70a2 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/KafkaInputListener.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 @@ -22,12 +22,14 @@ 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.CustomMediaType import dev.dnpm.etl.processor.PatientId import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.consent.ConsentStatus import dev.dnpm.etl.processor.services.RequestProcessor import org.apache.kafka.clients.consumer.ConsumerRecord import org.slf4j.LoggerFactory +import org.springframework.http.MediaType import org.springframework.kafka.listener.MessageListener class KafkaInputListener( @@ -36,10 +38,29 @@ class KafkaInputListener( ) : MessageListener { private val logger = LoggerFactory.getLogger(KafkaInputListener::class.java) - override fun onMessage(data: ConsumerRecord) { - val mtbFile = objectMapper.readValue(data.value(), MtbFile::class.java) + override fun onMessage(record: ConsumerRecord) { + when (guessMimeType(record)) { + MediaType.APPLICATION_JSON_VALUE -> handleBwhcMessage(record) + CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE -> handleDnpmV2Message(record) + else -> { + /* ignore other messages */ + } + } + } + + private fun guessMimeType(record: ConsumerRecord): String { + if (record.headers().headers("contentType").toList().isEmpty()) { + // Fallback if no contentType set (old behavior) + return MediaType.APPLICATION_JSON_VALUE + } + + return record.headers().headers("contentType")?.firstOrNull()?.value().contentToString() + } + + private fun handleBwhcMessage(record: ConsumerRecord) { + val mtbFile = objectMapper.readValue(record.value(), MtbFile::class.java) val patientId = PatientId(mtbFile.patient.id) - val firstRequestIdHeader = data.headers().headers("requestId")?.firstOrNull() + val firstRequestIdHeader = record.headers().headers("requestId")?.firstOrNull() val requestId = if (null != firstRequestIdHeader) { RequestId(String(firstRequestIdHeader.value())) } else { @@ -66,4 +87,10 @@ class KafkaInputListener( } } } -} \ No newline at end of file + + private fun handleDnpmV2Message(record: ConsumerRecord) { + // Do not handle DNPM-V2 for now + logger.warn("Ignoring MTB File in DNPM V2 format: Not implemented yet") + } + +} 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 de4950b..9146637 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/input/MtbFileRestController.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 @@ -21,16 +21,19 @@ 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.CustomMediaType import dev.dnpm.etl.processor.PatientId import dev.dnpm.etl.processor.consent.ICheckConsent import dev.dnpm.etl.processor.consent.ConsentStatus import dev.dnpm.etl.processor.services.RequestProcessor +import dev.pcvolkmer.mv64e.mtb.Mtb import org.slf4j.LoggerFactory +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* @RestController -@RequestMapping(path = ["mtbfile"]) +@RequestMapping(path = ["mtbfile", "mtb"]) class MtbFileRestController( private val requestProcessor: RequestProcessor, private val constService: ICheckConsent ) { @@ -42,35 +45,38 @@ class MtbFileRestController( return ResponseEntity.ok("Test") } - @PostMapping - fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity { - + @PostMapping( consumes = [ MediaType.APPLICATION_JSON_VALUE ] ) + fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity { var consentStatus = constService.isConsented(mtbFile.patient.id) + if (mtbFile.consent.status == Consent.Status.ACTIVE && (consentStatus.equals(ConsentStatus.CONSENTED) || consentStatus.equals( ConsentStatus.IGNORED )) ) { - logger.debug("Accepted MTB File for processing") + logger.debug("Accepted MTB File (bwHC V1) for processing") requestProcessor.processMtbFile(mtbFile) } else { - var msg = "Accepted MTB File and process deletion" - if (!consentStatus.equals(ConsentStatus.CONSENTED) || consentStatus.equals(ConsentStatus.IGNORED)) { - msg = "Accepted MTB File. But consent is missing, therefore process deletion." - } + logger.debug("Accepted MTB File (bwHC V1) and process deletion") if (mtbFile.consent.status == Consent.Status.REJECTED) consentStatus = ConsentStatus.CONSENT_REJECTED - logger.debug(msg) val patientId = PatientId(mtbFile.patient.id) requestProcessor.processDeletion(patientId, consentStatus) } return ResponseEntity.accepted().build() } + @PostMapping( consumes = [ CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE] ) + fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity { + logger.debug("Accepted MTB File (DNPM V2) for processing") + requestProcessor.processMtbFile(mtbFile) + return ResponseEntity.accepted().build() + } + @DeleteMapping(path = ["{patientId}"]) - fun deleteData(@PathVariable patientId: String): ResponseEntity { + fun deleteData(@PathVariable patientId: String): ResponseEntity { logger.debug("Accepted patient ID to process deletion") requestProcessor.processDeletion(PatientId(patientId), ConsentStatus.IGNORED) return ResponseEntity.accepted().build() } -} \ No newline at end of file +} 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 1afaa32..b845e21 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ConnectionCheckService.kt @@ -35,7 +35,7 @@ import java.time.Instant import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration -interface ConnectionCheckService { +fun interface ConnectionCheckService { fun connectionAvailable(): ConnectionCheckResult @@ -88,7 +88,7 @@ class KafkaConnectionCheckService( Instant.now(), if (result.available == available) { result.lastChange } else { Instant.now() } ) - } catch (e: TimeoutException) { + } catch (_: TimeoutException) { ConnectionCheckResult.KafkaConnectionCheckResult( false, Instant.now(), @@ -121,7 +121,15 @@ class RestConnectionCheckService( fun check() { result = try { val available = restTemplate.getForEntity( - restTargetProperties.uri?.replace("/etl/api", "").toString(), + 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 @@ -130,7 +138,7 @@ class RestConnectionCheckService( Instant.now(), if (result.available == available) { result.lastChange } else { Instant.now() } ) - } catch (e: Exception) { + } catch (_: Exception) { ConnectionCheckResult.RestConnectionCheckResult( false, Instant.now(), @@ -162,11 +170,8 @@ class GPasConnectionCheckService( fun check() { 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 @@ -186,7 +191,7 @@ class GPasConnectionCheckService( Instant.now(), if (result.available == available) { result.lastChange } else { Instant.now() } ) - } catch (e: Exception) { + } catch (_: Exception) { ConnectionCheckResult.GPasConnectionCheckResult( false, Instant.now(), 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 062f749..e9ea489 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/monitoring/ReportService.kt @@ -25,6 +25,9 @@ import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.ObjectMapper +import dev.dnpm.etl.processor.monitoring.ReportService.Issue +import dev.dnpm.etl.processor.monitoring.ReportService.Severity +import java.util.Optional class ReportService( private val objectMapper: ObjectMapper @@ -55,7 +58,11 @@ class ReportService( private data class DataQualityReport(val issues: List) @JsonIgnoreProperties(ignoreUnknown = true) - data class Issue(val severity: Severity, @JsonAlias("details") val message: String) + data class Issue( + val severity: Severity, + @JsonAlias("details") val message: String, + val path: Optional = Optional.empty() + ) enum class Severity(@JsonValue val value: String) { FATAL("fatal"), @@ -63,4 +70,13 @@ class ReportService( WARNING("warning"), INFO("info") } +} + +fun List.asRequestStatus(): RequestStatus { + val severity = this.minOfOrNull { it.severity } + return when (severity) { + Severity.FATAL, Severity.ERROR -> RequestStatus.ERROR + Severity.WARNING -> RequestStatus.WARNING + else -> RequestStatus.SUCCESS + } } \ No newline at end of file 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 4838689..c00b2fd 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSender.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 @@ -22,10 +22,12 @@ 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.CustomMediaType import dev.dnpm.etl.processor.config.KafkaProperties import dev.dnpm.etl.processor.monitoring.RequestStatus +import org.apache.kafka.clients.producer.ProducerRecord import org.slf4j.LoggerFactory +import org.springframework.http.MediaType import org.springframework.kafka.core.KafkaTemplate import org.springframework.retry.support.RetryTemplate @@ -38,14 +40,20 @@ class KafkaMtbFileSender( private val logger = LoggerFactory.getLogger(KafkaMtbFileSender::class.java) - override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response { + override fun send(request: MtbFileRequest): MtbFileSender.Response { return try { return retryTemplate.execute { - val result = kafkaTemplate.send( - kafkaProperties.topic, - key(request), - objectMapper.writeValueAsString(Data(request.requestId, request.mtbFile)) - ) + val record = + ProducerRecord(kafkaProperties.outputTopic, key(request), objectMapper.writeValueAsString(request)) + when (request) { + is BwhcV1MtbFileRequest -> record.headers() + .add("contentType", MediaType.APPLICATION_JSON_VALUE.toByteArray()) + + is DnpmV2MtbFileRequest -> record.headers() + .add("contentType", CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray()) + } + + val result = kafkaTemplate.send(record) if (result.get() != null) { logger.debug("Sent file via KafkaMtbFileSender") MtbFileSender.Response(RequestStatus.UNKNOWN) @@ -59,7 +67,7 @@ class KafkaMtbFileSender( } } - override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response { + override fun send(request: DeleteRequest): MtbFileSender.Response { val dummyMtbFile = MtbFile.builder() .withConsent( Consent.builder() @@ -71,12 +79,15 @@ class KafkaMtbFileSender( return try { return retryTemplate.execute { - val result = kafkaTemplate.send( - kafkaProperties.topic, - key(request), - objectMapper.writeValueAsString(Data(request.requestId, dummyMtbFile)) - ) + val record = + ProducerRecord( + kafkaProperties.outputTopic, + key(request), + // Always use old BwhcV1FileRequest with Consent REJECT + objectMapper.writeValueAsString(BwhcV1MtbFileRequest(request.requestId, dummyMtbFile)) + ) + val result = kafkaTemplate.send(record) if (result.get() != null) { logger.debug("Sent deletion request via KafkaMtbFileSender") MtbFileSender.Response(RequestStatus.UNKNOWN) @@ -91,16 +102,15 @@ class KafkaMtbFileSender( } override fun endpoint(): String { - return "${this.kafkaProperties.servers} (${this.kafkaProperties.topic}/${this.kafkaProperties.responseTopic})" + return "${this.kafkaProperties.servers} (${this.kafkaProperties.outputTopic}/${this.kafkaProperties.outputResponseTopic})" } - private fun key(request: MtbFileSender.MtbFileRequest): String { - return "{\"pid\": \"${request.mtbFile.patient.id}\"}" + private fun key(request: MtbRequest): String { + return when (request) { + is BwhcV1MtbFileRequest -> "{\"pid\": \"${request.content.patient.id}\"}" + is DnpmV2MtbFileRequest -> "{\"pid\": \"${request.content.patient.id}\"}" + is DeleteRequest -> "{\"pid\": \"${request.patientId.value}\"}" + else -> throw IllegalArgumentException("Unsupported request type: ${request::class.simpleName}") + } } - - private fun key(request: MtbFileSender.DeleteRequest): String { - return "{\"pid\": \"${request.patientId.value}\"}" - } - - 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 8d994c5..285ce07 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbFileSender.kt @@ -19,25 +19,17 @@ 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 interface MtbFileSender { - fun send(request: MtbFileRequest): Response + fun send(request: MtbFileRequest): Response fun send(request: DeleteRequest): Response fun endpoint(): String data class Response(val status: RequestStatus, val body: String = "") - - data class MtbFileRequest(val requestId: RequestId, val mtbFile: MtbFile) - - data class DeleteRequest(val requestId: RequestId, val patientId: PatientPseudonym) - } fun Int.asRequestStatus(): RequestStatus { @@ -51,4 +43,4 @@ fun Int.asRequestStatus(): RequestStatus { fun HttpStatusCode.asRequestStatus(): RequestStatus { return this.value().asRequestStatus() -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/output/MtbRequest.kt b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbRequest.kt new file mode 100644 index 0000000..9b500f0 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/MtbRequest.kt @@ -0,0 +1,59 @@ +/* + * 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 . + */ + +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.pcvolkmer.mv64e.mtb.Mtb + +interface MtbRequest { + val requestId: RequestId +} + +sealed interface MtbFileRequest : MtbRequest { + override val requestId: RequestId + val content: T + + fun patientPseudonym(): PatientPseudonym +} + +data class BwhcV1MtbFileRequest( + override val requestId: RequestId, + override val content: MtbFile +) : MtbFileRequest { + override fun patientPseudonym(): PatientPseudonym { + return PatientPseudonym(content.patient.id) + } +} + +data class DnpmV2MtbFileRequest( + override val requestId: RequestId, + override val content: Mtb +) : MtbFileRequest { + override fun patientPseudonym(): PatientPseudonym { + return PatientPseudonym(content.patient.id) + } +} + +data class DeleteRequest( + override val requestId: RequestId, + val patientId: PatientPseudonym +) : MtbRequest 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..fbe6d0d --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSender.kt @@ -0,0 +1,51 @@ +/* + * 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 . + */ + +package dev.dnpm.etl.processor.output + +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.config.RestTargetProperties +import dev.dnpm.etl.processor.monitoring.ReportService +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, + reportService: ReportService, +) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) { + + 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..1e6a5a7 --- /dev/null +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSender.kt @@ -0,0 +1,55 @@ +/* + * 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 . + */ + +package dev.dnpm.etl.processor.output + +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.config.RestTargetProperties +import dev.dnpm.etl.processor.monitoring.ReportService +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, + reportService: ReportService +) : RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) { + + 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..78222b2 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSender.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 @@ -19,62 +19,71 @@ package dev.dnpm.etl.processor.output +import dev.dnpm.etl.processor.CustomMediaType +import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.config.RestTargetProperties +import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.dnpm.etl.processor.monitoring.asRequestStatus 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 + private val retryTemplate: RetryTemplate, + private val reportService: ReportService ) : MtbFileSender { private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java) - override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response { + abstract fun sendUrl(): String + + abstract fun deleteUrl(patientId: PatientPseudonym): String + + override fun send(request: MtbFileRequest): MtbFileSender.Response { try { return retryTemplate.execute { - val headers = HttpHeaders() - headers.contentType = MediaType.APPLICATION_JSON - val entityReq = HttpEntity(request.mtbFile, headers) + val headers = getHttpHeaders(request) + val entityReq = HttpEntity(request.content, headers) val response = restTemplate.postForEntity( - "${restTargetProperties.uri}/MTBFile", + sendUrl(), entityReq, String::class.java ) if (!response.statusCode.is2xxSuccessful) { logger.warn("Error sending to remote system: {}", response.body) return@execute MtbFileSender.Response( - response.statusCode.asRequestStatus(), + reportService.deserialize(response.body).asRequestStatus(), "Status-Code: ${response.statusCode.value()}" ) } logger.debug("Sent file via RestMtbFileSender") - return@execute MtbFileSender.Response(response.statusCode.asRequestStatus(), response.body.orEmpty()) + return@execute MtbFileSender.Response(reportService.deserialize(response.body).asRequestStatus(), response.body.orEmpty()) } } 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(reportService.deserialize(e.responseBodyAsString).asRequestStatus(), e.responseBodyAsString) } return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") } - override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response { + override fun send(request: DeleteRequest): MtbFileSender.Response { try { return retryTemplate.execute { - val headers = HttpHeaders() - headers.contentType = MediaType.APPLICATION_JSON + val headers = getHttpHeaders(request) val entityReq = HttpEntity(null, headers) restTemplate.delete( - "${restTargetProperties.uri}/Patient/${request.patientId}", + deleteUrl(request.patientId), entityReq, String::class.java ) @@ -94,4 +103,22 @@ class RestMtbFileSender( return this.restTargetProperties.uri.orEmpty() } -} \ No newline at end of file + private fun getHttpHeaders(request: MtbRequest): HttpHeaders { + val username = restTargetProperties.username + val password = restTargetProperties.password + val headers = HttpHeaders() + headers.contentType = when (request) { + is BwhcV1MtbFileRequest -> MediaType.APPLICATION_JSON + is DnpmV2MtbFileRequest -> CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON + else -> MediaType.APPLICATION_JSON + } + + if (username.isNullOrBlank() || password.isNullOrBlank()) { + return headers + } + + headers.setBasicAuth(username, password) + return headers + } + +} 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 bf645f6..111494b 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt @@ -21,12 +21,12 @@ package dev.dnpm.etl.processor.pseudonym import de.ukw.ccc.bwhc.dto.MtbFile import dev.dnpm.etl.processor.PatientId +import dev.pcvolkmer.mv64e.mtb.Mtb import org.apache.commons.codec.digest.DigestUtils /** Replaces patient ID with generated patient pseudonym * * @param pseudonymizeService The pseudonymizeService to be used - * * @return The MTB file containing patient pseudonymes */ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { @@ -49,7 +49,11 @@ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { } this.lastGuidelineTherapies?.forEach { it.patient = patientPseudonym } this.molecularPathologyFindings?.forEach { it.patient = patientPseudonym } - this.molecularTherapies?.forEach { molecularTherapy -> molecularTherapy.history.forEach { it.patient = patientPseudonym } } + this.molecularTherapies?.forEach { molecularTherapy -> + molecularTherapy.history.forEach { + it.patient = patientPseudonym + } + } this.ngsReports?.forEach { it.patient = patientPseudonym } this.previousGuidelineTherapies?.forEach { it.patient = patientPseudonym } this.rebiopsyRequests?.forEach { it.patient = patientPseudonym } @@ -63,7 +67,6 @@ infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { * Creates new hash of content IDs with given prefix except for patient IDs * * @param pseudonymizeService The pseudonymizeService to be used - * * @return The MTB file containing rehashed content IDs */ infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) { @@ -120,8 +123,8 @@ infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) id = id?.let { anonymize(it) } } } - this.geneticCounsellingRequests?.onEach { geneticCounsellingRequest -> - geneticCounsellingRequest?.apply { + this.geneticCounsellingRequests?.onEach { geneticCounsellingRequest -> + geneticCounsellingRequest?.apply { id = id?.let { anonymize(it) } } } @@ -223,4 +226,90 @@ infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) id = id?.let { anonymize(it) } } } -} \ No newline at end of file +} + +/** Replaces patient ID with generated patient pseudonym + * + * @since 0.11.0 + * + * @param pseudonymizeService The pseudonymizeService to be used + * @return The MTB file containing patient pseudonymes + */ +infix fun Mtb.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { + val patientPseudonym = pseudonymizeService.patientPseudonym(PatientId(this.patient.id)).value + + this.episodesOfCare?.forEach { it.patient.id = patientPseudonym } + this.carePlans?.forEach { + it.patient.id = patientPseudonym + it.rebiopsyRequests?.forEach { it.patient.id = patientPseudonym } + it.histologyReevaluationRequests?.forEach { it.patient.id = patientPseudonym } + it.medicationRecommendations.forEach { it.patient.id = patientPseudonym } + it.studyEnrollmentRecommendations?.forEach { it.patient.id = patientPseudonym } + it.procedureRecommendations?.forEach { it.patient.id = patientPseudonym } + it.geneticCounselingRecommendation.patient.id = patientPseudonym + } + this.diagnoses?.forEach { it.patient.id = patientPseudonym } + this.guidelineTherapies?.forEach { it.patient.id = patientPseudonym } + this.guidelineProcedures?.forEach { it.patient.id = patientPseudonym } + this.patient.id = patientPseudonym + this.claims?.forEach { it.patient.id = patientPseudonym } + this.claimResponses?.forEach { it.patient.id = patientPseudonym } + this.diagnoses?.forEach { it.patient.id = patientPseudonym } + this.histologyReports?.forEach { + it.patient.id = patientPseudonym + it.results.tumorMorphology?.patient?.id = patientPseudonym + it.results.tumorCellContent?.patient?.id = patientPseudonym + } + this.ngsReports?.forEach { + it.patient.id = patientPseudonym + it.results.simpleVariants?.forEach { it.patient.id = patientPseudonym } + it.results.copyNumberVariants?.forEach { it.patient.id = patientPseudonym } + it.results.dnaFusions?.forEach { it.patient.id = patientPseudonym } + it.results.rnaFusions?.forEach { it.patient.id = patientPseudonym } + it.results.tumorCellContent?.patient?.id = patientPseudonym + it.results.brcaness?.patient?.id = patientPseudonym + it.results.tmb?.patient?.id = patientPseudonym + it.results.hrdScore?.patient?.id = patientPseudonym + } + this.ihcReports?.forEach { + it.patient.id = patientPseudonym + it.results.msiMmr?.forEach { it.patient.id = patientPseudonym } + it.results.proteinExpression?.forEach { it.patient.id = patientPseudonym } + } + this.responses?.forEach { it.patient.id = patientPseudonym } + this.specimens?.forEach { it.patient.id = patientPseudonym } + this.priorDiagnosticReports?.forEach { it.patient.id = patientPseudonym } + this.performanceStatus.forEach { it.patient.id = patientPseudonym } + this.systemicTherapies.forEach { + it.history?.forEach { + it.patient.id = patientPseudonym + } + } +} + +/** + * Creates new hash of content IDs with given prefix except for patient IDs + * + * @since 0.11.0 + * + * @param pseudonymizeService The pseudonymizeService to be used + * @return The MTB file containing rehashed content IDs + */ +infix fun Mtb.anonymizeContentWith(pseudonymizeService: PseudonymizeService) { + val prefix = pseudonymizeService.prefix() + + fun anonymize(id: String): String { + val hash = DigestUtils.sha256Hex("$prefix-$id").substring(0, 41).lowercase() + return "$prefix$hash" + } + + this.episodesOfCare?.forEach { + it?.apply { + id = id?.let { + anonymize(it) + } + } + } + + // TODO all other properties +} 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 29fad50..825f4e5 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2023 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 @@ -28,10 +28,11 @@ 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.output.MtbFileSender +import dev.dnpm.etl.processor.output.* import dev.dnpm.etl.processor.pseudonym.PseudonymizeService import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith +import dev.pcvolkmer.mv64e.mtb.Mtb import org.apache.commons.codec.binary.Base32 import org.apache.commons.codec.digest.DigestUtils import org.springframework.context.ApplicationEventPublisher @@ -56,30 +57,40 @@ class RequestProcessor( fun processMtbFile(mtbFile: MtbFile, requestId: RequestId) { val pid = PatientId(mtbFile.patient.id) - mtbFile pseudonymizeWith pseudonymizeService mtbFile anonymizeContentWith pseudonymizeService + val request = BwhcV1MtbFileRequest(requestId, transformationService.transform(mtbFile)) + saveAndSend(request, pid) + } - val request = - MtbFileSender.MtbFileRequest(requestId, transformationService.transform(mtbFile)) + fun processMtbFile(mtbFile: Mtb) { + processMtbFile(mtbFile, randomRequestId()) + } - val patientPseudonym = PatientPseudonym(request.mtbFile.patient.id) + fun processMtbFile(mtbFile: Mtb, requestId: RequestId) { + val pid = PatientId(mtbFile.patient.id) + mtbFile pseudonymizeWith pseudonymizeService + mtbFile anonymizeContentWith pseudonymizeService + val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile)) + saveAndSend(request, pid) + } + private fun saveAndSend(request: MtbFileRequest, pid: PatientId) { requestService.save( Request( - requestId, - patientPseudonym, + request.requestId, + request.patientPseudonym(), pid, - fingerprint(request.mtbFile), + fingerprint(request), RequestType.MTB_FILE, RequestStatus.UNKNOWN ) ) - if (appConfigProperties.duplicationDetection && isDuplication(mtbFile)) { + if (appConfigProperties.duplicationDetection && isDuplication(request)) { applicationEventPublisher.publishEvent( ResponseEvent( - requestId, + request.requestId, Instant.now(), RequestStatus.DUPLICATION ) @@ -91,19 +102,22 @@ class RequestProcessor( applicationEventPublisher.publishEvent( ResponseEvent( - requestId, + request.requestId, Instant.now(), responseStatus.status, when (responseStatus.status) { - RequestStatus.WARNING -> Optional.of(responseStatus.body) + RequestStatus.ERROR, RequestStatus.WARNING -> Optional.of(responseStatus.body) else -> Optional.empty() } ) ) } - private fun isDuplication(pseudonymizedMtbFile: MtbFile): Boolean { - val patientPseudonym = PatientPseudonym(pseudonymizedMtbFile.patient.id) + private fun isDuplication(pseudonymizedMtbFileRequest: MtbFileRequest): Boolean { + val patientPseudonym = when (pseudonymizedMtbFileRequest) { + is BwhcV1MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id) + is DnpmV2MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id) + } val lastMtbFileRequestForPatient = requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym) @@ -112,7 +126,7 @@ class RequestProcessor( return null != lastMtbFileRequestForPatient && !isLastRequestDeletion - && lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFile) + && lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFileRequest) } fun processDeletion(patientId: PatientId, isConsented: ConsentStatus) { @@ -141,10 +155,7 @@ class RequestProcessor( ) ) - val responseStatus = - sender.send(MtbFileSender.DeleteRequest(requestId, patientPseudonym)) - - //fixme: publish proper report if consent check failed + val responseStatus = sender.send(DeleteRequest(requestId, patientPseudonym)) applicationEventPublisher.publishEvent( ResponseEvent( @@ -173,8 +184,11 @@ class RequestProcessor( } } - private fun fingerprint(mtbFile: MtbFile): Fingerprint { - return fingerprint(objectMapper.writeValueAsString(mtbFile)) + private fun fingerprint(request: MtbFileRequest): Fingerprint { + return when (request) { + is BwhcV1MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content)) + is DnpmV2MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content)) + } } private fun fingerprint(s: String): Fingerprint { @@ -184,4 +198,5 @@ class RequestProcessor( .lowercase() ) } -} \ No newline at end of file + +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/TransformationService.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/TransformationService.kt index 2a9dc5b..9447a84 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/TransformationService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/TransformationService.kt @@ -23,10 +23,21 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.jayway.jsonpath.JsonPath import com.jayway.jsonpath.PathNotFoundException import de.ukw.ccc.bwhc.dto.MtbFile +import dev.pcvolkmer.mv64e.mtb.Mtb class TransformationService(private val objectMapper: ObjectMapper, private val transformations: List) { fun transform(mtbFile: MtbFile): MtbFile { - var json = objectMapper.writeValueAsString(mtbFile) + val json = transform(objectMapper.writeValueAsString(mtbFile)) + return objectMapper.readValue(json, MtbFile::class.java) + } + + fun transform(mtbFile: Mtb): Mtb { + val json = transform(objectMapper.writeValueAsString(mtbFile)) + return objectMapper.readValue(json, Mtb::class.java) + } + + private fun transform(content: String): String { + var json = content transformations.forEach { transformation -> val jsonPath = JsonPath.parse(json) @@ -48,7 +59,7 @@ class TransformationService(private val objectMapper: ObjectMapper, private val json = jsonPath.jsonString() } - return objectMapper.readValue(json, MtbFile::class.java) + return json } fun getTransformations(): List { diff --git a/src/main/kotlin/dev/dnpm/etl/processor/types.kt b/src/main/kotlin/dev/dnpm/etl/processor/types.kt index b2f13ef..90fa7cb 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/types.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/types.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 @@ -19,6 +19,7 @@ package dev.dnpm.etl.processor +import org.springframework.http.MediaType import java.util.* class Fingerprint(val value: String) { @@ -46,4 +47,17 @@ value class PatientId(val value: String) @JvmInline value class PatientPseudonym(val value: String) -fun emptyPatientPseudonym() = PatientPseudonym("") \ No newline at end of file +fun emptyPatientPseudonym() = PatientPseudonym("") + +/** + * Custom MediaTypes + * + * @since 0.11.0 + */ +object CustomMediaType { + val APPLICATION_VND_DNPM_V2_MTB_JSON = MediaType("application", "vnd.dnpm.v2.mtb+json") + const val APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE = "application/vnd.dnpm.v2.mtb+json" + + val APPLICATION_VND_DNPM_V2_RD_JSON = MediaType("application", "vnd.dnpm.v2.rd+json") + const val APPLICATION_VND_DNPM_V2_RD_JSON_VALUE = "application/vnd.dnpm.v2.rd+json" +} diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index 7066e2b..b6713d2 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -650,11 +650,12 @@ 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; @@ -696,4 +697,14 @@ a.reload { .no-token { padding: 1em; background: var(--bg-red-op); +} + +.issue-message { + font-family: monospace; + font-weight: bolder; +} + +.issue-path { + font-family: monospace; + line-height: 1rem; } \ No newline at end of file diff --git a/src/main/resources/templates/configs/outputConnectionAvailable.html b/src/main/resources/templates/configs/outputConnectionAvailable.html index 4b7f8d1..93ad549 100644 --- a/src/main/resources/templates/configs/outputConnectionAvailable.html +++ b/src/main/resources/templates/configs/outputConnectionAvailable.html @@ -20,7 +20,8 @@ Kafka-Broker ETL-Processor - bwHC-Backend + bwHC-Backend + DNPM:DIP-Backend Kafka-Broker \ No newline at end of file diff --git a/src/main/resources/templates/report.html b/src/main/resources/templates/report.html index 21d1b48..5442fd4 100644 --- a/src/main/resources/templates/report.html +++ b/src/main/resources/templates/report.html @@ -47,7 +47,7 @@ Schweregrad - Beschreibung + Beschreibung und Pfad @@ -56,7 +56,11 @@ [[ ${issue.severity} ]] [[ ${issue.severity} ]] [[ ${issue.severity} ]] - [[ ${issue.message} ]] + +
[[ ${issue.message} ]]
+
[[ ${issue.path.get()} ]]
+
Keine Angabe
+ diff --git a/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt b/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt index 55d6327..8caa908 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/helpers.kt @@ -17,13 +17,4 @@ * along with this program. If not, see . */ -package dev.dnpm.etl.processor - -import org.mockito.ArgumentMatchers - -inline fun anyValueClass(): T { - val unboxedClass = T::class.java.declaredFields.first().type - return ArgumentMatchers.any(unboxedClass as Class) - ?: T::class.java.getDeclaredMethod("box-impl", unboxedClass) - .invoke(null, null) as T -} \ No newline at end of file +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 8df9067..55ab373 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/KafkaInputListenerTest.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 @@ -25,6 +25,7 @@ import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.Patient import dev.dnpm.etl.processor.anyValueClass import dev.dnpm.etl.processor.consent.ConsentStatus +import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.services.RequestProcessor import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.common.header.internals.RecordHeader @@ -75,7 +76,7 @@ class KafkaInputListenerTest { ) ) - verify(requestProcessor, times(1)).processMtbFile(any()) + verify(requestProcessor, times(1)).processMtbFile(any()) } @Test @@ -108,14 +109,7 @@ class KafkaInputListenerTest { .withConsent(Consent.builder().withStatus(Consent.Status.ACTIVE).build()) .build() - val headers = RecordHeaders( - listOf( - RecordHeader( - "requestId", - UUID.randomUUID().toString().toByteArray() - ) - ) - ) + val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()))) kafkaInputListener.onMessage( ConsumerRecord( "testtopic", @@ -132,7 +126,7 @@ class KafkaInputListenerTest { ) ) - verify(requestProcessor, times(1)).processMtbFile(any(), anyValueClass()) + verify(requestProcessor, times(1)).processMtbFile(any(), anyValueClass()) } @Test @@ -142,12 +136,36 @@ class KafkaInputListenerTest { .withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build()) .build() + val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()))) + 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(anyValueClass(), anyValueClass(), eq(ConsentStatus.IGNORED) + } + + @Test + fun shouldNotProcessDnpmV2Request() { + val mtbFile = MtbFile.builder() + .withPatient(Patient.builder().withId("DUMMY_12345678").build()) + .withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build()) + .build() + val headers = RecordHeaders( listOf( - RecordHeader( - "requestId", - UUID.randomUUID().toString().toByteArray() - ) + RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()), + RecordHeader("contentType", CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray()) ) ) kafkaInputListener.onMessage( @@ -165,11 +183,7 @@ class KafkaInputListenerTest { Optional.empty() ) ) - verify(requestProcessor, times(1)).processDeletion( - anyValueClass(), - anyValueClass(), - eq(ConsentStatus.IGNORED) - ) + verify(requestProcessor, times(0)).processDeletion(anyValueClass(), anyValueClass(), eq(ConsentStatus.IGNORED) } -} \ 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 5c13c96..81fba4a 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/input/MtbFileRestControllerTest.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 @@ -24,14 +24,19 @@ import de.ukw.ccc.bwhc.dto.* import dev.dnpm.etl.processor.anyValueClass import dev.dnpm.etl.processor.consent.ConsentCheckedIgnored import dev.dnpm.etl.processor.consent.ConsentStatus +import dev.dnpm.etl.processor.CustomMediaType import dev.dnpm.etl.processor.services.RequestProcessor +import dev.pcvolkmer.mv64e.mtb.Mtb 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.Mockito.* import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any +import org.mockito.kotlin.anyValueClass +import org.springframework.core.io.ClassPathResource import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.delete @@ -41,27 +46,156 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders @ExtendWith(MockitoExtension::class) class MtbFileRestControllerTest { - private lateinit var mockMvc: MockMvc - - private lateinit var requestProcessor: RequestProcessor - private val objectMapper = ObjectMapper() - @BeforeEach - fun setup( - @Mock requestProcessor: RequestProcessor - ) { - this.requestProcessor = requestProcessor - val controller = MtbFileRestController( - requestProcessor, ConsentCheckedIgnored() - ) + @Nested + inner class BwhcRequests { - this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + private lateinit var mockMvc: MockMvc + + private lateinit var requestProcessor: RequestProcessor + + @BeforeEach + fun setup( + @Mock requestProcessor: RequestProcessor + ) { + this.requestProcessor = requestProcessor + val controller = MtbFileRestController(requestProcessor, ConsentCheckedIgnored()) + this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun shouldProcessPostRequest() { + mockMvc.post("/mtbfile") { + content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.ACTIVE)) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processMtbFile(any()) + } + + @Test + fun shouldProcessPostRequestWithRejectedConsent() { + mockMvc.post("/mtbfile") { + content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.REJECTED)) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) + } + + @Test + fun shouldProcessDeleteRequest() { + mockMvc.delete("/mtbfile/TEST_12345678").andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) + } } - @Test - fun shouldProcessMtbFilePostRequest() { - val mtbFile = MtbFile.builder() + @Nested + inner class BwhcRequestsWithAlias { + + private lateinit var mockMvc: MockMvc + + private lateinit var requestProcessor: RequestProcessor + + @BeforeEach + fun setup( + @Mock requestProcessor: RequestProcessor + ) { + this.requestProcessor = requestProcessor + val controller = MtbFileRestController(requestProcessor) + this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun shouldProcessPostRequest() { + mockMvc.post("/mtb") { + content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.ACTIVE)) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processMtbFile(any()) + } + + @Test + fun shouldProcessPostRequestWithRejectedConsent() { + mockMvc.post("/mtb") { + content = objectMapper.writeValueAsString(bwhcMtbFileContent(Consent.Status.REJECTED)) + contentType = MediaType.APPLICATION_JSON + }.andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) + } + + @Test + fun shouldProcessDeleteRequest() { + mockMvc.delete("/mtb/TEST_12345678").andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processDeletion(anyValueClass()) + } + } + + @Nested + inner class RequestsForDnpmDataModel21 { + + private lateinit var mockMvc: MockMvc + + private lateinit var requestProcessor: RequestProcessor + + @BeforeEach + fun setup( + @Mock requestProcessor: RequestProcessor + ) { + this.requestProcessor = requestProcessor + val controller = MtbFileRestController(requestProcessor) + this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun shouldRespondPostRequest() { + val mtbFileContent = ClassPathResource("mv64e-mtb-fake-patient.json").inputStream.readAllBytes().toString(Charsets.UTF_8) + + mockMvc.post("/mtb") { + content = mtbFileContent + contentType = CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON + }.andExpect { + status { + isAccepted() + } + } + + verify(requestProcessor, times(1)).processMtbFile(any()) + } + + } + + companion object { + fun bwhcMtbFileContent(consentStatus: Consent.Status) = MtbFile.builder() .withPatient( Patient.builder() .withId("TEST_12345678") @@ -72,7 +206,7 @@ class MtbFileRestControllerTest { .withConsent( Consent.builder() .withId("1") - .withStatus(Consent.Status.ACTIVE) + .withStatus(consentStatus) .withPatient("TEST_12345678") .build() ) @@ -84,72 +218,5 @@ class MtbFileRestControllerTest { .build() ) .build() - - mockMvc.post("/mtbfile") { - content = objectMapper.writeValueAsString(mtbFile) - contentType = MediaType.APPLICATION_JSON - }.andExpect { - status { - isAccepted() - } - } - - verify(requestProcessor, times(1)).processMtbFile(any()) } - - @Test - fun shouldProcessMtbFilePostRequestWithRejectedConsent() { - val mtbFile = MtbFile.builder() - .withPatient( - Patient.builder() - .withId("TEST_12345678") - .withBirthDate("2000-08-08") - .withGender(Patient.Gender.MALE) - .build() - ) - .withConsent( - Consent.builder() - .withId("1") - .withStatus(Consent.Status.REJECTED) - .withPatient("TEST_12345678") - .build() - ) - .withEpisode( - Episode.builder() - .withId("1") - .withPatient("TEST_12345678") - .withPeriod(PeriodStart("2023-08-08")) - .build() - ) - .build() - - mockMvc.post("/mtbfile") { - content = objectMapper.writeValueAsString(mtbFile) - contentType = MediaType.APPLICATION_JSON - }.andExpect { - status { - isAccepted() - } - } - - verify(requestProcessor, times(1)).processDeletion( - anyValueClass(), - org.mockito.kotlin.eq(ConsentStatus.CONSENT_REJECTED) - ) - } - - @Test - fun shouldProcessMtbFileDeleteRequest() { - mockMvc.delete("/mtbfile/TEST_12345678").andExpect { - status { - isAccepted() - } - } - - verify(requestProcessor, times(1)).processDeletion( - anyValueClass(), - org.mockito.kotlin.eq(ConsentStatus.IGNORED) - ) - } - -} \ 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 655e29e..e5fb925 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/KafkaMtbFileSenderTest.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 @@ -21,20 +21,25 @@ package dev.dnpm.etl.processor.output import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* +import de.ukw.ccc.bwhc.dto.Patient +import dev.dnpm.etl.processor.CustomMediaType 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 dev.pcvolkmer.mv64e.mtb.* +import org.apache.kafka.clients.producer.ProducerRecord 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.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.* +import org.springframework.http.MediaType import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.support.SendResult import org.springframework.retry.policy.SimpleRetryPolicy @@ -45,142 +50,231 @@ import java.util.concurrent.ExecutionException @ExtendWith(MockitoExtension::class) class KafkaMtbFileSenderTest { - private lateinit var kafkaTemplate: KafkaTemplate + @Nested + inner class BwhcV1Record { - private lateinit var kafkaMtbFileSender: KafkaMtbFileSender + private lateinit var kafkaTemplate: KafkaTemplate - private lateinit var objectMapper: ObjectMapper + private lateinit var kafkaMtbFileSender: KafkaMtbFileSender - @BeforeEach - fun setup( - @Mock kafkaTemplate: KafkaTemplate - ) { - val kafkaProperties = KafkaProperties("testtopic") - val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() + private lateinit var objectMapper: ObjectMapper - this.objectMapper = ObjectMapper() - this.kafkaTemplate = kafkaTemplate + @BeforeEach + fun setup( + @Mock kafkaTemplate: KafkaTemplate + ) { + val kafkaProperties = KafkaProperties("testtopic") + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() - this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper) - } + this.objectMapper = ObjectMapper() + this.kafkaTemplate = kafkaTemplate - @ParameterizedTest - @MethodSource("requestWithResponseSource") - fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) { - doAnswer { - if (null != testData.exception) { - throw testData.exception - } - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - val response = kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE))) - assertThat(response.status).isEqualTo(testData.requestStatus) - } - - @ParameterizedTest - @MethodSource("requestWithResponseSource") - fun shouldSendDeleteRequestAndReturnExpectedState(testData: TestData) { - doAnswer { - if (null != testData.exception) { - throw testData.exception - } - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - val response = kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) - assertThat(response.status).isEqualTo(testData.requestStatus) - } - - @Test - fun shouldSendMtbFileRequestWithCorrectKeyAndBody() { - doAnswer { - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE))) - - val captor = argumentCaptor() - 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(TEST_REQUEST_ID, Consent.Status.ACTIVE))) - } - - @Test - fun shouldSendDeleteRequestWithCorrectKeyAndBody() { - doAnswer { - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) - - val captor = argumentCaptor() - 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(TEST_REQUEST_ID, Consent.Status.REJECTED))) - } - - @ParameterizedTest - @MethodSource("requestWithResponseSource") - fun shouldRetryOnMtbFileKafkaSendError(testData: TestData) { - val kafkaProperties = KafkaProperties("testtopic") - val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() - this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper) - - doAnswer { - if (null != testData.exception) { - throw testData.exception - } - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile(Consent.Status.ACTIVE))) - - val expectedCount = when (testData.exception) { - // OK - No Retry - null -> times(1) - // Request failed - Retry max 3 times - else -> times(3) + this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper) } - verify(kafkaTemplate, expectedCount).send(anyString(), anyString(), anyString()) - } + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) { + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) - @ParameterizedTest - @MethodSource("requestWithResponseSource") - fun shouldRetryOnDeleteKafkaSendError(testData: TestData) { - val kafkaProperties = KafkaProperties("testtopic") - val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() - this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper) - - doAnswer { - if (null != testData.exception) { - throw testData.exception - } - completedFuture(SendResult(null, null)) - }.whenever(kafkaTemplate).send(anyString(), anyString(), anyString()) - - kafkaMtbFileSender.send(MtbFileSender.DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) - - val expectedCount = when (testData.exception) { - // OK - No Retry - null -> times(1) - // Request failed - Retry max 3 times - else -> times(3) + val response = kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE))) + assertThat(response.status).isEqualTo(testData.requestStatus) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldSendDeleteRequestAndReturnExpectedState(testData: TestData) { + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + val response = kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + assertThat(response.status).isEqualTo(testData.requestStatus) + } + + @Test + fun shouldSendMtbFileRequestWithCorrectKeyAndHeaderAndBody() { + doAnswer { + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE))) + + val captor = argumentCaptor>() + verify(kafkaTemplate, times(1)).send(captor.capture()) + assertThat(captor.firstValue.key()).isNotNull + assertThat(captor.firstValue.key()).isEqualTo("{\"pid\": \"PID\"}") + assertThat(captor.firstValue.headers().headers("contentType")).isNotNull + assertThat(captor.firstValue.headers().headers("contentType")?.firstOrNull()?.value()).isEqualTo(MediaType.APPLICATION_JSON_VALUE.toByteArray()) + assertThat(captor.firstValue.value()).isNotNull + assertThat(captor.firstValue.value()).isEqualTo(objectMapper.writeValueAsString(bwhcV1kafkaRecordData(TEST_REQUEST_ID, Consent.Status.ACTIVE))) + } + + @Test + fun shouldSendDeleteRequestWithCorrectKeyAndBody() { + doAnswer { + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + + val captor = argumentCaptor>() + verify(kafkaTemplate, times(1)).send(captor.capture()) + assertThat(captor.firstValue.key()).isNotNull + assertThat(captor.firstValue.key()).isEqualTo("{\"pid\": \"PID\"}") + assertThat(captor.firstValue.value()).isNotNull + assertThat(captor.firstValue.value()).isEqualTo(objectMapper.writeValueAsString(bwhcV1kafkaRecordData(TEST_REQUEST_ID, Consent.Status.REJECTED))) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldRetryOnMtbFileKafkaSendError(testData: TestData) { + val kafkaProperties = KafkaProperties("testtopic") + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() + this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper) + + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE))) + + val expectedCount = when (testData.exception) { + // OK - No Retry + null -> times(1) + // Request failed - Retry max 3 times + else -> times(3) + } + + verify(kafkaTemplate, expectedCount).send(any>()) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldRetryOnDeleteKafkaSendError(testData: TestData) { + val kafkaProperties = KafkaProperties("testtopic") + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() + this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper) + + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + + val expectedCount = when (testData.exception) { + // OK - No Retry + null -> times(1) + // Request failed - Retry max 3 times + else -> times(3) + } + + verify(kafkaTemplate, expectedCount).send(any>()) + } + + } + + @Nested + inner class DnpmV2Record { + + private lateinit var kafkaTemplate: KafkaTemplate + + private lateinit var kafkaMtbFileSender: KafkaMtbFileSender + + private lateinit var objectMapper: ObjectMapper + + @BeforeEach + fun setup( + @Mock kafkaTemplate: KafkaTemplate + ) { + val kafkaProperties = KafkaProperties("testtopic") + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() + + this.objectMapper = ObjectMapper() + this.kafkaTemplate = kafkaTemplate + + this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) { + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + val response = kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile())) + assertThat(response.status).isEqualTo(testData.requestStatus) + } + + @Test + fun shouldSendMtbFileRequestWithCorrectKeyAndHeaderAndBody() { + doAnswer { + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile())) + + val captor = argumentCaptor>() + verify(kafkaTemplate, times(1)).send(captor.capture()) + assertThat(captor.firstValue.key()).isNotNull + assertThat(captor.firstValue.key()).isEqualTo("{\"pid\": \"PID\"}") + assertThat(captor.firstValue.headers().headers("contentType")).isNotNull + assertThat(captor.firstValue.headers().headers("contentType")?.firstOrNull()?.value()).isEqualTo(CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray()) + assertThat(captor.firstValue.value()).isNotNull + assertThat(captor.firstValue.value()).isEqualTo(objectMapper.writeValueAsString(dnmpV2kafkaRecordData(TEST_REQUEST_ID))) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.KafkaMtbFileSenderTest#requestWithResponseSource") + fun shouldRetryOnMtbFileKafkaSendError(testData: TestData) { + val kafkaProperties = KafkaProperties("testtopic") + val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() + this.kafkaMtbFileSender = KafkaMtbFileSender(this.kafkaTemplate, kafkaProperties, retryTemplate, this.objectMapper) + + doAnswer { + if (null != testData.exception) { + throw testData.exception + } + completedFuture(SendResult(null, null)) + }.whenever(kafkaTemplate).send(any>()) + + kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile())) + + val expectedCount = when (testData.exception) { + // OK - No Retry + null -> times(1) + // Request failed - Retry max 3 times + else -> times(3) + } + + verify(kafkaTemplate, expectedCount).send(any>()) } - verify(kafkaTemplate, expectedCount).send(anyString(), anyString(), anyString()) } companion object { val TEST_REQUEST_ID = RequestId("TestId") val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") - fun mtbFile(consentStatus: Consent.Status): MtbFile { + fun bwhcV1MtbFile(consentStatus: Consent.Status): MtbFile { return if (consentStatus == Consent.Status.ACTIVE) { MtbFile.builder() .withPatient( @@ -215,8 +309,31 @@ class KafkaMtbFileSenderTest { }.build() } - fun kafkaRecordData(requestId: RequestId, consentStatus: Consent.Status): KafkaMtbFileSender.Data { - return KafkaMtbFileSender.Data(requestId, mtbFile(consentStatus)) + fun dnpmV2MtbFile(): Mtb = Mtb.builder() + .withPatient( + dev.pcvolkmer.mv64e.mtb.Patient.builder() + .withId("PID") + .withBirthDate("2000-08-08") + .withGender(CodingGender.builder().withCode(CodingGender.Code.MALE).build()) + .build() + ) + .withEpisodesOfCare( + listOf( + MTBEpisodeOfCare.builder() + .withId("1") + .withPatient(Reference("PID")) + .withPeriod(PeriodDate.builder().withStart("2023-08-08").build()) + .build() + ) + ) + .build() + + fun bwhcV1kafkaRecordData(requestId: RequestId, consentStatus: Consent.Status): MtbRequest { + return BwhcV1MtbFileRequest(requestId, bwhcV1MtbFile(consentStatus)) + } + + fun dnmpV2kafkaRecordData(requestId: RequestId): MtbRequest { + return DnpmV2MtbFileRequest(requestId, dnpmV2MtbFile()) } data class TestData(val requestStatus: RequestStatus, val exception: Throwable? = null) @@ -231,4 +348,4 @@ class KafkaMtbFileSenderTest { } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt similarity index 57% rename from src/test/kotlin/dev/dnpm/etl/processor/output/RestMtbFileSenderTest.kt rename to src/test/kotlin/dev/dnpm/etl/processor/output/RestBwhcMtbFileSenderTest.kt index 9cc1437..ead2496 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 @@ -19,54 +19,61 @@ package dev.dnpm.etl.processor.output +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule 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.ReportService 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.HttpHeaders import org.springframework.http.HttpMethod import org.springframework.http.HttpStatus +import org.springframework.http.MediaType 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.match.MockRestRequestMatchers.* import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus import org.springframework.web.client.RestTemplate -class RestMtbFileSenderTest { +class RestBwhcMtbFileSenderTest { private lateinit var mockRestServiceServer: MockRestServiceServer private lateinit var restMtbFileSender: RestMtbFileSender + private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) + @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, reportService) } @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(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -74,14 +81,15 @@ 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")) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } - val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest(TEST_REQUEST_ID, mtbFile)) + val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -90,11 +98,12 @@ 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, reportService) val expectedCount = when (requestWithResponse.httpStatus) { // OK - No Retry @@ -103,14 +112,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(TEST_REQUEST_ID, mtbFile)) + val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -119,11 +128,12 @@ 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, reportService) val expectedCount = when (requestWithResponse.httpStatus) { // OK - No Retry @@ -132,14 +142,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(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.body).isEqualTo(requestWithResponse.response.body) } @@ -154,24 +164,6 @@ class RestMtbFileSenderTest { 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() @@ -205,31 +197,44 @@ class RestMtbFileSenderTest { @JvmStatic fun mtbFileRequestWithResponseSource(): Set { return setOf( - RequestWithResponse(HttpStatus.OK, "{}", MtbFileSender.Response(RequestStatus.SUCCESS, "{}")), + RequestWithResponse( + HttpStatus.OK, + responseBodyWithMaxSeverity(ReportService.Severity.INFO), + MtbFileSender.Response( + RequestStatus.SUCCESS, + responseBodyWithMaxSeverity(ReportService.Severity.INFO) + ) + ), RequestWithResponse( HttpStatus.CREATED, - warningBody, - MtbFileSender.Response(RequestStatus.WARNING, warningBody) + responseBodyWithMaxSeverity(ReportService.Severity.WARNING), + MtbFileSender.Response( + RequestStatus.WARNING, + responseBodyWithMaxSeverity(ReportService.Severity.WARNING) + ) ), RequestWithResponse( HttpStatus.BAD_REQUEST, - "??", - MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + responseBodyWithMaxSeverity(ReportService.Severity.ERROR), + MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR)) ), RequestWithResponse( HttpStatus.UNPROCESSABLE_ENTITY, - errorBody, - MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) + responseBodyWithMaxSeverity(ReportService.Severity.FATAL), + MtbFileSender.Response( + RequestStatus.ERROR, + responseBodyWithMaxSeverity(ReportService.Severity.FATAL) + ) ), // 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) ) ) @@ -256,7 +261,53 @@ class RestMtbFileSenderTest { ) ) } + + fun responseBodyWithMaxSeverity(severity: ReportService.Severity): String { + return when (severity) { + ReportService.Severity.INFO -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" } + ] + } + """ + + ReportService.Severity.WARNING -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" } + ] + } + """ + + ReportService.Severity.ERROR -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" } + ] + } + """ + + ReportService.Severity.FATAL -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" }, + { "severity": "fatal", "message": "Fatal Message" } + ] + } + """ + } + } } -} \ No newline at end of file +} 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..b35fb47 --- /dev/null +++ b/src/test/kotlin/dev/dnpm/etl/processor/output/RestDipMtbFileSenderTest.kt @@ -0,0 +1,405 @@ +/* + * 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 . + */ + +package dev.dnpm.etl.processor.output + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import de.ukw.ccc.bwhc.dto.* +import de.ukw.ccc.bwhc.dto.Patient +import dev.dnpm.etl.processor.CustomMediaType +import dev.dnpm.etl.processor.PatientPseudonym +import dev.dnpm.etl.processor.RequestId +import dev.dnpm.etl.processor.config.AppConfigProperties +import dev.dnpm.etl.processor.config.AppConfiguration +import dev.dnpm.etl.processor.config.RestTargetProperties +import dev.dnpm.etl.processor.monitoring.ReportService +import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.pcvolkmer.mv64e.mtb.* +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.retry.backoff.NoBackOffPolicy +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.* +import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus +import org.springframework.web.client.RestTemplate + +class RestDipMtbFileSenderTest { + + @Nested + inner class BwhcV1ContentRequest { + + private lateinit var mockRestServiceServer: MockRestServiceServer + + private lateinit var restMtbFileSender: RestMtbFileSender + + private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) + + @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, reportService) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource") + fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) { + this.mockRestServiceServer + .expect(method(HttpMethod.POST)) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record")) + .andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } + + val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1mtbFile)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource") + fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) { + val restTemplate = RestTemplate() + val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) + val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000")) + retryTemplate.setBackOffPolicy(NoBackOffPolicy()) + + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + this.restMtbFileSender = + RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) + + val expectedCount = when (requestWithResponse.httpStatus) { + // OK - No Retry + HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> 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(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1mtbFile)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + } + + @Nested + inner class DnpmV2ContentRequest { + + private lateinit var mockRestServiceServer: MockRestServiceServer + + private lateinit var restMtbFileSender: RestMtbFileSender + + private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) + + @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, reportService) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#mtbFileRequestWithResponseSource") + fun shouldReturnExpectedResponseForDnpmV2MtbFilePost(requestWithResponse: RequestWithResponse) { + this.mockRestServiceServer + .expect(method(HttpMethod.POST)) + .andExpect(requestTo("http://localhost:9000/api/mtb/etl/patient-record")) + .andExpect(header(HttpHeaders.CONTENT_TYPE, CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE)) + .andRespond { + withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) + } + + val response = restMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + } + + @Nested + inner class DeleteRequest { + + private lateinit var mockRestServiceServer: MockRestServiceServer + + private lateinit var restMtbFileSender: RestMtbFileSender + + private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build())) + + @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, reportService) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#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(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) + assertThat(response.status).isEqualTo(requestWithResponse.response.status) + assertThat(response.body).isEqualTo(requestWithResponse.response.body) + } + + @ParameterizedTest + @MethodSource("dev.dnpm.etl.processor.output.RestDipMtbFileSenderTest#deleteRequestWithResponseSource") + fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) { + val restTemplate = RestTemplate() + val restTargetProperties = RestTargetProperties("http://localhost:9000/api", null, null, false) + val retryTemplate = AppConfiguration().retryTemplate(AppConfigProperties("http://localhost:9000")) + retryTemplate.setBackOffPolicy(NoBackOffPolicy()) + + this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) + this.restMtbFileSender = + RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService) + + val expectedCount = when (requestWithResponse.httpStatus) { + // OK - No Retry + HttpStatus.OK, HttpStatus.CREATED, HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.BAD_REQUEST -> 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(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") + + val bwhcV1mtbFile: 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() + + val dnpmV2MtbFile: Mtb = Mtb.builder() + .withPatient( + dev.pcvolkmer.mv64e.mtb.Patient.builder() + .withId("PID") + .withBirthDate("2000-08-08") + .withGender(CodingGender.builder().withCode(CodingGender.Code.MALE).build()) + .build() + ) + .withEpisodesOfCare( + listOf( + MTBEpisodeOfCare.builder() + .withId("1") + .withPatient(Reference("PID")) + .withPeriod(PeriodDate.builder().withStart("2023-08-08").build()) + .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 { + return setOf( + RequestWithResponse( + HttpStatus.OK, + responseBodyWithMaxSeverity(ReportService.Severity.INFO), + MtbFileSender.Response( + RequestStatus.SUCCESS, + responseBodyWithMaxSeverity(ReportService.Severity.INFO) + ) + ), + RequestWithResponse( + HttpStatus.CREATED, + responseBodyWithMaxSeverity(ReportService.Severity.WARNING), + MtbFileSender.Response(RequestStatus.WARNING, responseBodyWithMaxSeverity(ReportService.Severity.WARNING)) + ), + RequestWithResponse( + HttpStatus.BAD_REQUEST, + responseBodyWithMaxSeverity(ReportService.Severity.ERROR), + MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR)) + ), + RequestWithResponse( + HttpStatus.UNPROCESSABLE_ENTITY, + responseBodyWithMaxSeverity(ReportService.Severity.ERROR), + MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR)) + ), + // 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 { + 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) + ) + ) + } + + fun responseBodyWithMaxSeverity(severity: ReportService.Severity): String { + return when (severity) { + ReportService.Severity.INFO -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" } + ] + } + """ + + ReportService.Severity.WARNING -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" } + ] + } + """ + + ReportService.Severity.ERROR -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" } + ] + } + """ + + ReportService.Severity.FATAL -> """ + { + "patient": "PID", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" }, + { "severity": "fatal", "message": "Fatal Message" } + ] + } + """ + } + } + } + + +} 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 fbc26ae..d0ccb2b 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/ExtensionsTest.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2023 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 @@ -21,178 +21,261 @@ package dev.dnpm.etl.processor.pseudonym import com.fasterxml.jackson.databind.ObjectMapper import de.ukw.ccc.bwhc.dto.* -import dev.dnpm.etl.processor.anyValueClass +import dev.pcvolkmer.mv64e.mtb.MTBEpisodeOfCare +import dev.pcvolkmer.mv64e.mtb.Mtb +import dev.pcvolkmer.mv64e.mtb.PeriodDate +import dev.pcvolkmer.mv64e.mtb.Reference import org.assertj.core.api.Assertions.assertThat +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.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 -const val FAKE_MTB_FILE_PATH = "fake_MTBFile.json" -const val CLEAN_PATIENT_ID = "5dad2f0b-49c6-47d8-a952-7b9e9e0f7549" - @ExtendWith(MockitoExtension::class) class ExtensionsTest { - private fun fakeMtbFile(): MtbFile { - val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream - return ObjectMapper().readValue(mtbFile, MtbFile::class.java) - } + @Nested + inner class UsingBwhcDatamodel { - private fun MtbFile.serialized(): String { - return ObjectMapper().writeValueAsString(this) - } + val FAKE_MTB_FILE_PATH = "fake_MTBFile.json" + val CLEAN_PATIENT_ID = "5dad2f0b-49c6-47d8-a952-7b9e9e0f7549" - @Test - fun shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) { - doAnswer { - it.arguments[0] - "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) - - val mtbFile = fakeMtbFile() - - mtbFile.pseudonymizeWith(pseudonymizeService) - - assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID") - assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID) - } - - @Test - fun shouldNotContainAnyUuidAfterRehashingOfIds(@Mock pseudonymizeService: PseudonymizeService) { - doAnswer { - it.arguments[0] - "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) - - doAnswer { - "TESTDOMAIN" - }.whenever(pseudonymizeService).prefix() - - val mtbFile = fakeMtbFile() - - mtbFile.pseudonymizeWith(pseudonymizeService) - mtbFile.anonymizeContentWith(pseudonymizeService) - - val pattern = "\"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\"".toRegex().toPattern() - val matcher = pattern.matcher(mtbFile.serialized()) - - assertThrows { - matcher.find() - matcher.group() - }.also { - assertThat(it.message).isEqualTo("No match found") + private fun fakeMtbFile(): MtbFile { + val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream + return ObjectMapper().readValue(mtbFile, MtbFile::class.java) } + private fun MtbFile.serialized(): String { + return ObjectMapper().writeValueAsString(this) + } + + @Test + fun shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + + val mtbFile = fakeMtbFile() + + mtbFile.pseudonymizeWith(pseudonymizeService) + + assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID") + assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID) + } + + @Test + fun shouldNotContainAnyUuidAfterRehashingOfIds(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + + doAnswer { + "TESTDOMAIN" + }.whenever(pseudonymizeService).prefix() + + val mtbFile = fakeMtbFile() + + mtbFile.pseudonymizeWith(pseudonymizeService) + mtbFile.anonymizeContentWith(pseudonymizeService) + + val pattern = "\"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\"".toRegex().toPattern() + val matcher = pattern.matcher(mtbFile.serialized()) + + assertThrows { + matcher.find() + matcher.group() + }.also { + assertThat(it.message).isEqualTo("No match found") + } + + } + + @Test + fun shouldRehashIdsWithPrefix(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + + doAnswer { + "TESTDOMAIN" + }.whenever(pseudonymizeService).prefix() + + val mtbFile = MtbFile.builder() + .withPatient( + Patient.builder() + .withId("1") + .withBirthDate("2000-08-08") + .withGender(Patient.Gender.MALE) + .build() + ) + .withConsent( + Consent.builder() + .withId("1") + .withStatus(Consent.Status.ACTIVE) + .withPatient("123") + .build() + ) + .withEpisode( + Episode.builder() + .withId("1") + .withPatient("1") + .withPeriod(PeriodStart("2023-08-08")) + .build() + ) + .build() + + mtbFile.pseudonymizeWith(pseudonymizeService) + mtbFile.anonymizeContentWith(pseudonymizeService) + + + assertThat(mtbFile.episode.id) + // TESTDOMAIN + .isEqualTo("TESTDOMAIN44e20a53bbbf9f3ae39626d05df7014dcd77d6098") + } + + @Test + fun shouldNotThrowExceptionOnNullValues(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + + doAnswer { + "TESTDOMAIN" + }.whenever(pseudonymizeService).prefix() + + val mtbFile = MtbFile.builder() + .withPatient( + Patient.builder() + .withId("1") + .withBirthDate("2000-08-08") + .withGender(Patient.Gender.MALE) + .build() + ) + .withConsent( + Consent.builder() + .withId("1") + .withStatus(Consent.Status.ACTIVE) + .withPatient("123") + .build() + ) + .withEpisode( + Episode.builder() + .withId("1") + .withPatient("1") + .withPeriod(PeriodStart("2023-08-08")) + .build() + ) + .withClaims(null) + .withDiagnoses(null) + .withCarePlans(null) + .withClaimResponses(null) + .withEcogStatus(null) + .withFamilyMemberDiagnoses(null) + .withGeneticCounsellingRequests(null) + .withHistologyReevaluationRequests(null) + .withHistologyReports(null) + .withLastGuidelineTherapies(null) + .withMolecularPathologyFindings(null) + .withMolecularTherapies(null) + .withNgsReports(null) + .withPreviousGuidelineTherapies(null) + .withRebiopsyRequests(null) + .withRecommendations(null) + .withResponses(null) + .withStudyInclusionRequests(null) + .withSpecimens(null) + .build() + + mtbFile.pseudonymizeWith(pseudonymizeService) + mtbFile.anonymizeContentWith(pseudonymizeService) + + assertThat(mtbFile.episode.id).isNotNull() + } } - @Test - fun shouldRehashIdsWithPrefix(@Mock pseudonymizeService: PseudonymizeService) { - doAnswer { - it.arguments[0] - "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + @Nested + inner class UsingDnpmV2Datamodel { - doAnswer { - "TESTDOMAIN" - }.whenever(pseudonymizeService).prefix() + val FAKE_MTB_FILE_PATH = "mv64e-mtb-fake-patient.json" + val CLEAN_PATIENT_ID = "63f8fd7b-8127-4f3c-8843-aa9199e21c29" - val mtbFile = MtbFile.builder() - .withPatient( - Patient.builder() - .withId("1") - .withBirthDate("2000-08-08") - .withGender(Patient.Gender.MALE) - .build() - ) - .withConsent( - Consent.builder() - .withId("1") - .withStatus(Consent.Status.ACTIVE) - .withPatient("123") - .build() - ) - .withEpisode( - Episode.builder() - .withId("1") - .withPatient("1") - .withPeriod(PeriodStart("2023-08-08")) - .build() - ) - .build() + private fun fakeMtbFile(): Mtb { + val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream + return ObjectMapper().readValue(mtbFile, Mtb::class.java) + } - mtbFile.pseudonymizeWith(pseudonymizeService) - mtbFile.anonymizeContentWith(pseudonymizeService) + private fun Mtb.serialized(): String { + return ObjectMapper().writeValueAsString(this) + } + @Test + fun shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) - assertThat(mtbFile.episode.id) - // TESTDOMAIN - .isEqualTo("TESTDOMAIN44e20a53bbbf9f3ae39626d05df7014dcd77d6098") + val mtbFile = fakeMtbFile() + + mtbFile.pseudonymizeWith(pseudonymizeService) + + assertThat(mtbFile.patient.id).isEqualTo("PSEUDO-ID") + assertThat(mtbFile.serialized()).doesNotContain(CLEAN_PATIENT_ID) + } + + @Test + fun shouldNotThrowExceptionOnNullValues(@Mock pseudonymizeService: PseudonymizeService) { + doAnswer { + it.arguments[0] + "PSEUDO-ID" + }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) + + doAnswer { + "TESTDOMAIN" + }.whenever(pseudonymizeService).prefix() + + val mtbFile = Mtb.builder() + .withPatient( + dev.pcvolkmer.mv64e.mtb.Patient.builder() + .withId("1") + .withBirthDate("2000-08-08") + .withGender(null) + .build() + ) + .withEpisodesOfCare( + listOf( + MTBEpisodeOfCare.builder() + .withId("1") + .withPatient(Reference("1")) + .withPeriod(PeriodDate.builder().withStart("2023-08-08").build()) + .build() + ) + ) + .withClaims(null) + .withDiagnoses(null) + .withCarePlans(null) + .withClaimResponses(null) + .withHistologyReports(null) + .withNgsReports(null) + .withResponses(null) + .withSpecimens(null) + .build() + + mtbFile.pseudonymizeWith(pseudonymizeService) + mtbFile.anonymizeContentWith(pseudonymizeService) + + assertThat(mtbFile.episodesOfCare).hasSize(1) + assertThat(mtbFile.episodesOfCare.map { it.id }).isNotNull + } } - - @Test - fun shouldNotThrowExceptionOnNullValues(@Mock pseudonymizeService: PseudonymizeService) { - doAnswer { - it.arguments[0] - "PSEUDO-ID" - }.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) - - doAnswer { - "TESTDOMAIN" - }.whenever(pseudonymizeService).prefix() - - val mtbFile = MtbFile.builder() - .withPatient( - Patient.builder() - .withId("1") - .withBirthDate("2000-08-08") - .withGender(Patient.Gender.MALE) - .build() - ) - .withConsent( - Consent.builder() - .withId("1") - .withStatus(Consent.Status.ACTIVE) - .withPatient("123") - .build() - ) - .withEpisode( - Episode.builder() - .withId("1") - .withPatient("1") - .withPeriod(PeriodStart("2023-08-08")) - .build() - ) - .withClaims(null) - .withDiagnoses(null) - .withCarePlans(null) - .withClaimResponses(null) - .withEcogStatus(null) - .withFamilyMemberDiagnoses(null) - .withGeneticCounsellingRequests(null) - .withHistologyReevaluationRequests(null) - .withHistologyReports(null) - .withLastGuidelineTherapies(null) - .withMolecularPathologyFindings(null) - .withMolecularTherapies(null) - .withNgsReports(null) - .withPreviousGuidelineTherapies(null) - .withRebiopsyRequests(null) - .withRecommendations(null) - .withResponses(null) - .withStudyInclusionRequests(null) - .withSpecimens(null) - .build() - - mtbFile.pseudonymizeWith(pseudonymizeService) - mtbFile.anonymizeContentWith(pseudonymizeService) - - - assertThat(mtbFile.episode.id).isNotNull() - } - -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt index 349202a..fc95808 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/ReportServiceTest.kt @@ -1,7 +1,7 @@ /* * This file is part of ETL-Processor * - * Copyright (c) 2023 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 @@ -22,9 +22,14 @@ package dev.dnpm.etl.processor.services import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.KotlinModule import dev.dnpm.etl.processor.monitoring.ReportService +import dev.dnpm.etl.processor.monitoring.RequestStatus +import dev.dnpm.etl.processor.monitoring.asRequestStatus import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource class ReportServiceTest { @@ -60,6 +65,15 @@ class ReportServiceTest { assertThat(actual[2].message).isEqualTo("Warning Message") assertThat(actual[3].severity).isEqualTo(ReportService.Severity.INFO) assertThat(actual[3].message).isEqualTo("Info Message") + + assertThat(actual.asRequestStatus()).isEqualTo(RequestStatus.ERROR) + } + + @ParameterizedTest + @MethodSource("testData") + fun shouldParseDataQualityReport(json: String, requestStatus: RequestStatus) { + val actual = this.reportService.deserialize(json) + assertThat(actual.asRequestStatus()).isEqualTo(requestStatus) } @Test @@ -73,4 +87,75 @@ class ReportServiceTest { assertThat(actual[0].message).isEqualTo("Not parsable data quality report '$invalidResponse'") } + companion object { + + @JvmStatic + fun testData(): Set { + return setOf( + Arguments.of( + """ + { + "patient": "4711", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" }, + { "severity": "fatal", "message": "Fatal Message" } + ] + } + """.trimIndent(), + RequestStatus.ERROR + ), + Arguments.of( + """ + { + "patient": "4711", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" }, + { "severity": "error", "message": "Error Message" } + ] + } + """.trimIndent(), + RequestStatus.ERROR + ), + Arguments.of( + """ + { + "patient": "4711", + "issues": [ + { "severity": "error", "message": "Error Message" } + { "severity": "info", "message": "Info Message" } + ] + } + """.trimIndent(), + RequestStatus.ERROR + ), + Arguments.of( + """ + { + "patient": "4711", + "issues": [ + { "severity": "info", "message": "Info Message" }, + { "severity": "warning", "message": "Warning Message" } + ] + } + """.trimIndent(), + RequestStatus.WARNING + ), + Arguments.of( + """ + { + "patient": "4711", + "issues": [ + { "severity": "info", "message": "Info Message" } + ] + } + """.trimIndent(), + RequestStatus.SUCCESS + ) + ) + } + } + } \ 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 af1d80e..a6168af 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestProcessorTest.kt @@ -21,15 +21,20 @@ 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.Fingerprint +import dev.dnpm.etl.processor.PatientId +import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.config.AppConfigProperties import dev.dnpm.etl.processor.consent.ConsentStatus 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.output.BwhcV1MtbFileRequest +import dev.dnpm.etl.processor.output.DeleteRequest import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.RestMtbFileSender import dev.dnpm.etl.processor.pseudonym.PseudonymizeService +import dev.dnpm.etl.processor.randomRequestId import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -38,6 +43,7 @@ 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 @@ -109,7 +115,7 @@ class RequestProcessorTest { doAnswer { it.arguments[0] - }.whenever(transformationService).transform(any()) + }.whenever(transformationService).transform(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -168,7 +174,7 @@ class RequestProcessorTest { doAnswer { it.arguments[0] - }.whenever(transformationService).transform(any()) + }.whenever(transformationService).transform(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -223,7 +229,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) doAnswer { it.arguments[0] as String @@ -231,7 +237,7 @@ class RequestProcessorTest { doAnswer { it.arguments[0] - }.whenever(transformationService).transform(any()) + }.whenever(transformationService).transform(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -286,7 +292,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.ERROR) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) doAnswer { it.arguments[0] as String @@ -294,7 +300,7 @@ class RequestProcessorTest { doAnswer { it.arguments[0] - }.whenever(transformationService).transform(any()) + }.whenever(transformationService).transform(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -336,7 +342,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.UNKNOWN) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) this.requestProcessor.processDeletion(TEST_PATIENT_ID, isConsented = ConsentStatus.IGNORED) @@ -354,7 +360,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) this.requestProcessor.processDeletion(TEST_PATIENT_ID, isConsented = ConsentStatus.IGNORED) @@ -372,7 +378,7 @@ class RequestProcessorTest { doAnswer { MtbFileSender.Response(status = RequestStatus.ERROR) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) this.requestProcessor.processDeletion(TEST_PATIENT_ID, isConsented = ConsentStatus.IGNORED) @@ -404,11 +410,11 @@ class RequestProcessorTest { doAnswer { it.arguments[0] - }.whenever(transformationService).transform(any()) + }.whenever(transformationService).transform(any()) doAnswer { MtbFileSender.Response(status = RequestStatus.SUCCESS) - }.whenever(sender).send(any()) + }.whenever(sender).send(any()) val mtbFile = MtbFile.builder() .withPatient( @@ -446,4 +452,4 @@ class RequestProcessorTest { 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 2e289c5..c0e4400 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/services/RequestServiceTest.kt @@ -31,6 +31,7 @@ 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 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 diff --git a/src/test/resources/mv64e-mtb-fake-patient.json b/src/test/resources/mv64e-mtb-fake-patient.json new file mode 100644 index 0000000..c82d951 --- /dev/null +++ b/src/test/resources/mv64e-mtb-fake-patient.json @@ -0,0 +1,2243 @@ +{ + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "gender" : { + "code" : "female", + "display" : "Weiblich", + "system" : "Gender" + }, + "birthDate" : "1956-02-25", + "dateOfDeath" : "2007-02-25", + "healthInsurance" : { + "type" : { + "code" : "GKV", + "display" : "gesetzliche Krankenversicherung", + "system" : "http://fhir.de/CodeSystem/versicherungsart-de-basis" + }, + "reference" : { + "id" : "1234567890", + "system" : "https://www.dguv.de/arge-ik", + "display" : "AOK", + "type" : "HealthInsurance" + } + }, + "address" : { + "municipalityCode" : "12345" + }, + "age" : { + "value" : 51, + "unit" : "Years" + }, + "vitalStatus" : { + "code" : "deceased", + "display" : "Verstorben", + "system" : "dnpm-dip/patient/vital-status" + } + }, + "episodesOfCare" : [ { + "id" : "a95f44a6-5dbb-4acd-9d52-05db10f8410b", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "period" : { + "start" : "2024-10-03" + }, + "diagnoses" : [ { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "type" : "MTBDiagnosis" + } ] + } ], + "diagnoses" : [ { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "recordedOn" : "2004-01-25", + "type" : { + "history" : [ { + "value" : { + "code" : "main", + "display" : "Hauptdiagnose", + "system" : "dnpm-dip/mtb/diagnosis/type" + }, + "date" : "2004-01-25" + } ] + }, + "code" : { + "code" : "C69.0", + "display" : "Bösartige Neubildung: Konjunktiva", + "system" : "http://fhir.de/CodeSystem/bfarm/icd-10-gm", + "version" : "2025" + }, + "topography" : { + "code" : "C69.0", + "display" : "Konjunktiva", + "system" : "urn:oid:2.16.840.1.113883.6.43.1", + "version" : "Zweite Revision" + }, + "grading" : { + "history" : [ { + "date" : "2004-01-25", + "codes" : [ { + "code" : "U", + "display" : "U = unbekannt", + "system" : "https://www.basisdatensatz.de/feld/161/grading" + }, { + "code" : "4", + "display" : "Glioblastoma", + "system" : "dnpm-dip/mtb/who-grading-cns-tumors", + "version" : "2021" + } ] + } ] + }, + "staging" : { + "history" : [ { + "date" : "2004-01-25", + "method" : { + "code" : "clinical", + "display" : "Klinisch", + "system" : "dnpm-dip/mtb/tumor-staging/method" + }, + "tnmClassification" : { + "tumor" : { + "code" : "T1", + "system" : "UICC" + }, + "nodes" : { + "code" : "N2", + "system" : "UICC" + }, + "metastasis" : { + "code" : "Mx", + "system" : "UICC" + } + }, + "otherClassifications" : [ { + "code" : "metastasized", + "display" : "Metastasiert", + "system" : "dnpm-dip/mtb/diagnosis/kds-tumor-spread" + } ] + } ] + }, + "guidelineTreatmentStatus" : { + "code" : "non-exhausted", + "display" : "Leitlinien nicht ausgeschöpft", + "system" : "dnpm-dip/mtb/diagnosis/guideline-treatment-status" + }, + "notes" : [ "Notes on the tumor diagnosis..." ] + } ], + "guidelineTherapies" : [ { + "id" : "a3a6a53f-d531-4f46-8697-9052d98cc9e5", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "therapyLine" : 2, + "intent" : { + "code" : "S", + "display" : "Sonstiges", + "system" : "dnpm-dip/therapy/intent" + }, + "category" : { + "code" : "I", + "display" : "Intraopterativ", + "system" : "dnpm-dip/therapy/category" + }, + "recordedOn" : "2025-04-03", + "status" : { + "code" : "stopped", + "display" : "Abgebrochen", + "system" : "dnpm-dip/therapy/status" + }, + "statusReason" : { + "code" : "progression", + "display" : "Progression", + "system" : "dnpm-dip/therapy/status-reason" + }, + "period" : { + "start" : "2023-08-03", + "end" : "2024-01-18" + }, + "medication" : [ { + "code" : "L01EX24", + "display" : "Surufatinib", + "system" : "http://fhir.de/CodeSystem/bfarm/atc", + "version" : "2024" + } ], + "notes" : [ "Notes on the therapy..." ] + } ], + "guidelineProcedures" : [ { + "id" : "ff5148ce-94ab-487f-a2a4-ebc5e1ea8a53", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "therapyLine" : 1, + "intent" : { + "code" : "K", + "display" : "Kurativ", + "system" : "dnpm-dip/therapy/intent" + }, + "code" : { + "code" : "surgery", + "display" : "OP", + "system" : "dnpm-dip/mtb/procedure/type" + }, + "status" : { + "code" : "completed", + "display" : "Abgeschlossen", + "system" : "dnpm-dip/therapy/status" + }, + "statusReason" : { + "code" : "chronic-remission", + "display" : "Anhaltende Remission", + "system" : "dnpm-dip/therapy/status-reason" + }, + "recordedOn" : "2025-04-03", + "period" : { + "start" : "2024-10-03" + }, + "notes" : [ "Notes on the therapeutic procedure..." ] + }, { + "id" : "461105eb-c3c6-4fd4-bcd3-799e7eaf281d", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "therapyLine" : 8, + "intent" : { + "code" : "K", + "display" : "Kurativ", + "system" : "dnpm-dip/therapy/intent" + }, + "code" : { + "code" : "nuclear-medicine", + "display" : "Nuklearmedizinische Therapie", + "system" : "dnpm-dip/mtb/procedure/type" + }, + "status" : { + "code" : "stopped", + "display" : "Abgebrochen", + "system" : "dnpm-dip/therapy/status" + }, + "statusReason" : { + "code" : "progression", + "display" : "Progression", + "system" : "dnpm-dip/therapy/status-reason" + }, + "recordedOn" : "2025-04-03", + "period" : { + "start" : "2024-10-03" + }, + "notes" : [ "Notes on the therapeutic procedure..." ] + } ], + "performanceStatus" : [ { + "id" : "2b1522a8-9628-4e66-8769-e1f329bf37c5", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "effectiveDate" : "2025-04-03", + "value" : { + "code" : "3", + "display" : "ECOG 3", + "system" : "ECOG-Performance-Status" + } + } ], + "specimens" : [ { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "diagnosis" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "type" : "MTBDiagnosis" + }, + "type" : { + "code" : "FFPE", + "display" : "FFPE", + "system" : "dnpm-dip/mtb/tumor-specimen/type" + }, + "collection" : { + "date" : "2025-04-03", + "method" : { + "code" : "unknown", + "display" : "Unbekannt", + "system" : "dnpm-dip/mtb/tumor-specimen/collection/method" + }, + "localization" : { + "code" : "unknown", + "display" : "Unbekannt", + "system" : "dnpm-dip/mtb/tumor-specimen/collection/localization" + } + } + } ], + "priorDiagnosticReports" : [ { + "id" : "e3d6eb01-6afb-4cb2-8682-b5f67565a701", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "performer" : { + "id" : "xyz", + "display" : "Molekular-Pathologie UKx", + "type" : "Institute" + }, + "issuedOn" : "2025-04-03", + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "type" : { + "code" : "other", + "display" : "Other", + "system" : "dnpm-dip/mtb/molecular-diagnostics/type" + }, + "results" : [ "Result of diagnostics..." ] + } ], + "histologyReports" : [ { + "id" : "49154f97-84a9-4a8c-8f52-b5dcbf6973ce", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "issuedOn" : "2025-04-03", + "results" : { + "tumorMorphology" : { + "id" : "af23d218-7c03-4950-984c-a5c35295b696", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "value" : { + "code" : "8935/1", + "display" : "Stromatumor o.n.A.", + "system" : "urn:oid:2.16.840.1.113883.6.43.1", + "version" : "Zweite Revision" + }, + "notes" : "Notes..." + }, + "tumorCellContent" : { + "id" : "f45c7add-f441-4786-aff3-917bad76b140", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "method" : { + "code" : "histologic", + "display" : "Histologisch", + "system" : "dnpm-dip/mtb/tumor-cell-content/method" + }, + "value" : 0.8229387003304868 + } + } + } ], + "ihcReports" : [ { + "id" : "dfc2429b-4677-4c04-8359-2e8bd68e8006", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "issuedOn" : "2025-04-03", + "journalId" : "9dc66c04-ad2c-4d4c-9e38-b524e4e59c4a", + "blockIds" : [ "34c921a8-d047-414d-a1b6-b0bd24c6b771" ], + "results" : { + "proteinExpression" : [ { + "id" : "ca7b6082-f4ac-4be2-a28f-d1d73ad3eff3", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "protein" : { + "code" : "HGNC:391", + "display" : "AKT1", + "system" : "https://www.genenames.org/" + }, + "value" : { + "code" : "2+", + "display" : "2+", + "system" : "dnpm-dip/mtb/ihc/protein-expression/result" + }, + "tpsScore" : 64, + "icScore" : { + "code" : "3", + "display" : ">= 10%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/ic-score" + }, + "tcScore" : { + "code" : "6", + "display" : ">= 75%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/tc-score" + } + }, { + "id" : "824afa8e-332f-498f-9e98-5e03ba072857", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "protein" : { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, + "value" : { + "code" : "unknown", + "display" : "untersucht, kein Ergebnis", + "system" : "dnpm-dip/mtb/ihc/protein-expression/result" + }, + "tpsScore" : 67, + "icScore" : { + "code" : "2", + "display" : ">= 5%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/ic-score" + }, + "tcScore" : { + "code" : "4", + "display" : ">= 25%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/tc-score" + } + }, { + "id" : "697544ba-c91b-498c-825d-4768db65f064", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "protein" : { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + }, + "value" : { + "code" : "3+", + "display" : "3+", + "system" : "dnpm-dip/mtb/ihc/protein-expression/result" + }, + "tpsScore" : 99, + "icScore" : { + "code" : "3", + "display" : ">= 10%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/ic-score" + }, + "tcScore" : { + "code" : "1", + "display" : ">= 1%", + "system" : "dnpm-dip/mtb/ihc/protein-expression/tc-score" + } + } ], + "msiMmr" : [ ] + } + } ], + "ngsReports" : [ { + "id" : "3a17112d-3dd2-468a-8eb5-d2acd2439b47", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "issuedOn" : "2025-04-03", + "type" : { + "code" : "genome-long-read", + "display" : "Genome long-read", + "system" : "dnpm-dip/ngs/type" + }, + "metadata" : [ { + "kitType" : "Kit Type", + "kitManufacturer" : "Manufacturer", + "sequencer" : "Sequencer", + "referenceGenome" : "HG19", + "pipeline" : "https://github.com/pipeline-project" + } ], + "results" : { + "tumorCellContent" : { + "id" : "1d0df7a7-b298-450d-99f6-be2eaee4c3f2", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "method" : { + "code" : "bioinformatic", + "display" : "Bioinformatisch", + "system" : "dnpm-dip/mtb/tumor-cell-content/method" + }, + "value" : 0.4814437947770913 + }, + "tmb" : { + "id" : "e2b42e18-1c99-4d7f-a049-ebf71e3fc2f6", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "value" : { + "value" : 282329, + "unit" : "Mutations per megabase" + }, + "interpretation" : { + "code" : "low", + "display" : "Niedrig", + "system" : "dnpm-dip/mtb/ngs/tmb/interpretation" + } + }, + "brcaness" : { + "id" : "9246f72b-790a-4c6b-aa8d-d00f0cda7a00", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "value" : 0.5, + "confidenceRange" : { + "min" : 0.4, + "max" : 0.6 + } + }, + "hrdScore" : { + "id" : "7a89c96e-f4c3-4f74-b7e7-69676a750ab6", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "value" : 0.7825761496253648, + "components" : { + "lst" : 0.845193455817853, + "loh" : 0.12405816770424238, + "tai" : 0.8345960469445086 + }, + "interpretation" : { + "code" : "high", + "display" : "Hoch", + "system" : "dnpm-dip/mtb/ngs/hrd-score/interpretation" + } + }, + "simpleVariants" : [ { + "id" : "a7a6d971-ccaf-489b-9d6c-3dce0fad63aa", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "aad4cdeb-d085-41d9-a3af-02e4e165ccc1", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "a302ca16-8c98-4697-85c1-6e0a2623ca12", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr22", + "gene" : { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "coding-region", + "display" : "Coding region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "468fbe60-ff1f-4151-a8d4-4bd2bd53e9ad", + "system" : "https://www.ensembl.org" + }, + "exonId" : "10", + "position" : { + "start" : 442 + }, + "altAllele" : "G", + "refAllele" : "A", + "dnaChange" : { + "code" : "c.442A>G", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Val7del", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 7, + "allelicFrequency" : 0.05075371444497867, + "interpretation" : { + "code" : "3", + "display" : "Uncertain significance", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + }, { + "id" : "cfe756be-a9d1-4726-ad1f-16d18c40e1e4", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "3549065d-1a19-4f52-8b5a-7acdc0052981", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "915a64b9-dfd5-4d8f-ba53-5760c452b153", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr19", + "gene" : { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "30d82280-ce5d-4477-97f8-8dfb33491662", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "5", + "position" : { + "start" : 124 + }, + "altAllele" : "G", + "refAllele" : "A", + "dnaChange" : { + "code" : "c.124A>G", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Gly2_Met46del", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 20, + "allelicFrequency" : 0.623433864043018, + "interpretation" : { + "code" : "1", + "display" : "Benign", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + }, { + "id" : "d6088d5a-3059-40a3-ae44-22ff4a63fe20", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "59c813ad-f057-4d3e-92f0-f64d198e7a9e", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "f47ba852-77d2-4495-af83-ebbbade46041", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr6", + "gene" : { + "code" : "HGNC:1100", + "display" : "BRCA1", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "7dc56f62-01fe-48de-8425-f2407a5f6797", + "system" : "https://www.ensembl.org" + }, + "exonId" : "9", + "position" : { + "start" : 586 + }, + "altAllele" : "G", + "refAllele" : "C", + "dnaChange" : { + "code" : "c.586C>G", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Cys28_Lys29delinsTrp", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 11, + "allelicFrequency" : 0.7808371811689188, + "interpretation" : { + "code" : "1", + "display" : "Benign", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + }, { + "id" : "01a40602-1992-44ee-86cf-af4b9f8ede17", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "0c12c379-ec6b-48d2-ab7d-f3ef1e832782", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "e0d20f40-37c8-4203-825c-f8c1c7aabbc9", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr11", + "gene" : { + "code" : "HGNC:25829", + "display" : "ABRAXAS1", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "coding-region", + "display" : "Coding region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "45566daf-2799-45bf-836b-227ad57f1e13", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "11", + "position" : { + "start" : 340 + }, + "altAllele" : "A", + "refAllele" : "G", + "dnaChange" : { + "code" : "c.340G>A", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Trp24Cys", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 23, + "allelicFrequency" : 0.350726510140109, + "interpretation" : { + "code" : "2", + "display" : "Likely benign", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + }, { + "id" : "b548d991-4270-4b60-96e9-d4d1897e2f3f", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "87a18418-1e1e-4546-9316-c14907c53122", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "7ab23f44-806e-480e-ba8a-2974789b7acc", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr9", + "gene" : { + "code" : "HGNC:1777", + "display" : "CDK6", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "444c908a-a87b-401f-9446-f152b70abff6", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "3", + "position" : { + "start" : 82 + }, + "altAllele" : "C", + "refAllele" : "T", + "dnaChange" : { + "code" : "c.82T>C", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Cys28delinsTrpVal", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 9, + "allelicFrequency" : 0.7308207727279626, + "interpretation" : { + "code" : "1", + "display" : "Benign", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + }, { + "id" : "b9b37461-cad8-4aa9-ab9a-765cc390ae93", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "c9343e8a-a961-478d-abf7-af8eca753195", + "system" : "https://www.ncbi.nlm.nih.gov/snp" + }, { + "value" : "fe9cf415-da2c-48ee-8239-1c0fff308477", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "chromosome" : "chr22", + "gene" : { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, + "localization" : [ { + "code" : "coding-region", + "display" : "Coding region", + "system" : "dnpm-dip/variant/localization" + } ], + "transcriptId" : { + "value" : "e089a986-861c-4987-a2d4-4127cd94cfcc", + "system" : "https://www.ensembl.org" + }, + "exonId" : "5", + "position" : { + "start" : 350 + }, + "altAllele" : "A", + "refAllele" : "G", + "dnaChange" : { + "code" : "c.350G>A", + "system" : "https://hgvs-nomenclature.org" + }, + "proteinChange" : { + "code" : "p.Trp24Cys", + "system" : "https://hgvs-nomenclature.org" + }, + "readDepth" : 20, + "allelicFrequency" : 0.6561532201278295, + "interpretation" : { + "code" : "2", + "display" : "Likely benign", + "system" : "https://www.ncbi.nlm.nih.gov/clinvar" + } + } ], + "copyNumberVariants" : [ { + "id" : "674dcb35-ae1f-4c9a-bf96-d718631b0b76", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "chromosome" : "chr13", + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "startRange" : { + "start" : 7504, + "end" : 7546 + }, + "endRange" : { + "start" : 8028, + "end" : 8078 + }, + "totalCopyNumber" : 7, + "relativeCopyNumber" : 0.11223346698282377, + "cnA" : 0.4978945009603952, + "cnB" : 0.4387588889519498, + "reportedAffectedGenes" : [ { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1097", + "display" : "BRAF", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:9967", + "display" : "RET", + "system" : "https://www.genenames.org/" + } ], + "reportedFocality" : "partial q-arm", + "type" : { + "code" : "high-level-gain", + "display" : "High-level-gain", + "system" : "dnpm-dip/mtb/ngs-report/cnv/type" + }, + "copyNumberNeutralLoH" : [ { + "code" : "HGNC:25662", + "display" : "AAGAB", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:11998", + "display" : "TP53", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1753", + "display" : "CDH13", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + } ] + }, { + "id" : "0ccc8b6a-7692-405e-afb3-9ba79f6a6dc6", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "chromosome" : "chr4", + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "startRange" : { + "start" : 29821, + "end" : 29863 + }, + "endRange" : { + "start" : 30310, + "end" : 30360 + }, + "totalCopyNumber" : 2, + "relativeCopyNumber" : 0.004237951938893092, + "cnA" : 0.4120221366346364, + "cnB" : 0.021984357963086842, + "reportedAffectedGenes" : [ { + "code" : "HGNC:11998", + "display" : "TP53", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:25829", + "display" : "ABRAXAS1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1753", + "display" : "CDH13", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:886", + "display" : "ATRX", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3942", + "display" : "MTOR", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:9967", + "display" : "RET", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3689", + "display" : "FGFR2", + "system" : "https://www.genenames.org/" + } ], + "reportedFocality" : "partial q-arm", + "type" : { + "code" : "low-level-gain", + "display" : "Low-level-gain", + "system" : "dnpm-dip/mtb/ngs-report/cnv/type" + }, + "copyNumberNeutralLoH" : [ { + "code" : "HGNC:1097", + "display" : "BRAF", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3690", + "display" : "FGFR3", + "system" : "https://www.genenames.org/" + } ] + }, { + "id" : "5eec91bc-94e1-462e-8686-df33216192eb", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "chromosome" : "chrX", + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "startRange" : { + "start" : 18371, + "end" : 18413 + }, + "endRange" : { + "start" : 19283, + "end" : 19333 + }, + "totalCopyNumber" : 3, + "relativeCopyNumber" : 0.795318484180268, + "cnA" : 0.86546686869607, + "cnB" : 0.7216652781170053, + "reportedAffectedGenes" : [ { + "code" : "HGNC:33", + "display" : "ABCA3", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:6973", + "display" : "MDM2", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1100", + "display" : "BRCA1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:76", + "display" : "ABL1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:21298", + "display" : "AACS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:6407", + "display" : "KRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3236", + "display" : "EGFR", + "system" : "https://www.genenames.org/" + } ], + "reportedFocality" : "partial q-arm", + "type" : { + "code" : "low-level-gain", + "display" : "Low-level-gain", + "system" : "dnpm-dip/mtb/ngs-report/cnv/type" + }, + "copyNumberNeutralLoH" : [ { + "code" : "HGNC:33", + "display" : "ABCA3", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:25829", + "display" : "ABRAXAS1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3690", + "display" : "FGFR3", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:25662", + "display" : "AAGAB", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:76", + "display" : "ABL1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:886", + "display" : "ATRX", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:18615", + "display" : "BRAFP1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3236", + "display" : "EGFR", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + } ] + }, { + "id" : "7a162258-d213-4243-be7e-59244f4561e9", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "chromosome" : "chr9", + "localization" : [ { + "code" : "intronic", + "display" : "Intronic", + "system" : "dnpm-dip/variant/localization" + } ], + "startRange" : { + "start" : 23025, + "end" : 23067 + }, + "endRange" : { + "start" : 23220, + "end" : 23270 + }, + "totalCopyNumber" : 1, + "relativeCopyNumber" : 0.3220959397254798, + "cnA" : 0.11998983501009763, + "cnB" : 0.08203835493839595, + "reportedAffectedGenes" : [ { + "code" : "HGNC:33", + "display" : "ABCA3", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3690", + "display" : "FGFR3", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1097", + "display" : "BRAF", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:25662", + "display" : "AAGAB", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:1100", + "display" : "BRCA1", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3236", + "display" : "EGFR", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + } ], + "reportedFocality" : "partial q-arm", + "type" : { + "code" : "loss", + "display" : "Loss", + "system" : "dnpm-dip/mtb/ngs-report/cnv/type" + }, + "copyNumberNeutralLoH" : [ { + "code" : "HGNC:25662", + "display" : "AAGAB", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:886", + "display" : "ATRX", + "system" : "https://www.genenames.org/" + }, { + "code" : "HGNC:3689", + "display" : "FGFR2", + "system" : "https://www.genenames.org/" + } ] + } ], + "dnaFusions" : [ { + "id" : "bfbb4eb3-fecf-4be6-a0b6-39ed3ab9f54c", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "chromosome" : "chr9", + "gene" : { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + }, + "position" : 788 + }, + "fusionPartner3prime" : { + "chromosome" : "chr19", + "gene" : { + "code" : "HGNC:1753", + "display" : "CDH13", + "system" : "https://www.genenames.org/" + }, + "position" : 384 + }, + "reportedNumReads" : 7 + }, { + "id" : "e99862ce-c098-4aa1-932c-5bfb7de2bb54", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "chromosome" : "chr10", + "gene" : { + "code" : "HGNC:1100", + "display" : "BRCA1", + "system" : "https://www.genenames.org/" + }, + "position" : 426 + }, + "fusionPartner3prime" : { + "chromosome" : "chrY", + "gene" : { + "code" : "HGNC:76", + "display" : "ABL1", + "system" : "https://www.genenames.org/" + }, + "position" : 587 + }, + "reportedNumReads" : 8 + }, { + "id" : "362f5786-4521-409a-b63f-69aad335abcb", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "localization" : [ { + "code" : "intronic", + "display" : "Intronic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "chromosome" : "chrX", + "gene" : { + "code" : "HGNC:1777", + "display" : "CDK6", + "system" : "https://www.genenames.org/" + }, + "position" : 421 + }, + "fusionPartner3prime" : { + "chromosome" : "chr15", + "gene" : { + "code" : "HGNC:3942", + "display" : "MTOR", + "system" : "https://www.genenames.org/" + }, + "position" : 618 + }, + "reportedNumReads" : 3 + }, { + "id" : "d9cc0ae1-1f22-4545-bcfc-3fc93faf1c7b", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "chromosome" : "chr16", + "gene" : { + "code" : "HGNC:6407", + "display" : "KRAS", + "system" : "https://www.genenames.org/" + }, + "position" : 727 + }, + "fusionPartner3prime" : { + "chromosome" : "chr22", + "gene" : { + "code" : "HGNC:6973", + "display" : "MDM2", + "system" : "https://www.genenames.org/" + }, + "position" : 955 + }, + "reportedNumReads" : 6 + }, { + "id" : "7acb8b5a-28e5-49e8-9af0-8f0b07dd7928", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "localization" : [ { + "code" : "intergenic", + "display" : "Intergenic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "chromosome" : "chr8", + "gene" : { + "code" : "HGNC:6407", + "display" : "KRAS", + "system" : "https://www.genenames.org/" + }, + "position" : 910 + }, + "fusionPartner3prime" : { + "chromosome" : "chr6", + "gene" : { + "code" : "HGNC:33", + "display" : "ABCA3", + "system" : "https://www.genenames.org/" + }, + "position" : 567 + }, + "reportedNumReads" : 7 + } ], + "rnaFusions" : [ { + "id" : "809f015f-8e17-45ae-82fe-1d2642a379c0", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "a961e828-b8eb-46a7-b92b-077b188d22eb", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "7f69ae21-f0ae-4f10-965b-116b5a3a4306", + "system" : "https://www.ensembl.org" + }, + "exonId" : "3", + "gene" : { + "code" : "HGNC:76", + "display" : "ABL1", + "system" : "https://www.genenames.org/" + }, + "position" : 939, + "strand" : "-" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "3e4978f1-a4e6-4822-bca2-6d65bfa903d0", + "system" : "https://www.ensembl.org" + }, + "exonId" : "5", + "gene" : { + "code" : "HGNC:1097", + "display" : "BRAF", + "system" : "https://www.genenames.org/" + }, + "position" : 898, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 9 + }, { + "id" : "e2686fdf-5aee-4a1c-a9f9-b5117d586a56", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "3530f838-6a51-4f34-84ff-a978182da6a6", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "intronic", + "display" : "Intronic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "ccf31cee-09ab-4bfc-a92b-f48f5523989e", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "9", + "gene" : { + "code" : "HGNC:21597", + "display" : "ACAD10", + "system" : "https://www.genenames.org/" + }, + "position" : 272, + "strand" : "-" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "71d1e8fc-9296-4d46-9d1f-a36e1f382d35", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "7", + "gene" : { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, + "position" : 848, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 8 + }, { + "id" : "139c8db9-edde-4bd0-ac25-4b7b1729f5cc", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "a1324b2a-96de-46a1-86a7-a575fb29b41a", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "20da1339-f72c-4481-a844-b690a0b950e5", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "6", + "gene" : { + "code" : "HGNC:3689", + "display" : "FGFR2", + "system" : "https://www.genenames.org/" + }, + "position" : 996, + "strand" : "+" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "96d13df2-6551-41f7-94b5-c7da14dd5ce0", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "6", + "gene" : { + "code" : "HGNC:18615", + "display" : "BRAFP1", + "system" : "https://www.genenames.org/" + }, + "position" : 814, + "strand" : "-" + }, + "effect" : "Effect", + "reportedNumReads" : 7 + }, { + "id" : "bf815c71-c890-40a3-84fb-714f31814c59", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "a6e3dd2f-1d7c-404d-953c-710873f846dd", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "d7d2344f-6651-4527-aa93-cb5b93f05aee", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "6", + "gene" : { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, + "position" : 292, + "strand" : "-" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "4092f456-1d4a-43e0-84a9-f70c3c14cf6b", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "5", + "gene" : { + "code" : "HGNC:6973", + "display" : "MDM2", + "system" : "https://www.genenames.org/" + }, + "position" : 925, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 7 + }, { + "id" : "5436e5f8-db2d-4947-a88a-1ab6b07e5faa", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "b83aa40a-fd2f-49c3-a34f-1411cc32783f", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "intergenic", + "display" : "Intergenic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "f9481fda-4a3f-442d-ad3f-e6adccfc4e73", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "5", + "gene" : { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, + "position" : 951, + "strand" : "+" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "18488fee-209c-4abe-9896-f49007bcc648", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "9", + "gene" : { + "code" : "HGNC:5173", + "display" : "HRAS", + "system" : "https://www.genenames.org/" + }, + "position" : 944, + "strand" : "-" + }, + "effect" : "Effect", + "reportedNumReads" : 6 + }, { + "id" : "a206e483-f18c-4656-924b-0f79969da5ab", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "adae872d-f6a7-4c3f-a7be-c4aad3f57694", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "regulatory-region", + "display" : "Regulatory region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "1a5c5afe-c41d-4300-b483-9a55a2ca0ac7", + "system" : "https://www.ensembl.org" + }, + "exonId" : "10", + "gene" : { + "code" : "HGNC:3942", + "display" : "MTOR", + "system" : "https://www.genenames.org/" + }, + "position" : 778, + "strand" : "+" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "16ca230d-78b7-4dfe-9025-616b2d1e0e0e", + "system" : "https://www.ensembl.org" + }, + "exonId" : "10", + "gene" : { + "code" : "HGNC:34", + "display" : "ABCA4", + "system" : "https://www.genenames.org/" + }, + "position" : 216, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 7 + }, { + "id" : "3345abf6-6afa-4069-8b45-390c5ceda24c", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "1b970bcf-766a-472e-a210-4b245c6b697d", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "intergenic", + "display" : "Intergenic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "030c35d3-eafb-4980-9d26-a6d124e5b411", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "8", + "gene" : { + "code" : "HGNC:33", + "display" : "ABCA3", + "system" : "https://www.genenames.org/" + }, + "position" : 496, + "strand" : "+" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "4e673024-533a-4931-9ac0-f069d139d0ad", + "system" : "https://www.ensembl.org" + }, + "exonId" : "11", + "gene" : { + "code" : "HGNC:3689", + "display" : "FGFR2", + "system" : "https://www.genenames.org/" + }, + "position" : 525, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 6 + }, { + "id" : "74254963-0987-4123-ba2c-c57e9de9afd7", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "28050793-0c1d-4d3b-abac-3d569d26a154", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "splicing-region", + "display" : "splicing region", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "b6536e66-bc89-4288-812f-436aa4d5a9a2", + "system" : "https://www.ensembl.org" + }, + "exonId" : "10", + "gene" : { + "code" : "HGNC:1100", + "display" : "BRCA1", + "system" : "https://www.genenames.org/" + }, + "position" : 51, + "strand" : "-" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "1289121c-c6fa-4179-b7fe-5c1f6326c2fa", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "3", + "gene" : { + "code" : "HGNC:21298", + "display" : "AACS", + "system" : "https://www.genenames.org/" + }, + "position" : 905, + "strand" : "-" + }, + "effect" : "Effect", + "reportedNumReads" : 5 + }, { + "id" : "476bf705-ea3e-4a4e-8e9b-29a8fd79446c", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "externalIds" : [ { + "value" : "ed80a31e-8f15-4e4f-8c78-654bc16e56a2", + "system" : "https://cancer.sanger.ac.uk/cosmic" + } ], + "localization" : [ { + "code" : "intronic", + "display" : "Intronic", + "system" : "dnpm-dip/variant/localization" + } ], + "fusionPartner5prime" : { + "transcriptId" : { + "value" : "6ec1314f-0301-4448-adad-da588a340928", + "system" : "https://www.ensembl.org" + }, + "exonId" : "3", + "gene" : { + "code" : "HGNC:21298", + "display" : "AACS", + "system" : "https://www.genenames.org/" + }, + "position" : 335, + "strand" : "+" + }, + "fusionPartner3prime" : { + "transcriptId" : { + "value" : "869e0853-097c-4c03-81f2-5d2b1f702e83", + "system" : "https://www.ncbi.nlm.nih.gov/refseq" + }, + "exonId" : "6", + "gene" : { + "code" : "HGNC:1753", + "display" : "CDH13", + "system" : "https://www.genenames.org/" + }, + "position" : 927, + "strand" : "+" + }, + "effect" : "Effect", + "reportedNumReads" : 5 + } ], + "rnaSeqs" : [ ] + } + } ], + "carePlans" : [ { + "id" : "ddecb45f-d328-4a31-9f0b-504dd74a09bb", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03", + "statusReason" : { + "code" : "targeted-diagnostics-recommended", + "display" : "Zieldiagnostik empfohlen", + "system" : "dnpm-dip/mtb/careplan/status-reason" + }, + "geneticCounselingRecommendation" : { + "id" : "caee4091-8b35-4be8-954e-e6d7f4d6f8b2", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "issuedOn" : "2025-04-03", + "reason" : { + "code" : "unknown", + "display" : "Unbekannt", + "system" : "dnpm-dip/mtb/recommendation/genetic-counseling/reason" + } + }, + "medicationRecommendations" : [ { + "id" : "083a04e9-05f3-4b37-9e7d-e0bf703a8c3c", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03", + "priority" : { + "code" : "2", + "display" : "2", + "system" : "dnpm-dip/recommendation/priority" + }, + "levelOfEvidence" : { + "grading" : { + "code" : "m1B", + "display" : "m1B", + "system" : "dnpm-dip/mtb/level-of-evidence/grading" + }, + "addendums" : [ { + "code" : "is", + "display" : "is", + "system" : "dnpm-dip/mtb/level-of-evidence/addendum" + } ], + "publications" : [ { + "id" : "884742948", + "system" : "https://pubmed.ncbi.nlm.nih.gov", + "type" : "Publication" + } ] + }, + "category" : { + "code" : "IM", + "display" : "Immun-/Antikörpertherapie", + "system" : "dnpm-dip/mtb/recommendation/systemic-therapy/category" + }, + "medication" : [ { + "code" : "L01EN01", + "display" : "Erdafitinib", + "system" : "http://fhir.de/CodeSystem/bfarm/atc", + "version" : "2024" + } ], + "useType" : { + "code" : "in-label", + "display" : "In-label Use", + "system" : "dnpm-dip/mtb/recommendation/systemic-therapy/use-type" + }, + "supportingVariants" : [ { + "variant" : { + "id" : "7acb8b5a-28e5-49e8-9af0-8f0b07dd7928", + "type" : "Variant" + }, + "gene" : { + "code" : "HGNC:6407", + "display" : "KRAS", + "system" : "https://www.genenames.org/" + } + } ] + }, { + "id" : "5650387b-2f3b-4f77-863c-9af4d02294fb", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03", + "priority" : { + "code" : "1", + "display" : "1", + "system" : "dnpm-dip/recommendation/priority" + }, + "levelOfEvidence" : { + "grading" : { + "code" : "m2C", + "display" : "m2C", + "system" : "dnpm-dip/mtb/level-of-evidence/grading" + }, + "addendums" : [ { + "code" : "Z", + "display" : "Z", + "system" : "dnpm-dip/mtb/level-of-evidence/addendum" + } ], + "publications" : [ { + "id" : "1566646481", + "system" : "https://pubmed.ncbi.nlm.nih.gov", + "type" : "Publication" + } ] + }, + "category" : { + "code" : "HO", + "display" : "Hormontherapie", + "system" : "dnpm-dip/mtb/recommendation/systemic-therapy/category" + }, + "medication" : [ { + "code" : "L01FX33", + "display" : "Tarlatamab", + "system" : "http://fhir.de/CodeSystem/bfarm/atc", + "version" : "2024" + } ], + "useType" : { + "code" : "off-label", + "display" : "Off-bel Use", + "system" : "dnpm-dip/mtb/recommendation/systemic-therapy/use-type" + }, + "supportingVariants" : [ { + "variant" : { + "id" : "0ccc8b6a-7692-405e-afb3-9ba79f6a6dc6", + "type" : "Variant" + }, + "gene" : { + "code" : "HGNC:11998", + "display" : "TP53", + "system" : "https://www.genenames.org/" + } + } ] + } ], + "procedureRecommendations" : [ { + "id" : "1e76d94c-a57a-4b3f-9d6b-4af303c05199", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03", + "priority" : { + "code" : "3", + "display" : "3", + "system" : "dnpm-dip/recommendation/priority" + }, + "levelOfEvidence" : { + "grading" : { + "code" : "m2C", + "display" : "m2C", + "system" : "dnpm-dip/mtb/level-of-evidence/grading" + }, + "addendums" : [ { + "code" : "iv", + "display" : "iv", + "system" : "dnpm-dip/mtb/level-of-evidence/addendum" + } ], + "publications" : [ { + "id" : "9936302", + "system" : "https://pubmed.ncbi.nlm.nih.gov", + "type" : "Publication" + } ] + }, + "code" : { + "code" : "SO", + "display" : "Sonstiges", + "system" : "dnpm-dip/mtb/recommendation/procedure/category" + }, + "supportingVariants" : [ { + "variant" : { + "id" : "01a40602-1992-44ee-86cf-af4b9f8ede17", + "type" : "Variant" + }, + "gene" : { + "code" : "HGNC:25829", + "display" : "ABRAXAS1", + "system" : "https://www.genenames.org/" + } + } ] + } ], + "studyEnrollmentRecommendations" : [ { + "id" : "c617652e-d118-4033-9678-ea8eade11abb", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03", + "levelOfEvidence" : { + "code" : "m1B", + "display" : "m1B", + "system" : "dnpm-dip/mtb/level-of-evidence/grading" + }, + "priority" : { + "code" : "1", + "display" : "1", + "system" : "dnpm-dip/recommendation/priority" + }, + "study" : [ { + "id" : "fe066270-e69e-4fc7-95b1-59a3e8456a11", + "system" : "EUDAMED", + "type" : "Study" + } ], + "supportingVariants" : [ { + "variant" : { + "id" : "7acb8b5a-28e5-49e8-9af0-8f0b07dd7928", + "type" : "Variant" + }, + "gene" : { + "code" : "HGNC:6407", + "display" : "KRAS", + "system" : "https://www.genenames.org/" + } + } ] + } ], + "histologyReevaluationRequests" : [ { + "id" : "d9565325-726d-4fc8-bfa9-0fa4c0b53d7b", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "specimen" : { + "id" : "b0557dde-6e54-4d01-8982-7ff88313adaa", + "type" : "TumorSpecimen" + }, + "issuedOn" : "2025-04-03" + } ], + "rebiopsyRequests" : [ { + "id" : "858ce32e-3dea-4cdf-b85a-ae9fa84d113b", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "tumorEntity" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "type" : "MTBDiagnosis" + }, + "issuedOn" : "2025-04-03" + } ], + "notes" : [ "Protocol of the MTB conference..." ] + } ], + "followUps" : [ { + "date" : "2006-12-10" + } ], + "claims" : [ { + "id" : "d7afc9e8-5342-443d-9e13-0a88b4dd6037", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "recommendation" : { + "id" : "083a04e9-05f3-4b37-9e7d-e0bf703a8c3c", + "type" : "MTBMedicationRecommendation" + }, + "issuedOn" : "2025-04-03", + "stage" : { + "code" : "initial-claim", + "display" : "Erstantrag", + "system" : "dnpm-dip/mtb/claim/stage" + } + }, { + "id" : "2ef6b3c7-ce7e-4415-8a43-bd7a3a67be95", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "recommendation" : { + "id" : "5650387b-2f3b-4f77-863c-9af4d02294fb", + "type" : "MTBMedicationRecommendation" + }, + "issuedOn" : "2025-04-03", + "stage" : { + "code" : "revocation", + "display" : "Widerspruch", + "system" : "dnpm-dip/mtb/claim/stage" + } + } ], + "claimResponses" : [ { + "id" : "d9085718-77f6-4ace-b6da-c9358c853ff0", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "claim" : { + "id" : "d7afc9e8-5342-443d-9e13-0a88b4dd6037", + "type" : "Claim" + }, + "issuedOn" : "2025-04-03", + "status" : { + "code" : "rejected", + "display" : "Abgelehnt", + "system" : "dnpm-dip/mtb/claim-response/status" + }, + "statusReason" : { + "code" : "unknown", + "display" : "Unbekant", + "system" : "dnpm-dip/mtb/claim-response/status-reason" + } + }, { + "id" : "d93a0943-628d-47c6-9eda-4fba38df42be", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "claim" : { + "id" : "2ef6b3c7-ce7e-4415-8a43-bd7a3a67be95", + "type" : "Claim" + }, + "issuedOn" : "2025-04-03", + "status" : { + "code" : "rejected", + "display" : "Abgelehnt", + "system" : "dnpm-dip/mtb/claim-response/status" + }, + "statusReason" : { + "code" : "approval-revocation", + "display" : "Rücknahme der Zulassung", + "system" : "dnpm-dip/mtb/claim-response/status-reason" + } + } ], + "systemicTherapies" : [ { + "history" : [ { + "id" : "6b150d30-1c82-4663-833f-6a12ac582ca4", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "intent" : { + "code" : "S", + "display" : "Sonstiges", + "system" : "dnpm-dip/therapy/intent" + }, + "category" : { + "code" : "I", + "display" : "Intraopterativ", + "system" : "dnpm-dip/therapy/category" + }, + "basedOn" : { + "id" : "083a04e9-05f3-4b37-9e7d-e0bf703a8c3c", + "type" : "MTBMedicationRecommendation" + }, + "recordedOn" : "2025-04-03", + "status" : { + "code" : "stopped", + "display" : "Abgebrochen", + "system" : "dnpm-dip/therapy/status" + }, + "statusReason" : { + "code" : "progression", + "display" : "Progression", + "system" : "dnpm-dip/therapy/status-reason" + }, + "recommendationFulfillmentStatus" : { + "code" : "complete", + "display" : "Komplett", + "system" : "dnpm-dip/therapy/recommendation-fulfillment-status" + }, + "period" : { + "start" : "2006-10-15", + "end" : "2007-02-25" + }, + "medication" : [ { + "code" : "L01EN01", + "display" : "Erdafitinib", + "system" : "http://fhir.de/CodeSystem/bfarm/atc", + "version" : "2024" + } ], + "notes" : [ "Notes on the therapy..." ] + } ] + }, { + "history" : [ { + "id" : "27b6eddc-1a4f-4d68-a027-190a2c38754b", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "reason" : { + "id" : "744bf622-ba4b-4e64-867c-0807847d9da4", + "display" : "Bösartige Neubildung: Konjunktiva", + "type" : "MTBDiagnosis" + }, + "intent" : { + "code" : "X", + "display" : "Keine Angabe", + "system" : "dnpm-dip/therapy/intent" + }, + "category" : { + "code" : "A", + "display" : "Adjuvant", + "system" : "dnpm-dip/therapy/category" + }, + "basedOn" : { + "id" : "5650387b-2f3b-4f77-863c-9af4d02294fb", + "type" : "MTBMedicationRecommendation" + }, + "recordedOn" : "2025-04-03", + "status" : { + "code" : "stopped", + "display" : "Abgebrochen", + "system" : "dnpm-dip/therapy/status" + }, + "statusReason" : { + "code" : "progression", + "display" : "Progression", + "system" : "dnpm-dip/therapy/status-reason" + }, + "recommendationFulfillmentStatus" : { + "code" : "partial", + "display" : "Partiell", + "system" : "dnpm-dip/therapy/recommendation-fulfillment-status" + }, + "period" : { + "start" : "2006-10-29", + "end" : "2007-02-25" + }, + "medication" : [ { + "code" : "L01FX33", + "display" : "Tarlatamab", + "system" : "http://fhir.de/CodeSystem/bfarm/atc", + "version" : "2024" + } ], + "notes" : [ "Notes on the therapy..." ] + } ] + } ], + "responses" : [ { + "id" : "bbbbbaaf-8486-454c-ba59-28593519342c", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "therapy" : { + "id" : "6b150d30-1c82-4663-833f-6a12ac582ca4", + "type" : "MTBSystemicTherapy" + }, + "effectiveDate" : "2006-12-10", + "method" : { + "code" : "RECIST", + "display" : "Nach RECIST-Kriterien", + "system" : "dnpm-dip/mtb/response/method" + }, + "value" : { + "code" : "SD", + "display" : "Stable Disease", + "system" : "RECIST" + } + }, { + "id" : "6b93b820-4d03-4883-af96-cf504fa6798e", + "patient" : { + "id" : "63f8fd7b-8127-4f3c-8843-aa9199e21c29", + "type" : "Patient" + }, + "therapy" : { + "id" : "27b6eddc-1a4f-4d68-a027-190a2c38754b", + "type" : "MTBSystemicTherapy" + }, + "effectiveDate" : "2007-02-11", + "method" : { + "code" : "RECIST", + "display" : "Nach RECIST-Kriterien", + "system" : "dnpm-dip/mtb/response/method" + }, + "value" : { + "code" : "PD", + "display" : "Progressive Disease", + "system" : "RECIST" + } + } ] +}