1
0
mirror of https://github.com/pcvolkmer/etl-processor.git synced 2025-04-20 09:46:50 +00:00

Compare commits

..

No commits in common. "master" and "v0.9.0" have entirely different histories.

76 changed files with 1313 additions and 6750 deletions

View File

@ -21,7 +21,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v2
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@ -30,6 +30,6 @@ jobs:
- name: Execute image build and push - name: Execute image build and push
run: | run: |
./gradlew bootBuildImage ./gradlew bootBuildImage
docker tag ghcr.io/${{ github.repository }} ghcr.io/${{ github.repository }}:${{ github.ref_name }} docker tag ghcr.io/ccc-mf/etl-processor ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }}
docker push ghcr.io/${{ github.repository }} docker push ghcr.io/ccc-mf/etl-processor
docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }} docker push ghcr.io/ccc-mf/etl-processor:${{ github.ref_name }}

View File

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-java@v4 - uses: actions/setup-java@v3
with: with:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'
@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-java@v4 - uses: actions/setup-java@v3
with: with:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'

View File

@ -1,6 +1,6 @@
# 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) # 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)
Diese Anwendung versendet ein bwHC-MTB-File im bwHC-Datenmodell 1.0 an DNPM:DIP und pseudonymisiert die Patienten-ID. Diese Anwendung versendet ein bwHC-MTB-File an das bwHC-Backend und pseudonymisiert die Patienten-ID.
## Einordnung innerhalb einer DNPM-ETL-Strecke ## 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. Der Inhalt einer Anfrage, wenn ein bwHC-MTBFile, wird pseudonymisiert und auf Duplikate geprüft.
Duplikate werden verworfen, Änderungen werden weitergeleitet. Duplikate werden verworfen, Änderungen werden weitergeleitet.
Löschanfragen werden immer als Löschanfrage an DNPM:DIP weitergeleitet. Löschanfragen werden immer als Löschanfrage an das bwHC-backend weitergeleitet.
Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand der Anwendung gewährt. Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand der Anwendung gewährt.
@ -22,17 +22,7 @@ Die Erkennung von Duplikaten ist normalerweise immer aktiv, kann jedoch über de
### Datenübermittlung über HTTP/REST ### Datenübermittlung über HTTP/REST
Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an DNPM:DIP gesendet. Anfragen werden, wenn nicht als Duplikat behandelt, nach der Pseudonymisierung direkt an das bwHC-Backend 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 ### Datenübermittlung mit Apache Kafka
@ -43,21 +33,6 @@ Siehe hierzu auch: https://github.com/CCC-MF/kafka-to-bwhc
## Konfiguration ## Konfiguration
### 🔥 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_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 ### Pseudonymisierung der Patienten-ID
Wenn eine URI zu einer gPAS-Instanz (Version >= 2023.1.0) angegeben ist, wird diese verwendet. Wenn eine URI zu einer gPAS-Instanz (Version >= 2023.1.0) angegeben ist, wird diese verwendet.
@ -66,11 +41,13 @@ Ist diese nicht gesetzt. wird intern eine Anonymisierung der Patienten-ID vorgen
* `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Präfix - `UNKNOWN`, wenn nicht gesetzt * `APP_PSEUDONYMIZE_PREFIX`: Standortbezogenes Präfix - `UNKNOWN`, wenn nicht gesetzt
* `APP_PSEUDONYMIZE_GENERATOR`: `BUILDIN` oder `GPAS` - `BUILDIN`, wenn nicht gesetzt * `APP_PSEUDONYMIZE_GENERATOR`: `BUILDIN` oder `GPAS` - `BUILDIN`, wenn nicht gesetzt
**Hinweis** **Hinweise**:
Die Pseudonymisierung erfolgt im ETL-Prozessor nur für die Patienten-ID. * Der alte Konfigurationsparameter `APP_PSEUDONYMIZER` mit den Werten `GPAS` oder `BUILDIN` sollte nicht mehr verwendet
Andere IDs werden mithilfe des standortbezogenen Präfixes (erneut) anonymisiert, um für den aktuellen Kontext nicht werden.
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 #### Eingebaute Anonymisierung
@ -86,6 +63,10 @@ Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfiguri
* `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname * `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname
* `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername * `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername
* `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort * `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.
Der Konfigurationsparameter `APP_PSEUDONYMIZE_GPAS_SSLCALOCATION` sollte nicht mehr verwendet werden und wird in einer kommenden Version 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 ### Anmeldung mit einem Passwort
@ -152,7 +133,7 @@ Sie bekommen dabei wieder die Standardrolle zugewiesen.
#### Auswirkungen auf den dargestellten Inhalt #### Auswirkungen auf den dargestellten Inhalt
Nur Administratoren haben Zugriff auf den Konfigurationsbereich, nur angemeldete Benutzer können die anonymisierte oder Nur Administratoren haben Zugriff auf den Konfigurationsbereich, nur angemeldete Benutzer können die anonymisierte oder
pseudonymisierte Patienten-ID sowie den Qualitätsbericht von DNPM:DIP einsehen. pseudonymisierte Patienten-ID sowie den Qualitätsbericht des bwHC-Backends einsehen.
Wurde kein Administrator-Account konfiguriert, sind diese Inhalte generell nicht verfügbar. Wurde kein Administrator-Account konfiguriert, sind diese Inhalte generell nicht verfügbar.
@ -168,7 +149,7 @@ zur Nutzung des MTB-File-Endpunkts eine HTTP-Basic-Authentifizierung voraussetze
![Tokenverwaltung](docs/tokens.png) ![Tokenverwaltung](docs/tokens.png)
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: 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:
``` ```
https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile
@ -176,12 +157,10 @@ https://testonkostar:MTg1NTL...NGU4@etl.example.com/mtbfile
Ist die Verwendung von Tokens aktiv, werden Anfragen ohne die Angabe der Token-Information abgelehnt. Ist die Verwendung von Tokens aktiv, werden Anfragen ohne die Angabe der Token-Information abgelehnt.
Alternativ kann eine Authentifizierung über Benutzername/Passwort oder OIDC erfolgen.
### Transformation von Werten ### Transformation von Werten
In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht, In Onkostar kann es vorkommen, dass ein Wert eines Merkmalskatalogs an einem Standort angepasst wurde und dadurch nicht dem Wert entspricht,
der von DNPM:DIP akzeptiert wird. der vom bwHC-Backend akzeptiert wird.
Diese Anwendung bietet daher die Möglichkeit, eine Transformation vorzunehmen. Hierzu muss der "Pfad" innerhalb des JSON-MTB-Files angegeben werden und 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. welcher Wert wie ersetzt werden soll.
@ -201,21 +180,18 @@ Werden sowohl REST als auch Kafka-Endpunkt konfiguriert, wird nur der REST-Endpu
#### REST #### REST
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an DNPM:DIP gesendet wird: Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an das bwHC-Backend gesendet wird:
* `APP_REST_URI`: URI der zu benutzenden API der Backend-Instanz. Zum Beispiel: * `APP_REST_URI`: URI der zu benutzenden API der bwHC-Backend-Instanz. z.B.: `http://localhost:9000/bwhc/etl/api`
* `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 #### Kafka-Topics
Folgende Umgebungsvariablen müssen gesetzt sein, damit ein bwHC-MTB-File an ein Kafka-Topic übermittelt wird: 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. * `APP_KAFKA_OUTPUT_TOPIC`: Zu verwendendes Topic zum Versenden von Anfragen.
Ersetzt in einer kommenden Version `APP_KAFKA_TOPIC`.
* `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`: Topic mit Antworten über den Erfolg des Versendens. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_response". * `APP_KAFKA_OUTPUT_RESPONSE_TOPIC`: Topic mit Antworten über den Erfolg des Versendens. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_response".
Ersetzt in einer kommenden Version `APP_KAFKA_RESPONSE_TOPIC`.
* `APP_KAFKA_GROUP_ID`: Kafka GroupID des Consumers. Standardwert: `APP_KAFKA_TOPIC` mit Anhang "_group". * `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 * `APP_KAFKA_SERVERS`: Zu verwendende Kafka-Bootstrap-Server als kommagetrennte Liste
@ -223,7 +199,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. Weitere Einstellungen können über die Parameter von Spring Kafka konfiguriert werden.
Lässt sich keine Verbindung zu dem Backend aufbauen, wird eine Rückantwort mit Status-Code `900` erwartet, welchen es Lässt sich keine Verbindung zu dem bwHC-Backend aufbauen, wird eine Rückantwort mit Status-Code `900` erwartet, welchen es
für HTTP nicht gibt. 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. Wird die Umgebungsvariable `APP_KAFKA_INPUT_TOPIC` gesetzt, kann eine Nachricht auch über dieses Kafka-Topic an den ETL-Prozessor übermittelt werden.
@ -260,7 +236,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 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. 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 DNPM:DIP, als auch für die Rückantwort identisch aufgebaut ist, lassen sich so Da der Key sowohl für die Records in Richtung bwHC-Backend 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 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. Antwort effektiv verhindern, da diese nach 10 Sekunden gelöscht werden.
@ -269,30 +245,9 @@ ein Consent-Widerspruch erfolgte.
Dieses Vorgehen empfiehlt sich, wenn Sie gespeicherte Records nachgelagert für andere Auswertungen verwenden möchten. 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 ## Docker-Images
Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/pcvolkmer/etl-processor/pkgs/container/etl-processor Diese Anwendung ist auch als Docker-Image verfügbar: https://github.com/CCC-MF/etl-processor/pkgs/container/etl-processor
### Images lokal bauen ### Images lokal bauen
@ -405,5 +360,3 @@ 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. Beim Ausführen der Integrationstests wird eine Testdatenbank in einem Docker-Container gestartet.
Siehe hier auch die Klasse `AbstractTestcontainerTest` unter `src/integrationTest`. 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.

View File

@ -1,36 +1,30 @@
import org.gradle.api.tasks.testing.logging.TestLogEvent import org.gradle.api.tasks.testing.logging.TestLogEvent
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
plugins { plugins {
war war
id("org.springframework.boot") version "3.4.4" id("org.springframework.boot") version "3.2.3"
id("io.spring.dependency-management") version "1.1.7" id("io.spring.dependency-management") version "1.1.4"
kotlin("jvm") version "1.9.25" kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version "1.9.25" kotlin("plugin.spring") version "1.9.22"
jacoco
} }
group = "dev.dnpm" group = "de.ukw.ccc"
version = "0.11.0-SNAPSHOT" version = "0.9.0"
var versions = mapOf( var versions = mapOf(
"bwhc-dto-java" to "0.4.0", "bwhc-dto-java" to "0.2.0",
"mtb-dto" to "0.1.0-SNAPSHOT", "hapi-fhir" to "6.10.2",
"hapi-fhir" to "7.6.0", "httpclient5" to "5.2.1",
"mockito-kotlin" to "5.4.0", "mockito-kotlin" to "5.2.1",
"archunit" to "1.3.0",
// Webjars // Webjars
"webjars-locator" to "0.52",
"echarts" to "5.4.3", "echarts" to "5.4.3",
"htmx.org" to "1.9.12" "htmx.org" to "1.9.10"
) )
java { java {
toolchain { sourceCompatibility = JavaVersion.VERSION_21
languageVersion = JavaLanguageVersion.of(21)
}
} }
sourceSets { sourceSets {
@ -49,18 +43,9 @@ configurations {
compileOnly { compileOnly {
extendsFrom(configurations.annotationProcessor.get()) extendsFrom(configurations.annotationProcessor.get())
} }
all {
resolutionStrategy {
cacheChangingModulesFor(5, "minutes")
}
}
} }
repositories { repositories {
maven {
url = uri("https://git.dnpm.dev/api/packages/public-snapshots/maven")
}
maven { maven {
url = uri("https://git.dnpm.dev/api/packages/public/maven") url = uri("https://git.dnpm.dev/api/packages/public/maven")
} }
@ -77,46 +62,35 @@ dependencies {
implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6") implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.kafka:spring-kafka") implementation("org.springframework.kafka:spring-kafka")
implementation("org.flywaydb:flyway-database-postgresql")
implementation("org.flywaydb:flyway-mysql") implementation("org.flywaydb:flyway-mysql")
implementation("commons-codec:commons-codec") implementation("commons-codec:commons-codec")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions") implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("de.ukw.ccc:bwhc-dto-java:${versions["bwhc-dto-java"]}") 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-base:${versions["hapi-fhir"]}")
implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}") implementation("ca.uhn.hapi.fhir:hapi-fhir-structures-r4:${versions["hapi-fhir"]}")
implementation("org.apache.httpcomponents.client5:httpclient5") implementation("org.apache.httpcomponents.client5:httpclient5:${versions["httpclient5"]}")
implementation("com.jayway.jsonpath:json-path") implementation("com.jayway.jsonpath:json-path")
implementation("org.webjars:webjars-locator:${versions["webjars-locator"]}") implementation("org.webjars:webjars-locator:0.50")
implementation("org.webjars.npm:echarts:${versions["echarts"]}") implementation("org.webjars.npm:echarts:${versions["echarts"]}")
implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}") implementation("org.webjars.npm:htmx.org:${versions["htmx.org"]}")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client") runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
runtimeOnly("org.postgresql:postgresql") runtimeOnly("org.postgresql:postgresql")
developmentOnly("org.springframework.boot:spring-boot-devtools") developmentOnly("org.springframework.boot:spring-boot-devtools")
developmentOnly("org.springframework.boot:spring-boot-docker-compose") developmentOnly("org.springframework.boot:spring-boot-docker-compose")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
providedRuntime("org.springframework.boot:spring-boot-starter-tomcat") providedRuntime("org.springframework.boot:spring-boot-starter-tomcat")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test") testImplementation("org.springframework.security:spring-security-test")
testImplementation("io.projectreactor:reactor-test") testImplementation("io.projectreactor:reactor-test")
testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}") testImplementation("org.mockito.kotlin:mockito-kotlin:${versions["mockito-kotlin"]}")
integrationTestImplementation("org.testcontainers:junit-jupiter") integrationTestImplementation("org.testcontainers:junit-jupiter")
integrationTestImplementation("org.testcontainers:postgresql") integrationTestImplementation("org.testcontainers:postgresql")
integrationTestImplementation("com.tngtech.archunit:archunit:${versions["archunit"]}")
integrationTestImplementation("org.htmlunit:htmlunit")
integrationTestImplementation("org.springframework:spring-webflux")
} }
tasks.withType<KotlinCompile> { tasks.withType<KotlinCompile> {
compilerOptions { kotlinOptions {
freeCompilerArgs.add("-Xjsr305=strict") freeCompilerArgs += "-Xjsr305=strict"
jvmTarget.set(JvmTarget.JVM_21) jvmTarget = "21"
} }
} }
@ -127,9 +101,8 @@ tasks.withType<Test> {
} }
} }
tasks.register<Test>("integrationTest") { task<Test>("integrationTest") {
description = "Runs integration tests" description = "Runs integration tests"
group = "verification"
testClassesDirs = sourceSets["integrationTest"].output.classesDirs testClassesDirs = sourceSets["integrationTest"].output.classesDirs
classpath = sourceSets["integrationTest"].runtimeClasspath classpath = sourceSets["integrationTest"].runtimeClasspath
@ -137,24 +110,8 @@ tasks.register<Test>("integrationTest") {
shouldRunAfter("test") shouldRunAfter("test")
} }
tasks.register("allTests") {
description = "Run all tests"
group = JavaBasePlugin.VERIFICATION_GROUP
dependsOn(tasks.withType<Test>())
}
tasks.jacocoTestReport {
dependsOn("allTests")
executionData(fileTree(project.rootDir.absolutePath).include("**/build/jacoco/*.exec"))
reports {
xml.required = true
}
}
tasks.named<BootBuildImage>("bootBuildImage") { tasks.named<BootBuildImage>("bootBuildImage") {
imageName.set("ghcr.io/pcvolkmer/etl-processor") imageName.set("ghcr.io/ccc-mf/etl-processor")
// Binding for CA Certs // Binding for CA Certs
bindings.set(listOf( bindings.set(listOf(
@ -164,7 +121,7 @@ tasks.named<BootBuildImage>("bootBuildImage") {
environment.set(environment.get() + mapOf( environment.set(environment.get() + mapOf(
// Enable this line to embed CA Certs into image on build time // Enable this line to embed CA Certs into image on build time
//"BP_EMBED_CERTS" to "true", //"BP_EMBED_CERTS" to "true",
"BP_OCI_SOURCE" to "https://github.com/pcvolkmer/etl-processor", "BP_OCI_SOURCE" to "https://github.com/CCC-MF/etl-processor",
"BP_OCI_LICENSES" to "AGPLv3", "BP_OCI_LICENSES" to "AGPLv3",
"BP_OCI_DESCRIPTION" to "ETL Processor for bwHC MTB files" "BP_OCI_DESCRIPTION" to "ETL Processor for bwHC MTB files"
)) ))

View File

@ -18,9 +18,6 @@ services:
APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID} APP_KAFKA_GROUP_ID: ${DNPM_KAFKA_GROUP_ID}
APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC} APP_KAFKA_RESPONSE_TOPIC: ${DNPM_KAFKA_RESPONSE_TOPIC}
APP_REST_URI: ${DNPM_BWHC_REST_URI} 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_USER: ${DNPM_ADMIN_USER}
APP_SECURITY_ADMIN_PASSWORD: ${DNPM_ADMIN_PASSWORD} APP_SECURITY_ADMIN_PASSWORD: ${DNPM_ADMIN_PASSWORD}
SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL} SPRING_DATASOURCE_URL: ${DNPM_DATASOURCE_URL}

View File

@ -28,9 +28,6 @@ DNPM_DATASOURCE_URL=jdbc:mariadb://dnpm-monitor-db:3306/$DNPM_MARIADB_DB
## TARGET SYSTEMS CONFIG ## TARGET SYSTEMS CONFIG
# in case of direct access to bwhc enter endpoint url here # in case of direct access to bwhc enter endpoint url here
DNPM_BWHC_REST_URI= 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 # produce mtb files to this topic - values 'false' disabling kafka processing
DNPM_KAFKA_TOPIC=false DNPM_KAFKA_TOPIC=false

View File

@ -17,9 +17,8 @@ services:
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093 KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
## Use AKHQ as Kafka web frontend
akhq: akhq:
image: tchiotludo/akhq:0.25.0 image: tchiotludo/akhq:0.21.0
environment: environment:
AKHQ_CONFIGURATION: | AKHQ_CONFIGURATION: |
akhq: akhq:
@ -33,8 +32,6 @@ services:
ports: ports:
- "8084:8080" - "8084:8080"
## For use with MariaDB
mariadb: mariadb:
image: mariadb:10 image: mariadb:10
ports: ports:
@ -45,7 +42,6 @@ services:
MARIADB_PASSWORD: dev MARIADB_PASSWORD: dev
MARIADB_ROOT_PASSWORD: dev MARIADB_ROOT_PASSWORD: dev
## For use with Postgres
# postgres: # postgres:
# image: postgres:alpine # image: postgres:alpine
# ports: # ports:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 120 KiB

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
networkTimeout=10000 networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@ -23,7 +23,6 @@ import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.output.BwhcV1MtbFileRequest
import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.MtbFileSender
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
@ -34,10 +33,10 @@ import org.mockito.kotlin.*
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContext
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.context.TestPropertySource 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.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.post import org.springframework.test.web.servlet.post
@ -46,7 +45,7 @@ import org.testcontainers.junit.jupiter.Testcontainers
@Testcontainers @Testcontainers
@ExtendWith(SpringExtension::class) @ExtendWith(SpringExtension::class)
@SpringBootTest @SpringBootTest
@MockitoBean(types = [MtbFileSender::class]) @MockBean(MtbFileSender::class)
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.rest.uri=http://example.com", "app.rest.uri=http://example.com",
@ -74,7 +73,7 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
) )
inner class TransformationTest { inner class TransformationTest {
@MockitoBean @MockBean
private lateinit var mtbFileSender: MtbFileSender private lateinit var mtbFileSender: MtbFileSender
@Autowired @Autowired
@ -92,7 +91,7 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
fun mtbFileIsTransformed() { fun mtbFileIsTransformed() {
doAnswer { doAnswer {
MtbFileSender.Response(RequestStatus.SUCCESS) MtbFileSender.Response(RequestStatus.SUCCESS)
}.whenever(mtbFileSender).send(any<BwhcV1MtbFileRequest>()) }.whenever(mtbFileSender).send(any<MtbFileSender.MtbFileRequest>())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -135,9 +134,9 @@ class EtlProcessorApplicationTests : AbstractTestcontainerTest() {
} }
} }
val captor = argumentCaptor<BwhcV1MtbFileRequest>() val captor = argumentCaptor<MtbFileSender.MtbFileRequest>()
verify(mtbFileSender).send(captor.capture()) verify(mtbFileSender).send(captor.capture())
assertThat(captor.firstValue.content.diagnoses).hasSize(1).allMatch { diagnosis -> assertThat(captor.firstValue.mtbFile.diagnoses).hasSize(1).allMatch { diagnosis ->
diagnosis.icd10.version == "2014" diagnosis.icd10.version == "2014"
} }
} }

View File

@ -1,73 +0,0 @@
package dev.dnpm.etl.processor
import com.tngtech.archunit.core.domain.JavaClasses
import com.tngtech.archunit.core.importer.ClassFileImporter
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.data.repository.Repository
class EtlProcessorArchTest {
private lateinit var noTestClasses: JavaClasses
@BeforeEach
fun setUp() {
this.noTestClasses = ClassFileImporter()
.withImportOption { !(it.contains("/test/") || it.contains("/integrationTest/")) }
.importPackages("dev.dnpm.etl.processor")
}
@Test
fun noClassesInInputPackageShouldDependOnMonitoringPackage() {
val rule = noClasses()
.that()
.resideInAPackage("..input")
.should().dependOnClassesThat()
.resideInAnyPackage("..monitoring")
rule.check(noTestClasses)
}
@Test
fun noClassesInInputPackageShouldDependOnRepositories() {
val rule = noClasses()
.that()
.resideInAPackage("..input")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
rule.check(noTestClasses)
}
@Test
fun noClassesInOutputPackageShouldDependOnRepositories() {
val rule = noClasses()
.that()
.resideInAPackage("..output")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
rule.check(noTestClasses)
}
@Test
fun noClassesInWebPackageShouldDependOnRepositories() {
val rule = noClasses()
.that()
.resideInAPackage("..web")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Repository")
rule.check(noTestClasses)
}
@Test
fun repositoryClassNamesShouldEndWithRepository() {
val rule = classes()
.that()
.areInterfaces().and().areAssignableTo(Repository::class.java)
.should().haveSimpleNameEndingWith("Repository")
rule.check(noTestClasses)
}
}

View File

@ -26,9 +26,9 @@ import dev.dnpm.etl.processor.output.KafkaMtbFileSender
import dev.dnpm.etl.processor.output.RestMtbFileSender import dev.dnpm.etl.processor.output.RestMtbFileSender
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
import dev.dnpm.etl.processor.security.TokenRepository
import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import dev.dnpm.etl.processor.services.TokenRepository
import dev.dnpm.etl.processor.services.TokenService
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -36,25 +36,24 @@ import org.junit.jupiter.api.assertThrows
import org.springframework.beans.factory.NoSuchBeanDefinitionException import org.springframework.beans.factory.NoSuchBeanDefinitionException
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration
import org.springframework.boot.test.context.SpringBootTest 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.context.ApplicationContext
import org.springframework.retry.support.RetryTemplate import org.springframework.retry.support.RetryTemplate
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.bean.override.mockito.MockitoBean
@SpringBootTest @SpringBootTest
@ContextConfiguration( @ContextConfiguration(classes = [
classes = [ AppConfiguration::class,
AppConfiguration::class, AppSecurityConfiguration::class,
AppSecurityConfiguration::class, KafkaAutoConfiguration::class,
KafkaAutoConfiguration::class, AppKafkaConfiguration::class,
AppKafkaConfiguration::class, AppRestConfiguration::class
AppRestConfiguration::class ])
] @MockBean(ObjectMapper::class)
)
@MockitoBean(types = [ObjectMapper::class])
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=BUILDIN", "app.pseudonymize.generator=BUILDIN",
@ -87,7 +86,7 @@ class AppConfigurationTest {
"app.kafka.group-id=test" "app.kafka.group-id=test"
] ]
) )
@MockitoBean(types = [RequestRepository::class]) @MockBean(RequestRepository::class)
inner class AppConfigurationKafkaTest(private val context: ApplicationContext) { inner class AppConfigurationKafkaTest(private val context: ApplicationContext) {
@Test @Test
@ -146,7 +145,7 @@ class AppConfigurationTest {
"app.kafka.group-id=test" "app.kafka.group-id=test"
] ]
) )
@MockitoBean(types = [RequestProcessor::class]) @MockBean(RequestProcessor::class)
inner class AppConfigurationUsingKafkaInputTest(private val context: ApplicationContext) { inner class AppConfigurationUsingKafkaInputTest(private val context: ApplicationContext) {
@Test @Test
@ -182,7 +181,40 @@ class AppConfigurationTest {
@Nested @Nested
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=buildin" "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=",
] ]
) )
inner class AppConfigurationPseudonymizeGeneratorBuildinTest(private val context: ApplicationContext) { inner class AppConfigurationPseudonymizeGeneratorBuildinTest(private val context: ApplicationContext) {
@ -197,7 +229,8 @@ class AppConfigurationTest {
@Nested @Nested
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=gpas" "app.pseudonymize.generator=gpas",
"app.pseudonymizer=",
] ]
) )
inner class AppConfigurationPseudonymizeGeneratorGpasTest(private val context: ApplicationContext) { inner class AppConfigurationPseudonymizeGeneratorGpasTest(private val context: ApplicationContext) {
@ -215,13 +248,11 @@ class AppConfigurationTest {
"app.security.enable-tokens=true" "app.security.enable-tokens=true"
] ]
) )
@MockitoBean( @MockBeans(value = [
types = [ MockBean(InMemoryUserDetailsManager::class),
InMemoryUserDetailsManager::class, MockBean(PasswordEncoder::class),
PasswordEncoder::class, MockBean(TokenRepository::class)
TokenRepository::class ])
]
)
inner class AppConfigurationTokenEnabledTest(private val context: ApplicationContext) { inner class AppConfigurationTokenEnabledTest(private val context: ApplicationContext) {
@Test @Test
@ -232,13 +263,11 @@ class AppConfigurationTest {
} }
@Nested @Nested
@MockitoBean( @MockBeans(value = [
types = [ MockBean(InMemoryUserDetailsManager::class),
InMemoryUserDetailsManager::class, MockBean(PasswordEncoder::class),
PasswordEncoder::class, MockBean(TokenRepository::class)
TokenRepository::class ])
]
)
inner class AppConfigurationTokenDisabledTest(private val context: ApplicationContext) { inner class AppConfigurationTokenDisabledTest(private val context: ApplicationContext) {
@Test @Test

View File

@ -1,30 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor
import org.mockito.ArgumentMatchers
@Suppress("UNCHECKED_CAST")
inline fun <reified T> anyValueClass(): T {
val unboxedClass = T::class.java.declaredFields.first().type
return ArgumentMatchers.any(unboxedClass as Class<T>)
?: T::class.java.getDeclaredMethod("box-impl", unboxedClass)
.invoke(null, null) as T
}

View File

@ -21,15 +21,13 @@ package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.anyValueClass
import dev.dnpm.etl.processor.config.AppSecurityConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration
import dev.dnpm.etl.processor.security.TokenRepository
import dev.dnpm.etl.processor.security.UserRoleRepository
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import dev.dnpm.etl.processor.services.TokenRepository
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers.anyString
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.never import org.mockito.kotlin.never
@ -37,13 +35,12 @@ import org.mockito.kotlin.times
import org.mockito.kotlin.verify import org.mockito.kotlin.verify
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 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.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.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource 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.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.delete
@ -57,7 +54,7 @@ import org.springframework.test.web.servlet.post
AppSecurityConfiguration::class AppSecurityConfiguration::class
] ]
) )
@MockitoBean(types = [TokenRepository::class, RequestProcessor::class]) @MockBean(TokenRepository::class, RequestProcessor::class)
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=BUILDIN", "app.pseudonymize.generator=BUILDIN",
@ -91,20 +88,7 @@ class MtbFileRestControllerTest {
status { isAccepted() } status { isAccepted() }
} }
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>()) verify(requestProcessor, times(1)).processMtbFile(any())
}
@Test
fun testShouldGrantPermissionToSendMtbFileToAdminUser() {
mockMvc.post("/mtbfile") {
with(user("onkostarserver").roles("ADMIN"))
contentType = MediaType.APPLICATION_JSON
content = ObjectMapper().writeValueAsString(mtbFile)
}.andExpect {
status { isAccepted() }
}
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
} }
@Test @Test
@ -117,20 +101,7 @@ class MtbFileRestControllerTest {
status { isUnauthorized() } status { isUnauthorized() }
} }
verify(requestProcessor, never()).processMtbFile(any<MtbFile>()) verify(requestProcessor, never()).processMtbFile(any())
}
@Test
fun testShouldDenyPermissionToSendMtbFileForUser() {
mockMvc.post("/mtbfile") {
with(user("fakeuser").roles("USER"))
contentType = MediaType.APPLICATION_JSON
content = ObjectMapper().writeValueAsString(mtbFile)
}.andExpect {
status { isForbidden() }
}
verify(requestProcessor, never()).processMtbFile(any<MtbFile>())
} }
@Test @Test
@ -141,7 +112,7 @@ class MtbFileRestControllerTest {
status { isAccepted() } status { isAccepted() }
} }
verify(requestProcessor, times(1)).processDeletion(anyValueClass()) verify(requestProcessor, times(1)).processDeletion(anyString())
} }
@Test @Test
@ -152,46 +123,7 @@ class MtbFileRestControllerTest {
status { isUnauthorized() } status { isUnauthorized() }
} }
verify(requestProcessor, never()).processDeletion(anyValueClass()) verify(requestProcessor, never()).processDeletion(anyString())
}
@Nested
@MockitoBean(types = [UserRoleRepository::class, ClientRegistrationRepository::class])
@TestPropertySource(
properties = [
"app.pseudonymize.generator=BUILDIN",
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret",
"app.security.enable-tokens=true",
"app.security.enable-oidc=true"
]
)
inner class WithOidcEnabled {
@Test
fun testShouldGrantPermissionToSendMtbFileToAdminUser() {
mockMvc.post("/mtbfile") {
with(user("onkostarserver").roles("ADMIN"))
contentType = MediaType.APPLICATION_JSON
content = ObjectMapper().writeValueAsString(mtbFile)
}.andExpect {
status { isAccepted() }
}
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
}
@Test
fun testShouldGrantPermissionToSendMtbFileToUser() {
mockMvc.post("/mtbfile") {
with(user("onkostarserver").roles("USER"))
contentType = MediaType.APPLICATION_JSON
content = ObjectMapper().writeValueAsString(mtbFile)
}.andExpect {
status { isAccepted() }
}
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>())
}
} }
companion object { companion object {

View File

@ -1,75 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.monitoring
import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.output.MtbFileSender
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.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
import java.time.Instant
@Testcontainers
@ExtendWith(SpringExtension::class)
@DataJdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Transactional
@MockitoBean(types = [MtbFileSender::class])
@TestPropertySource(
properties = [
"app.pseudonymize.generator=buildin",
"app.rest.uri=http://example.com"
]
)
class RequestRepositoryTest : AbstractTestcontainerTest() {
private lateinit var requestRepository: RequestRepository
@BeforeEach
fun setUp(
@Autowired requestRepository: RequestRepository
) {
this.requestRepository = requestRepository
}
@Test
fun shouldSaveRequest() {
val request = Request(
randomRequestId(),
PatientPseudonym("TEST_12345678901"),
PatientId("P1"),
Fingerprint("0123456789abcdef1"),
RequestType.MTB_FILE,
RequestStatus.WARNING,
Instant.parse("2023-07-07T00:00:00Z")
)
requestRepository.save(request)
}
}

View File

@ -1,135 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.pseudonym
import dev.dnpm.etl.processor.config.GPasConfigProperties
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.retry.policy.SimpleRetryPolicy
import org.springframework.retry.support.RetryTemplateBuilder
import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.method
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
import org.springframework.test.web.client.response.MockRestResponseCreators.withException
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.web.client.RestTemplate
import java.io.IOException
class GpasPseudonymGeneratorTest {
private lateinit var mockRestServiceServer: MockRestServiceServer
private lateinit var generator: GpasPseudonymGenerator
private lateinit var restTemplate: RestTemplate
@BeforeEach
fun setup() {
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
val gPasConfigProperties = GPasConfigProperties(
"https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate",
"test",
null,
null
)
this.restTemplate = RestTemplate()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.generator = GpasPseudonymGenerator(gPasConfigProperties, retryTemplate, restTemplate)
}
@Test
fun shouldReturnExpectedPseudonym() {
this.mockRestServiceServer.expect {
method(HttpMethod.POST)
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
}.andRespond {
withStatus(HttpStatus.OK).body(getDummyResponseBody("1234", "test", "test1234ABCDEF567890"))
.createResponse(it)
}
assertThat(this.generator.generate("ID1234")).isEqualTo("test1234ABCDEF567890")
}
@Test
fun shouldThrowExceptionIfGpasNotAvailable() {
this.mockRestServiceServer.expect {
method(HttpMethod.POST)
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
}.andRespond {
withException(IOException("Simulated IO error")).createResponse(it)
}
assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") }
}
@Test
fun shouldThrowExceptionIfGpasDoesNotReturn2xxResponse() {
this.mockRestServiceServer.expect {
method(HttpMethod.POST)
requestTo("https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
}.andRespond {
withStatus(HttpStatus.FOUND)
.header(HttpHeaders.LOCATION, "https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate")
.createResponse(it)
}
assertThrows<PseudonymRequestFailed> { this.generator.generate("ID1234") }
}
companion object {
fun getDummyResponseBody(original: String, target: String, pseudonym: String) = """{
"resourceType": "Parameters",
"parameter": [
{
"name": "pseudonym",
"part": [
{
"name": "original",
"valueIdentifier": {
"system": "https://ths-greifswald.de/gpas",
"value": "$original"
}
},
{
"name": "target",
"valueIdentifier": {
"system": "https://ths-greifswald.de/gpas",
"value": "$target"
}
},
{
"name": "pseudonym",
"valueIdentifier": {
"system": "https://ths-greifswald.de/gpas",
"value": "$pseudonym"
}
}
]
}
]
}""".trimIndent()
}
}

View File

@ -19,7 +19,7 @@
package dev.dnpm.etl.processor.services package dev.dnpm.etl.processor.services
import dev.dnpm.etl.processor.* import dev.dnpm.etl.processor.AbstractTestcontainerTest
import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
@ -31,18 +31,19 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest 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.TestPropertySource
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import org.testcontainers.junit.jupiter.Testcontainers import org.testcontainers.junit.jupiter.Testcontainers
import java.time.Instant import java.time.Instant
import java.util.*
@Testcontainers @Testcontainers
@ExtendWith(SpringExtension::class) @ExtendWith(SpringExtension::class)
@SpringBootTest @SpringBootTest
@Transactional @Transactional
@MockitoBean(types = [MtbFileSender::class]) @MockBean(MtbFileSender::class)
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=buildin", "app.pseudonymize.generator=buildin",
@ -65,7 +66,7 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
@Test @Test
fun shouldResultInEmptyRequestList() { fun shouldResultInEmptyRequestList() {
val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM) val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901")
assertThat(actual).isEmpty() assertThat(actual).isEmpty()
} }
@ -75,33 +76,33 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
this.requestRepository.saveAll( this.requestRepository.saveAll(
listOf( listOf(
Request( Request(
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("0123456789abcdef1"), fingerprint = "0123456789abcdef1",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.SUCCESS, status = RequestStatus.SUCCESS,
Instant.parse("2023-07-07T02:00:00Z") processedAt = Instant.parse("2023-07-07T02:00:00Z")
), ),
// Should be ignored - wrong patient ID --> // Should be ignored - wrong patient ID -->
Request( Request(
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678902"), patientId = "TEST_12345678902",
PatientId("P2"), pid = "P2",
Fingerprint("0123456789abcdef2"), fingerprint = "0123456789abcdef2",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.WARNING, status = RequestStatus.WARNING,
Instant.parse("2023-08-08T00:00:00Z") processedAt = Instant.parse("2023-08-08T00:00:00Z")
), ),
// <-- // <--
Request( Request(
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P2"), pid = "P2",
Fingerprint("0123456789abcdee1"), fingerprint = "0123456789abcdee1",
RequestType.DELETE, type = RequestType.DELETE,
RequestStatus.SUCCESS, status = RequestStatus.SUCCESS,
Instant.parse("2023-08-08T02:00:00Z") processedAt = Instant.parse("2023-08-08T02:00:00Z")
) )
) )
) )
@ -111,18 +112,18 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
fun shouldResultInSortedRequestList() { fun shouldResultInSortedRequestList() {
setupTestData() setupTestData()
val actual = requestService.allRequestsByPatientPseudonym(TEST_PATIENT_PSEUDONYM) val actual = requestService.allRequestsByPatientPseudonym("TEST_12345678901")
assertThat(actual).hasSize(2) assertThat(actual).hasSize(2)
assertThat(actual[0].fingerprint).isEqualTo(Fingerprint("0123456789abcdee1")) assertThat(actual[0].fingerprint).isEqualTo("0123456789abcdee1")
assertThat(actual[1].fingerprint).isEqualTo(Fingerprint("0123456789abcdef1")) assertThat(actual[1].fingerprint).isEqualTo("0123456789abcdef1")
} }
@Test @Test
fun shouldReturnDeleteRequestAsLastRequest() { fun shouldReturnDeleteRequestAsLastRequest() {
setupTestData() setupTestData()
val actual = requestService.isLastRequestWithKnownStatusDeletion(TEST_PATIENT_PSEUDONYM) val actual = requestService.isLastRequestWithKnownStatusDeletion("TEST_12345678901")
assertThat(actual).isTrue() assertThat(actual).isTrue()
} }
@ -131,14 +132,10 @@ class RequestServiceIntegrationTest : AbstractTestcontainerTest() {
fun shouldReturnLastMtbFileRequest() { fun shouldReturnLastMtbFileRequest() {
setupTestData() setupTestData()
val actual = requestService.lastMtbFileRequestForPatientPseudonym(TEST_PATIENT_PSEUDONYM) val actual = requestService.lastMtbFileRequestForPatientPseudonym("TEST_12345678901")
assertThat(actual).isNotNull assertThat(actual).isNotNull
assertThat(actual?.fingerprint).isEqualTo(Fingerprint("0123456789abcdef1")) assertThat(actual?.fingerprint).isEqualTo("0123456789abcdef1")
}
companion object {
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("TEST_12345678901")
} }
} }

View File

@ -21,49 +21,30 @@ package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.config.AppConfiguration import dev.dnpm.etl.processor.config.AppConfiguration
import dev.dnpm.etl.processor.config.AppSecurityConfiguration import dev.dnpm.etl.processor.config.AppSecurityConfiguration
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService import dev.dnpm.etl.processor.monitoring.RestConnectionCheckService
import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.security.Role
import dev.dnpm.etl.processor.security.TokenService
import dev.dnpm.etl.processor.security.UserRoleService
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import dev.dnpm.etl.processor.services.TokenRepository
import dev.dnpm.etl.processor.services.TransformationService 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.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers.anyString
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 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.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.MediaType.TEXT_EVENT_STREAM
import org.springframework.security.test.context.support.WithMockUser
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource 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.context.junit.jupiter.SpringExtension
import org.springframework.test.web.reactive.server.WebTestClient import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.* import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.client.MockMvcWebTestClient
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
import org.springframework.web.context.WebApplicationContext
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import reactor.test.StepVerifier
import java.time.Instant
abstract class MockSink : Sinks.Many<Boolean> abstract class MockSink : Sinks.Many<Boolean>
@ -78,50 +59,44 @@ abstract class MockSink : Sinks.Many<Boolean>
) )
@TestPropertySource( @TestPropertySource(
properties = [ properties = [
"app.pseudonymize.generator=BUILDIN" "app.pseudonymize.generator=BUILDIN",
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret",
"app.security.enable-tokens=true"
] ]
) )
@MockitoBean(name = "configsUpdateProducer", types = [MockSink::class]) @MockBean(name = "configsUpdateProducer", classes = [MockSink::class])
@MockitoBean( @MockBean(
types = [ Generator::class,
Generator::class, MtbFileSender::class,
MtbFileSender::class, ConnectionCheckService::class,
RequestProcessor::class, RequestProcessor::class,
TransformationService::class, TransformationService::class,
GPasConnectionCheckService::class, TokenRepository::class,
RestConnectionCheckService::class RestConnectionCheckService::class
]
) )
class ConfigControllerTest { class ConfigControllerTest {
private lateinit var mockMvc: MockMvc private lateinit var mockMvc: MockMvc
private lateinit var webClient: WebClient
private lateinit var requestProcessor: RequestProcessor private lateinit var requestProcessor: RequestProcessor
private lateinit var connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
@BeforeEach @BeforeEach
fun setup( fun setup(
@Autowired mockMvc: MockMvc, @Autowired mockMvc: MockMvc,
@Autowired requestProcessor: RequestProcessor, @Autowired requestProcessor: RequestProcessor
@Autowired connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) { ) {
this.mockMvc = mockMvc this.mockMvc = mockMvc
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
this.requestProcessor = requestProcessor this.requestProcessor = requestProcessor
this.connectionCheckUpdateProducer = connectionCheckUpdateProducer
webClient.options.isThrowExceptionOnScriptError = false
} }
@Test @Test
fun testShouldRequestConfigPageIfLoggedIn() { fun testShouldShowConfigPageIfLoggedIn() {
mockMvc.get("/configs") { mockMvc.get("/configs") {
with(user("admin").roles("ADMIN")) with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML) accept(MediaType.TEXT_HTML)
}.andExpect { }.andExpect {
status { isOk() } status { isOk() }
view { name("configs") }
} }
} }
@ -138,232 +113,4 @@ class ConfigControllerTest {
} }
} }
@Nested
@TestPropertySource(
properties = [
"app.security.enable-tokens=true",
"app.security.admin-user=admin"
]
)
@MockitoBean(
types = [
TokenService::class
]
)
inner class WithTokensEnabled {
private lateinit var tokenService: TokenService
@BeforeEach
fun setup(
@Autowired tokenService: TokenService
) {
webClient.options.isThrowExceptionOnScriptError = false
this.tokenService = tokenService
}
@Test
fun testShouldSaveNewToken() {
mockMvc.post("/configs/tokens") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
contentType = MediaType.APPLICATION_FORM_URLENCODED
content = "name=Testtoken"
}.andExpect {
status { is2xxSuccessful() }
view { name("configs/tokens") }
}
val captor = argumentCaptor<String>()
verify(tokenService, times(1)).addToken(captor.capture())
assertThat(captor.firstValue).isEqualTo("Testtoken")
}
@Test
fun testShouldNotSaveTokenWithExstingName() {
whenever(tokenService.addToken(anyString())).thenReturn(Result.failure(RuntimeException("Testfailure")))
mockMvc.post("/configs/tokens") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
contentType = MediaType.APPLICATION_FORM_URLENCODED
content = "name=Testtoken"
}.andExpect {
status { is2xxSuccessful() }
view { name("configs/tokens") }
}
val captor = argumentCaptor<String>()
verify(tokenService, times(1)).addToken(captor.capture())
assertThat(captor.firstValue).isEqualTo("Testtoken")
}
@Test
fun testShouldDeleteToken() {
mockMvc.delete("/configs/tokens/42") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
}.andExpect {
status { is2xxSuccessful() }
view { name("configs/tokens") }
}
val captor = argumentCaptor<Long>()
verify(tokenService, times(1)).deleteToken(captor.capture())
assertThat(captor.firstValue).isEqualTo(42)
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldRenderConfigPageWithTokens() {
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
assertThat(
page.getElementById("tokens")
).isNotNull
}
}
@Nested
@TestPropertySource(
properties = [
"app.security.enable-tokens=false"
]
)
inner class WithTokensDisabled {
@BeforeEach
fun setup() {
webClient.options.isThrowExceptionOnScriptError = false
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldRenderConfigPageWithoutTokens() {
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
assertThat(
page.getElementById("tokens")
).isNull()
}
}
@Nested
@TestPropertySource(
properties = [
"app.security.enable-tokens=false",
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret"
]
)
@MockitoBean(
types = [
UserRoleService::class
]
)
inner class WithUserRolesEnabled {
private lateinit var userRoleService: UserRoleService
@BeforeEach
fun setup(
@Autowired userRoleService: UserRoleService
) {
webClient.options.isThrowExceptionOnScriptError = false
this.userRoleService = userRoleService
}
@Test
fun testShouldDeleteUserRole() {
mockMvc.delete("/configs/userroles/42") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
}.andExpect {
status { is2xxSuccessful() }
view { name("configs/userroles") }
}
val captor = argumentCaptor<Long>()
verify(userRoleService, times(1)).deleteUserRole(captor.capture())
assertThat(captor.firstValue).isEqualTo(42)
}
@Test
fun testShouldUpdateUserRole() {
mockMvc.put("/configs/userroles/42") {
with(user("admin").roles("ADMIN"))
accept(MediaType.TEXT_HTML)
contentType = MediaType.APPLICATION_FORM_URLENCODED
content = "role=ADMIN"
}.andExpect {
status { is2xxSuccessful() }
view { name("configs/userroles") }
}
val idCaptor = argumentCaptor<Long>()
val roleCaptor = argumentCaptor<Role>()
verify(userRoleService, times(1)).updateUserRole(idCaptor.capture(), roleCaptor.capture())
assertThat(idCaptor.firstValue).isEqualTo(42)
assertThat(roleCaptor.firstValue).isEqualTo(Role.ADMIN)
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldRenderConfigPageWithUserRoles() {
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
assertThat(
page.getElementById("userroles")
).isNotNull
}
}
@Nested
inner class WithUserRolesDisabled {
@BeforeEach
fun setup() {
webClient.options.isThrowExceptionOnScriptError = false
}
@Test
fun testShouldRenderConfigPageWithoutUserRoles() {
val page = webClient.getPage<HtmlPage>("http://localhost/configs")
assertThat(
page.getElementById("userroles")
).isNull()
}
}
@Nested
inner class SseTest {
private lateinit var webClient: WebTestClient
@BeforeEach
fun setup(
applicationContext: WebApplicationContext,
) {
this.webClient = MockMvcWebTestClient
.bindToApplicationContext(applicationContext).build()
}
@Test
fun testShouldRequestSSE() {
val expectedEvent = ConnectionCheckResult.GPasConnectionCheckResult(true, Instant.now(), Instant.now())
connectionCheckUpdateProducer.tryEmitNext(expectedEvent)
connectionCheckUpdateProducer.emitComplete { _, _ -> true }
val result = webClient.get().uri("http://localhost/configs/events").accept(TEXT_EVENT_STREAM).exchange()
.expectStatus().isOk()
.expectHeader().contentType(TEXT_EVENT_STREAM)
.returnResult(ConnectionCheckResult.GPasConnectionCheckResult::class.java)
StepVerifier.create(result.responseBody)
.expectNext(expectedEvent)
.expectComplete()
.verify()
}
}
} }

View File

@ -1,287 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.config.AppConfiguration
import dev.dnpm.etl.processor.config.AppSecurityConfiguration
import dev.dnpm.etl.processor.monitoring.Report
import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType
import dev.dnpm.etl.processor.services.RequestService
import org.assertj.core.api.Assertions.assertThat
import org.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
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any
import org.mockito.kotlin.whenever
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.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
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
import java.io.IOException
import java.time.Instant
import java.util.*
@WebMvcTest(controllers = [HomeController::class])
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
@ContextConfiguration(
classes = [
HomeController::class,
AppConfiguration::class,
AppSecurityConfiguration::class
]
)
@TestPropertySource(
properties = [
"app.pseudonymize.generator=BUILDIN",
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret"
]
)
@MockitoBean(
types = [RequestService::class]
)
class HomeControllerTest {
private lateinit var mockMvc: MockMvc
private lateinit var webClient: WebClient
@BeforeEach
fun setup(
@Autowired mockMvc: MockMvc,
@Autowired requestService: RequestService
) {
this.mockMvc = mockMvc
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
whenever(requestService.findAll(any<Pageable>())).thenReturn(Page.empty())
}
@Test
fun testShouldRequestHomePage() {
mockMvc.get("/").andExpect {
status { isOk() }
view { name("index") }
}
}
@Nested
inner class WithRequests {
private lateinit var requestService: RequestService
@BeforeEach
fun setup(
@Autowired requestService: RequestService
) {
this.requestService = requestService
}
@Test
fun testShouldShowHomePage() {
whenever(requestService.findAll(any<Pageable>())).thenReturn(
PageImpl(
listOf(
Request(
2L,
randomRequestId(),
PatientPseudonym("PSEUDO1"),
PatientId("PATIENT1"),
Fingerprint("ashdkasdh"),
RequestType.MTB_FILE,
RequestStatus.SUCCESS
),
Request(
1L,
randomRequestId(),
PatientPseudonym("PSEUDO1"),
PatientId("PATIENT1"),
Fingerprint("asdasdasd"),
RequestType.MTB_FILE,
RequestStatus.ERROR
)
)
)
)
val page = webClient.getPage<HtmlPage>("http://localhost/")
assertThat(page.querySelectorAll("tbody tr")).hasSize(2)
assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldShowRequestDetails() {
val requestId = randomRequestId()
whenever(requestService.findByUuid(anyValueClass())).thenReturn(
Optional.of(
Request(
2L,
requestId,
PatientPseudonym("PSEUDO1"),
PatientId("PATIENT1"),
Fingerprint("ashdkasdh"),
RequestType.MTB_FILE,
RequestStatus.SUCCESS,
Instant.now(),
Report("Test")
)
)
)
val page = webClient.getPage<HtmlPage>("http://localhost/report/${requestId.value}")
assertThat(page.querySelectorAll("tbody tr")).hasSize(1)
assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldShowPatientDetails() {
whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(
PageImpl(
listOf(
Request(
2L,
randomRequestId(),
PatientPseudonym("PSEUDO1"),
PatientId("PATIENT1"),
Fingerprint("ashdkasdh"),
RequestType.MTB_FILE,
RequestStatus.SUCCESS
),
Request(
1L,
randomRequestId(),
PatientPseudonym("PSEUDO1"),
PatientId("PATIENT1"),
Fingerprint("asdasdasd"),
RequestType.MTB_FILE,
RequestStatus.ERROR
)
)
)
)
val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
assertThat(page.querySelectorAll("tbody tr")).hasSize(2)
assertThat(page.querySelectorAll("div.notification.info")).isEmpty()
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldShowPatientPseudonym() {
whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(
PageImpl(
listOf(
Request(
2L,
randomRequestId(),
PatientPseudonym("PSEUDO1"),
PatientId("PATIENT1"),
Fingerprint("ashdkasdh"),
RequestType.MTB_FILE,
RequestStatus.SUCCESS
),
Request(
1L,
randomRequestId(),
PatientPseudonym("PSEUDO1"),
PatientId("PATIENT1"),
Fingerprint("asdasdasd"),
RequestType.MTB_FILE,
RequestStatus.ERROR
)
)
)
)
val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
assertThat(page.querySelectorAll("h2 > span")).hasSize(1)
assertThat(page.querySelectorAll("h2 > span").first().textContent).isEqualTo("PSEUDO1")
}
}
@Nested
inner class WithoutRequests {
private lateinit var requestService: RequestService
@BeforeEach
fun setup(
@Autowired requestService: RequestService
) {
this.requestService = requestService
whenever(requestService.findAll(any<Pageable>())).thenReturn(Page.empty())
}
@Test
fun testShouldShowHomePage() {
val page = webClient.getPage<HtmlPage>("http://localhost/")
assertThat(page.querySelectorAll("tbody tr")).isEmpty()
assertThat(page.querySelectorAll("div.notification.info")).hasSize(1)
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldThrowNotFoundExceptionForUnknownReport() {
val requestId = randomRequestId()
whenever(requestService.findByUuid(anyValueClass())).thenReturn(
Optional.empty()
)
assertThrows<IOException> {
webClient.getPage<HtmlPage>("http://localhost/report/${requestId.value}")
}.also {
assertThat(it).hasRootCauseInstanceOf(NotFoundException::class.java)
}
}
@Test
@WithMockUser(username = "admin", roles = ["ADMIN"])
fun testShouldShowEmptyPatientDetails() {
whenever(requestService.findRequestByPatientId(anyValueClass(), any<Pageable>())).thenReturn(Page.empty())
val page = webClient.getPage<HtmlPage>("http://localhost/patient/PSEUDO1")
assertThat(page.querySelectorAll("tbody tr")).isEmpty()
assertThat(page.querySelectorAll("div.notification.info")).hasSize(1)
}
}
}

View File

@ -1,88 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.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.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
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
@WebMvcTest(controllers = [LoginController::class])
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
@ContextConfiguration(
classes = [
LoginController::class,
AppConfiguration::class,
AppSecurityConfiguration::class
]
)
@TestPropertySource(
properties = [
"app.pseudonymize.generator=BUILDIN",
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret",
"app.security.enable-tokens=true"
]
)
@MockitoBean(
types = [TokenService::class]
)
class LoginControllerTest {
private lateinit var mockMvc: MockMvc
private lateinit var webClient: WebClient
@BeforeEach
fun setup(@Autowired mockMvc: MockMvc) {
this.mockMvc = mockMvc
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
}
@Test
fun testShouldRequestLoginPage() {
mockMvc.get("/login").andExpect {
status { isOk() }
view { name("login") }
}
}
@Test
fun testShouldShowLoginForm() {
val page = webClient.getPage<HtmlPage>("http://localhost/login")
assertThat(
page.getElementsByTagName("main").first().firstElementChild.getAttribute("class")
).isEqualTo("login-form")
}
}

View File

@ -1,73 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.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
import org.mockito.junit.jupiter.MockitoExtension
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.htmlunit.MockMvcWebClientBuilder
@WebMvcTest(controllers = [StatisticsController::class])
@ExtendWith(value = [MockitoExtension::class, SpringExtension::class])
@ContextConfiguration(
classes = [
StatisticsController::class,
AppConfiguration::class,
AppSecurityConfiguration::class
]
)
@TestPropertySource(
properties = [
"app.pseudonymize.generator=BUILDIN",
"app.security.admin-user=admin",
"app.security.admin-password={noop}very-secret"
]
)
class StatisticsControllerTest {
private lateinit var mockMvc: MockMvc
private lateinit var webClient: WebClient
@BeforeEach
fun setup(@Autowired mockMvc: MockMvc) {
this.mockMvc = mockMvc
this.webClient = MockMvcWebClientBuilder.mockMvcSetup(mockMvc).build()
}
@Test
fun testShouldRequestLoginPage() {
mockMvc.get("/statistics").andExpect {
status { isOk() }
view { name("statistics") }
}
}
}

View File

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

View File

@ -23,17 +23,41 @@ import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.parser.IParser;
import dev.dnpm.etl.processor.config.GPasConfigProperties; import dev.dnpm.etl.processor.config.GPasConfigProperties;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager;
import org.apache.hc.client5.http.socket.ConnectionSocketFactory;
import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
import org.apache.hc.core5.http.config.Registry;
import org.apache.hc.core5.http.config.RegistryBuilder;
import org.hl7.fhir.r4.model.Identifier; import org.hl7.fhir.r4.model.Identifier;
import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
import org.hl7.fhir.r4.model.StringType; import org.hl7.fhir.r4.model.StringType;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.*; import org.springframework.http.*;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.retry.support.RetryTemplate; import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Base64;
public class GpasPseudonymGenerator implements Generator { public class GpasPseudonymGenerator implements Generator {
private final static FhirContext r4Context = FhirContext.forR4(); private final static FhirContext r4Context = FhirContext.forR4();
@ -45,13 +69,27 @@ public class GpasPseudonymGenerator implements Generator {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, RestTemplate restTemplate) { private SSLContext customSslContext;
public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate) {
this.retryTemplate = retryTemplate; this.retryTemplate = retryTemplate;
this.restTemplate = restTemplate; this.restTemplate = getRestTemplete();
this.gPasUrl = gpasCfg.getUri(); this.gPasUrl = gpasCfg.getUri();
this.psnTargetDomain = gpasCfg.getTarget(); this.psnTargetDomain = gpasCfg.getTarget();
httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword()); httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword());
try {
if (StringUtils.isNotBlank(gpasCfg.getSslCaLocation())) {
customSslContext = getSslContext(gpasCfg.getSslCaLocation());
log.warn(String.format("%s has been initialized with SSL certificate %s. This is deprecated in favor of including Root CA.",
this.getClass().getName(), gpasCfg.getSslCaLocation()));
}
} catch (IOException | KeyManagementException | KeyStoreException | CertificateException |
NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
log.debug(String.format("%s has been initialized", this.getClass().getName())); log.debug(String.format("%s has been initialized", this.getClass().getName()));
} }
@ -61,7 +99,7 @@ public class GpasPseudonymGenerator implements Generator {
var gPasRequestBody = getGpasRequestBody(id); var gPasRequestBody = getGpasRequestBody(id);
var responseEntity = getGpasPseudonym(gPasRequestBody); var responseEntity = getGpasPseudonym(gPasRequestBody);
var gPasPseudonymResult = (Parameters) r4Context.newJsonParser() var gPasPseudonymResult = (Parameters) r4Context.newJsonParser()
.parseResource(responseEntity.getBody()); .parseResource(responseEntity.getBody());
return unwrapPseudonym(gPasPseudonymResult); return unwrapPseudonym(gPasPseudonymResult);
} }
@ -75,9 +113,9 @@ public class GpasPseudonymGenerator implements Generator {
} }
final var identifier = (Identifier) parameters.get().getPart().stream() final var identifier = (Identifier) parameters.get().getPart().stream()
.filter(a -> a.getName().equals("pseudonym")) .filter(a -> a.getName().equals("pseudonym"))
.findFirst() .findFirst()
.orElseGet(ParametersParameterComponent::new).getValue(); .orElseGet(ParametersParameterComponent::new).getValue();
// pseudonym // pseudonym
return sanitizeValue(identifier.getValue()); return sanitizeValue(identifier.getValue());
@ -106,8 +144,8 @@ public class GpasPseudonymGenerator implements Generator {
try { try {
responseEntity = retryTemplate.execute( responseEntity = retryTemplate.execute(
ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity, ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity,
String.class)); String.class));
if (responseEntity.getStatusCode().is2xxSuccessful()) { if (responseEntity.getStatusCode().is2xxSuccessful()) {
log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode()); log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode());
@ -119,16 +157,16 @@ public class GpasPseudonymGenerator implements Generator {
return responseEntity; return responseEntity;
} catch (Exception unexpected) { } catch (Exception unexpected) {
throw new PseudonymRequestFailed( throw new PseudonymRequestFailed(
"API request due unexpected error unsuccessful gPas unsuccessful.", unexpected); "API request due unexpected error unsuccessful gPas unsuccessful.", unexpected);
} }
} }
protected String getGpasRequestBody(String id) { protected String getGpasRequestBody(String id) {
var requestParameters = new Parameters(); var requestParameters = new Parameters();
requestParameters.addParameter().setName("target") requestParameters.addParameter().setName("target")
.setValue(new StringType().setValue(psnTargetDomain)); .setValue(new StringType().setValue(psnTargetDomain));
requestParameters.addParameter().setName("original") requestParameters.addParameter().setName("original")
.setValue(new StringType().setValue(id)); .setValue(new StringType().setValue(id));
final IParser iParser = r4Context.newJsonParser(); final IParser iParser = r4Context.newJsonParser();
return iParser.encodeResourceToString(requestParameters); return iParser.encodeResourceToString(requestParameters);
} }
@ -142,7 +180,67 @@ public class GpasPseudonymGenerator implements Generator {
return headers; return headers;
} }
headers.setBasicAuth(gPasUserName, gPasPassword); String authHeader = gPasUserName + ":" + gPasPassword;
byte[] authHeaderBytes = authHeader.getBytes();
byte[] encodedAuthHeaderBytes = Base64.getEncoder().encode(authHeaderBytes);
String encodedAuthHeader = new String(encodedAuthHeaderBytes);
if (StringUtils.isNotBlank(gPasUserName) && StringUtils.isNotBlank(gPasPassword)) {
headers.set("Authorization", "Basic " + encodedAuthHeader);
}
return headers; return headers;
} }
/**
* Read SSL root certificate and return SSLContext
*
* @param certificateLocation file location to root certificate (PEM)
* @return initialized SSLContext
* @throws IOException file cannot be read
* @throws CertificateException in case we have an invalid certificate of type X.509
* @throws KeyStoreException keystore cannot be initialized
* @throws NoSuchAlgorithmException missing trust manager algorithmus
* @throws KeyManagementException key management failed at init SSLContext
*/
@Nullable
protected SSLContext getSslContext(String certificateLocation)
throws IOException, CertificateException, KeyStoreException, KeyManagementException, NoSuchAlgorithmException {
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
FileInputStream fis = new FileInputStream(certificateLocation);
X509Certificate ca = (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new BufferedInputStream(fis));
ks.load(null, null);
ks.setCertificateEntry(Integer.toString(1), ca);
TrustManagerFactory tmf = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm());
tmf.init(ks);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tmf.getTrustManagers(), null);
return sslContext;
}
protected RestTemplate getRestTemplete() {
if (customSslContext == null) {
return new RestTemplate();
}
final var sslsf = new SSLConnectionSocketFactory(customSslContext);
final Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
.register("https", sslsf).register("http", new PlainConnectionSocketFactory()).build();
final BasicHttpClientConnectionManager connectionManager = new BasicHttpClientConnectionManager(
socketFactoryRegistry);
final CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager).build();
final HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(
httpClient);
return new RestTemplate(requestFactory);
}
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,10 +21,16 @@ package dev.dnpm.etl.processor.config
import dev.dnpm.etl.processor.security.Role import dev.dnpm.etl.processor.security.Role
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.DeprecatedConfigurationProperty
@ConfigurationProperties(AppConfigProperties.NAME) @ConfigurationProperties(AppConfigProperties.NAME)
data class AppConfigProperties( data class AppConfigProperties(
var bwhcUri: String?, 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<TransformationProperties> = listOf(), var transformations: List<TransformationProperties> = listOf(),
var maxRetryAttempts: Int = 3, var maxRetryAttempts: Int = 3,
var duplicationDetection: Boolean = true var duplicationDetection: Boolean = true
@ -50,6 +56,10 @@ data class GPasConfigProperties(
val target: String = "etl-processor", val target: String = "etl-processor",
val username: String?, val username: String?,
val password: String?, val password: String?,
@get:DeprecatedConfigurationProperty(
reason = "Deprecated in favor of including Root CA"
)
val sslCaLocation: String?
) { ) {
companion object { companion object {
const val NAME = "app.pseudonymize.gpas" const val NAME = "app.pseudonymize.gpas"
@ -59,9 +69,6 @@ data class GPasConfigProperties(
@ConfigurationProperties(RestTargetProperties.NAME) @ConfigurationProperties(RestTargetProperties.NAME)
data class RestTargetProperties( data class RestTargetProperties(
val uri: String?, val uri: String?,
val username: String?,
val password: String?,
val isBwhc: Boolean = false,
) { ) {
companion object { companion object {
const val NAME = "app.rest" const val NAME = "app.rest"
@ -72,8 +79,18 @@ data class RestTargetProperties(
data class KafkaProperties( data class KafkaProperties(
val inputTopic: String?, val inputTopic: String?,
val outputTopic: String = "etl-processor", val outputTopic: String = "etl-processor",
@get:DeprecatedConfigurationProperty(
reason = "Deprecated",
replacement = "outputTopic"
)
val topic: String = outputTopic,
val outputResponseTopic: String = "${outputTopic}_response", val outputResponseTopic: String = "${outputTopic}_response",
val groupId: String = "${outputTopic}_group", @get:DeprecatedConfigurationProperty(
reason = "Deprecated",
replacement = "outputResponseTopic"
)
val responseTopic: String = outputResponseTopic,
val groupId: String = "${topic}_group",
val servers: String = "" val servers: String = ""
) { ) {
companion object { companion object {

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -20,16 +20,13 @@
package dev.dnpm.etl.processor.config package dev.dnpm.etl.processor.config
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult import dev.dnpm.etl.processor.monitoring.*
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService
import dev.dnpm.etl.processor.monitoring.GPasConnectionCheckService
import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator import dev.dnpm.etl.processor.pseudonym.AnonymizingGenerator
import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator import dev.dnpm.etl.processor.pseudonym.GpasPseudonymGenerator
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
import dev.dnpm.etl.processor.security.TokenRepository import dev.dnpm.etl.processor.services.TokenRepository
import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.services.TokenService
import dev.dnpm.etl.processor.services.Transformation import dev.dnpm.etl.processor.services.Transformation
import dev.dnpm.etl.processor.services.TransformationService import dev.dnpm.etl.processor.services.TransformationService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@ -38,7 +35,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
import org.springframework.retry.RetryCallback import org.springframework.retry.RetryCallback
import org.springframework.retry.RetryContext import org.springframework.retry.RetryContext
import org.springframework.retry.RetryListener import org.springframework.retry.RetryListener
@ -48,7 +44,6 @@ import org.springframework.retry.support.RetryTemplateBuilder
import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.provisioning.InMemoryUserDetailsManager import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -75,8 +70,8 @@ class AppConfiguration {
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS") @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "GPAS")
@Bean @Bean
fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate, restTemplate: RestTemplate): Generator { fun gpasPseudonymGenerator(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate): Generator {
return GpasPseudonymGenerator(configProperties, retryTemplate, restTemplate) return GpasPseudonymGenerator(configProperties, retryTemplate)
} }
@ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "BUILDIN", matchIfMissing = true) @ConditionalOnProperty(value = ["app.pseudonymize.generator"], havingValue = "BUILDIN", matchIfMissing = true)
@ -85,6 +80,20 @@ class AppConfiguration {
return AnonymizingGenerator() return AnonymizingGenerator()
} }
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "GPAS")
@ConditionalOnMissingBean
@Bean
fun gpasPseudonymGeneratorOnDeprecatedProperty(configProperties: GPasConfigProperties, retryTemplate: RetryTemplate): Generator {
return GpasPseudonymGenerator(configProperties, retryTemplate)
}
@ConditionalOnProperty(value = ["app.pseudonymizer"], havingValue = "BUILDIN")
@ConditionalOnMissingBean
@Bean
fun buildinPseudonymGeneratorOnDeprecatedProperty(): Generator {
return AnonymizingGenerator()
}
@Bean @Bean
fun pseudonymizeService( fun pseudonymizeService(
generator: Generator, generator: Generator,
@ -113,8 +122,6 @@ class AppConfiguration {
fun retryTemplate(configProperties: AppConfigProperties): RetryTemplate { fun retryTemplate(configProperties: AppConfigProperties): RetryTemplate {
return RetryTemplateBuilder() return RetryTemplateBuilder()
.notRetryOn(IllegalArgumentException::class.java) .notRetryOn(IllegalArgumentException::class.java)
.notRetryOn(HttpClientErrorException.BadRequest::class.java)
.notRetryOn(HttpClientErrorException.UnprocessableEntity::class.java)
.exponentialBackoff(2.seconds.toJavaDuration(), 1.25, 5.seconds.toJavaDuration()) .exponentialBackoff(2.seconds.toJavaDuration(), 1.25, 5.seconds.toJavaDuration())
.customPolicy(SimpleRetryPolicy(configProperties.maxRetryAttempts)) .customPolicy(SimpleRetryPolicy(configProperties.maxRetryAttempts))
.withListener(object : RetryListener { .withListener(object : RetryListener {
@ -166,9 +173,5 @@ class AppConfiguration {
return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer) return GPasConnectionCheckService(restTemplate, gPasConfigProperties, connectionCheckUpdateProducer)
} }
@Bean
fun jdbcConfiguration(): AbstractJdbcConfiguration {
return AppJdbcConfiguration()
}
} }

View File

@ -1,25 +0,0 @@
package dev.dnpm.etl.processor.config
import dev.dnpm.etl.processor.Fingerprint
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.data.jdbc.repository.config.AbstractJdbcConfiguration
@Configuration
class AppJdbcConfiguration : AbstractJdbcConfiguration() {
override fun userConverters(): MutableList<*> {
return mutableListOf(StringToFingerprintConverter(), FingerprintToStringConverter())
}
}
class StringToFingerprintConverter : Converter<String, Fingerprint> {
override fun convert(source: String): Fingerprint {
return Fingerprint(source)
}
}
class FingerprintToStringConverter : Converter<Fingerprint, String> {
override fun convert(source: Fingerprint): String {
return source.value
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -71,7 +71,7 @@ class AppKafkaConfiguration {
kafkaProperties: KafkaProperties, kafkaProperties: KafkaProperties,
kafkaResponseProcessor: KafkaResponseProcessor kafkaResponseProcessor: KafkaResponseProcessor
): KafkaMessageListenerContainer<String, String> { ): KafkaMessageListenerContainer<String, String> {
val containerProperties = ContainerProperties(kafkaProperties.outputResponseTopic) val containerProperties = ContainerProperties(kafkaProperties.responseTopic)
containerProperties.messageListener = kafkaResponseProcessor containerProperties.messageListener = kafkaResponseProcessor
return KafkaMessageListenerContainer(consumerFactory, containerProperties) return KafkaMessageListenerContainer(consumerFactory, containerProperties)
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,11 +21,9 @@ package dev.dnpm.etl.processor.config
import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult import dev.dnpm.etl.processor.monitoring.ConnectionCheckResult
import dev.dnpm.etl.processor.monitoring.ConnectionCheckService 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.monitoring.RestConnectionCheckService
import dev.dnpm.etl.processor.output.MtbFileSender import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.output.RestBwhcMtbFileSender import dev.dnpm.etl.processor.output.RestMtbFileSender
import dev.dnpm.etl.processor.output.RestDipMtbFileSender
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
@ -54,16 +52,10 @@ class AppRestConfiguration {
fun restMtbFileSender( fun restMtbFileSender(
restTemplate: RestTemplate, restTemplate: RestTemplate,
restTargetProperties: RestTargetProperties, restTargetProperties: RestTargetProperties,
retryTemplate: RetryTemplate, retryTemplate: RetryTemplate
reportService: ReportService,
): MtbFileSender { ): MtbFileSender {
if (restTargetProperties.isBwhc) { logger.info("Selected 'RestMtbFileSender'")
logger.info("Selected 'RestBwhcMtbFileSender'") return RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
return RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
}
logger.info("Selected 'RestDipMtbFileSender'")
return RestDipMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
} }
@Bean @Bean

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,7 +21,7 @@ package dev.dnpm.etl.processor.config
import dev.dnpm.etl.processor.security.UserRole import dev.dnpm.etl.processor.security.UserRole
import dev.dnpm.etl.processor.security.UserRoleRepository import dev.dnpm.etl.processor.security.UserRoleRepository
import dev.dnpm.etl.processor.security.UserRoleService import dev.dnpm.etl.processor.services.UserRoleService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
@ -44,8 +44,6 @@ import org.springframework.security.web.SecurityFilterChain
import java.util.* import java.util.*
private const val LOGIN_PATH = "/login"
@Configuration @Configuration
@EnableConfigurationProperties( @EnableConfigurationProperties(
value = [ value = [
@ -87,16 +85,11 @@ class AppSecurityConfiguration(
@Bean @Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true") @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
fun filterChainOidc( fun filterChainOidc(http: HttpSecurity, passwordEncoder: PasswordEncoder, userRoleRepository: UserRoleRepository, sessionRegistry: SessionRegistry): SecurityFilterChain {
http: HttpSecurity,
passwordEncoder: PasswordEncoder,
userRoleRepository: UserRoleRepository,
sessionRegistry: SessionRegistry
): SecurityFilterChain {
http { http {
authorizeHttpRequests { authorizeRequests {
authorize("/configs/**", hasRole("ADMIN")) authorize("/configs/**", hasRole("ADMIN"))
authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN", "USER")) authorize("/mtbfile/**", hasAnyRole("MTBFILE"))
authorize("/report/**", hasAnyRole("ADMIN", "USER")) authorize("/report/**", hasAnyRole("ADMIN", "USER"))
authorize("*.css", permitAll) authorize("*.css", permitAll)
authorize("*.ico", permitAll) authorize("*.ico", permitAll)
@ -111,15 +104,15 @@ class AppSecurityConfiguration(
realmName = "ETL-Processor" realmName = "ETL-Processor"
} }
formLogin { formLogin {
loginPage = LOGIN_PATH loginPage = "/login"
} }
oauth2Login { oauth2Login {
loginPage = LOGIN_PATH loginPage = "/login"
} }
sessionManagement { sessionManagement {
sessionConcurrency { sessionConcurrency {
maximumSessions = 1 maximumSessions = 1
expiredUrl = "$LOGIN_PATH?expired" expiredUrl = "/login?expired"
} }
sessionFixation { sessionFixation {
newSession() newSession()
@ -132,22 +125,13 @@ class AppSecurityConfiguration(
@Bean @Bean
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true") @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "true")
fun grantedAuthoritiesMapper( fun grantedAuthoritiesMapper(userRoleRepository: UserRoleRepository, appSecurityConfigProperties: SecurityConfigProperties): GrantedAuthoritiesMapper {
userRoleRepository: UserRoleRepository,
appSecurityConfigProperties: SecurityConfigProperties
): GrantedAuthoritiesMapper {
return GrantedAuthoritiesMapper { grantedAuthority -> return GrantedAuthoritiesMapper { grantedAuthority ->
grantedAuthority.filterIsInstance<OidcUserAuthority>() grantedAuthority.filterIsInstance<OidcUserAuthority>()
.onEach { .onEach {
val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername) val userRole = userRoleRepository.findByUsername(it.userInfo.preferredUsername)
if (userRole.isEmpty) { if (userRole.isEmpty) {
userRoleRepository.save( userRoleRepository.save(UserRole(null, it.userInfo.preferredUsername, appSecurityConfigProperties.defaultNewUserRole))
UserRole(
null,
it.userInfo.preferredUsername,
appSecurityConfigProperties.defaultNewUserRole
)
)
} }
} }
.map { .map {
@ -161,9 +145,9 @@ class AppSecurityConfiguration(
@ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true) @ConditionalOnProperty(value = ["app.security.enable-oidc"], havingValue = "false", matchIfMissing = true)
fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain { fun filterChain(http: HttpSecurity, passwordEncoder: PasswordEncoder): SecurityFilterChain {
http { http {
authorizeHttpRequests { authorizeRequests {
authorize("/configs/**", hasRole("ADMIN")) authorize("/configs/**", hasRole("ADMIN"))
authorize("/mtbfile/**", hasAnyRole("MTBFILE", "ADMIN")) authorize("/mtbfile/**", hasAnyRole("MTBFILE"))
authorize("/report/**", hasRole("ADMIN")) authorize("/report/**", hasRole("ADMIN"))
authorize(anyRequest, permitAll) authorize(anyRequest, permitAll)
} }
@ -171,7 +155,7 @@ class AppSecurityConfiguration(
realmName = "ETL-Processor" realmName = "ETL-Processor"
} }
formLogin { formLogin {
loginPage = LOGIN_PATH loginPage = "/login"
} }
csrf { disable() } csrf { disable() }
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -22,13 +22,9 @@ package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile 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.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.clients.consumer.ConsumerRecord
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.kafka.listener.MessageListener import org.springframework.kafka.listener.MessageListener
class KafkaInputListener( class KafkaInputListener(
@ -37,33 +33,13 @@ class KafkaInputListener(
) : MessageListener<String, String> { ) : MessageListener<String, String> {
private val logger = LoggerFactory.getLogger(KafkaInputListener::class.java) private val logger = LoggerFactory.getLogger(KafkaInputListener::class.java)
override fun onMessage(record: ConsumerRecord<String, String>) { override fun onMessage(data: ConsumerRecord<String, String>) {
when (guessMimeType(record)) { val mtbFile = objectMapper.readValue(data.value(), MtbFile::class.java)
MediaType.APPLICATION_JSON_VALUE -> handleBwhcMessage(record) val firstRequestIdHeader = data.headers().headers("requestId")?.firstOrNull()
CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE -> handleDnpmV2Message(record)
else -> {
/* ignore other messages */
}
}
}
private fun guessMimeType(record: ConsumerRecord<String, String>): 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<String, String>) {
val mtbFile = objectMapper.readValue(record.value(), MtbFile::class.java)
val patientId = PatientId(mtbFile.patient.id)
val firstRequestIdHeader = record.headers().headers("requestId")?.firstOrNull()
val requestId = if (null != firstRequestIdHeader) { val requestId = if (null != firstRequestIdHeader) {
RequestId(String(firstRequestIdHeader.value())) String(firstRequestIdHeader.value())
} else { } else {
RequestId("") ""
} }
if (mtbFile.consent.status == Consent.Status.ACTIVE) { if (mtbFile.consent.status == Consent.Status.ACTIVE) {
@ -76,16 +52,10 @@ class KafkaInputListener(
} else { } else {
logger.debug("Accepted MTB File and process deletion") logger.debug("Accepted MTB File and process deletion")
if (requestId.isBlank()) { if (requestId.isBlank()) {
requestProcessor.processDeletion(patientId) requestProcessor.processDeletion(mtbFile.patient.id)
} else { } else {
requestProcessor.processDeletion(patientId, requestId) requestProcessor.processDeletion(mtbFile.patient.id, requestId)
} }
} }
} }
private fun handleDnpmV2Message(record: ConsumerRecord<String, String>) {
// Do not handle DNPM-V2 for now
logger.warn("Ignoring MTB File in DNPM V2 format: Not implemented yet")
}
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,17 +21,13 @@ package dev.dnpm.etl.processor.input
import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.Mtb
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@RestController @RestController
@RequestMapping(path = ["mtbfile", "mtb"]) @RequestMapping(path = ["mtbfile"])
class MtbFileRestController( class MtbFileRestController(
private val requestProcessor: RequestProcessor, private val requestProcessor: RequestProcessor,
) { ) {
@ -43,30 +39,22 @@ class MtbFileRestController(
return ResponseEntity.ok("Test") return ResponseEntity.ok("Test")
} }
@PostMapping( consumes = [ MediaType.APPLICATION_JSON_VALUE ] ) @PostMapping
fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Unit> { fun mtbFile(@RequestBody mtbFile: MtbFile): ResponseEntity<Void> {
if (mtbFile.consent.status == Consent.Status.ACTIVE) { if (mtbFile.consent.status == Consent.Status.ACTIVE) {
logger.debug("Accepted MTB File (bwHC V1) for processing") logger.debug("Accepted MTB File for processing")
requestProcessor.processMtbFile(mtbFile) requestProcessor.processMtbFile(mtbFile)
} else { } else {
logger.debug("Accepted MTB File (bwHC V1) and process deletion") logger.debug("Accepted MTB File and process deletion")
val patientId = PatientId(mtbFile.patient.id) requestProcessor.processDeletion(mtbFile.patient.id)
requestProcessor.processDeletion(patientId)
} }
return ResponseEntity.accepted().build() return ResponseEntity.accepted().build()
} }
@PostMapping( consumes = [ CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE] )
fun mtbFile(@RequestBody mtbFile: Mtb): ResponseEntity<Unit> {
logger.debug("Accepted MTB File (DNPM V2) for processing")
requestProcessor.processMtbFile(mtbFile)
return ResponseEntity.accepted().build()
}
@DeleteMapping(path = ["{patientId}"]) @DeleteMapping(path = ["{patientId}"])
fun deleteData(@PathVariable patientId: String): ResponseEntity<Unit> { fun deleteData(@PathVariable patientId: String): ResponseEntity<Void> {
logger.debug("Accepted patient ID to process deletion") logger.debug("Accepted patient ID to process deletion")
requestProcessor.processDeletion(PatientId(patientId)) requestProcessor.processDeletion(patientId)
return ResponseEntity.accepted().build() return ResponseEntity.accepted().build()
} }

View File

@ -26,18 +26,22 @@ import jakarta.annotation.PostConstruct
import org.apache.kafka.clients.consumer.Consumer import org.apache.kafka.clients.consumer.Consumer
import org.apache.kafka.common.errors.TimeoutException import org.apache.kafka.common.errors.TimeoutException
import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.* import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.RequestEntity
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
import org.springframework.web.util.UriComponentsBuilder import org.springframework.web.util.UriComponentsBuilder
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import java.time.Instant
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration import kotlin.time.toJavaDuration
fun interface ConnectionCheckService { interface ConnectionCheckService {
fun connectionAvailable(): ConnectionCheckResult fun connectionAvailable(): Boolean
} }
@ -47,27 +51,9 @@ sealed class ConnectionCheckResult {
abstract val available: Boolean abstract val available: Boolean
abstract val timestamp: Instant data class KafkaConnectionCheckResult(override val available: Boolean) : ConnectionCheckResult()
data class RestConnectionCheckResult(override val available: Boolean) : ConnectionCheckResult()
abstract val lastChange: Instant data class GPasConnectionCheckResult(override val available: Boolean) : ConnectionCheckResult()
data class KafkaConnectionCheckResult(
override val available: Boolean,
override val timestamp: Instant,
override val lastChange: Instant
) : ConnectionCheckResult()
data class RestConnectionCheckResult(
override val available: Boolean,
override val timestamp: Instant,
override val lastChange: Instant
) : ConnectionCheckResult()
data class GPasConnectionCheckResult(
override val available: Boolean,
override val timestamp: Instant,
override val lastChange: Instant
) : ConnectionCheckResult()
} }
class KafkaConnectionCheckService( class KafkaConnectionCheckService(
@ -76,33 +62,25 @@ class KafkaConnectionCheckService(
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) : OutputConnectionCheckService { ) : OutputConnectionCheckService {
private var result = ConnectionCheckResult.KafkaConnectionCheckResult(false, Instant.now(), Instant.now()) private var connectionAvailable: Boolean = false
@PostConstruct @PostConstruct
@Scheduled(cron = "0 * * * * *") @Scheduled(cron = "0 * * * * *")
fun check() { fun check() {
result = try { connectionAvailable = try {
val available = null != consumer.listTopics(5.seconds.toJavaDuration()) null != consumer.listTopics(5.seconds.toJavaDuration())
ConnectionCheckResult.KafkaConnectionCheckResult( } catch (e: TimeoutException) {
available, false
Instant.now(),
if (result.available == available) { result.lastChange } else { Instant.now() }
)
} catch (_: TimeoutException) {
ConnectionCheckResult.KafkaConnectionCheckResult(
false,
Instant.now(),
if (!result.available) { result.lastChange } else { Instant.now() }
)
} }
connectionCheckUpdateProducer.emitNext( connectionCheckUpdateProducer.emitNext(
result, ConnectionCheckResult.KafkaConnectionCheckResult(connectionAvailable),
Sinks.EmitFailureHandler.FAIL_FAST Sinks.EmitFailureHandler.FAIL_FAST
) )
} }
override fun connectionAvailable(): ConnectionCheckResult.KafkaConnectionCheckResult { override fun connectionAvailable(): Boolean {
return this.result return this.connectionAvailable
} }
} }
@ -114,45 +92,27 @@ class RestConnectionCheckService(
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) : OutputConnectionCheckService { ) : OutputConnectionCheckService {
private var result = ConnectionCheckResult.RestConnectionCheckResult(false, Instant.now(), Instant.now()) private var connectionAvailable: Boolean = false
@PostConstruct @PostConstruct
@Scheduled(cron = "0 * * * * *") @Scheduled(cron = "0 * * * * *")
fun check() { fun check() {
result = try { connectionAvailable = try {
val available = restTemplate.getForEntity( restTemplate.getForEntity(
if (restTargetProperties.isBwhc) { restTargetProperties.uri?.replace("/etl/api", "").toString(),
UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString()).path("").toUriString()
} else {
UriComponentsBuilder.fromUriString(restTargetProperties.uri.toString())
.pathSegment("mtb")
.pathSegment("kaplan-meier")
.pathSegment("config")
.toUriString()
},
String::class.java String::class.java
).statusCode == HttpStatus.OK ).statusCode == HttpStatus.OK
} catch (e: Exception) {
ConnectionCheckResult.RestConnectionCheckResult( false
available,
Instant.now(),
if (result.available == available) { result.lastChange } else { Instant.now() }
)
} catch (_: Exception) {
ConnectionCheckResult.RestConnectionCheckResult(
false,
Instant.now(),
if (!result.available) { result.lastChange } else { Instant.now() }
)
} }
connectionCheckUpdateProducer.emitNext( connectionCheckUpdateProducer.emitNext(
result, ConnectionCheckResult.RestConnectionCheckResult(connectionAvailable),
Sinks.EmitFailureHandler.FAIL_FAST Sinks.EmitFailureHandler.FAIL_FAST
) )
} }
override fun connectionAvailable(): ConnectionCheckResult.RestConnectionCheckResult { override fun connectionAvailable(): Boolean {
return this.result return this.connectionAvailable
} }
} }
@ -163,48 +123,40 @@ class GPasConnectionCheckService(
private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult> private val connectionCheckUpdateProducer: Sinks.Many<ConnectionCheckResult>
) : ConnectionCheckService { ) : ConnectionCheckService {
private var result = ConnectionCheckResult.GPasConnectionCheckResult(false, Instant.now(), Instant.now()) private var connectionAvailable: Boolean = false
@PostConstruct @PostConstruct
@Scheduled(cron = "0 * * * * *") @Scheduled(cron = "0 * * * * *")
fun check() { fun check() {
result = try { connectionAvailable = try {
val uri = UriComponentsBuilder.fromUriString( val uri = UriComponentsBuilder.fromUriString(
gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/metadata").toString() gPasConfigProperties.uri?.replace("/\$pseudonymizeAllowCreate", "/\$pseudonymize").toString()
).build().toUri() )
.queryParam("target", gPasConfigProperties.target)
.queryParam("original", "???")
.build().toUri()
val headers = HttpHeaders() val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON headers.contentType = MediaType.APPLICATION_JSON
if (!gPasConfigProperties.username.isNullOrBlank() && !gPasConfigProperties.password.isNullOrBlank()) { if (!gPasConfigProperties.username.isNullOrBlank() && !gPasConfigProperties.password.isNullOrBlank()) {
headers.setBasicAuth(gPasConfigProperties.username, gPasConfigProperties.password) headers.setBasicAuth(gPasConfigProperties.username, gPasConfigProperties.password)
} }
restTemplate.exchange(
val available = restTemplate.exchange(
uri, uri,
HttpMethod.GET, HttpMethod.GET,
HttpEntity<Void>(headers), HttpEntity<Void>(headers),
Void::class.java Void::class.java
).statusCode == HttpStatus.OK ).statusCode == HttpStatus.OK
} catch (e: Exception) {
ConnectionCheckResult.GPasConnectionCheckResult( false
available,
Instant.now(),
if (result.available == available) { result.lastChange } else { Instant.now() }
)
} catch (_: Exception) {
ConnectionCheckResult.GPasConnectionCheckResult(
false,
Instant.now(),
if (!result.available) { result.lastChange } else { Instant.now() }
)
} }
connectionCheckUpdateProducer.emitNext( connectionCheckUpdateProducer.emitNext(
result, ConnectionCheckResult.GPasConnectionCheckResult(connectionAvailable),
Sinks.EmitFailureHandler.FAIL_FAST Sinks.EmitFailureHandler.FAIL_FAST
) )
} }
override fun connectionAvailable(): ConnectionCheckResult.GPasConnectionCheckResult { override fun connectionAvailable(): Boolean {
return this.result return this.connectionAvailable
} }
} }

View File

@ -19,15 +19,11 @@
package dev.dnpm.etl.processor.monitoring package dev.dnpm.etl.processor.monitoring
import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.annotation.JsonValue
import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.core.JsonParseException
import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.JsonMappingException
import com.fasterxml.jackson.databind.ObjectMapper 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( class ReportService(
private val objectMapper: ObjectMapper private val objectMapper: ObjectMapper
@ -58,25 +54,11 @@ class ReportService(
private data class DataQualityReport(val issues: List<Issue>) private data class DataQualityReport(val issues: List<Issue>)
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class Issue( data class Issue(val severity: Severity, val message: String)
val severity: Severity,
@JsonAlias("details") val message: String,
val path: Optional<String> = Optional.empty()
)
enum class Severity(@JsonValue val value: String) { enum class Severity(@JsonValue val value: String) {
FATAL("fatal"),
ERROR("error"), ERROR("error"),
WARNING("warning"), WARNING("warning"),
INFO("info") INFO("info")
} }
} }
fun List<Issue>.asRequestStatus(): RequestStatus {
val severity = this.minOfOrNull { it.severity }
return when (severity) {
Severity.FATAL, Severity.ERROR -> RequestStatus.ERROR
Severity.WARNING -> RequestStatus.WARNING
else -> RequestStatus.SUCCESS
}
}

View File

@ -19,12 +19,10 @@
package dev.dnpm.etl.processor.monitoring package dev.dnpm.etl.processor.monitoring
import dev.dnpm.etl.processor.*
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.jdbc.repository.query.Query import org.springframework.data.jdbc.repository.query.Query
import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Embedded import org.springframework.data.relational.core.mapping.Embedded
import org.springframework.data.relational.core.mapping.Table import org.springframework.data.relational.core.mapping.Table
import org.springframework.data.repository.CrudRepository import org.springframework.data.repository.CrudRepository
@ -32,48 +30,26 @@ import org.springframework.data.repository.PagingAndSortingRepository
import java.time.Instant import java.time.Instant
import java.util.* import java.util.*
typealias RequestId = UUID
@Table("request") @Table("request")
data class Request( data class Request(
@Id val id: Long? = null, @Id val id: Long? = null,
val uuid: RequestId = randomRequestId(), val uuid: String = RequestId.randomUUID().toString(),
val patientPseudonym: PatientPseudonym, val patientId: String,
val pid: PatientId, val pid: String,
@Column("fingerprint") val fingerprint: String,
val fingerprint: Fingerprint,
val type: RequestType, val type: RequestType,
var status: RequestStatus, var status: RequestStatus,
var processedAt: Instant = Instant.now(), var processedAt: Instant = Instant.now(),
@Embedded.Nullable var report: Report? = null @Embedded.Nullable var report: Report? = null
) { )
constructor(
uuid: RequestId,
patientPseudonym: PatientPseudonym,
pid: PatientId,
fingerprint: Fingerprint,
type: RequestType,
status: RequestStatus
) :
this(null, uuid, patientPseudonym, pid, fingerprint, type, status, Instant.now())
constructor(
uuid: RequestId,
patientPseudonym: PatientPseudonym,
pid: PatientId,
fingerprint: Fingerprint,
type: RequestType,
status: RequestStatus,
processedAt: Instant
) :
this(null, uuid, patientPseudonym, pid, fingerprint, type, status, processedAt)
}
@JvmRecord
data class Report( data class Report(
val description: String, val description: String,
val dataQualityReport: String = "" val dataQualityReport: String = ""
) )
@JvmRecord
data class CountedState( data class CountedState(
val count: Int, val count: Int,
val status: RequestStatus, val status: RequestStatus,
@ -81,17 +57,17 @@ data class CountedState(
interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRepository<Request, Long> { interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRepository<Request, Long> {
fun findAllByPatientPseudonymOrderByProcessedAtDesc(patientId: PatientPseudonym): List<Request> fun findAllByPatientIdOrderByProcessedAtDesc(patientId: String): List<Request>
fun findByUuidEquals(uuid: RequestId): Optional<Request> fun findByUuidEquals(uuid: String): Optional<Request>
fun findRequestByPatientPseudonym(patientPseudonym: PatientPseudonym, pageable: Pageable): Page<Request> fun findRequestByPatientId(patientId: String, pageable: Pageable): Page<Request>
@Query("SELECT count(*) AS count, status FROM request WHERE type = 'MTB_FILE' GROUP BY status ORDER BY status, count DESC;") @Query("SELECT count(*) AS count, status FROM request WHERE type = 'MTB_FILE' GROUP BY status ORDER BY status, count DESC;")
fun countStates(): List<CountedState> fun countStates(): List<CountedState>
@Query("SELECT count(*) AS count, status FROM (" + @Query("SELECT count(*) AS count, status FROM (" +
"SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " + "SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " +
"WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " + "WHERE type = 'MTB_FILE' AND status NOT IN ('DUPLICATION') " +
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;") ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
fun findPatientUniqueStates(): List<CountedState> fun findPatientUniqueStates(): List<CountedState>
@ -100,7 +76,7 @@ interface RequestRepository : CrudRepository<Request, Long>, PagingAndSortingRep
fun countDeleteStates(): List<CountedState> fun countDeleteStates(): List<CountedState>
@Query("SELECT count(*) AS count, status FROM (" + @Query("SELECT count(*) AS count, status FROM (" +
"SELECT status, rank() OVER (PARTITION BY patient_pseudonym ORDER BY processed_at DESC) AS rank FROM request " + "SELECT status, rank() OVER (PARTITION BY patient_id ORDER BY processed_at DESC) AS rank FROM request " +
"WHERE type = 'DELETE'" + "WHERE type = 'DELETE'" +
") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;") ") rank WHERE rank = 1 GROUP BY status ORDER BY status, count DESC;")
fun findPatientUniqueDeleteStates(): List<CountedState> fun findPatientUniqueDeleteStates(): List<CountedState>

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -22,12 +22,9 @@ package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.config.KafkaProperties import dev.dnpm.etl.processor.config.KafkaProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.apache.kafka.clients.producer.ProducerRecord
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.MediaType
import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.KafkaTemplate
import org.springframework.retry.support.RetryTemplate import org.springframework.retry.support.RetryTemplate
@ -40,20 +37,14 @@ class KafkaMtbFileSender(
private val logger = LoggerFactory.getLogger(KafkaMtbFileSender::class.java) private val logger = LoggerFactory.getLogger(KafkaMtbFileSender::class.java)
override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response { override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response {
return try { return try {
return retryTemplate.execute<MtbFileSender.Response, Exception> { return retryTemplate.execute<MtbFileSender.Response, Exception> {
val record = val result = kafkaTemplate.send(
ProducerRecord(kafkaProperties.outputTopic, key(request), objectMapper.writeValueAsString(request)) kafkaProperties.topic,
when (request) { key(request),
is BwhcV1MtbFileRequest -> record.headers() objectMapper.writeValueAsString(Data(request.requestId, request.mtbFile))
.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) { if (result.get() != null) {
logger.debug("Sent file via KafkaMtbFileSender") logger.debug("Sent file via KafkaMtbFileSender")
MtbFileSender.Response(RequestStatus.UNKNOWN) MtbFileSender.Response(RequestStatus.UNKNOWN)
@ -67,11 +58,11 @@ class KafkaMtbFileSender(
} }
} }
override fun send(request: DeleteRequest): MtbFileSender.Response { override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response {
val dummyMtbFile = MtbFile.builder() val dummyMtbFile = MtbFile.builder()
.withConsent( .withConsent(
Consent.builder() Consent.builder()
.withPatient(request.patientId.value) .withPatient(request.patientId)
.withStatus(Consent.Status.REJECTED) .withStatus(Consent.Status.REJECTED)
.build() .build()
) )
@ -79,15 +70,12 @@ class KafkaMtbFileSender(
return try { return try {
return retryTemplate.execute<MtbFileSender.Response, Exception> { return retryTemplate.execute<MtbFileSender.Response, Exception> {
val record = val result = kafkaTemplate.send(
ProducerRecord( kafkaProperties.topic,
kafkaProperties.outputTopic, key(request),
key(request), objectMapper.writeValueAsString(Data(request.requestId, dummyMtbFile))
// Always use old BwhcV1FileRequest with Consent REJECT )
objectMapper.writeValueAsString(BwhcV1MtbFileRequest(request.requestId, dummyMtbFile))
)
val result = kafkaTemplate.send(record)
if (result.get() != null) { if (result.get() != null) {
logger.debug("Sent deletion request via KafkaMtbFileSender") logger.debug("Sent deletion request via KafkaMtbFileSender")
MtbFileSender.Response(RequestStatus.UNKNOWN) MtbFileSender.Response(RequestStatus.UNKNOWN)
@ -102,15 +90,16 @@ class KafkaMtbFileSender(
} }
override fun endpoint(): String { override fun endpoint(): String {
return "${this.kafkaProperties.servers} (${this.kafkaProperties.outputTopic}/${this.kafkaProperties.outputResponseTopic})" return "${this.kafkaProperties.servers} (${this.kafkaProperties.topic}/${this.kafkaProperties.responseTopic})"
} }
private fun key(request: MtbRequest): String { private fun key(request: MtbFileSender.MtbFileRequest): String {
return when (request) { return "{\"pid\": \"${request.mtbFile.patient.id}\"}"
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}\"}"
}
data class Data(val requestId: String, val content: MtbFile)
} }

View File

@ -19,17 +19,23 @@
package dev.dnpm.etl.processor.output package dev.dnpm.etl.processor.output
import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.springframework.http.HttpStatusCode import org.springframework.http.HttpStatusCode
interface MtbFileSender { interface MtbFileSender {
fun <T> send(request: MtbFileRequest<T>): Response fun send(request: MtbFileRequest): Response
fun send(request: DeleteRequest): Response fun send(request: DeleteRequest): Response
fun endpoint(): String fun endpoint(): String
data class Response(val status: RequestStatus, val body: String = "") data class Response(val status: RequestStatus, val body: String = "")
data class MtbFileRequest(val requestId: String, val mtbFile: MtbFile)
data class DeleteRequest(val requestId: String, val patientId: String)
} }
fun Int.asRequestStatus(): RequestStatus { fun Int.asRequestStatus(): RequestStatus {

View File

@ -1,59 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.output
import de.ukw.ccc.bwhc.dto.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<out T> : MtbRequest {
override val requestId: RequestId
val content: T
fun patientPseudonym(): PatientPseudonym
}
data class BwhcV1MtbFileRequest(
override val requestId: RequestId,
override val content: MtbFile
) : MtbFileRequest<MtbFile> {
override fun patientPseudonym(): PatientPseudonym {
return PatientPseudonym(content.patient.id)
}
}
data class DnpmV2MtbFileRequest(
override val requestId: RequestId,
override val content: Mtb
) : MtbFileRequest<Mtb> {
override fun patientPseudonym(): PatientPseudonym {
return PatientPseudonym(content.patient.id)
}
}
data class DeleteRequest(
override val requestId: RequestId,
val patientId: PatientPseudonym
) : MtbRequest

View File

@ -1,51 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.output
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.config.RestTargetProperties
import 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()
}
}

View File

@ -1,55 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.output
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.config.RestTargetProperties
import 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()
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -19,71 +19,62 @@
package dev.dnpm.etl.processor.output 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.config.RestTargetProperties
import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.asRequestStatus
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.HttpEntity import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.retry.support.RetryTemplate import org.springframework.retry.support.RetryTemplate
import org.springframework.web.client.RestClientException import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestClientResponseException
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
abstract class RestMtbFileSender( class RestMtbFileSender(
private val restTemplate: RestTemplate, private val restTemplate: RestTemplate,
private val restTargetProperties: RestTargetProperties, private val restTargetProperties: RestTargetProperties,
private val retryTemplate: RetryTemplate, private val retryTemplate: RetryTemplate
private val reportService: ReportService
) : MtbFileSender { ) : MtbFileSender {
private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java) private val logger = LoggerFactory.getLogger(RestMtbFileSender::class.java)
abstract fun sendUrl(): String override fun send(request: MtbFileSender.MtbFileRequest): MtbFileSender.Response {
abstract fun deleteUrl(patientId: PatientPseudonym): String
override fun <T> send(request: MtbFileRequest<T>): MtbFileSender.Response {
try { try {
return retryTemplate.execute<MtbFileSender.Response, Exception> { return retryTemplate.execute<MtbFileSender.Response, Exception> {
val headers = getHttpHeaders(request) val headers = HttpHeaders()
val entityReq = HttpEntity(request.content, headers) headers.contentType = MediaType.APPLICATION_JSON
val entityReq = HttpEntity(request.mtbFile, headers)
val response = restTemplate.postForEntity( val response = restTemplate.postForEntity(
sendUrl(), "${restTargetProperties.uri}/MTBFile",
entityReq, entityReq,
String::class.java String::class.java
) )
if (!response.statusCode.is2xxSuccessful) { if (!response.statusCode.is2xxSuccessful) {
logger.warn("Error sending to remote system: {}", response.body) logger.warn("Error sending to remote system: {}", response.body)
return@execute MtbFileSender.Response( return@execute MtbFileSender.Response(
reportService.deserialize(response.body).asRequestStatus(), response.statusCode.asRequestStatus(),
"Status-Code: ${response.statusCode.value()}" "Status-Code: ${response.statusCode.value()}"
) )
} }
logger.debug("Sent file via RestMtbFileSender") logger.debug("Sent file via RestMtbFileSender")
return@execute MtbFileSender.Response(reportService.deserialize(response.body).asRequestStatus(), response.body.orEmpty()) return@execute MtbFileSender.Response(response.statusCode.asRequestStatus(), response.body.orEmpty())
} }
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!) logger.error("Not a valid URI to export to: '{}'", restTargetProperties.uri!!)
} catch (e: RestClientResponseException) { } catch (e: RestClientException) {
logger.info(restTargetProperties.uri!!.toString()) logger.info(restTargetProperties.uri!!.toString())
logger.error("Request data not accepted by remote system", e) logger.error("Cannot send data to remote system", e)
return MtbFileSender.Response(reportService.deserialize(e.responseBodyAsString).asRequestStatus(), e.responseBodyAsString)
} }
return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung") return MtbFileSender.Response(RequestStatus.ERROR, "Sonstiger Fehler bei der Übertragung")
} }
override fun send(request: DeleteRequest): MtbFileSender.Response { override fun send(request: MtbFileSender.DeleteRequest): MtbFileSender.Response {
try { try {
return retryTemplate.execute<MtbFileSender.Response, Exception> { return retryTemplate.execute<MtbFileSender.Response, Exception> {
val headers = getHttpHeaders(request) val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
val entityReq = HttpEntity(null, headers) val entityReq = HttpEntity(null, headers)
restTemplate.delete( restTemplate.delete(
deleteUrl(request.patientId), "${restTargetProperties.uri}/Patient/${request.patientId}",
entityReq, entityReq,
String::class.java String::class.java
) )
@ -103,22 +94,4 @@ abstract class RestMtbFileSender(
return this.restTargetProperties.uri.orEmpty() return this.restTargetProperties.uri.orEmpty()
} }
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
}
} }

View File

@ -19,8 +19,6 @@
package dev.dnpm.etl.processor.pseudonym package dev.dnpm.etl.processor.pseudonym
import dev.dnpm.etl.processor.PatientId
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties import dev.dnpm.etl.processor.config.PseudonymizeConfigProperties
class PseudonymizeService( class PseudonymizeService(
@ -28,10 +26,10 @@ class PseudonymizeService(
private val configProperties: PseudonymizeConfigProperties private val configProperties: PseudonymizeConfigProperties
) { ) {
fun patientPseudonym(patientId: PatientId): PatientPseudonym { fun patientPseudonym(patientId: String): String {
return when (generator) { return when (generator) {
is GpasPseudonymGenerator -> PatientPseudonym(generator.generate(patientId.value)) is GpasPseudonymGenerator -> generator.generate(patientId)
else -> PatientPseudonym("${configProperties.prefix}_${generator.generate(patientId.value)}") else -> "${configProperties.prefix}_${generator.generate(patientId)}"
} }
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -20,53 +20,49 @@
package dev.dnpm.etl.processor.pseudonym package dev.dnpm.etl.processor.pseudonym
import de.ukw.ccc.bwhc.dto.MtbFile 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 import org.apache.commons.codec.digest.DigestUtils
/** Replaces patient ID with generated patient pseudonym /** Replaces patient ID with generated patient pseudonym
* *
* @param pseudonymizeService The pseudonymizeService to be used * @param pseudonymizeService The pseudonymizeService to be used
*
* @return The MTB file containing patient pseudonymes * @return The MTB file containing patient pseudonymes
*/ */
infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) { infix fun MtbFile.pseudonymizeWith(pseudonymizeService: PseudonymizeService) {
val patientPseudonym = pseudonymizeService.patientPseudonym(PatientId(this.patient.id)).value val patientPseudonym = pseudonymizeService.patientPseudonym(this.patient.id)
this.episode?.patient = patientPseudonym this.episode.patient = patientPseudonym
this.carePlans?.forEach { it.patient = patientPseudonym } this.carePlans.forEach { it.patient = patientPseudonym }
this.patient.id = patientPseudonym this.patient.id = patientPseudonym
this.claims?.forEach { it.patient = patientPseudonym } this.claims.forEach { it.patient = patientPseudonym }
this.consent?.patient = patientPseudonym this.consent.patient = patientPseudonym
this.claimResponses?.forEach { it.patient = patientPseudonym } this.claimResponses.forEach { it.patient = patientPseudonym }
this.diagnoses?.forEach { it.patient = patientPseudonym } this.diagnoses.forEach { it.patient = patientPseudonym }
this.ecogStatus?.forEach { it.patient = patientPseudonym } this.ecogStatus.forEach { it.patient = patientPseudonym }
this.familyMemberDiagnoses?.forEach { it.patient = patientPseudonym } this.familyMemberDiagnoses.forEach { it.patient = patientPseudonym }
this.geneticCounsellingRequests?.forEach { it.patient = patientPseudonym } this.geneticCounsellingRequests.forEach { it.patient = patientPseudonym }
this.histologyReevaluationRequests?.forEach { it.patient = patientPseudonym } this.histologyReevaluationRequests.forEach { it.patient = patientPseudonym }
this.histologyReports?.forEach { this.histologyReports.forEach {
it.patient = patientPseudonym it.patient = patientPseudonym
it.tumorMorphology?.patient = patientPseudonym it.tumorMorphology.patient = patientPseudonym
} }
this.lastGuidelineTherapies?.forEach { it.patient = patientPseudonym } this.lastGuidelineTherapies.forEach { it.patient = patientPseudonym }
this.molecularPathologyFindings?.forEach { it.patient = patientPseudonym } this.molecularPathologyFindings.forEach { it.patient = patientPseudonym }
this.molecularTherapies?.forEach { molecularTherapy -> this.molecularTherapies.forEach { molecularTherapy -> molecularTherapy.history.forEach { it.patient = patientPseudonym } }
molecularTherapy.history.forEach { this.ngsReports.forEach { it.patient = patientPseudonym }
it.patient = patientPseudonym this.previousGuidelineTherapies.forEach { it.patient = patientPseudonym }
} this.rebiopsyRequests.forEach { it.patient = patientPseudonym }
} this.recommendations.forEach { it.patient = patientPseudonym }
this.ngsReports?.forEach { it.patient = patientPseudonym } this.responses.forEach { it.patient = patientPseudonym }
this.previousGuidelineTherapies?.forEach { it.patient = patientPseudonym } this.studyInclusionRequests.forEach { it.patient = patientPseudonym }
this.rebiopsyRequests?.forEach { it.patient = patientPseudonym } this.specimens.forEach { it.patient = patientPseudonym }
this.recommendations?.forEach { it.patient = patientPseudonym }
this.responses?.forEach { it.patient = patientPseudonym }
this.studyInclusionRequests?.forEach { it.patient = patientPseudonym }
this.specimens?.forEach { it.patient = patientPseudonym }
} }
/** /**
* Creates new hash of content IDs with given prefix except for patient IDs * Creates new hash of content IDs with given prefix except for patient IDs
* *
* @param pseudonymizeService The pseudonymizeService to be used * @param pseudonymizeService The pseudonymizeService to be used
*
* @return The MTB file containing rehashed content IDs * @return The MTB file containing rehashed content IDs
*/ */
infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) { infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService) {
@ -77,239 +73,151 @@ infix fun MtbFile.anonymizeContentWith(pseudonymizeService: PseudonymizeService)
return "$prefix$hash" return "$prefix$hash"
} }
this.episode?.apply { this.episode.apply {
id = id?.let { id = anonymize(id)
anonymize(it) }
this.carePlans.onEach { carePlan ->
carePlan.apply {
id = anonymize(id)
diagnosis = anonymize(diagnosis)
geneticCounsellingRequest = anonymize(geneticCounsellingRequest)
rebiopsyRequests = rebiopsyRequests.map { anonymize(it) }
recommendations = recommendations.map { anonymize(it) }
studyInclusionRequests = studyInclusionRequests.map { anonymize(it) }
} }
} }
this.carePlans?.onEach { carePlan -> this.claims.onEach { claim ->
carePlan?.apply { claim.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
diagnosis = diagnosis?.let { anonymize(it) } therapy = anonymize(therapy)
geneticCounsellingRequest = geneticCounsellingRequest?.let { anonymize(it) }
rebiopsyRequests = rebiopsyRequests.map { it?.let { anonymize(it) } }
recommendations = recommendations.map { it?.let { anonymize(it) } }
studyInclusionRequests = studyInclusionRequests.map { it?.let { anonymize(it) } }
} }
} }
this.claims?.onEach { claim -> this.claimResponses.onEach { claimResponse ->
claim?.apply { claimResponse.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
therapy = therapy?.let { anonymize(it) } claim = anonymize(claim)
} }
} }
this.claimResponses?.onEach { claimResponse -> this.consent.apply {
claimResponse?.apply { id = anonymize(id)
id = id?.let { anonymize(it) } }
claim = claim?.let { anonymize(it) } this.diagnoses.onEach { diagnosis ->
diagnosis.apply {
id = anonymize(id)
histologyResults = histologyResults.map { anonymize(it) }
} }
} }
this.consent?.apply { this.ecogStatus.onEach { ecogStatus ->
id = id?.let { anonymize(it) } ecogStatus.apply {
} id = anonymize(id)
this.diagnoses?.onEach { diagnosis ->
diagnosis?.apply {
id = id?.let { anonymize(it) }
histologyResults = histologyResults?.map { it?.let { anonymize(it) } }
} }
} }
this.ecogStatus?.onEach { ecogStatus -> this.familyMemberDiagnoses.onEach { familyMemberDiagnosis ->
ecogStatus?.apply { familyMemberDiagnosis.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
} }
} }
this.familyMemberDiagnoses?.onEach { familyMemberDiagnosis -> this.geneticCounsellingRequests.onEach { geneticCounsellingRequest ->
familyMemberDiagnosis?.apply { geneticCounsellingRequest.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
} }
} }
this.geneticCounsellingRequests?.onEach { geneticCounsellingRequest -> this.histologyReevaluationRequests.onEach { histologyReevaluationRequest ->
geneticCounsellingRequest?.apply { histologyReevaluationRequest.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
specimen = anonymize(specimen)
} }
} }
this.histologyReevaluationRequests?.onEach { histologyReevaluationRequest -> this.histologyReports.onEach { histologyReport ->
histologyReevaluationRequest?.apply { histologyReport.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
specimen = specimen?.let { anonymize(it) } specimen = anonymize(specimen)
} tumorMorphology.apply {
} id = anonymize(id)
this.histologyReports?.onEach { histologyReport -> specimen = anonymize(specimen)
histologyReport?.apply {
id = id?.let { anonymize(it) }
specimen = specimen?.let { anonymize(it) }
tumorMorphology?.apply {
id = id?.let { anonymize(it) }
specimen = specimen?.let { anonymize(it) }
} }
tumorCellContent?.apply { tumorCellContent.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
specimen = specimen?.let { anonymize(it) } specimen = anonymize(specimen)
} }
} }
} }
this.lastGuidelineTherapies?.onEach { lastGuidelineTherapy -> this.lastGuidelineTherapies.onEach { lastGuidelineTherapy ->
lastGuidelineTherapy?.apply { lastGuidelineTherapy.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
diagnosis = diagnosis?.let { anonymize(it) } diagnosis = anonymize(diagnosis)
} }
} }
this.molecularPathologyFindings?.onEach { molecularPathologyFinding -> this.molecularPathologyFindings.onEach { molecularPathologyFinding ->
molecularPathologyFinding?.apply { molecularPathologyFinding.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
specimen = specimen?.let { anonymize(it) } specimen = anonymize(specimen)
} }
} }
this.molecularTherapies?.onEach { molecularTherapy -> this.molecularTherapies.onEach { molecularTherapy ->
molecularTherapy?.apply { molecularTherapy.apply {
history?.onEach { history -> history.onEach { history ->
history?.apply { history.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
basedOn = basedOn?.let { anonymize(it) } basedOn = anonymize(basedOn)
} }
} }
} }
} }
this.ngsReports?.onEach { ngsReport -> this.ngsReports.onEach { ngsReport ->
ngsReport?.apply { ngsReport.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
specimen = specimen?.let { anonymize(it) } specimen = anonymize(specimen)
tumorCellContent?.apply { tumorCellContent.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
specimen = specimen?.let { anonymize(it) } specimen = anonymize(specimen)
} }
simpleVariants?.onEach { simpleVariant -> simpleVariants.onEach { simpleVariant ->
simpleVariant?.apply { simpleVariant.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
} }
} }
} }
} }
this.previousGuidelineTherapies?.onEach { previousGuidelineTherapy -> this.previousGuidelineTherapies.onEach { previousGuidelineTherapy ->
previousGuidelineTherapy?.apply { previousGuidelineTherapy.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
diagnosis = diagnosis?.let { anonymize(it) } diagnosis = anonymize(diagnosis)
medication.forEach { medication -> this.medication.forEach { medication ->
medication?.apply { medication.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
} }
} }
} }
} }
this.rebiopsyRequests?.onEach { rebiopsyRequest -> this.rebiopsyRequests.onEach { rebiopsyRequest ->
rebiopsyRequest?.apply { rebiopsyRequest.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
specimen = specimen?.let { anonymize(it) } specimen = anonymize(specimen)
} }
} }
this.recommendations?.onEach { recommendation -> this.recommendations.onEach { recommendation ->
recommendation?.apply { recommendation.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
diagnosis = diagnosis?.let { anonymize(it) } diagnosis = anonymize(diagnosis)
ngsReport = ngsReport?.let { anonymize(it) } ngsReport = anonymize(ngsReport)
} }
} }
this.responses?.onEach { response -> this.responses.onEach { response ->
response?.apply { response.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
therapy = therapy?.let { anonymize(it) } therapy = anonymize(therapy)
} }
} }
this.studyInclusionRequests?.onEach { studyInclusionRequest -> this.studyInclusionRequests.onEach { studyInclusionRequest ->
studyInclusionRequest?.apply { studyInclusionRequest.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
reason = reason?.let { anonymize(it) } reason = anonymize(reason)
} }
} }
this.specimens?.onEach { specimen -> this.specimens.onEach { specimen ->
specimen?.apply { specimen.apply {
id = id?.let { anonymize(it) } id = anonymize(id)
} }
} }
} }
/** 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
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,17 +21,15 @@ package dev.dnpm.etl.processor.services
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.config.AppConfigProperties import dev.dnpm.etl.processor.config.AppConfigProperties
import dev.dnpm.etl.processor.monitoring.Report import dev.dnpm.etl.processor.monitoring.Report
import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType import dev.dnpm.etl.processor.monitoring.RequestType
import dev.dnpm.etl.processor.output.* import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith
import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith 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.binary.Base32
import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.codec.digest.DigestUtils
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
@ -51,45 +49,32 @@ class RequestProcessor(
) { ) {
fun processMtbFile(mtbFile: MtbFile) { fun processMtbFile(mtbFile: MtbFile) {
processMtbFile(mtbFile, randomRequestId()) processMtbFile(mtbFile, UUID.randomUUID().toString())
} }
fun processMtbFile(mtbFile: MtbFile, requestId: RequestId) { fun processMtbFile(mtbFile: MtbFile, requestId: String) {
val pid = PatientId(mtbFile.patient.id) val pid = mtbFile.patient.id
mtbFile pseudonymizeWith pseudonymizeService mtbFile pseudonymizeWith pseudonymizeService
mtbFile anonymizeContentWith pseudonymizeService mtbFile anonymizeContentWith pseudonymizeService
val request = BwhcV1MtbFileRequest(requestId, transformationService.transform(mtbFile))
saveAndSend(request, pid)
}
fun processMtbFile(mtbFile: Mtb) { val request = MtbFileSender.MtbFileRequest(requestId, transformationService.transform(mtbFile))
processMtbFile(mtbFile, randomRequestId())
}
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 <T> saveAndSend(request: MtbFileRequest<T>, pid: PatientId) {
requestService.save( requestService.save(
Request( Request(
request.requestId, uuid = requestId,
request.patientPseudonym(), patientId = request.mtbFile.patient.id,
pid, pid = pid,
fingerprint(request), fingerprint = fingerprint(request.mtbFile),
RequestType.MTB_FILE, status = RequestStatus.UNKNOWN,
RequestStatus.UNKNOWN type = RequestType.MTB_FILE
) )
) )
if (appConfigProperties.duplicationDetection && isDuplication(request)) { if (appConfigProperties.duplicationDetection && isDuplication(mtbFile)) {
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
ResponseEvent( ResponseEvent(
request.requestId, requestId,
Instant.now(), Instant.now(),
RequestStatus.DUPLICATION RequestStatus.DUPLICATION
) )
@ -101,52 +86,47 @@ class RequestProcessor(
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
ResponseEvent( ResponseEvent(
request.requestId, requestId,
Instant.now(), Instant.now(),
responseStatus.status, responseStatus.status,
when (responseStatus.status) { when (responseStatus.status) {
RequestStatus.ERROR, RequestStatus.WARNING -> Optional.of(responseStatus.body) RequestStatus.WARNING -> Optional.of(responseStatus.body)
else -> Optional.empty() else -> Optional.empty()
} }
) )
) )
} }
private fun <T> isDuplication(pseudonymizedMtbFileRequest: MtbFileRequest<T>): Boolean { private fun isDuplication(pseudonymizedMtbFile: MtbFile): Boolean {
val patientPseudonym = when (pseudonymizedMtbFileRequest) {
is BwhcV1MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
is DnpmV2MtbFileRequest -> PatientPseudonym(pseudonymizedMtbFileRequest.content.patient.id)
}
val lastMtbFileRequestForPatient = val lastMtbFileRequestForPatient =
requestService.lastMtbFileRequestForPatientPseudonym(patientPseudonym) requestService.lastMtbFileRequestForPatientPseudonym(pseudonymizedMtbFile.patient.id)
val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(patientPseudonym) val isLastRequestDeletion = requestService.isLastRequestWithKnownStatusDeletion(pseudonymizedMtbFile.patient.id)
return null != lastMtbFileRequestForPatient return null != lastMtbFileRequestForPatient
&& !isLastRequestDeletion && !isLastRequestDeletion
&& lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFileRequest) && lastMtbFileRequestForPatient.fingerprint == fingerprint(pseudonymizedMtbFile)
} }
fun processDeletion(patientId: PatientId) { fun processDeletion(patientId: String) {
processDeletion(patientId, randomRequestId()) processDeletion(patientId, UUID.randomUUID().toString())
} }
fun processDeletion(patientId: PatientId, requestId: RequestId) { fun processDeletion(patientId: String, requestId: String) {
try { try {
val patientPseudonym = pseudonymizeService.patientPseudonym(patientId) val patientPseudonym = pseudonymizeService.patientPseudonym(patientId)
requestService.save( requestService.save(
Request( Request(
requestId, uuid = requestId,
patientPseudonym, patientId = patientPseudonym,
patientId, pid = patientId,
fingerprint(patientPseudonym.value), fingerprint = fingerprint(patientPseudonym),
RequestType.DELETE, status = RequestStatus.UNKNOWN,
RequestStatus.UNKNOWN type = RequestType.DELETE
) )
) )
val responseStatus = sender.send(DeleteRequest(requestId, patientPseudonym)) val responseStatus = sender.send(MtbFileSender.DeleteRequest(requestId, patientPseudonym))
applicationEventPublisher.publishEvent( applicationEventPublisher.publishEvent(
ResponseEvent( ResponseEvent(
@ -164,9 +144,9 @@ class RequestProcessor(
requestService.save( requestService.save(
Request( Request(
uuid = requestId, uuid = requestId,
patientPseudonym = emptyPatientPseudonym(), patientId = "???",
pid = patientId, pid = patientId,
fingerprint = Fingerprint.empty(), fingerprint = "",
status = RequestStatus.ERROR, status = RequestStatus.ERROR,
type = RequestType.DELETE, type = RequestType.DELETE,
report = Report("Fehler bei der Pseudonymisierung") report = Report("Fehler bei der Pseudonymisierung")
@ -175,19 +155,14 @@ class RequestProcessor(
} }
} }
private fun <T> fingerprint(request: MtbFileRequest<T>): Fingerprint { private fun fingerprint(mtbFile: MtbFile): String {
return when (request) { return fingerprint(objectMapper.writeValueAsString(mtbFile))
is BwhcV1MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content))
is DnpmV2MtbFileRequest -> fingerprint(objectMapper.writeValueAsString(request.content))
}
} }
private fun fingerprint(s: String): Fingerprint { private fun fingerprint(s: String): String {
return Fingerprint( return Base32().encodeAsString(DigestUtils.sha256(s))
Base32().encodeAsString(DigestUtils.sha256(s)) .replace("=", "")
.replace("=", "") .lowercase()
.lowercase()
)
} }
} }

View File

@ -19,13 +19,11 @@
package dev.dnpm.etl.processor.services package dev.dnpm.etl.processor.services
import dev.dnpm.etl.processor.PatientPseudonym import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.RequestId import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.* import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.springframework.data.domain.Page import dev.dnpm.etl.processor.monitoring.RequestType
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.*
@Service @Service
class RequestService( class RequestService(
@ -34,32 +32,15 @@ class RequestService(
fun save(request: Request) = requestRepository.save(request) fun save(request: Request) = requestRepository.save(request)
fun findAll(): Iterable<Request> = requestRepository.findAll() fun allRequestsByPatientPseudonym(patientPseudonym: String) = requestRepository
.findAllByPatientIdOrderByProcessedAtDesc(patientPseudonym)
fun findAll(pageable: Pageable): Page<Request> = requestRepository.findAll(pageable) fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: String) =
fun findByUuid(uuid: RequestId): Optional<Request> =
requestRepository.findByUuidEquals(uuid)
fun findRequestByPatientId(patientPseudonym: PatientPseudonym, pageable: Pageable): Page<Request> = requestRepository.findRequestByPatientPseudonym(patientPseudonym, pageable)
fun allRequestsByPatientPseudonym(patientPseudonym: PatientPseudonym) = requestRepository
.findAllByPatientPseudonymOrderByProcessedAtDesc(patientPseudonym)
fun lastMtbFileRequestForPatientPseudonym(patientPseudonym: PatientPseudonym) =
Companion.lastMtbFileRequestForPatientPseudonym(allRequestsByPatientPseudonym(patientPseudonym)) Companion.lastMtbFileRequestForPatientPseudonym(allRequestsByPatientPseudonym(patientPseudonym))
fun isLastRequestWithKnownStatusDeletion(patientPseudonym: PatientPseudonym) = fun isLastRequestWithKnownStatusDeletion(patientPseudonym: String) =
Companion.isLastRequestWithKnownStatusDeletion(allRequestsByPatientPseudonym(patientPseudonym)) Companion.isLastRequestWithKnownStatusDeletion(allRequestsByPatientPseudonym(patientPseudonym))
fun countStates(): Iterable<CountedState> = requestRepository.countStates()
fun countDeleteStates(): Iterable<CountedState> = requestRepository.countDeleteStates()
fun findPatientUniqueStates(): List<CountedState> = requestRepository.findPatientUniqueStates()
fun findPatientUniqueDeleteStates(): List<CountedState> = requestRepository.findPatientUniqueDeleteStates()
companion object { companion object {
fun lastMtbFileRequestForPatientPseudonym(allRequests: List<Request>) = allRequests fun lastMtbFileRequestForPatientPseudonym(allRequests: List<Request>) = allRequests

View File

@ -19,8 +19,8 @@
package dev.dnpm.etl.processor.services package dev.dnpm.etl.processor.services
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.Report import dev.dnpm.etl.processor.monitoring.Report
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.context.event.EventListener import org.springframework.context.event.EventListener
@ -31,7 +31,7 @@ import java.util.*
@Service @Service
class ResponseProcessor( class ResponseProcessor(
private val requestService: RequestService, private val requestRepository: RequestRepository,
private val statisticsUpdateProducer: Sinks.Many<Any> private val statisticsUpdateProducer: Sinks.Many<Any>
) { ) {
@ -39,7 +39,7 @@ class ResponseProcessor(
@EventListener(classes = [ResponseEvent::class]) @EventListener(classes = [ResponseEvent::class])
fun handleResponseEvent(event: ResponseEvent) { fun handleResponseEvent(event: ResponseEvent) {
requestService.findByUuid(event.requestUuid).ifPresentOrElse({ requestRepository.findByUuidEquals(event.requestUuid).ifPresentOrElse({
it.processedAt = event.timestamp it.processedAt = event.timestamp
it.status = event.status it.status = event.status
@ -76,7 +76,7 @@ class ResponseProcessor(
} }
} }
requestService.save(it) requestRepository.save(it)
statisticsUpdateProducer.emitNext("", Sinks.EmitFailureHandler.FAIL_FAST) statisticsUpdateProducer.emitNext("", Sinks.EmitFailureHandler.FAIL_FAST)
}, { }, {
@ -87,7 +87,7 @@ class ResponseProcessor(
} }
data class ResponseEvent( data class ResponseEvent(
val requestUuid: RequestId, val requestUuid: String,
val timestamp: Instant, val timestamp: Instant,
val status: RequestStatus, val status: RequestStatus,
val body: Optional<String> = Optional.empty() val body: Optional<String> = Optional.empty()

View File

@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.dnpm.etl.processor.security package dev.dnpm.etl.processor.services
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import org.springframework.data.annotation.Id import org.springframework.data.annotation.Id

View File

@ -23,21 +23,10 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.jayway.jsonpath.JsonPath import com.jayway.jsonpath.JsonPath
import com.jayway.jsonpath.PathNotFoundException import com.jayway.jsonpath.PathNotFoundException
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import dev.pcvolkmer.mv64e.mtb.Mtb
class TransformationService(private val objectMapper: ObjectMapper, private val transformations: List<Transformation>) { class TransformationService(private val objectMapper: ObjectMapper, private val transformations: List<Transformation>) {
fun transform(mtbFile: MtbFile): MtbFile { fun transform(mtbFile: MtbFile): MtbFile {
val json = transform(objectMapper.writeValueAsString(mtbFile)) var json = 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 -> transformations.forEach { transformation ->
val jsonPath = JsonPath.parse(json) val jsonPath = JsonPath.parse(json)
@ -59,7 +48,7 @@ class TransformationService(private val objectMapper: ObjectMapper, private val
json = jsonPath.jsonString() json = jsonPath.jsonString()
} }
return json return objectMapper.readValue(json, MtbFile::class.java)
} }
fun getTransformations(): List<Transformation> { fun getTransformations(): List<Transformation> {

View File

@ -17,8 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.dnpm.etl.processor.security package dev.dnpm.etl.processor.services
import dev.dnpm.etl.processor.security.Role
import dev.dnpm.etl.processor.security.UserRole
import dev.dnpm.etl.processor.security.UserRoleRepository
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.core.session.SessionRegistry import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.oauth2.core.oidc.user.OidcUser import org.springframework.security.oauth2.core.oidc.user.OidcUser

View File

@ -22,7 +22,6 @@ package dev.dnpm.etl.processor.services.kafka
import com.fasterxml.jackson.annotation.JsonAlias import com.fasterxml.jackson.annotation.JsonAlias
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.output.asRequestStatus import dev.dnpm.etl.processor.output.asRequestStatus
import dev.dnpm.etl.processor.services.ResponseEvent import dev.dnpm.etl.processor.services.ResponseEvent
@ -48,7 +47,7 @@ class KafkaResponseProcessor(
Optional.empty() Optional.empty()
}.ifPresentOrElse({ responseBody -> }.ifPresentOrElse({ responseBody ->
val event = ResponseEvent( val event = ResponseEvent(
RequestId(responseBody.requestId), responseBody.requestId,
Instant.ofEpochMilli(data.timestamp()), Instant.ofEpochMilli(data.timestamp()),
responseBody.statusCode.asRequestStatus(), responseBody.statusCode.asRequestStatus(),
when (responseBody.statusCode.asRequestStatus()) { when (responseBody.statusCode.asRequestStatus()) {

View File

@ -1,63 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor
import org.springframework.http.MediaType
import java.util.*
class Fingerprint(val value: String) {
override fun hashCode() = value.hashCode()
override fun equals(other: Any?) = other is Fingerprint && other.value == value
companion object {
fun empty() = Fingerprint("")
}
}
@JvmInline
value class RequestId(val value: String) {
fun isBlank() = value.isBlank()
}
fun randomRequestId() = RequestId(UUID.randomUUID().toString())
@JvmInline
value class PatientId(val value: String)
@JvmInline
value class PatientPseudonym(val value: String)
fun emptyPatientPseudonym() = PatientPseudonym("")
/**
* 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"
}

View File

@ -27,10 +27,10 @@ import dev.dnpm.etl.processor.output.MtbFileSender
import dev.dnpm.etl.processor.pseudonym.Generator import dev.dnpm.etl.processor.pseudonym.Generator
import dev.dnpm.etl.processor.security.Role import dev.dnpm.etl.processor.security.Role
import dev.dnpm.etl.processor.security.UserRole import dev.dnpm.etl.processor.security.UserRole
import dev.dnpm.etl.processor.security.Token import dev.dnpm.etl.processor.services.Token
import dev.dnpm.etl.processor.security.TokenService import dev.dnpm.etl.processor.services.TokenService
import dev.dnpm.etl.processor.services.TransformationService import dev.dnpm.etl.processor.services.TransformationService
import dev.dnpm.etl.processor.security.UserRoleService import dev.dnpm.etl.processor.services.UserRoleService
import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.codec.ServerSentEvent import org.springframework.http.codec.ServerSentEvent
@ -56,7 +56,7 @@ class ConfigController(
@GetMapping @GetMapping
fun index(model: Model): String { fun index(model: Model): String {
val outputConnectionAvailable = val outputConnectionAvailable =
connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().firstOrNull()?.connectionAvailable() connectionCheckServices.filterIsInstance<OutputConnectionCheckService>().first().connectionAvailable()
val gPasConnectionAvailable = val gPasConnectionAvailable =
connectionCheckServices.filterIsInstance<GPasConnectionCheckService>().firstOrNull()?.connectionAvailable() connectionCheckServices.filterIsInstance<GPasConnectionCheckService>().firstOrNull()?.connectionAvailable()
@ -127,11 +127,10 @@ class ConfigController(
} else { } else {
model.addAttribute("tokensEnabled", true) model.addAttribute("tokensEnabled", true)
val result = tokenService.addToken(name) val result = tokenService.addToken(name)
result.onSuccess { if (result.isSuccess) {
model.addAttribute("newTokenValue", it) model.addAttribute("newTokenValue", result.getOrDefault(""))
model.addAttribute("success", true) model.addAttribute("success", true)
} } else {
result.onFailure {
model.addAttribute("success", false) model.addAttribute("success", false)
} }
model.addAttribute("tokens", tokenService.findAll()) model.addAttribute("tokens", tokenService.findAll())
@ -183,7 +182,6 @@ class ConfigController(
} }
@GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) @GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
@ResponseBody
fun events(): Flux<ServerSentEvent<Any>> { fun events(): Flux<ServerSentEvent<Any>> {
return connectionCheckUpdateProducer.asFlux().map { return connectionCheckUpdateProducer.asFlux().map {
val event = when (it) { val event = when (it) {

View File

@ -20,10 +20,9 @@
package dev.dnpm.etl.processor.web package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.NotFoundException import dev.dnpm.etl.processor.NotFoundException
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.monitoring.ReportService import dev.dnpm.etl.processor.monitoring.ReportService
import dev.dnpm.etl.processor.services.RequestService import dev.dnpm.etl.processor.monitoring.RequestId
import dev.dnpm.etl.processor.monitoring.RequestRepository
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault import org.springframework.data.web.PageableDefault
@ -36,7 +35,7 @@ import org.springframework.web.bind.annotation.RequestMapping
@Controller @Controller
@RequestMapping(path = ["/"]) @RequestMapping(path = ["/"])
class HomeController( class HomeController(
private val requestService: RequestService, private val requestRepository: RequestRepository,
private val reportService: ReportService private val reportService: ReportService
) { ) {
@ -45,20 +44,20 @@ class HomeController(
@PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable, @PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable,
model: Model model: Model
): String { ): String {
val requests = requestService.findAll(pageable) val requests = requestRepository.findAll(pageable)
model.addAttribute("requests", requests) model.addAttribute("requests", requests)
return "index" return "index"
} }
@GetMapping(path = ["patient/{patientPseudonym}"]) @GetMapping(path = ["patient/{patientId}"])
fun byPatient( fun byPatient(
@PathVariable patientPseudonym: PatientPseudonym, @PathVariable patientId: String,
@PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable, @PageableDefault(page = 0, size = 20, sort = ["processedAt"], direction = Sort.Direction.DESC) pageable: Pageable,
model: Model model: Model
): String { ): String {
val requests = requestService.findRequestByPatientId(patientPseudonym, pageable) val requests = requestRepository.findRequestByPatientId(patientId, pageable)
model.addAttribute("patientPseudonym", patientPseudonym.value) model.addAttribute("patientId", patientId)
model.addAttribute("requests", requests) model.addAttribute("requests", requests)
return "index" return "index"
@ -66,7 +65,7 @@ class HomeController(
@GetMapping(path = ["/report/{id}"]) @GetMapping(path = ["/report/{id}"])
fun report(@PathVariable id: RequestId, model: Model): String { fun report(@PathVariable id: RequestId, model: Model): String {
val request = requestService.findByUuid(id).orElse(null) ?: throw NotFoundException() val request = requestRepository.findByUuidEquals(id.toString()).orElse(null) ?: throw NotFoundException()
model.addAttribute("request", request) model.addAttribute("request", request)
model.addAttribute("issues", reportService.deserialize(request.report?.dataQualityReport)) model.addAttribute("issues", reportService.deserialize(request.report?.dataQualityReport))

View File

@ -19,9 +19,9 @@
package dev.dnpm.etl.processor.web package dev.dnpm.etl.processor.web
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType import dev.dnpm.etl.processor.monitoring.RequestType
import dev.dnpm.etl.processor.services.RequestService
import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.codec.ServerSentEvent import org.springframework.http.codec.ServerSentEvent
@ -41,15 +41,15 @@ import java.time.temporal.ChronoUnit
class StatisticsRestController( class StatisticsRestController(
@Qualifier("statisticsUpdateProducer") @Qualifier("statisticsUpdateProducer")
private val statisticsUpdateProducer: Sinks.Many<Any>, private val statisticsUpdateProducer: Sinks.Many<Any>,
private val requestService: RequestService private val requestRepository: RequestRepository
) { ) {
@GetMapping(path = ["requeststates"]) @GetMapping(path = ["requeststates"])
fun requestStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> { fun requestStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
val states = if (delete) { val states = if (delete) {
requestService.countDeleteStates() requestRepository.countDeleteStates()
} else { } else {
requestService.countStates() requestRepository.countStates()
} }
return states return states
@ -79,7 +79,7 @@ class StatisticsRestController(
} }
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin")) val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.of("Europe/Berlin"))
val data = requestService.findAll() val data = requestRepository.findAll()
.filter { it.type == requestType } .filter { it.type == requestType }
.filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) } .filter { it.processedAt.isAfter(Instant.now().minus(30, ChronoUnit.DAYS)) }
.groupBy { formatter.format(it.processedAt) } .groupBy { formatter.format(it.processedAt) }
@ -115,9 +115,9 @@ class StatisticsRestController(
@GetMapping(path = ["requestpatientstates"]) @GetMapping(path = ["requestpatientstates"])
fun requestPatientStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> { fun requestPatientStates(@RequestParam(required = false, defaultValue = "false") delete: Boolean): List<NameValue> {
val states = if (delete) { val states = if (delete) {
requestService.findPatientUniqueDeleteStates() requestRepository.findPatientUniqueDeleteStates()
} else { } else {
requestService.findPatientUniqueStates() requestRepository.findPatientUniqueStates()
} }
return states.map { return states.map {
@ -134,6 +134,7 @@ class StatisticsRestController(
@GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) @GetMapping(path = ["events"], produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun updater(): Flux<ServerSentEvent<Any>> { fun updater(): Flux<ServerSentEvent<Any>> {
return statisticsUpdateProducer.asFlux().flatMap { return statisticsUpdateProducer.asFlux().flatMap {
println(it)
Flux.fromIterable( Flux.fromIterable(
listOf( listOf(
ServerSentEvent.builder<Any>() ServerSentEvent.builder<Any>()

View File

@ -3,34 +3,17 @@ spring:
compose: compose:
file: ./dev-compose.yml file: ./dev-compose.yml
security:
oauth2:
client:
registration:
custom:
client-name: App-Dev
client-id: app-dev
client-secret: very-secret-ae3f7a-5a9f-1190
scope:
- openid
provider:
custom:
issuer-uri: https://dnpm.dev/auth/realms/intern
user-name-attribute: name
app: app:
rest: #rest:
uri: http://localhost:9000/bwhc/etl/api # uri: http://localhost:9000/bwhc/etl/api
#kafka: kafka:
# topic: test topic: test
# response-topic: test_response response-topic: test_response
# servers: localhost:9094 servers: localhost:9094
security: #security:
admin-user: admin # admin-user: admin
admin-password: "{noop}very-secret" # admin-password: "{noop}very-secret"
enable-oidc: "true"
server: server:
port: 8000 port: 8000

View File

@ -1 +0,0 @@
ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;

View File

@ -1 +0,0 @@
ALTER TABLE request RENAME COLUMN patient_id TO patient_pseudonym;

View File

@ -22,10 +22,6 @@
--bg-gray-op: rgba(112, 128, 144, .35); --bg-gray-op: rgba(112, 128, 144, .35);
} }
* {
font-family: sans-serif;
}
html { html {
background: linear-gradient(-5deg, var(--bg-blue-op), transparent 10em); background: linear-gradient(-5deg, var(--bg-blue-op), transparent 10em);
min-height: 100vh; min-height: 100vh;
@ -34,6 +30,7 @@ html {
body { body {
margin: 0 0 5em 0; margin: 0 0 5em 0;
font-family: sans-serif;
font-size: .8rem; font-size: .8rem;
color: var(--text); color: var(--text);
@ -622,10 +619,6 @@ input.inline:focus-visible {
text-align: center; text-align: center;
} }
.notification.info {
color: var(--bg-blue);
}
.notification.success { .notification.success {
color: var(--bg-green); color: var(--bg-green);
} }
@ -650,16 +643,14 @@ input.inline:focus-visible {
.tab:hover, .tab:hover,
.tab.active { .tab.active {
background: var(--bg-gray); background: var(--table-border);
color: white;
} }
.tabcontent { .tabcontent {
border: 2px solid var(--bg-gray); border: 1px solid var(--table-border);
border-radius: 0 .5em .5em .5em; border-radius: 0 .5em .5em .5em;
display: none; display: none;
padding: 1em; padding: 1em;
background: white;
} }
.tabcontent.active { .tabcontent.active {
@ -698,13 +689,3 @@ a.reload {
padding: 1em; padding: 1em;
background: var(--bg-red-op); background: var(--bg-red-op);
} }
.issue-message {
font-family: monospace;
font-weight: bolder;
}
.issue-path {
font-family: monospace;
line-height: 1rem;
}

View File

@ -2,20 +2,15 @@
<h2><span>🟦</span> gPAS nicht konfiguriert - Patienten-IDs werden intern anonymisiert</h2> <h2><span>🟦</span> gPAS nicht konfiguriert - Patienten-IDs werden intern anonymisiert</h2>
</th:block> </th:block>
<th:block th:if="${gPasConnectionAvailable != null}"> <th:block th:if="${gPasConnectionAvailable != null}">
<h2><span th:if="${gPasConnectionAvailable.available}"></span><span th:if="${not(gPasConnectionAvailable.available)}"></span> Verbindung zu gPAS</h2> <h2><span th:if="${gPasConnectionAvailable}"></span><span th:if="${not(gPasConnectionAvailable)}"></span> Verbindung zu gPAS</h2>
<div> <div>
Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gPasConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(gPasConnectionAvailable.timestamp)}"></time> Die Verbindung ist aktuell
&nbsp;|&nbsp; <strong th:if="${gPasConnectionAvailable}" style="color: green">verfügbar.</strong>
Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(gPasConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(gPasConnectionAvailable.lastChange)}"></time> <strong th:if="${not(gPasConnectionAvailable)}" style="color: red">nicht verfügbar.</strong>
</div>
<div>
<span>Die Verbindung ist aktuell</span>
<strong th:if="${gPasConnectionAvailable.available}" style="color: green">verfügbar.</strong>
<strong th:if="${not(gPasConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong>
</div> </div>
<div class="connection-display border"> <div class="connection-display border">
<img th:src="@{/server.png}" alt="ETL-Processor" /> <img th:src="@{/server.png}" alt="ETL-Processor" />
<span class="connection" th:classappend="${gPasConnectionAvailable.available ? 'available' : ''}"></span> <span class="connection" th:classappend="${gPasConnectionAvailable ? 'available' : ''}"></span>
<img th:src="@{/server.png}" alt="gPAS" /> <img th:src="@{/server.png}" alt="gPAS" />
<span>ETL-Processor</span> <span>ETL-Processor</span>
<span></span> <span></span>

View File

@ -1,27 +1,16 @@
<th:block th:if="${outputConnectionAvailable == null}"> <h2><span th:if="${outputConnectionAvailable}"></span><span th:if="${not(outputConnectionAvailable)}"></span> MTB-File Verbindung</h2>
<h2><span>🟦</span> Keine Ausgabenkonfiguration</h2> <div>
</th:block> Verbindung über <code>[[ ${mtbFileSender} ]]</code>. Die Verbindung ist aktuell
<th:block th:if="${outputConnectionAvailable != null}"> <strong th:if="${outputConnectionAvailable}" style="color: green">verfügbar.</strong>
<h2><span th:if="${outputConnectionAvailable.available}"></span><span th:if="${not(outputConnectionAvailable.available)}"></span> MTB-File Verbindung</h2> <strong th:if="${not(outputConnectionAvailable)}" style="color: red">nicht verfügbar.</strong>
<div> </div>
Stand: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(outputConnectionAvailable.timestamp)}" th:text="${#temporals.formatISO(outputConnectionAvailable.timestamp)}"></time> <div class="connection-display border">
&nbsp;|&nbsp; <img th:src="@{/server.png}" alt="ETL-Processor" />
Letzte Änderung: <time style="font-weight: bold" th:datetime="${#temporals.formatISO(outputConnectionAvailable.lastChange)}" th:text="${#temporals.formatISO(outputConnectionAvailable.lastChange)}"></time> <span class="connection" th:classappend="${outputConnectionAvailable ? 'available' : ''}"></span>
</div> <img th:if="${mtbFileSender.startsWith('Rest')}" th:src="@{/server.png}" alt="bwHC-Backend" />
<div> <img th:if="${mtbFileSender.startsWith('Kafka')}" th:src="@{/kafka.png}" alt="Kafka-Broker" />
Verbindung über <code>[[ ${mtbFileSender} ]]</code>. Die Verbindung ist aktuell <span>ETL-Processor</span>
<strong th:if="${outputConnectionAvailable.available}" style="color: green">verfügbar.</strong> <span></span>
<strong th:if="${not(outputConnectionAvailable.available)}" style="color: red">nicht verfügbar.</strong> <span th:if="${mtbFileSender.startsWith('Rest')}">bwHC-Backend</span>
</div> <span th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker</span>
<div class="connection-display border"> </div>
<img th:src="@{/server.png}" alt="ETL-Processor" />
<span class="connection" th:classappend="${outputConnectionAvailable.available ? 'available' : ''}"></span>
<img th:if="${mtbFileSender.startsWith('Rest')}" th:src="@{/server.png}" alt="bwHC-Backend" />
<img th:if="${mtbFileSender.startsWith('Kafka')}" th:src="@{/kafka.png}" alt="Kafka-Broker" />
<span>ETL-Processor</span>
<span></span>
<span th:if="${mtbFileSender.startsWith('RestBwhc')}">bwHC-Backend</span>
<span th:if="${mtbFileSender.startsWith('RestDip')}">DNPM:DIP-Backend</span>
<span th:if="${mtbFileSender.startsWith('Kafka')}">Kafka-Broker</span>
</div>
</th:block>

View File

@ -12,30 +12,26 @@
<h1>Alle Anfragen<a id="reload-notify" class="reload" title="Neue Anfragen" th:href="@{/}"></a></h1> <h1>Alle Anfragen<a id="reload-notify" class="reload" title="Neue Anfragen" th:href="@{/}"></a></h1>
<div> <div>
<h2 th:if="${patientPseudonym != null}"> <h2 th:if="${patientId != null}">
Betreffend Patienten-Pseudonym <span class="monospace" th:text="${patientPseudonym}">***</span> Betreffend Patienten-Pseudonym <span class="monospace" th:text="${patientId}">***</span>
<a class="btn btn-blue" th:if="${patientPseudonym != null}" th:href="@{/}">Alle anzeigen</a> <a class="btn btn-blue" th:if="${patientId != null}" th:href="@{/}">Alle anzeigen</a>
</h2> </h2>
</div> </div>
<div class="border" th:if="${requests.totalElements == 0}"> <div class="border">
<div class="notification info">Noch keine Anfragen eingegangen</div> <div th:if="${patientId == null}" class="page-control">
</div>
<div class="border" th:if="${requests.totalElements > 0}">
<div th:if="${patientPseudonym == null}" class="page-control">
<a id="first-page-link" th:href="@{/(page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</a> <a id="first-page-link" th:href="@{/(page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</a>
<a id="prev-page-link" th:href="@{/(page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</a> <a id="prev-page-link" th:href="@{/(page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</a>
<span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span> <span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span>
<a id="next-page-link" th:href="@{/(page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a> <a id="next-page-link" th:href="@{/(page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a>
<a id="last-page-link" th:href="@{/(page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</a> <a id="last-page-link" th:href="@{/(page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</a>
</div> </div>
<div th:if="${patientPseudonym != null}" class="page-control"> <div th:if="${patientId != null}" class="page-control">
<a id="first-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</a> <a id="first-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${0})}" title="Zum Anfang: Taste W" th:if="${not requests.isFirst()}">&larrb;</a><a th:if="${requests.isFirst()}">&larrb;</a>
<a id="prev-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</a> <a id="prev-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${requests.getNumber() - 1})}" title="Seite zurück: Taste A" th:if="${not requests.isFirst()}">&larr;</a><a th:if="${requests.isFirst()}">&larr;</a>
<span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span> <span>Seite [[ ${requests.getNumber() + 1} ]] von [[ ${requests.getTotalPages()} ]]</span>
<a id="next-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a> <a id="next-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${requests.getNumber() + 1})}" title="Seite vor: Taste D" th:if="${not requests.isLast()}">&rarr;</a><a th:if="${requests.isLast()}">&rarr;</a>
<a id="last-page-link" th:href="@{/patient/{patientPseudonym}(patientPseudonym=${patientPseudonym},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</a> <a id="last-page-link" th:href="@{/patient/{patientId}(patientId=${patientId},page=${requests.getTotalPages() - 1})}" title="Zum Ende: Taste S" th:if="${not requests.isLast()}">&rarrb;</a><a th:if="${requests.isLast()}">&rarrb;</a>
</div> </div>
<table class="paged"> <table class="paged">
<thead> <thead>
@ -61,11 +57,11 @@
<th:block sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">[[ ${request.uuid} ]]</th:block> <th:block sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">[[ ${request.uuid} ]]</th:block>
</td> </td>
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td> <td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
<td class="patient-id" th:if="${patientPseudonym != null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')"> <td class="patient-id" th:if="${patientId != null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
[[ ${request.patientPseudonym} ]] [[ ${request.patientId} ]]
</td> </td>
<td class="patient-id" th:if="${patientPseudonym == null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')"> <td class="patient-id" th:if="${patientId == null}" sec:authorize="hasRole('USER') or hasRole('ADMIN')">
<a th:href="@{/patient/{pid}(pid=${request.patientPseudonym})}">[[ ${request.patientPseudonym} ]]</a> <a th:href="@{/patient/{pid}(pid=${request.patientId})}">[[ ${request.patientId} ]]</a>
</td> </td>
<td class="patient-id" sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">***</td> <td class="patient-id" sec:authorize="not (hasRole('USER') or hasRole('ADMIN'))">***</td>
</tr> </tr>

View File

@ -31,7 +31,7 @@
<td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td> <td th:style="${request.type.value == 'delete'} ? 'color: red;'"><small>[[ ${request.type} ]]</small></td>
<td>[[ ${request.uuid} ]]</td> <td>[[ ${request.uuid} ]]</td>
<td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td> <td><time th:datetime="${request.processedAt}">[[ ${request.processedAt} ]]</time></td>
<td class="patient-id" sec:authorize="authenticated">[[ ${request.patientPseudonym} ]]</td> <td class="patient-id" sec:authorize="authenticated">[[ ${request.patientId} ]]</td>
<td class="patient-id" sec:authorize="not authenticated">***</td> <td class="patient-id" sec:authorize="not authenticated">***</td>
</tr> </tr>
</tbody> </tbody>
@ -47,7 +47,7 @@
<thead> <thead>
<tr> <tr>
<th>Schweregrad</th> <th>Schweregrad</th>
<th>Beschreibung und Pfad</th> <th>Beschreibung</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -55,12 +55,7 @@
<td th:if="${issue.severity.value == 'info'}" class="bg-blue"><small>[[ ${issue.severity} ]]</small></td> <td th:if="${issue.severity.value == 'info'}" class="bg-blue"><small>[[ ${issue.severity} ]]</small></td>
<td th:if="${issue.severity.value == 'warning'}" class="bg-yellow"><small>[[ ${issue.severity} ]]</small></td> <td th:if="${issue.severity.value == 'warning'}" class="bg-yellow"><small>[[ ${issue.severity} ]]</small></td>
<td th:if="${issue.severity.value == 'error'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td> <td th:if="${issue.severity.value == 'error'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td>
<td th:if="${issue.severity.value == 'fatal'}" class="bg-red"><small>[[ ${issue.severity} ]]</small></td> <td>[[ ${issue.message} ]]</td>
<td>
<div class="issue-message">[[ ${issue.message} ]]</div>
<div class="issue-path" th:if="${issue.path.isPresent()}">[[ ${issue.path.get()} ]]</div>
<div class="issue-path" th:if="${issue.path.isEmpty()}"><i>Keine Angabe</i></div>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -1,20 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -23,7 +23,6 @@ import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.Consent import de.ukw.ccc.bwhc.dto.Consent
import de.ukw.ccc.bwhc.dto.MtbFile import de.ukw.ccc.bwhc.dto.MtbFile
import de.ukw.ccc.bwhc.dto.Patient import de.ukw.ccc.bwhc.dto.Patient
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import org.apache.kafka.clients.consumer.ConsumerRecord import org.apache.kafka.clients.consumer.ConsumerRecord
import org.apache.kafka.common.header.internals.RecordHeader import org.apache.kafka.common.header.internals.RecordHeader
@ -32,10 +31,10 @@ import org.apache.kafka.common.record.TimestampType
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.anyValueClass
import org.mockito.kotlin.times import org.mockito.kotlin.times
import org.mockito.kotlin.verify import org.mockito.kotlin.verify
import java.util.* import java.util.*
@ -64,17 +63,9 @@ class KafkaInputListenerTest {
.withConsent(Consent.builder().withStatus(Consent.Status.ACTIVE).build()) .withConsent(Consent.builder().withStatus(Consent.Status.ACTIVE).build())
.build() .build()
kafkaInputListener.onMessage( kafkaInputListener.onMessage(ConsumerRecord("testtopic", 0, 0, "", this.objectMapper.writeValueAsString(mtbFile)))
ConsumerRecord(
"testtopic",
0,
0,
"",
this.objectMapper.writeValueAsString(mtbFile)
)
)
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>()) verify(requestProcessor, times(1)).processMtbFile(any())
} }
@Test @Test
@ -84,17 +75,9 @@ class KafkaInputListenerTest {
.withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build()) .withConsent(Consent.builder().withStatus(Consent.Status.REJECTED).build())
.build() .build()
kafkaInputListener.onMessage( kafkaInputListener.onMessage(ConsumerRecord("testtopic", 0, 0, "", this.objectMapper.writeValueAsString(mtbFile)))
ConsumerRecord(
"testtopic",
0,
0,
"",
this.objectMapper.writeValueAsString(mtbFile)
)
)
verify(requestProcessor, times(1)).processDeletion(anyValueClass()) verify(requestProcessor, times(1)).processDeletion(anyString())
} }
@Test @Test
@ -106,22 +89,10 @@ class KafkaInputListenerTest {
val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()))) val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray())))
kafkaInputListener.onMessage( kafkaInputListener.onMessage(
ConsumerRecord( ConsumerRecord("testtopic", 0, 0, -1L, TimestampType.NO_TIMESTAMP_TYPE, -1, -1, "", this.objectMapper.writeValueAsString(mtbFile), headers, Optional.empty())
"testtopic",
0,
0,
-1L,
TimestampType.NO_TIMESTAMP_TYPE,
-1,
-1,
"",
this.objectMapper.writeValueAsString(mtbFile),
headers,
Optional.empty()
)
) )
verify(requestProcessor, times(1)).processMtbFile(any<MtbFile>(), anyValueClass()) verify(requestProcessor, times(1)).processMtbFile(any(), anyString())
} }
@Test @Test
@ -133,52 +104,9 @@ class KafkaInputListenerTest {
val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray()))) val headers = RecordHeaders(listOf(RecordHeader("requestId", UUID.randomUUID().toString().toByteArray())))
kafkaInputListener.onMessage( kafkaInputListener.onMessage(
ConsumerRecord( ConsumerRecord("testtopic", 0, 0, -1L, TimestampType.NO_TIMESTAMP_TYPE, -1, -1, "", this.objectMapper.writeValueAsString(mtbFile), headers, Optional.empty())
"testtopic",
0,
0,
-1L,
TimestampType.NO_TIMESTAMP_TYPE,
-1,
-1,
"",
this.objectMapper.writeValueAsString(mtbFile),
headers,
Optional.empty()
)
) )
verify(requestProcessor, times(1)).processDeletion(anyValueClass(), anyValueClass()) verify(requestProcessor, times(1)).processDeletion(anyString(), anyString())
}
@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("contentType", CustomMediaType.APPLICATION_VND_DNPM_V2_MTB_JSON_VALUE.toByteArray())
)
)
kafkaInputListener.onMessage(
ConsumerRecord(
"testtopic",
0,
0,
-1L,
TimestampType.NO_TIMESTAMP_TYPE,
-1,
-1,
"",
this.objectMapper.writeValueAsString(mtbFile),
headers,
Optional.empty()
)
)
verify(requestProcessor, times(0)).processDeletion(anyValueClass(), anyValueClass())
} }
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,11 +21,9 @@ package dev.dnpm.etl.processor.input
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* import de.ukw.ccc.bwhc.dto.*
import dev.dnpm.etl.processor.CustomMediaType
import dev.dnpm.etl.processor.services.RequestProcessor import dev.dnpm.etl.processor.services.RequestProcessor
import dev.pcvolkmer.mv64e.mtb.Mtb import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mock import org.mockito.Mock
@ -33,8 +31,7 @@ import org.mockito.Mockito.times
import org.mockito.Mockito.verify import org.mockito.Mockito.verify
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.anyValueClass import org.mockito.kotlin.argumentCaptor
import org.springframework.core.io.ClassPathResource
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete import org.springframework.test.web.servlet.delete
@ -44,156 +41,24 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders
@ExtendWith(MockitoExtension::class) @ExtendWith(MockitoExtension::class)
class MtbFileRestControllerTest { class MtbFileRestControllerTest {
private lateinit var mockMvc: MockMvc
private lateinit var requestProcessor: RequestProcessor
private val objectMapper = ObjectMapper() private val objectMapper = ObjectMapper()
@Nested @BeforeEach
inner class BwhcRequests { fun setup(
@Mock requestProcessor: RequestProcessor
private lateinit var mockMvc: MockMvc ) {
this.requestProcessor = requestProcessor
private lateinit var requestProcessor: RequestProcessor val controller = MtbFileRestController(requestProcessor)
this.mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
@BeforeEach
fun setup(
@Mock requestProcessor: RequestProcessor
) {
this.requestProcessor = requestProcessor
val controller = MtbFileRestController(requestProcessor)
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<MtbFile>())
}
@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())
}
} }
@Nested @Test
inner class BwhcRequestsWithAlias { fun shouldProcessMtbFilePostRequest() {
val mtbFile = MtbFile.builder()
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<MtbFile>())
}
@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<Mtb>())
}
}
companion object {
fun bwhcMtbFileContent(consentStatus: Consent.Status) = MtbFile.builder()
.withPatient( .withPatient(
Patient.builder() Patient.builder()
.withId("TEST_12345678") .withId("TEST_12345678")
@ -204,7 +69,7 @@ class MtbFileRestControllerTest {
.withConsent( .withConsent(
Consent.builder() Consent.builder()
.withId("1") .withId("1")
.withStatus(consentStatus) .withStatus(Consent.Status.ACTIVE)
.withPatient("TEST_12345678") .withPatient("TEST_12345678")
.build() .build()
) )
@ -216,5 +81,70 @@ class MtbFileRestControllerTest {
.build() .build()
) )
.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()
}
}
val captor = argumentCaptor<String>()
verify(requestProcessor, times(1)).processDeletion(captor.capture())
assertThat(captor.firstValue).isEqualTo("TEST_12345678")
}
@Test
fun shouldProcessMtbFileDeleteRequest() {
mockMvc.delete("/mtbfile/TEST_12345678").andExpect {
status {
isAccepted()
}
}
val captor = argumentCaptor<String>()
verify(requestProcessor, times(1)).processDeletion(captor.capture())
assertThat(captor.firstValue).isEqualTo("TEST_12345678")
}
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,25 +21,18 @@ package dev.dnpm.etl.processor.output
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* 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.config.KafkaProperties
import dev.dnpm.etl.processor.monitoring.RequestStatus 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.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.* import org.mockito.kotlin.*
import org.springframework.http.MediaType
import org.springframework.kafka.core.KafkaTemplate import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult import org.springframework.kafka.support.SendResult
import org.springframework.retry.policy.SimpleRetryPolicy import org.springframework.retry.policy.SimpleRetryPolicy
@ -50,231 +43,139 @@ import java.util.concurrent.ExecutionException
@ExtendWith(MockitoExtension::class) @ExtendWith(MockitoExtension::class)
class KafkaMtbFileSenderTest { class KafkaMtbFileSenderTest {
@Nested private lateinit var kafkaTemplate: KafkaTemplate<String, String>
inner class BwhcV1Record {
private lateinit var kafkaTemplate: KafkaTemplate<String, String> private lateinit var kafkaMtbFileSender: KafkaMtbFileSender
private lateinit var kafkaMtbFileSender: KafkaMtbFileSender private lateinit var objectMapper: ObjectMapper
private lateinit var objectMapper: ObjectMapper @BeforeEach
fun setup(
@Mock kafkaTemplate: KafkaTemplate<String, String>
) {
val kafkaProperties = KafkaProperties("testtopic")
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
@BeforeEach this.objectMapper = ObjectMapper()
fun setup( this.kafkaTemplate = kafkaTemplate
@Mock kafkaTemplate: KafkaTemplate<String, String>
) {
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
val response = kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
assertThat(response.status).isEqualTo(testData.requestStatus)
}
@Test
fun shouldSendMtbFileRequestWithCorrectKeyAndHeaderAndBody() {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, bwhcV1MtbFile(Consent.Status.ACTIVE)))
val captor = argumentCaptor<ProducerRecord<String, String>>()
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM))
val captor = argumentCaptor<ProducerRecord<String, String>>()
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
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<ProducerRecord<String, String>>())
}
@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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
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<ProducerRecord<String, String>>())
}
this.kafkaMtbFileSender = KafkaMtbFileSender(kafkaTemplate, kafkaProperties, retryTemplate, objectMapper)
} }
@Nested @ParameterizedTest
inner class DnpmV2Record { @MethodSource("requestWithResponseSource")
fun shouldSendMtbFileRequestAndReturnExpectedState(testData: TestData) {
private lateinit var kafkaTemplate: KafkaTemplate<String, String> doAnswer {
if (null != testData.exception) {
private lateinit var kafkaMtbFileSender: KafkaMtbFileSender throw testData.exception
private lateinit var objectMapper: ObjectMapper
@BeforeEach
fun setup(
@Mock kafkaTemplate: KafkaTemplate<String, String>
) {
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
val response = kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile()))
assertThat(response.status).isEqualTo(testData.requestStatus)
}
@Test
fun shouldSendMtbFileRequestWithCorrectKeyAndHeaderAndBody() {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
kafkaMtbFileSender.send(DnpmV2MtbFileRequest(TEST_REQUEST_ID, dnpmV2MtbFile()))
val captor = argumentCaptor<ProducerRecord<String, String>>()
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<String, String>(null, null))
}.whenever(kafkaTemplate).send(any<ProducerRecord<String, String>>())
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)
} }
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
verify(kafkaTemplate, expectedCount).send(any<ProducerRecord<String, String>>()) val response = kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", 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<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
val response = kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
assertThat(response.status).isEqualTo(testData.requestStatus)
}
@Test
fun shouldSendMtbFileRequestWithCorrectKeyAndBody() {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile(Consent.Status.ACTIVE)))
val captor = argumentCaptor<String>()
verify(kafkaTemplate, times(1)).send(anyString(), captor.capture(), captor.capture())
assertThat(captor.firstValue).isNotNull
assertThat(captor.firstValue).isEqualTo("{\"pid\": \"PID\"}")
assertThat(captor.secondValue).isNotNull
assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData("TestID", Consent.Status.ACTIVE)))
}
@Test
fun shouldSendDeleteRequestWithCorrectKeyAndBody() {
doAnswer {
completedFuture(SendResult<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
val captor = argumentCaptor<String>()
verify(kafkaTemplate, times(1)).send(anyString(), captor.capture(), captor.capture())
assertThat(captor.firstValue).isNotNull
assertThat(captor.firstValue).isEqualTo("{\"pid\": \"PID\"}")
assertThat(captor.secondValue).isNotNull
assertThat(captor.secondValue).isEqualTo(objectMapper.writeValueAsString(kafkaRecordData("TestID", Consent.Status.REJECTED)))
}
@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<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
kafkaMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile(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(anyString(), anyString(), anyString())
}
@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<String, String>(null, null))
}.whenever(kafkaTemplate).send(anyString(), anyString(), anyString())
kafkaMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
val expectedCount = when (testData.exception) {
// OK - No Retry
null -> times(1)
// Request failed - Retry max 3 times
else -> times(3)
}
verify(kafkaTemplate, expectedCount).send(anyString(), anyString(), anyString())
} }
companion object { companion object {
val TEST_REQUEST_ID = RequestId("TestId") fun mtbFile(consentStatus: Consent.Status): MtbFile {
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID")
fun bwhcV1MtbFile(consentStatus: Consent.Status): MtbFile {
return if (consentStatus == Consent.Status.ACTIVE) { return if (consentStatus == Consent.Status.ACTIVE) {
MtbFile.builder() MtbFile.builder()
.withPatient( .withPatient(
@ -309,31 +210,8 @@ class KafkaMtbFileSenderTest {
}.build() }.build()
} }
fun dnpmV2MtbFile(): Mtb = Mtb.builder() fun kafkaRecordData(requestId: String, consentStatus: Consent.Status): KafkaMtbFileSender.Data {
.withPatient( return KafkaMtbFileSender.Data(requestId, mtbFile(consentStatus))
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) data class TestData(val requestStatus: RequestStatus, val exception: Throwable? = null)

View File

@ -1,405 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.output
import 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<RequestWithResponse> {
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<RequestWithResponse> {
return setOf(
RequestWithResponse(HttpStatus.OK, "", MtbFileSender.Response(RequestStatus.SUCCESS)),
// Some more errors not mentioned in documentation
RequestWithResponse(
HttpStatus.NOT_FOUND,
"what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
),
RequestWithResponse(
HttpStatus.INTERNAL_SERVER_ERROR,
"what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
)
)
}
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" }
]
}
"""
}
}
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -19,61 +19,52 @@
package dev.dnpm.etl.processor.output 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.*
import dev.dnpm.etl.processor.PatientPseudonym
import dev.dnpm.etl.processor.RequestId
import dev.dnpm.etl.processor.config.RestTargetProperties 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.RequestStatus
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.retry.policy.SimpleRetryPolicy import org.springframework.retry.policy.SimpleRetryPolicy
import org.springframework.retry.support.RetryTemplateBuilder import org.springframework.retry.support.RetryTemplateBuilder
import org.springframework.test.web.client.ExpectedCount import org.springframework.test.web.client.ExpectedCount
import org.springframework.test.web.client.MockRestServiceServer import org.springframework.test.web.client.MockRestServiceServer
import org.springframework.test.web.client.match.MockRestRequestMatchers.* import org.springframework.test.web.client.match.MockRestRequestMatchers.method
import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.web.client.RestTemplate import org.springframework.web.client.RestTemplate
class RestBwhcMtbFileSenderTest { class RestMtbFileSenderTest {
private lateinit var mockRestServiceServer: MockRestServiceServer private lateinit var mockRestServiceServer: MockRestServiceServer
private lateinit var restMtbFileSender: RestMtbFileSender private lateinit var restMtbFileSender: RestMtbFileSender
private var reportService = ReportService(ObjectMapper().registerModule(KotlinModule.Builder().build()))
@BeforeEach @BeforeEach
fun setup() { fun setup() {
val restTemplate = RestTemplate() val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile")
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender = this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
} }
@ParameterizedTest @ParameterizedTest
@MethodSource("deleteRequestWithResponseSource") @MethodSource("deleteRequestWithResponseSource")
fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) { fun shouldReturnExpectedResponseForDelete(requestWithResponse: RequestWithResponse) {
this.mockRestServiceServer this.mockRestServiceServer.expect {
.expect(method(HttpMethod.DELETE)) method(HttpMethod.DELETE)
.andExpect(requestTo("http://localhost:9000/mtbfile/Patient/${TEST_PATIENT_PSEUDONYM.value}")) requestTo("/mtbfile")
.andRespond { }.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
} }
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) val response = restMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body) assertThat(response.body).isEqualTo(requestWithResponse.response.body)
} }
@ -81,15 +72,14 @@ class RestBwhcMtbFileSenderTest {
@ParameterizedTest @ParameterizedTest
@MethodSource("mtbFileRequestWithResponseSource") @MethodSource("mtbFileRequestWithResponseSource")
fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) { fun shouldReturnExpectedResponseForMtbFilePost(requestWithResponse: RequestWithResponse) {
this.mockRestServiceServer this.mockRestServiceServer.expect {
.expect(method(HttpMethod.POST)) method(HttpMethod.POST)
.andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile")) requestTo("/mtbfile")
.andExpect(header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)) }.andRespond {
.andRespond { withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) }
}
val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile)) val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body) assertThat(response.body).isEqualTo(requestWithResponse.response.body)
} }
@ -98,12 +88,11 @@ class RestBwhcMtbFileSenderTest {
@MethodSource("mtbFileRequestWithResponseSource") @MethodSource("mtbFileRequestWithResponseSource")
fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) { fun shouldRetryOnMtbFileHttpRequestError(requestWithResponse: RequestWithResponse) {
val restTemplate = RestTemplate() val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile")
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender = this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
val expectedCount = when (requestWithResponse.httpStatus) { val expectedCount = when (requestWithResponse.httpStatus) {
// OK - No Retry // OK - No Retry
@ -112,14 +101,14 @@ class RestBwhcMtbFileSenderTest {
else -> ExpectedCount.max(3) else -> ExpectedCount.max(3)
} }
this.mockRestServiceServer this.mockRestServiceServer.expect(expectedCount) {
.expect(expectedCount, method(HttpMethod.POST)) method(HttpMethod.POST)
.andExpect(requestTo("http://localhost:9000/mtbfile/MTBFile")) requestTo("/mtbfile")
.andRespond { }.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
} }
val response = restMtbFileSender.send(BwhcV1MtbFileRequest(TEST_REQUEST_ID, mtbFile)) val response = restMtbFileSender.send(MtbFileSender.MtbFileRequest("TestID", mtbFile))
assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body) assertThat(response.body).isEqualTo(requestWithResponse.response.body)
} }
@ -128,12 +117,11 @@ class RestBwhcMtbFileSenderTest {
@MethodSource("deleteRequestWithResponseSource") @MethodSource("deleteRequestWithResponseSource")
fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) { fun shouldRetryOnDeleteHttpRequestError(requestWithResponse: RequestWithResponse) {
val restTemplate = RestTemplate() val restTemplate = RestTemplate()
val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile", null, null) val restTargetProperties = RestTargetProperties("http://localhost:9000/mtbfile")
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build() val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(3)).build()
this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate) this.mockRestServiceServer = MockRestServiceServer.createServer(restTemplate)
this.restMtbFileSender = this.restMtbFileSender = RestMtbFileSender(restTemplate, restTargetProperties, retryTemplate)
RestBwhcMtbFileSender(restTemplate, restTargetProperties, retryTemplate, reportService)
val expectedCount = when (requestWithResponse.httpStatus) { val expectedCount = when (requestWithResponse.httpStatus) {
// OK - No Retry // OK - No Retry
@ -142,14 +130,14 @@ class RestBwhcMtbFileSenderTest {
else -> ExpectedCount.max(3) else -> ExpectedCount.max(3)
} }
this.mockRestServiceServer this.mockRestServiceServer.expect(expectedCount) {
.expect(expectedCount, method(HttpMethod.DELETE)) method(HttpMethod.DELETE)
.andExpect(requestTo("http://localhost:9000/mtbfile/Patient/${TEST_PATIENT_PSEUDONYM.value}")) requestTo("/mtbfile")
.andRespond { }.andRespond {
withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it) withStatus(requestWithResponse.httpStatus).body(requestWithResponse.body).createResponse(it)
} }
val response = restMtbFileSender.send(DeleteRequest(TEST_REQUEST_ID, TEST_PATIENT_PSEUDONYM)) val response = restMtbFileSender.send(MtbFileSender.DeleteRequest("TestID", "PID"))
assertThat(response.status).isEqualTo(requestWithResponse.response.status) assertThat(response.status).isEqualTo(requestWithResponse.response.status)
assertThat(response.body).isEqualTo(requestWithResponse.response.body) assertThat(response.body).isEqualTo(requestWithResponse.response.body)
} }
@ -161,8 +149,23 @@ class RestBwhcMtbFileSenderTest {
val response: MtbFileSender.Response val response: MtbFileSender.Response
) )
val TEST_REQUEST_ID = RequestId("TestId") private val warningBody = """
val TEST_PATIENT_PSEUDONYM = PatientPseudonym("PID") {
"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() val mtbFile: MtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -197,44 +200,31 @@ class RestBwhcMtbFileSenderTest {
@JvmStatic @JvmStatic
fun mtbFileRequestWithResponseSource(): Set<RequestWithResponse> { fun mtbFileRequestWithResponseSource(): Set<RequestWithResponse> {
return setOf( return setOf(
RequestWithResponse( RequestWithResponse(HttpStatus.OK, "{}", MtbFileSender.Response(RequestStatus.SUCCESS, "{}")),
HttpStatus.OK,
responseBodyWithMaxSeverity(ReportService.Severity.INFO),
MtbFileSender.Response(
RequestStatus.SUCCESS,
responseBodyWithMaxSeverity(ReportService.Severity.INFO)
)
),
RequestWithResponse( RequestWithResponse(
HttpStatus.CREATED, HttpStatus.CREATED,
responseBodyWithMaxSeverity(ReportService.Severity.WARNING), warningBody,
MtbFileSender.Response( MtbFileSender.Response(RequestStatus.WARNING, warningBody)
RequestStatus.WARNING,
responseBodyWithMaxSeverity(ReportService.Severity.WARNING)
)
), ),
RequestWithResponse( RequestWithResponse(
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
responseBodyWithMaxSeverity(ReportService.Severity.ERROR), "??",
MtbFileSender.Response(RequestStatus.ERROR, responseBodyWithMaxSeverity(ReportService.Severity.ERROR)) MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
), ),
RequestWithResponse( RequestWithResponse(
HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.UNPROCESSABLE_ENTITY,
responseBodyWithMaxSeverity(ReportService.Severity.FATAL), errorBody,
MtbFileSender.Response( MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
RequestStatus.ERROR,
responseBodyWithMaxSeverity(ReportService.Severity.FATAL)
)
), ),
// Some more errors not mentioned in documentation // Some more errors not mentioned in documentation
RequestWithResponse( RequestWithResponse(
HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND,
ERROR_RESPONSE_BODY, "what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
), ),
RequestWithResponse( RequestWithResponse(
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR,
ERROR_RESPONSE_BODY, "what????",
MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY) MtbFileSender.Response(RequestStatus.ERROR, ERROR_RESPONSE_BODY)
) )
) )
@ -261,52 +251,6 @@ class RestBwhcMtbFileSenderTest {
) )
) )
} }
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" }
]
}
"""
}
}
} }

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -21,261 +21,117 @@ package dev.dnpm.etl.processor.pseudonym
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* import de.ukw.ccc.bwhc.dto.*
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.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers
import org.mockito.Mock import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.anyValueClass
import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.springframework.core.io.ClassPathResource 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) @ExtendWith(MockitoExtension::class)
class ExtensionsTest { class ExtensionsTest {
@Nested private fun fakeMtbFile(): MtbFile {
inner class UsingBwhcDatamodel { val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream
return ObjectMapper().readValue(mtbFile, MtbFile::class.java)
val FAKE_MTB_FILE_PATH = "fake_MTBFile.json"
val CLEAN_PATIENT_ID = "5dad2f0b-49c6-47d8-a952-7b9e9e0f7549"
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<IllegalStateException> {
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<sha256(TESTDOMAIN-1)[0-41]>
.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()
}
} }
@Nested private fun MtbFile.serialized(): String {
inner class UsingDnpmV2Datamodel { return ObjectMapper().writeValueAsString(this)
val FAKE_MTB_FILE_PATH = "mv64e-mtb-fake-patient.json"
val CLEAN_PATIENT_ID = "63f8fd7b-8127-4f3c-8843-aa9199e21c29"
private fun fakeMtbFile(): Mtb {
val mtbFile = ClassPathResource(FAKE_MTB_FILE_PATH).inputStream
return ObjectMapper().readValue(mtbFile, Mtb::class.java)
}
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())
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 shouldNotContainCleanPatientId(@Mock pseudonymizeService: PseudonymizeService) {
doAnswer {
it.arguments[0]
"PSEUDO-ID"
}.whenever(pseudonymizeService).patientPseudonym(ArgumentMatchers.anyString())
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(ArgumentMatchers.anyString())
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<IllegalStateException> {
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(ArgumentMatchers.anyString())
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<sha256(TESTDOMAIN-1)[0-41]>
.isEqualTo("TESTDOMAIN44e20a53bbbf9f3ae39626d05df7014dcd77d6098")
}
} }

View File

@ -1,202 +0,0 @@
/*
* This file is part of ETL-Processor
*
* Copyright (c) 2024 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.dnpm.etl.processor.security
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.*
import org.springframework.security.core.session.SessionInformation
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.oauth2.core.oidc.OidcIdToken
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser
import org.springframework.security.oauth2.core.oidc.user.OidcUser
import java.time.Instant
import java.util.*
@ExtendWith(MockitoExtension::class)
class UserRoleServiceTest {
private lateinit var userRoleRepository: UserRoleRepository
private lateinit var sessionRegistry: SessionRegistry
private lateinit var userRoleService: UserRoleService
@BeforeEach
fun setup(
@Mock userRoleRepository: UserRoleRepository,
@Mock sessionRegistry: SessionRegistry
) {
this.userRoleRepository = userRoleRepository
this.sessionRegistry = sessionRegistry
this.userRoleService = UserRoleService(userRoleRepository, sessionRegistry)
}
@Test
fun shouldDelegateFindAllToRepository() {
userRoleService.findAll()
verify(userRoleRepository, times(1)).findAll()
}
@Nested
inner class WithExistingUserRole {
@BeforeEach
fun setup() {
doAnswer { invocation ->
Optional.of(
UserRole(invocation.getArgument(0), "patrick.tester", Role.USER)
)
}.whenever(userRoleRepository).findById(any<Long>())
doAnswer { _ ->
listOf(
dummyPrincipal()
)
}.whenever(sessionRegistry).allPrincipals
}
@Test
fun shouldUpdateUserRole() {
userRoleService.updateUserRole(1, Role.ADMIN)
val userRoleCaptor = argumentCaptor<UserRole>()
verify(userRoleRepository, times(1)).save(userRoleCaptor.capture())
assertThat(userRoleCaptor.firstValue.id).isEqualTo(1L)
assertThat(userRoleCaptor.firstValue.role).isEqualTo(Role.ADMIN)
}
@Test
fun shouldExpireSessionOnUpdate() {
val dummySessions = dummySessions()
whenever(sessionRegistry.getAllSessions(any(), any<Boolean>())).thenReturn(
dummySessions
)
assertThat(dummySessions.filter { it.isExpired }).hasSize(0)
userRoleService.updateUserRole(1, Role.ADMIN)
verify(sessionRegistry, times(1)).getAllSessions(any<OidcUser>(), any<Boolean>())
assertThat(dummySessions.filter { it.isExpired }).hasSize(2)
}
@Test
fun shouldDeleteUserRole() {
userRoleService.deleteUserRole(1)
val userRoleCaptor = argumentCaptor<UserRole>()
verify(userRoleRepository, times(1)).delete(userRoleCaptor.capture())
assertThat(userRoleCaptor.firstValue.id).isEqualTo(1L)
assertThat(userRoleCaptor.firstValue.role).isEqualTo(Role.USER)
}
@Test
fun shouldExpireSessionOnDelete() {
val dummySessions = dummySessions()
whenever(sessionRegistry.getAllSessions(any(), any<Boolean>())).thenReturn(
dummySessions
)
assertThat(dummySessions.filter { it.isExpired }).hasSize(0)
userRoleService.deleteUserRole(1)
verify(sessionRegistry, times(1)).getAllSessions(any<OidcUser>(), any<Boolean>())
assertThat(dummySessions.filter { it.isExpired }).hasSize(2)
}
}
@Nested
inner class WithoutExistingUserRole {
@BeforeEach
fun setup() {
doAnswer { _ ->
Optional.empty<UserRole>()
}.whenever(userRoleRepository).findById(any<Long>())
}
@Test
fun shouldNotUpdateUserRole() {
userRoleService.updateUserRole(1, Role.ADMIN)
verify(userRoleRepository, never()).save(any<UserRole>())
}
@Test
fun shouldNotExpireSessionOnUpdate() {
userRoleService.updateUserRole(1, Role.ADMIN)
verify(sessionRegistry, never()).getAllSessions(any<OidcUser>(), any<Boolean>())
}
@Test
fun shouldNotDeleteUserRole() {
userRoleService.deleteUserRole(1)
verify(userRoleRepository, never()).delete(any<UserRole>())
}
@Test
fun shouldNotExpireSessionOnDelete() {
userRoleService.deleteUserRole(1)
verify(sessionRegistry, never()).getAllSessions(any<OidcUser>(), any<Boolean>())
}
}
companion object {
private fun dummyPrincipal() = DefaultOidcUser(
listOf(),
OidcIdToken(
"anytokenvalue",
Instant.now(),
Instant.now().plusSeconds(10),
mapOf("sub" to "testsub", "preferred_username" to "patrick.tester")
)
)
private fun dummySessions() = listOf(
SessionInformation(
dummyPrincipal(),
"SESSIONID1",
Date.from(Instant.now()),
),
SessionInformation(
dummyPrincipal(),
"SESSIONID2",
Date.from(Instant.now()),
)
)
}
}

View File

@ -1,7 +1,7 @@
/* /*
* This file is part of ETL-Processor * This file is part of ETL-Processor
* *
* Copyright (c) 2025 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors * Copyright (c) 2023 Comprehensive Cancer Center Mainfranken, Datenintegrationszentrum Philipps-Universität Marburg and Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * it under the terms of the GNU Affero General Public License as published
@ -22,14 +22,9 @@ package dev.dnpm.etl.processor.services
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.KotlinModule
import dev.dnpm.etl.processor.monitoring.ReportService 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.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test 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 { class ReportServiceTest {
@ -48,32 +43,20 @@ class ReportServiceTest {
"issues": [ "issues": [
{ "severity": "info", "message": "Info Message" }, { "severity": "info", "message": "Info Message" },
{ "severity": "warning", "message": "Warning Message" }, { "severity": "warning", "message": "Warning Message" },
{ "severity": "error", "message": "Error Message" }, { "severity": "error", "message": "Error Message" }
{ "severity": "fatal", "message": "Fatal Message" }
] ]
} }
""".trimIndent() """.trimIndent()
val actual = this.reportService.deserialize(json) val actual = this.reportService.deserialize(json)
assertThat(actual).hasSize(4) assertThat(actual).hasSize(3)
assertThat(actual[0].severity).isEqualTo(ReportService.Severity.FATAL) assertThat(actual[0].severity).isEqualTo(ReportService.Severity.ERROR)
assertThat(actual[0].message).isEqualTo("Fatal Message") assertThat(actual[0].message).isEqualTo("Error Message")
assertThat(actual[1].severity).isEqualTo(ReportService.Severity.ERROR) assertThat(actual[1].severity).isEqualTo(ReportService.Severity.WARNING)
assertThat(actual[1].message).isEqualTo("Error Message") assertThat(actual[1].message).isEqualTo("Warning Message")
assertThat(actual[2].severity).isEqualTo(ReportService.Severity.WARNING) assertThat(actual[2].severity).isEqualTo(ReportService.Severity.INFO)
assertThat(actual[2].message).isEqualTo("Warning Message") assertThat(actual[2].message).isEqualTo("Info 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 @Test
@ -87,75 +70,4 @@ class ReportServiceTest {
assertThat(actual[0].message).isEqualTo("Not parsable data quality report '$invalidResponse'") assertThat(actual[0].message).isEqualTo("Not parsable data quality report '$invalidResponse'")
} }
companion object {
@JvmStatic
fun testData(): Set<Arguments> {
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
)
)
}
}
} }

View File

@ -21,32 +21,27 @@ package dev.dnpm.etl.processor.services
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.ukw.ccc.bwhc.dto.* import de.ukw.ccc.bwhc.dto.*
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.config.AppConfigProperties
import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType 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.MtbFileSender
import dev.dnpm.etl.processor.output.RestMtbFileSender import dev.dnpm.etl.processor.output.RestMtbFileSender
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService import dev.dnpm.etl.processor.pseudonym.PseudonymizeService
import dev.dnpm.etl.processor.randomRequestId
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock import org.mockito.Mock
import org.mockito.Mockito.* import org.mockito.Mockito.*
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.any import org.mockito.kotlin.any
import org.mockito.kotlin.anyValueClass
import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.whenever import org.mockito.kotlin.whenever
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import java.time.Instant import java.time.Instant
import java.util.*
@ExtendWith(MockitoExtension::class) @ExtendWith(MockitoExtension::class)
@ -93,28 +88,28 @@ class RequestProcessorTest {
fun testShouldSendMtbFileDuplicationAndSaveUnknownRequestStatusAtFirst() { fun testShouldSendMtbFileDuplicationAndSaveUnknownRequestStatusAtFirst() {
doAnswer { doAnswer {
Request( Request(
1L, id = 1L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga"), fingerprint = "zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.SUCCESS, status = RequestStatus.SUCCESS,
Instant.parse("2023-08-08T02:00:00Z") processedAt = Instant.parse("2023-08-08T02:00:00Z")
) )
}.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass()) }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString())
doAnswer { doAnswer {
false false
}.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass()) }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString())
doAnswer { doAnswer {
it.arguments[0] as String it.arguments[0] as String
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) }.`when`(pseudonymizeService).patientPseudonym(any())
doAnswer { doAnswer {
it.arguments[0] it.arguments[0]
}.whenever(transformationService).transform(any<MtbFile>()) }.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -152,28 +147,28 @@ class RequestProcessorTest {
fun testShouldDetectMtbFileDuplicationAndSendDuplicationEvent() { fun testShouldDetectMtbFileDuplicationAndSendDuplicationEvent() {
doAnswer { doAnswer {
Request( Request(
1L, id = 1L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga"), fingerprint = "zdlzv5s5ydmd4ktw2v5piohegc4jcyrm6j66bq6tv2uxuerndmga",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.SUCCESS, status = RequestStatus.SUCCESS,
Instant.parse("2023-08-08T02:00:00Z") processedAt = Instant.parse("2023-08-08T02:00:00Z")
) )
}.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass()) }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString())
doAnswer { doAnswer {
false false
}.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass()) }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString())
doAnswer { doAnswer {
it.arguments[0] as String it.arguments[0] as String
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) }.`when`(pseudonymizeService).patientPseudonym(any())
doAnswer { doAnswer {
it.arguments[0] it.arguments[0]
}.whenever(transformationService).transform(any<MtbFile>()) }.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -211,32 +206,32 @@ class RequestProcessorTest {
fun testShouldSendMtbFileAndSendSuccessEvent() { fun testShouldSendMtbFileAndSendSuccessEvent() {
doAnswer { doAnswer {
Request( Request(
1L, id = 1L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("different"), fingerprint = "different",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.SUCCESS, status = RequestStatus.SUCCESS,
Instant.parse("2023-08-08T02:00:00Z") processedAt = Instant.parse("2023-08-08T02:00:00Z")
) )
}.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass()) }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString())
doAnswer { doAnswer {
false false
}.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass()) }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString())
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.SUCCESS) MtbFileSender.Response(status = RequestStatus.SUCCESS)
}.whenever(sender).send(any<BwhcV1MtbFileRequest>()) }.`when`(sender).send(any<MtbFileSender.MtbFileRequest>())
doAnswer { doAnswer {
it.arguments[0] as String it.arguments[0] as String
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) }.`when`(pseudonymizeService).patientPseudonym(any())
doAnswer { doAnswer {
it.arguments[0] it.arguments[0]
}.whenever(transformationService).transform(any<MtbFile>()) }.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -274,32 +269,32 @@ class RequestProcessorTest {
fun testShouldSendMtbFileAndSendErrorEvent() { fun testShouldSendMtbFileAndSendErrorEvent() {
doAnswer { doAnswer {
Request( Request(
1L, id = 1L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("different"), fingerprint = "different",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.SUCCESS, status = RequestStatus.SUCCESS,
Instant.parse("2023-08-08T02:00:00Z") processedAt = Instant.parse("2023-08-08T02:00:00Z")
) )
}.whenever(requestService).lastMtbFileRequestForPatientPseudonym(anyValueClass()) }.`when`(requestService).lastMtbFileRequestForPatientPseudonym(anyString())
doAnswer { doAnswer {
false false
}.whenever(requestService).isLastRequestWithKnownStatusDeletion(anyValueClass()) }.`when`(requestService).isLastRequestWithKnownStatusDeletion(anyString())
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.ERROR) MtbFileSender.Response(status = RequestStatus.ERROR)
}.whenever(sender).send(any<BwhcV1MtbFileRequest>()) }.`when`(sender).send(any<MtbFileSender.MtbFileRequest>())
doAnswer { doAnswer {
it.arguments[0] as String it.arguments[0] as String
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) }.`when`(pseudonymizeService).patientPseudonym(any())
doAnswer { doAnswer {
it.arguments[0] it.arguments[0]
}.whenever(transformationService).transform(any<MtbFile>()) }.whenever(transformationService).transform(any())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -337,13 +332,13 @@ class RequestProcessorTest {
fun testShouldSendDeleteRequestAndSaveUnknownRequestStatusAtFirst() { fun testShouldSendDeleteRequestAndSaveUnknownRequestStatusAtFirst() {
doAnswer { doAnswer {
"PSEUDONYM" "PSEUDONYM"
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) }.`when`(pseudonymizeService).patientPseudonym(anyString())
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.UNKNOWN) MtbFileSender.Response(status = RequestStatus.UNKNOWN)
}.whenever(sender).send(any<DeleteRequest>()) }.`when`(sender).send(any<MtbFileSender.DeleteRequest>())
this.requestProcessor.processDeletion(TEST_PATIENT_ID) this.requestProcessor.processDeletion("TEST_12345678901")
val requestCaptor = argumentCaptor<Request>() val requestCaptor = argumentCaptor<Request>()
verify(requestService, times(1)).save(requestCaptor.capture()) verify(requestService, times(1)).save(requestCaptor.capture())
@ -355,13 +350,13 @@ class RequestProcessorTest {
fun testShouldSendDeleteRequestAndSendSuccessEvent() { fun testShouldSendDeleteRequestAndSendSuccessEvent() {
doAnswer { doAnswer {
"PSEUDONYM" "PSEUDONYM"
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) }.`when`(pseudonymizeService).patientPseudonym(anyString())
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.SUCCESS) MtbFileSender.Response(status = RequestStatus.SUCCESS)
}.whenever(sender).send(any<DeleteRequest>()) }.`when`(sender).send(any<MtbFileSender.DeleteRequest>())
this.requestProcessor.processDeletion(TEST_PATIENT_ID) this.requestProcessor.processDeletion("TEST_12345678901")
val eventCaptor = argumentCaptor<ResponseEvent>() val eventCaptor = argumentCaptor<ResponseEvent>()
verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture()) verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture())
@ -373,13 +368,13 @@ class RequestProcessorTest {
fun testShouldSendDeleteRequestAndSendErrorEvent() { fun testShouldSendDeleteRequestAndSendErrorEvent() {
doAnswer { doAnswer {
"PSEUDONYM" "PSEUDONYM"
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) }.`when`(pseudonymizeService).patientPseudonym(anyString())
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.ERROR) MtbFileSender.Response(status = RequestStatus.ERROR)
}.whenever(sender).send(any<DeleteRequest>()) }.`when`(sender).send(any<MtbFileSender.DeleteRequest>())
this.requestProcessor.processDeletion(TEST_PATIENT_ID) this.requestProcessor.processDeletion("TEST_12345678901")
val eventCaptor = argumentCaptor<ResponseEvent>() val eventCaptor = argumentCaptor<ResponseEvent>()
verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture()) verify(applicationEventPublisher, times(1)).publishEvent(eventCaptor.capture())
@ -389,9 +384,9 @@ class RequestProcessorTest {
@Test @Test
fun testShouldSendDeleteRequestWithPseudonymErrorAndSaveErrorRequestStatus() { fun testShouldSendDeleteRequestWithPseudonymErrorAndSaveErrorRequestStatus() {
doThrow(RuntimeException()).whenever(pseudonymizeService).patientPseudonym(anyValueClass()) doThrow(RuntimeException()).`when`(pseudonymizeService).patientPseudonym(anyString())
this.requestProcessor.processDeletion(TEST_PATIENT_ID) this.requestProcessor.processDeletion("TEST_12345678901")
val requestCaptor = argumentCaptor<Request>() val requestCaptor = argumentCaptor<Request>()
verify(requestService, times(1)).save(requestCaptor.capture()) verify(requestService, times(1)).save(requestCaptor.capture())
@ -405,15 +400,15 @@ class RequestProcessorTest {
doAnswer { doAnswer {
it.arguments[0] as String it.arguments[0] as String
}.whenever(pseudonymizeService).patientPseudonym(anyValueClass()) }.`when`(pseudonymizeService).patientPseudonym(any())
doAnswer { doAnswer {
it.arguments[0] it.arguments[0]
}.whenever(transformationService).transform(any<MtbFile>()) }.whenever(transformationService).transform(any())
doAnswer { doAnswer {
MtbFileSender.Response(status = RequestStatus.SUCCESS) MtbFileSender.Response(status = RequestStatus.SUCCESS)
}.whenever(sender).send(any<BwhcV1MtbFileRequest>()) }.`when`(sender).send(any<MtbFileSender.MtbFileRequest>())
val mtbFile = MtbFile.builder() val mtbFile = MtbFile.builder()
.withPatient( .withPatient(
@ -447,8 +442,4 @@ class RequestProcessorTest {
assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS) assertThat(eventCaptor.firstValue.status).isEqualTo(RequestStatus.SUCCESS)
} }
companion object {
val TEST_PATIENT_ID = PatientId("TEST_12345678901")
}
} }

View File

@ -19,7 +19,6 @@
package dev.dnpm.etl.processor.services package dev.dnpm.etl.processor.services
import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestRepository import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
@ -31,9 +30,8 @@ import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mock import org.mockito.Mock
import org.mockito.Mockito.* import org.mockito.Mockito.*
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.anyValueClass
import org.mockito.kotlin.whenever
import java.time.Instant import java.time.Instant
import java.util.*
@ExtendWith(MockitoExtension::class) @ExtendWith(MockitoExtension::class)
class RequestServiceTest { class RequestServiceTest {
@ -43,14 +41,14 @@ class RequestServiceTest {
private lateinit var requestService: RequestService private lateinit var requestService: RequestService
private fun anyRequest() = any(Request::class.java) ?: Request( private fun anyRequest() = any(Request::class.java) ?: Request(
0L, id = 0L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_dummy"), patientId = "TEST_dummy",
PatientId("PX"), pid = "PX",
Fingerprint("dummy"), fingerprint = "dummy",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.SUCCESS, status = RequestStatus.SUCCESS,
Instant.parse("2023-08-08T02:00:00Z") processedAt = Instant.parse("2023-08-08T02:00:00Z")
) )
@BeforeEach @BeforeEach
@ -65,34 +63,34 @@ class RequestServiceTest {
fun shouldIndicateLastRequestIsDeleteRequest() { fun shouldIndicateLastRequestIsDeleteRequest() {
val requests = listOf( val requests = listOf(
Request( Request(
1L, id = 1L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("0123456789abcdef1"), fingerprint = "0123456789abcdef1",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.WARNING, status = RequestStatus.WARNING,
Instant.parse("2023-07-07T00:00:00Z") processedAt = Instant.parse("2023-07-07T00:00:00Z")
), ),
Request( Request(
2L, id = 2L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("0123456789abcdefd"), fingerprint = "0123456789abcdefd",
RequestType.DELETE, type = RequestType.DELETE,
RequestStatus.WARNING, status = RequestStatus.WARNING,
Instant.parse("2023-07-07T02:00:00Z") processedAt = Instant.parse("2023-07-07T02:00:00Z")
), ),
Request( Request(
3L, id = 3L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("0123456789abcdef1"), fingerprint = "0123456789abcdef1",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.UNKNOWN, status = RequestStatus.UNKNOWN,
Instant.parse("2023-08-11T00:00:00Z") processedAt = Instant.parse("2023-08-11T00:00:00Z")
) )
) )
@ -105,34 +103,34 @@ class RequestServiceTest {
fun shouldIndicateLastRequestIsNotDeleteRequest() { fun shouldIndicateLastRequestIsNotDeleteRequest() {
val requests = listOf( val requests = listOf(
Request( Request(
1L, id = 1L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("0123456789abcdef1"), fingerprint = "0123456789abcdef1",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.WARNING, status = RequestStatus.WARNING,
Instant.parse("2023-07-07T00:00:00Z") processedAt = Instant.parse("2023-07-07T00:00:00Z")
), ),
Request( Request(
2L, id = 2L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("0123456789abcdef1"), fingerprint = "0123456789abcdef1",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.WARNING, status = RequestStatus.WARNING,
Instant.parse("2023-07-07T02:00:00Z") processedAt = Instant.parse("2023-07-07T02:00:00Z")
), ),
Request( Request(
3L, id = 3L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("0123456789abcdef1"), fingerprint = "0123456789abcdef1",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.UNKNOWN, status = RequestStatus.UNKNOWN,
Instant.parse("2023-08-11T00:00:00Z") processedAt = Instant.parse("2023-08-11T00:00:00Z")
) )
) )
@ -145,31 +143,31 @@ class RequestServiceTest {
fun shouldReturnPatientsLastRequest() { fun shouldReturnPatientsLastRequest() {
val requests = listOf( val requests = listOf(
Request( Request(
1L, id = 1L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("0123456789abcdef1"), fingerprint = "0123456789abcdef1",
RequestType.DELETE, type = RequestType.DELETE,
RequestStatus.SUCCESS, status = RequestStatus.SUCCESS,
Instant.parse("2023-07-07T02:00:00Z") processedAt = Instant.parse("2023-07-07T02:00:00Z")
), ),
Request( Request(
1L, id = 1L,
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678902"), patientId = "TEST_12345678902",
PatientId("P2"), pid = "P2",
Fingerprint("0123456789abcdef2"), fingerprint = "0123456789abcdef2",
RequestType.MTB_FILE, type = RequestType.MTB_FILE,
RequestStatus.WARNING, status = RequestStatus.WARNING,
Instant.parse("2023-08-08T00:00:00Z") processedAt = Instant.parse("2023-08-08T00:00:00Z")
) )
) )
val actual = RequestService.lastMtbFileRequestForPatientPseudonym(requests) val actual = RequestService.lastMtbFileRequestForPatientPseudonym(requests)
assertThat(actual).isInstanceOf(Request::class.java) assertThat(actual).isInstanceOf(Request::class.java)
assertThat(actual?.fingerprint).isEqualTo(Fingerprint("0123456789abcdef2")) assertThat(actual?.fingerprint).isEqualTo("0123456789abcdef2")
} }
@Test @Test
@ -186,16 +184,16 @@ class RequestServiceTest {
doAnswer { doAnswer {
val obj = it.arguments[0] as Request val obj = it.arguments[0] as Request
obj.copy(id = 1L) obj.copy(id = 1L)
}.whenever(requestRepository).save(anyRequest()) }.`when`(requestRepository).save(anyRequest())
val request = Request( val request = Request(
randomRequestId(), uuid = UUID.randomUUID().toString(),
PatientPseudonym("TEST_12345678901"), patientId = "TEST_12345678901",
PatientId("P1"), pid = "P1",
Fingerprint("0123456789abcdef1"), fingerprint = "0123456789abcdef1",
RequestType.DELETE, type = RequestType.DELETE,
RequestStatus.SUCCESS, status = RequestStatus.SUCCESS,
Instant.parse("2023-07-07T02:00:00Z") processedAt = Instant.parse("2023-07-07T02:00:00Z")
) )
requestService.save(request) requestService.save(request)
@ -205,23 +203,23 @@ class RequestServiceTest {
@Test @Test
fun allRequestsByPatientPseudonymShouldRequestAllRequestsForPatientPseudonym() { fun allRequestsByPatientPseudonymShouldRequestAllRequestsForPatientPseudonym() {
requestService.allRequestsByPatientPseudonym(PatientPseudonym("TEST_12345678901")) requestService.allRequestsByPatientPseudonym("TEST_12345678901")
verify(requestRepository, times(1)).findAllByPatientPseudonymOrderByProcessedAtDesc(anyValueClass()) verify(requestRepository, times(1)).findAllByPatientIdOrderByProcessedAtDesc(anyString())
} }
@Test @Test
fun lastMtbFileRequestForPatientPseudonymShouldRequestAllRequestsForPatientPseudonym() { fun lastMtbFileRequestForPatientPseudonymShouldRequestAllRequestsForPatientPseudonym() {
requestService.lastMtbFileRequestForPatientPseudonym(PatientPseudonym("TEST_12345678901")) requestService.lastMtbFileRequestForPatientPseudonym("TEST_12345678901")
verify(requestRepository, times(1)).findAllByPatientPseudonymOrderByProcessedAtDesc(anyValueClass()) verify(requestRepository, times(1)).findAllByPatientIdOrderByProcessedAtDesc(anyString())
} }
@Test @Test
fun isLastRequestDeletionShouldRequestAllRequestsForPatientPseudonym() { fun isLastRequestDeletionShouldRequestAllRequestsForPatientPseudonym() {
requestService.isLastRequestWithKnownStatusDeletion(PatientPseudonym("TEST_12345678901")) requestService.isLastRequestWithKnownStatusDeletion("TEST_12345678901")
verify(requestRepository, times(1)).findAllByPatientPseudonymOrderByProcessedAtDesc(anyValueClass()) verify(requestRepository, times(1)).findAllByPatientIdOrderByProcessedAtDesc(anyString())
} }
} }

View File

@ -19,8 +19,8 @@
package dev.dnpm.etl.processor.services package dev.dnpm.etl.processor.services
import dev.dnpm.etl.processor.*
import dev.dnpm.etl.processor.monitoring.Request import dev.dnpm.etl.processor.monitoring.Request
import dev.dnpm.etl.processor.monitoring.RequestRepository
import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType import dev.dnpm.etl.processor.monitoring.RequestType
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
@ -29,6 +29,7 @@ import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock import org.mockito.Mock
import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.* import org.mockito.kotlin.*
@ -39,64 +40,64 @@ import java.util.*
@ExtendWith(MockitoExtension::class) @ExtendWith(MockitoExtension::class)
class ResponseProcessorTest { class ResponseProcessorTest {
private lateinit var requestService: RequestService private lateinit var requestRepository: RequestRepository
private lateinit var statisticsUpdateProducer: Sinks.Many<Any> private lateinit var statisticsUpdateProducer: Sinks.Many<Any>
private lateinit var responseProcessor: ResponseProcessor private lateinit var responseProcessor: ResponseProcessor
private val testRequest = Request( private val testRequest = Request(
1L, 1L,
RequestId("TestID1234"), "TestID1234",
PatientPseudonym("PSEUDONYM-A"), "PSEUDONYM-A",
PatientId("1"), "1",
Fingerprint("dummyfingerprint"), "dummyfingerprint",
RequestType.MTB_FILE, RequestType.MTB_FILE,
RequestStatus.UNKNOWN RequestStatus.UNKNOWN
) )
@BeforeEach @BeforeEach
fun setup( fun setup(
@Mock requestService: RequestService, @Mock requestRepository: RequestRepository,
@Mock statisticsUpdateProducer: Sinks.Many<Any> @Mock statisticsUpdateProducer: Sinks.Many<Any>
) { ) {
this.requestService = requestService this.requestRepository = requestRepository
this.statisticsUpdateProducer = statisticsUpdateProducer this.statisticsUpdateProducer = statisticsUpdateProducer
this.responseProcessor = ResponseProcessor(requestService, statisticsUpdateProducer) this.responseProcessor = ResponseProcessor(requestRepository, statisticsUpdateProducer)
} }
@Test @Test
fun shouldNotSaveStatusForUnknownRequest() { fun shouldNotSaveStatusForUnknownRequest() {
doAnswer { doAnswer {
Optional.empty<Request>() Optional.empty<Request>()
}.whenever(requestService).findByUuid(anyValueClass()) }.whenever(requestRepository).findByUuidEquals(anyString())
val event = ResponseEvent( val event = ResponseEvent(
RequestId("TestID1234"), "TestID1234",
Instant.parse("2023-09-09T00:00:00Z"), Instant.parse("2023-09-09T00:00:00Z"),
RequestStatus.SUCCESS RequestStatus.SUCCESS
) )
this.responseProcessor.handleResponseEvent(event) this.responseProcessor.handleResponseEvent(event)
verify(requestService, never()).save(any()) verify(requestRepository, never()).save(any())
} }
@Test @Test
fun shouldNotSaveStatusWithUnknownState() { fun shouldNotSaveStatusWithUnknownState() {
doAnswer { doAnswer {
Optional.of(testRequest) Optional.of(testRequest)
}.whenever(requestService).findByUuid(anyValueClass()) }.whenever(requestRepository).findByUuidEquals(anyString())
val event = ResponseEvent( val event = ResponseEvent(
RequestId("TestID1234"), "TestID1234",
Instant.parse("2023-09-09T00:00:00Z"), Instant.parse("2023-09-09T00:00:00Z"),
RequestStatus.UNKNOWN RequestStatus.UNKNOWN
) )
this.responseProcessor.handleResponseEvent(event) this.responseProcessor.handleResponseEvent(event)
verify(requestService, never()).save(any<Request>()) verify(requestRepository, never()).save(any())
} }
@ParameterizedTest @ParameterizedTest
@ -104,10 +105,10 @@ class ResponseProcessorTest {
fun shouldSaveStatusForKnownRequest(requestStatus: RequestStatus) { fun shouldSaveStatusForKnownRequest(requestStatus: RequestStatus) {
doAnswer { doAnswer {
Optional.of(testRequest) Optional.of(testRequest)
}.whenever(requestService).findByUuid(anyValueClass()) }.whenever(requestRepository).findByUuidEquals(anyString())
val event = ResponseEvent( val event = ResponseEvent(
RequestId("TestID1234"), "TestID1234",
Instant.parse("2023-09-09T00:00:00Z"), Instant.parse("2023-09-09T00:00:00Z"),
requestStatus requestStatus
) )
@ -115,7 +116,7 @@ class ResponseProcessorTest {
this.responseProcessor.handleResponseEvent(event) this.responseProcessor.handleResponseEvent(event)
val captor = argumentCaptor<Request>() val captor = argumentCaptor<Request>()
verify(requestService, times(1)).save(captor.capture()) verify(requestRepository, times(1)).save(captor.capture())
assertThat(captor.firstValue).isNotNull assertThat(captor.firstValue).isNotNull
assertThat(captor.firstValue.status).isEqualTo(requestStatus) assertThat(captor.firstValue.status).isEqualTo(requestStatus)
} }

View File

@ -17,12 +17,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.dnpm.etl.processor.security package dev.dnpm.etl.processor.services
import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.anyLong import org.mockito.ArgumentMatchers.anyLong
import org.mockito.ArgumentMatchers.anyString import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mock import org.mockito.Mock
@ -95,11 +96,11 @@ class TokenServiceTest {
val actual = this.tokenService.addToken("Test Token") val actual = this.tokenService.addToken("Test Token")
val captor = argumentCaptor<Token>() val captor = ArgumentCaptor.forClass(Token::class.java)
verify(tokenRepository, times(1)).save(captor.capture()) verify(tokenRepository, times(1)).save(captor.capture())
assertThat(actual).satisfies(Consumer { assertThat(it.isSuccess).isTrue() }) assertThat(actual).satisfies(Consumer { assertThat(it.isSuccess).isTrue() })
assertThat(captor.firstValue).satisfies( assertThat(captor.value).satisfies(
Consumer { assertThat(it.name).isEqualTo("Test Token") }, Consumer { assertThat(it.name).isEqualTo("Test Token") },
Consumer { assertThat(it.username).isEqualTo("testtoken") }, Consumer { assertThat(it.username).isEqualTo("testtoken") },
Consumer { assertThat(it.password).isEqualTo("{test}verysecret") } Consumer { assertThat(it.password).isEqualTo("{test}verysecret") }
@ -115,13 +116,13 @@ class TokenServiceTest {
this.tokenService.deleteToken(42) this.tokenService.deleteToken(42)
val stringCaptor = argumentCaptor<String>() val stringCaptor = ArgumentCaptor.forClass(String::class.java)
verify(userDetailsManager, times(1)).deleteUser(stringCaptor.capture()) verify(userDetailsManager, times(1)).deleteUser(stringCaptor.capture())
assertThat(stringCaptor.firstValue).isEqualTo("testtoken") assertThat(stringCaptor.value).isEqualTo("testtoken")
val tokenCaptor = argumentCaptor<Token>() val tokenCaptor = ArgumentCaptor.forClass(Token::class.java)
verify(tokenRepository, times(1)).delete(tokenCaptor.capture()) verify(tokenRepository, times(1)).delete(tokenCaptor.capture())
assertThat(tokenCaptor.firstValue.id).isEqualTo(42) assertThat(tokenCaptor.value.id).isEqualTo(42)
} }
@Test @Test

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff