diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerConfig.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerConfig.java
new file mode 100644
index 000000000..fa5aa3749
--- /dev/null
+++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerConfig.java
@@ -0,0 +1,48 @@
+package care.smith.fts.cda.impl;
+
+import care.smith.fts.util.HttpClientConfig;
+import jakarta.validation.constraints.NotNull;
+import java.time.Duration;
+import java.util.Optional;
+
+/**
+ * Configuration for FHIR Pseudonymizer service integration in Clinical Domain Agent.
+ *
+ *
This configuration enables deidentification via an external FHIR Pseudonymizer service, which
+ * delegates pseudonym generation to the Trust Center Agent's Vfps-compatible FHIR operations.
+ *
+ *
Configuration example:
+ *
+ *
{@code
+ * deidentificator:
+ * fhir-pseudonymizer:
+ * server:
+ * baseUrl: "https://fhir-pseudonymizer.clinical.example.com"
+ * auth:
+ * type: oauth2
+ * clientId: "cda-client"
+ * clientSecret: "${FHIR_PSEUDONYMIZER_SECRET}"
+ * timeout: 60s
+ * maxRetries: 3
+ * }
+ *
+ * @param server HTTP client configuration for the FHIR Pseudonymizer service
+ * @param timeout Request timeout (default: 60 seconds)
+ * @param maxRetries Maximum retry attempts (default: 3)
+ */
+public record FhirPseudonymizerConfig(
+ @NotNull HttpClientConfig server, Duration timeout, Integer maxRetries) {
+
+ public FhirPseudonymizerConfig(HttpClientConfig server, Duration timeout, Integer maxRetries) {
+ this.server = server;
+ this.timeout = Optional.ofNullable(timeout).orElse(Duration.ofSeconds(60));
+ this.maxRetries = Optional.ofNullable(maxRetries).orElse(3);
+
+ if (this.timeout.isNegative() || this.timeout.isZero()) {
+ throw new IllegalArgumentException("Timeout must be positive");
+ }
+ if (this.maxRetries < 0) {
+ throw new IllegalArgumentException("Max retries must be non-negative");
+ }
+ }
+}
diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStep.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStep.java
new file mode 100644
index 000000000..5e90478ef
--- /dev/null
+++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStep.java
@@ -0,0 +1,121 @@
+package care.smith.fts.cda.impl;
+
+import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON;
+import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy;
+
+import ca.uhn.fhir.context.FhirContext;
+import care.smith.fts.api.ConsentedPatientBundle;
+import care.smith.fts.api.TransportBundle;
+import care.smith.fts.api.cda.Deidentificator;
+import io.micrometer.core.instrument.MeterRegistry;
+import lombok.extern.slf4j.Slf4j;
+import org.hl7.fhir.r4.model.Bundle;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+/**
+ * Deidentificator implementation that delegates deidentification to an external FHIR Pseudonymizer
+ * service.
+ *
+ * This implementation provides an alternative to DeidentifhirStep, enabling deidentification via
+ * a configurable external service. The FHIR Pseudonymizer service internally calls the TCA's
+ * Vfps-compatible FHIR operations for transport ID generation.
+ *
+ *
Architecture:
+ *
+ *
+ * ConsentedPatientBundle
+ * ↓
+ * FhirPseudonymizerStep (this class)
+ * ↓ [HTTP POST /fhir with FHIR Bundle]
+ * FHIR Pseudonymizer Service (external, clinical domain)
+ * ↓ [POST /$create-pseudonym]
+ * TCA CdAgentFhirPseudonymizerController
+ * ↓ [transport ID generation, sID stored in Redis]
+ * TransportBundle (with transport IDs)
+ *
+ *
+ * The transport IDs in the returned bundle are temporary identifiers that will be resolved to
+ * real pseudonyms by the RDA via TCA's /rd-agent/fhir endpoint.
+ */
+@Slf4j
+public class FhirPseudonymizerStep implements Deidentificator {
+
+ private static final String FHIR_ENDPOINT = "/fhir";
+
+ private final WebClient fhirPseudonymizerClient;
+ private final FhirPseudonymizerConfig config;
+ private final MeterRegistry meterRegistry;
+ private final FhirContext fhirContext;
+
+ public FhirPseudonymizerStep(
+ WebClient fhirPseudonymizerClient,
+ FhirPseudonymizerConfig config,
+ MeterRegistry meterRegistry,
+ FhirContext fhirContext) {
+ this.fhirPseudonymizerClient = fhirPseudonymizerClient;
+ this.config = config;
+ this.meterRegistry = meterRegistry;
+ this.fhirContext = fhirContext;
+ }
+
+ /**
+ * Deidentifies a FHIR Bundle via external FHIR Pseudonymizer service.
+ *
+ *
Sends the ConsentedPatientBundle to the FHIR Pseudonymizer service, which processes the
+ * bundle and returns a deidentified version containing transport IDs instead of real pseudonyms.
+ *
+ * @param bundle ConsentedPatientBundle containing patient data to deidentify
+ * @return Mono of TransportBundle with deidentified bundle and transfer ID
+ */
+ @Override
+ public Mono deidentify(ConsentedPatientBundle bundle) {
+ log.debug(
+ "Deidentifying bundle for patient {} via FHIR Pseudonymizer",
+ bundle.consentedPatient().id());
+
+ String bundleJson = fhirContext.newJsonParser().encodeResourceToString(bundle.bundle());
+
+ return fhirPseudonymizerClient
+ .post()
+ .uri(FHIR_ENDPOINT)
+ .contentType(APPLICATION_FHIR_JSON)
+ .accept(APPLICATION_FHIR_JSON)
+ .bodyValue(bundleJson)
+ .retrieve()
+ .bodyToMono(String.class)
+ .map(this::parseDeidentifiedBundle)
+ .map(this::createTransportBundle)
+ .timeout(config.timeout())
+ .retryWhen(defaultRetryStrategy(meterRegistry, "fhirPseudonymizerDeidentification"))
+ .doOnSuccess(
+ result ->
+ log.debug(
+ "Successfully deidentified bundle for patient {}, transfer ID: {}",
+ bundle.consentedPatient().id(),
+ result.transferId()))
+ .doOnError(
+ error ->
+ log.error(
+ "Failed to deidentify bundle for patient {}: {}",
+ bundle.consentedPatient().id(),
+ error.getMessage()));
+ }
+
+ private Bundle parseDeidentifiedBundle(String bundleJson) {
+ return fhirContext.newJsonParser().parseResource(Bundle.class, bundleJson);
+ }
+
+ private TransportBundle createTransportBundle(Bundle deidentifiedBundle) {
+ var transferId = deidentifiedBundle.getIdPart();
+
+ // Note: HAPI FHIR's getIdPart() returns null for empty/missing IDs, never empty string
+ if (transferId == null) {
+ throw new IllegalStateException(
+ "FHIR Pseudonymizer returned bundle without transfer ID (bundle.id is null)");
+ }
+
+ log.trace("Extracted transfer ID from deidentified bundle: {}", transferId);
+ return new TransportBundle(deidentifiedBundle, transferId);
+ }
+}
diff --git a/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactory.java b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactory.java
new file mode 100644
index 000000000..0d9b8e20a
--- /dev/null
+++ b/clinical-domain-agent/src/main/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactory.java
@@ -0,0 +1,65 @@
+package care.smith.fts.cda.impl;
+
+import ca.uhn.fhir.context.FhirContext;
+import care.smith.fts.api.cda.Deidentificator;
+import care.smith.fts.util.WebClientFactory;
+import io.micrometer.core.instrument.MeterRegistry;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * Factory for creating FhirPseudonymizerStep instances in the Clinical Domain Agent.
+ *
+ * This factory implements the transfer process step factory pattern, enabling
+ * configuration-based instantiation of FHIR Pseudonymizer deidentificators.
+ *
+ *
Configuration example:
+ *
+ *
{@code
+ * deidentificator:
+ * fhir-pseudonymizer:
+ * server:
+ * baseUrl: "https://fhir-pseudonymizer.clinical.example.com"
+ * auth:
+ * type: oauth2
+ * clientId: "cda-client"
+ * clientSecret: "${FHIR_PSEUDONYMIZER_SECRET}"
+ * timeout: 60s
+ * maxRetries: 3
+ * }
+ *
+ * The factory is registered as a Spring bean with name "fhir-pseudonymizerDeidentificator" to
+ * enable configuration-based selection between deidentification methods (deidentifhir vs
+ * fhir-pseudonymizer).
+ */
+@Slf4j
+@Component("fhir-pseudonymizerDeidentificator")
+public class FhirPseudonymizerStepFactory
+ implements Deidentificator.Factory {
+
+ private final WebClientFactory clientFactory;
+ private final MeterRegistry meterRegistry;
+ private final FhirContext fhirContext;
+
+ public FhirPseudonymizerStepFactory(
+ WebClientFactory clientFactory, MeterRegistry meterRegistry, FhirContext fhirContext) {
+ this.clientFactory = clientFactory;
+ this.meterRegistry = meterRegistry;
+ this.fhirContext = fhirContext;
+ }
+
+ @Override
+ public Class getConfigType() {
+ return FhirPseudonymizerConfig.class;
+ }
+
+ @Override
+ public Deidentificator create(
+ Deidentificator.Config commonConfig, FhirPseudonymizerConfig implConfig) {
+ var httpClient = clientFactory.create(implConfig.server());
+
+ log.info("Created FhirPseudonymizerStep with service URL: {}", implConfig.server().baseUrl());
+
+ return new FhirPseudonymizerStep(httpClient, implConfig, meterRegistry, fhirContext);
+ }
+}
diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerConfigTest.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerConfigTest.java
new file mode 100644
index 000000000..b3a312ea2
--- /dev/null
+++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerConfigTest.java
@@ -0,0 +1,88 @@
+package care.smith.fts.cda.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import care.smith.fts.util.HttpClientConfig;
+import java.time.Duration;
+import org.junit.jupiter.api.Test;
+
+class FhirPseudonymizerConfigTest {
+
+ @Test
+ void createWithAllParametersExplicit() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+ var timeout = Duration.ofSeconds(30);
+ var maxRetries = 5;
+
+ var config = new FhirPseudonymizerConfig(server, timeout, maxRetries);
+
+ assertThat(config.server()).isEqualTo(server);
+ assertThat(config.timeout()).isEqualTo(timeout);
+ assertThat(config.maxRetries()).isEqualTo(5);
+ }
+
+ @Test
+ void createWithDefaultTimeout() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ var config = new FhirPseudonymizerConfig(server, null, 5);
+
+ assertThat(config.timeout()).isEqualTo(Duration.ofSeconds(60));
+ }
+
+ @Test
+ void createWithDefaultMaxRetries() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ var config = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), null);
+
+ assertThat(config.maxRetries()).isEqualTo(3);
+ }
+
+ @Test
+ void createWithAllDefaults() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ var config = new FhirPseudonymizerConfig(server, null, null);
+
+ assertThat(config.timeout()).isEqualTo(Duration.ofSeconds(60));
+ assertThat(config.maxRetries()).isEqualTo(3);
+ }
+
+ @Test
+ void negativeTimeoutThrowsException() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ofSeconds(-1), 3))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Timeout must be positive");
+ }
+
+ @Test
+ void zeroTimeoutThrowsException() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ZERO, 3))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Timeout must be positive");
+ }
+
+ @Test
+ void negativeMaxRetriesThrowsException() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), -1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Max retries must be non-negative");
+ }
+
+ @Test
+ void zeroMaxRetriesIsAllowed() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ var config = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), 0);
+
+ assertThat(config.maxRetries()).isZero();
+ }
+}
diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactoryTest.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactoryTest.java
new file mode 100644
index 000000000..061944f43
--- /dev/null
+++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepFactoryTest.java
@@ -0,0 +1,70 @@
+package care.smith.fts.cda.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import ca.uhn.fhir.context.FhirContext;
+import care.smith.fts.api.cda.Deidentificator;
+import care.smith.fts.util.HttpClientConfig;
+import care.smith.fts.util.WebClientFactory;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import java.time.Duration;
+import org.junit.jupiter.api.BeforeEach;
+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.springframework.web.reactive.function.client.WebClient;
+
+@ExtendWith(MockitoExtension.class)
+class FhirPseudonymizerStepFactoryTest {
+
+ @Mock private WebClientFactory clientFactory;
+ @Mock private WebClient webClient;
+
+ private MeterRegistry meterRegistry;
+ private FhirContext fhirContext;
+ private FhirPseudonymizerStepFactory factory;
+
+ @BeforeEach
+ void setUp() {
+ meterRegistry = new SimpleMeterRegistry();
+ fhirContext = FhirContext.forR4();
+ factory = new FhirPseudonymizerStepFactory(clientFactory, meterRegistry, fhirContext);
+ }
+
+ @Test
+ void getConfigTypeReturnsFhirPseudonymizerConfigClass() {
+ assertThat(factory.getConfigType()).isEqualTo(FhirPseudonymizerConfig.class);
+ }
+
+ @Test
+ void createReturnsDeidentificator() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+ var implConfig = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), 3);
+ var commonConfig = new Deidentificator.Config();
+
+ when(clientFactory.create(any(HttpClientConfig.class))).thenReturn(webClient);
+
+ var result = factory.create(commonConfig, implConfig);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isInstanceOf(FhirPseudonymizerStep.class);
+ }
+
+ @Test
+ void createWithDefaultConfigValues() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+ var implConfig = new FhirPseudonymizerConfig(server, null, null);
+ var commonConfig = new Deidentificator.Config();
+
+ when(clientFactory.create(any(HttpClientConfig.class))).thenReturn(webClient);
+
+ var result = factory.create(commonConfig, implConfig);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isInstanceOf(FhirPseudonymizerStep.class);
+ }
+}
diff --git a/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepIT.java b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepIT.java
new file mode 100644
index 000000000..93eae3e86
--- /dev/null
+++ b/clinical-domain-agent/src/test/java/care/smith/fts/cda/impl/FhirPseudonymizerStepIT.java
@@ -0,0 +1,253 @@
+package care.smith.fts.cda.impl;
+
+import static care.smith.fts.test.MockServerUtil.APPLICATION_FHIR_JSON;
+import static care.smith.fts.test.MockServerUtil.clientConfig;
+import static care.smith.fts.test.TestPatientGenerator.generateOnePatient;
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
+import static org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE;
+import static reactor.test.StepVerifier.create;
+
+import ca.uhn.fhir.context.FhirContext;
+import care.smith.fts.api.ConsentedPatient;
+import care.smith.fts.api.ConsentedPatientBundle;
+import care.smith.fts.cda.ClinicalDomainAgent;
+import care.smith.fts.util.WebClientFactory;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import io.micrometer.core.instrument.MeterRegistry;
+import java.time.Duration;
+import lombok.extern.slf4j.Slf4j;
+import org.hl7.fhir.r4.model.Bundle;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+/**
+ * Integration tests for FhirPseudonymizerStep in Clinical Domain Agent.
+ *
+ * These tests verify:
+ *
+ *
+ * Deidentification via external FHIR Pseudonymizer service
+ * Retry behavior on service unavailability
+ * Timeout handling
+ * TransportBundle creation with transfer ID
+ *
+ */
+@Slf4j
+@SpringBootTest(classes = ClinicalDomainAgent.class)
+@WireMockTest
+class FhirPseudonymizerStepIT {
+
+ private static final String FHIR_PSEUDONYMIZER_ENDPOINT = "/fhir";
+
+ private FhirPseudonymizerStep step;
+ private WireMock wireMock;
+ private ConsentedPatientBundle testBundle;
+ private FhirContext fhirContext;
+
+ @BeforeEach
+ void setUp(
+ WireMockRuntimeInfo wireMockRuntime,
+ @Autowired WebClientFactory clientFactory,
+ @Autowired MeterRegistry meterRegistry)
+ throws java.io.IOException {
+ var config =
+ new FhirPseudonymizerConfig(clientConfig(wireMockRuntime), Duration.ofSeconds(30), 3);
+
+ var client = clientFactory.create(clientConfig(wireMockRuntime));
+ wireMock = wireMockRuntime.getWireMock();
+ fhirContext = FhirContext.forR4();
+
+ step = new FhirPseudonymizerStep(client, config, meterRegistry, fhirContext);
+
+ var bundle =
+ generateOnePatient("patient-test-123", "2024", "http://test.example.com", "test-id");
+ var consentedPatient = new ConsentedPatient("patient-test-123", "http://test.example.com");
+ testBundle = new ConsentedPatientBundle(bundle, consentedPatient);
+ }
+
+ @Test
+ void testDeidentifyWithFhirPseudonymizer() {
+ var deidentifiedBundle = new Bundle();
+ deidentifiedBundle.setType(Bundle.BundleType.COLLECTION);
+ deidentifiedBundle.setId("transport-bundle-123");
+
+ var deidentifiedBundleJson =
+ fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle);
+
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON))
+ .willReturn(
+ aResponse()
+ .withStatus(200)
+ .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .withBody(deidentifiedBundleJson)));
+
+ var result = step.deidentify(testBundle);
+
+ create(result)
+ .assertNext(
+ transportBundle -> {
+ assertThat(transportBundle).isNotNull();
+ assertThat(transportBundle.bundle()).isNotNull();
+ assertThat(transportBundle.transferId()).isNotEmpty();
+ assertThat(transportBundle.transferId()).isEqualTo("transport-bundle-123");
+ })
+ .verifyComplete();
+
+ wireMock.verifyThat(1, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)));
+ }
+
+ @Test
+ void testDeidentifyServiceUnavailableRetry() {
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .willReturn(
+ aResponse()
+ .withStatus(SERVICE_UNAVAILABLE.value())
+ .withBody("Service temporarily unavailable")));
+
+ var result = step.deidentify(testBundle);
+
+ create(result)
+ .expectErrorSatisfies(
+ error -> {
+ assertThat(error).isNotNull();
+ assertThat(error.getMessage())
+ .satisfiesAnyOf(
+ msg -> assertThat(msg).contains("Service temporarily unavailable"),
+ msg -> assertThat(msg).contains("503"),
+ msg -> assertThat(msg).contains("SERVICE_UNAVAILABLE"),
+ msg -> assertThat(msg).contains("Retries exhausted"));
+ })
+ .verify();
+
+ wireMock.verifyThat(4, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)));
+ }
+
+ @Test
+ void testDeidentifySuccessAfterRetry() {
+ var deidentifiedBundle = new Bundle();
+ deidentifiedBundle.setType(Bundle.BundleType.COLLECTION);
+ deidentifiedBundle.setId("transport-bundle-retry-success");
+
+ var deidentifiedBundleJson =
+ fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle);
+
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .inScenario("Retry Scenario")
+ .whenScenarioStateIs("Started")
+ .willReturn(aResponse().withStatus(SERVICE_UNAVAILABLE.value()))
+ .willSetStateTo("First Failure"));
+
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .inScenario("Retry Scenario")
+ .whenScenarioStateIs("First Failure")
+ .willReturn(aResponse().withStatus(SERVICE_UNAVAILABLE.value()))
+ .willSetStateTo("Second Failure"));
+
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .inScenario("Retry Scenario")
+ .whenScenarioStateIs("Second Failure")
+ .willReturn(
+ aResponse()
+ .withStatus(200)
+ .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .withBody(deidentifiedBundleJson)));
+
+ var result = step.deidentify(testBundle);
+
+ create(result)
+ .assertNext(
+ transportBundle -> {
+ assertThat(transportBundle).isNotNull();
+ assertThat(transportBundle.transferId()).isEqualTo("transport-bundle-retry-success");
+ })
+ .verifyComplete();
+
+ wireMock.verifyThat(3, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)));
+ }
+
+ @Test
+ void testConfigurationValues() {
+ assertThat(step).isNotNull();
+ }
+
+ @Test
+ void testDeidentifyWithNullTransferIdThrowsIllegalStateException() {
+ var deidentifiedBundle = new Bundle();
+ deidentifiedBundle.setType(Bundle.BundleType.COLLECTION);
+ // Note: not setting the ID leaves it null
+
+ var deidentifiedBundleJson =
+ fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle);
+
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .willReturn(
+ aResponse()
+ .withStatus(200)
+ .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .withBody(deidentifiedBundleJson)));
+
+ var result = step.deidentify(testBundle);
+
+ create(result)
+ .expectErrorSatisfies(
+ error -> {
+ assertThat(error).isInstanceOf(IllegalStateException.class);
+ assertThat(error.getMessage()).contains("bundle.id is null");
+ })
+ .verify();
+ }
+
+ @Test
+ void testDeidentifyWithEmptyTransferIdThrowsIllegalStateException() {
+ // Note: HAPI FHIR normalizes empty IDs to null during JSON serialization/deserialization,
+ // so this test effectively also tests the null case. This is expected behavior.
+ var deidentifiedBundle = new Bundle();
+ deidentifiedBundle.setType(Bundle.BundleType.COLLECTION);
+ deidentifiedBundle.setId("");
+
+ var deidentifiedBundleJson =
+ fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle);
+
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .willReturn(
+ aResponse()
+ .withStatus(200)
+ .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .withBody(deidentifiedBundleJson)));
+
+ var result = step.deidentify(testBundle);
+
+ create(result)
+ .expectErrorSatisfies(
+ error -> {
+ assertThat(error).isInstanceOf(IllegalStateException.class);
+ assertThat(error.getMessage()).contains("bundle.id is null");
+ })
+ .verify();
+ }
+
+ @AfterEach
+ void tearDown() {
+ wireMock.resetMappings();
+ wireMock.resetScenarios();
+ }
+}
diff --git a/clinical-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml b/clinical-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml
new file mode 100644
index 000000000..857405eee
--- /dev/null
+++ b/clinical-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml
@@ -0,0 +1,27 @@
+# Example project configuration using FHIR Pseudonymizer for deidentification
+# This configuration demonstrates how to use the FhirPseudonymizerStep as the deidentificator
+
+cohortSelector:
+ tca:
+ domain: "MII"
+ policySystem: "http://mii.de/fhir/sid/policy"
+ policies: ["IDAT_erheben", "IDAT_speichern_verarbeiten"]
+ patientIdSystem: "http://fts.smith.care"
+
+dataSelector:
+ everything:
+ fhirServer:
+ baseUrl: "http://cd-hds:8080/fhir"
+
+deidentificator:
+ fhir-pseudonymizer:
+ server:
+ baseUrl: "http://fhir-pseudonymizer:8080"
+ timeout: 60s
+ maxRetries: 3
+
+bundleSender:
+ rda:
+ server:
+ baseUrl: "http://rda:8080"
+ project: "example"
diff --git a/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerConfig.java b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerConfig.java
new file mode 100644
index 000000000..1d731ae63
--- /dev/null
+++ b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerConfig.java
@@ -0,0 +1,51 @@
+package care.smith.fts.rda.impl;
+
+import care.smith.fts.util.HttpClientConfig;
+import jakarta.validation.constraints.NotNull;
+import java.time.Duration;
+import java.util.Optional;
+
+/**
+ * Configuration for FHIR Pseudonymizer service integration in Research Domain Agent.
+ *
+ * This configuration enables deidentification via an external FHIR Pseudonymizer service, which
+ * delegates pseudonym resolution to the Trust Center Agent's Vfps-compatible FHIR operations.
+ *
+ *
In the RDA context, the FHIR Pseudonymizer resolves transport IDs (tIDs) in the incoming
+ * bundle to their corresponding secure pseudonyms (sIDs) via TCA's /rd-agent/fhir endpoint.
+ *
+ *
Configuration example:
+ *
+ *
{@code
+ * deidentificator:
+ * fhir-pseudonymizer:
+ * server:
+ * baseUrl: "https://fhir-pseudonymizer.research.example.com"
+ * auth:
+ * type: oauth2
+ * clientId: "rda-client"
+ * clientSecret: "${FHIR_PSEUDONYMIZER_SECRET}"
+ * timeout: 60s
+ * maxRetries: 3
+ * }
+ *
+ * @param server HTTP client configuration for the FHIR Pseudonymizer service
+ * @param timeout Request timeout (default: 60 seconds)
+ * @param maxRetries Maximum retry attempts (default: 3)
+ */
+public record FhirPseudonymizerConfig(
+ @NotNull HttpClientConfig server, Duration timeout, Integer maxRetries) {
+
+ public FhirPseudonymizerConfig(HttpClientConfig server, Duration timeout, Integer maxRetries) {
+ this.server = server;
+ this.timeout = Optional.ofNullable(timeout).orElse(Duration.ofSeconds(60));
+ this.maxRetries = Optional.ofNullable(maxRetries).orElse(3);
+
+ if (this.timeout.isNegative() || this.timeout.isZero()) {
+ throw new IllegalArgumentException("Timeout must be positive");
+ }
+ if (this.maxRetries < 0) {
+ throw new IllegalArgumentException("Max retries must be non-negative");
+ }
+ }
+}
diff --git a/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStep.java b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStep.java
new file mode 100644
index 000000000..68bb0f934
--- /dev/null
+++ b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStep.java
@@ -0,0 +1,106 @@
+package care.smith.fts.rda.impl;
+
+import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON;
+import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy;
+
+import ca.uhn.fhir.context.FhirContext;
+import care.smith.fts.api.TransportBundle;
+import care.smith.fts.api.rda.Deidentificator;
+import io.micrometer.core.instrument.MeterRegistry;
+import lombok.extern.slf4j.Slf4j;
+import org.hl7.fhir.r4.model.Bundle;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+/**
+ * RDA Deidentificator implementation that delegates deidentification to an external FHIR
+ * Pseudonymizer service.
+ *
+ * This implementation provides an alternative to DeidentifhirStep in the Research Domain Agent.
+ * It sends bundles containing transport IDs to a local FHIR Pseudonymizer service, which resolves
+ * the tIDs to secure pseudonyms (sIDs) via TCA's /rd-agent/fhir endpoint.
+ *
+ *
Architecture:
+ *
+ *
+ * TransportBundle (with transport IDs)
+ * ↓
+ * FhirPseudonymizerStep (this class)
+ * ↓ [HTTP POST /fhir with FHIR Bundle]
+ * FHIR Pseudonymizer Service (external, research domain)
+ * ↓ [POST /$create-pseudonym]
+ * TCA RdAgentFhirPseudonymizerController
+ * ↓ [tID→sID resolution from Redis]
+ * Bundle (with final sIDs)
+ *
+ *
+ * The returned bundle contains real secure pseudonyms (sIDs) ready for storage in the research
+ * FHIR store.
+ */
+@Slf4j
+public class FhirPseudonymizerStep implements Deidentificator {
+
+ private static final String FHIR_ENDPOINT = "/fhir";
+
+ private final WebClient fhirPseudonymizerClient;
+ private final FhirPseudonymizerConfig config;
+ private final MeterRegistry meterRegistry;
+ private final FhirContext fhirContext;
+
+ public FhirPseudonymizerStep(
+ WebClient fhirPseudonymizerClient,
+ FhirPseudonymizerConfig config,
+ MeterRegistry meterRegistry,
+ FhirContext fhirContext) {
+ this.fhirPseudonymizerClient = fhirPseudonymizerClient;
+ this.config = config;
+ this.meterRegistry = meterRegistry;
+ this.fhirContext = fhirContext;
+ }
+
+ /**
+ * Deidentifies a FHIR Bundle via external FHIR Pseudonymizer service.
+ *
+ *
Sends the TransportBundle to the FHIR Pseudonymizer service, which resolves transport IDs to
+ * secure pseudonyms via TCA and returns the final deidentified bundle.
+ *
+ * @param bundle TransportBundle containing data with transport IDs
+ * @return Mono of Bundle with resolved secure pseudonyms (sIDs)
+ */
+ @Override
+ public Mono deidentify(TransportBundle bundle) {
+ log.debug(
+ "Resolving transport IDs in bundle via FHIR Pseudonymizer, transfer ID: {}",
+ bundle.transferId());
+
+ String bundleJson = fhirContext.newJsonParser().encodeResourceToString(bundle.bundle());
+
+ return fhirPseudonymizerClient
+ .post()
+ .uri(FHIR_ENDPOINT)
+ .contentType(APPLICATION_FHIR_JSON)
+ .accept(APPLICATION_FHIR_JSON)
+ .bodyValue(bundleJson)
+ .retrieve()
+ .bodyToMono(String.class)
+ .map(this::parseDeidentifiedBundle)
+ .timeout(config.timeout())
+ .retryWhen(defaultRetryStrategy(meterRegistry, "rdaFhirPseudonymizerDeidentification"))
+ .doOnSuccess(
+ result ->
+ log.debug(
+ "Successfully resolved transport IDs for transfer ID: {}, bundle entries: {}",
+ bundle.transferId(),
+ result.getEntry().size()))
+ .doOnError(
+ error ->
+ log.error(
+ "Failed to resolve transport IDs for transfer ID {}: {}",
+ bundle.transferId(),
+ error.getMessage()));
+ }
+
+ private Bundle parseDeidentifiedBundle(String bundleJson) {
+ return fhirContext.newJsonParser().parseResource(Bundle.class, bundleJson);
+ }
+}
diff --git a/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactory.java b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactory.java
new file mode 100644
index 000000000..db0e7d2a3
--- /dev/null
+++ b/research-domain-agent/src/main/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactory.java
@@ -0,0 +1,66 @@
+package care.smith.fts.rda.impl;
+
+import ca.uhn.fhir.context.FhirContext;
+import care.smith.fts.api.rda.Deidentificator;
+import care.smith.fts.util.WebClientFactory;
+import io.micrometer.core.instrument.MeterRegistry;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * Factory for creating FhirPseudonymizerStep instances in the Research Domain Agent.
+ *
+ * This factory implements the transfer process step factory pattern, enabling
+ * configuration-based instantiation of FHIR Pseudonymizer deidentificators for tID→sID resolution.
+ *
+ *
Configuration example:
+ *
+ *
{@code
+ * deidentificator:
+ * fhir-pseudonymizer:
+ * server:
+ * baseUrl: "https://fhir-pseudonymizer.research.example.com"
+ * auth:
+ * type: oauth2
+ * clientId: "rda-client"
+ * clientSecret: "${FHIR_PSEUDONYMIZER_SECRET}"
+ * timeout: 60s
+ * maxRetries: 3
+ * }
+ *
+ * The factory is registered as a Spring bean with name "fhir-pseudonymizerDeidentificator" to
+ * enable configuration-based selection between deidentification methods (deidentifhir vs
+ * fhir-pseudonymizer).
+ */
+@Slf4j
+@Component("fhir-pseudonymizerDeidentificator")
+public class FhirPseudonymizerStepFactory
+ implements Deidentificator.Factory {
+
+ private final WebClientFactory clientFactory;
+ private final MeterRegistry meterRegistry;
+ private final FhirContext fhirContext;
+
+ public FhirPseudonymizerStepFactory(
+ WebClientFactory clientFactory, MeterRegistry meterRegistry, FhirContext fhirContext) {
+ this.clientFactory = clientFactory;
+ this.meterRegistry = meterRegistry;
+ this.fhirContext = fhirContext;
+ }
+
+ @Override
+ public Class getConfigType() {
+ return FhirPseudonymizerConfig.class;
+ }
+
+ @Override
+ public Deidentificator create(
+ Deidentificator.Config commonConfig, FhirPseudonymizerConfig implConfig) {
+ var httpClient = clientFactory.create(implConfig.server());
+
+ log.info(
+ "Created RDA FhirPseudonymizerStep with service URL: {}", implConfig.server().baseUrl());
+
+ return new FhirPseudonymizerStep(httpClient, implConfig, meterRegistry, fhirContext);
+ }
+}
diff --git a/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerConfigTest.java b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerConfigTest.java
new file mode 100644
index 000000000..d8d12c08f
--- /dev/null
+++ b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerConfigTest.java
@@ -0,0 +1,88 @@
+package care.smith.fts.rda.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import care.smith.fts.util.HttpClientConfig;
+import java.time.Duration;
+import org.junit.jupiter.api.Test;
+
+class FhirPseudonymizerConfigTest {
+
+ @Test
+ void createWithAllParametersExplicit() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+ var timeout = Duration.ofSeconds(30);
+ var maxRetries = 5;
+
+ var config = new FhirPseudonymizerConfig(server, timeout, maxRetries);
+
+ assertThat(config.server()).isEqualTo(server);
+ assertThat(config.timeout()).isEqualTo(timeout);
+ assertThat(config.maxRetries()).isEqualTo(5);
+ }
+
+ @Test
+ void createWithDefaultTimeout() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ var config = new FhirPseudonymizerConfig(server, null, 5);
+
+ assertThat(config.timeout()).isEqualTo(Duration.ofSeconds(60));
+ }
+
+ @Test
+ void createWithDefaultMaxRetries() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ var config = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), null);
+
+ assertThat(config.maxRetries()).isEqualTo(3);
+ }
+
+ @Test
+ void createWithAllDefaults() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ var config = new FhirPseudonymizerConfig(server, null, null);
+
+ assertThat(config.timeout()).isEqualTo(Duration.ofSeconds(60));
+ assertThat(config.maxRetries()).isEqualTo(3);
+ }
+
+ @Test
+ void negativeTimeoutThrowsException() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ofSeconds(-1), 3))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Timeout must be positive");
+ }
+
+ @Test
+ void zeroTimeoutThrowsException() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ZERO, 3))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Timeout must be positive");
+ }
+
+ @Test
+ void negativeMaxRetriesThrowsException() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ assertThatThrownBy(() -> new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), -1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Max retries must be non-negative");
+ }
+
+ @Test
+ void zeroMaxRetriesIsAllowed() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+
+ var config = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), 0);
+
+ assertThat(config.maxRetries()).isZero();
+ }
+}
diff --git a/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactoryTest.java b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactoryTest.java
new file mode 100644
index 000000000..0e6438076
--- /dev/null
+++ b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepFactoryTest.java
@@ -0,0 +1,70 @@
+package care.smith.fts.rda.impl;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import ca.uhn.fhir.context.FhirContext;
+import care.smith.fts.api.rda.Deidentificator;
+import care.smith.fts.util.HttpClientConfig;
+import care.smith.fts.util.WebClientFactory;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import java.time.Duration;
+import org.junit.jupiter.api.BeforeEach;
+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.springframework.web.reactive.function.client.WebClient;
+
+@ExtendWith(MockitoExtension.class)
+class FhirPseudonymizerStepFactoryTest {
+
+ @Mock private WebClientFactory clientFactory;
+ @Mock private WebClient webClient;
+
+ private MeterRegistry meterRegistry;
+ private FhirContext fhirContext;
+ private FhirPseudonymizerStepFactory factory;
+
+ @BeforeEach
+ void setUp() {
+ meterRegistry = new SimpleMeterRegistry();
+ fhirContext = FhirContext.forR4();
+ factory = new FhirPseudonymizerStepFactory(clientFactory, meterRegistry, fhirContext);
+ }
+
+ @Test
+ void getConfigTypeReturnsFhirPseudonymizerConfigClass() {
+ assertThat(factory.getConfigType()).isEqualTo(FhirPseudonymizerConfig.class);
+ }
+
+ @Test
+ void createReturnsDeidentificator() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+ var implConfig = new FhirPseudonymizerConfig(server, Duration.ofSeconds(30), 3);
+ var commonConfig = new Deidentificator.Config();
+
+ when(clientFactory.create(any(HttpClientConfig.class))).thenReturn(webClient);
+
+ var result = factory.create(commonConfig, implConfig);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isInstanceOf(FhirPseudonymizerStep.class);
+ }
+
+ @Test
+ void createWithDefaultConfigValues() {
+ var server = new HttpClientConfig("http://fhir-pseudonymizer:8080");
+ var implConfig = new FhirPseudonymizerConfig(server, null, null);
+ var commonConfig = new Deidentificator.Config();
+
+ when(clientFactory.create(any(HttpClientConfig.class))).thenReturn(webClient);
+
+ var result = factory.create(commonConfig, implConfig);
+
+ assertThat(result).isNotNull();
+ assertThat(result).isInstanceOf(FhirPseudonymizerStep.class);
+ }
+}
diff --git a/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepIT.java b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepIT.java
new file mode 100644
index 000000000..178f559fe
--- /dev/null
+++ b/research-domain-agent/src/test/java/care/smith/fts/rda/impl/FhirPseudonymizerStepIT.java
@@ -0,0 +1,193 @@
+package care.smith.fts.rda.impl;
+
+import static care.smith.fts.test.MockServerUtil.APPLICATION_FHIR_JSON;
+import static care.smith.fts.test.MockServerUtil.clientConfig;
+import static care.smith.fts.test.TestPatientGenerator.generateOnePatient;
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
+import static org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE;
+import static reactor.test.StepVerifier.create;
+
+import ca.uhn.fhir.context.FhirContext;
+import care.smith.fts.api.TransportBundle;
+import care.smith.fts.rda.ResearchDomainAgent;
+import care.smith.fts.util.WebClientFactory;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import io.micrometer.core.instrument.MeterRegistry;
+import java.io.IOException;
+import java.time.Duration;
+import lombok.extern.slf4j.Slf4j;
+import org.hl7.fhir.r4.model.Bundle;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+/**
+ * Integration tests for FhirPseudonymizerStep in Research Domain Agent.
+ *
+ * These tests verify:
+ *
+ *
+ * Transport ID resolution to secure pseudonyms via external FHIR Pseudonymizer service
+ * Retry behavior on service unavailability
+ * Timeout handling
+ * Bundle transformation from transport IDs to secure IDs
+ *
+ */
+@Slf4j
+@SpringBootTest(classes = ResearchDomainAgent.class)
+@WireMockTest
+class FhirPseudonymizerStepIT {
+
+ private static final String FHIR_PSEUDONYMIZER_ENDPOINT = "/fhir";
+
+ private FhirPseudonymizerStep step;
+ private WireMock wireMock;
+ private TransportBundle testBundle;
+ private FhirContext fhirContext;
+
+ @BeforeEach
+ void setUp(
+ WireMockRuntimeInfo wireMockRuntime,
+ @Autowired WebClientFactory clientFactory,
+ @Autowired MeterRegistry meterRegistry)
+ throws IOException {
+ var config =
+ new FhirPseudonymizerConfig(clientConfig(wireMockRuntime), Duration.ofSeconds(30), 3);
+
+ var client = clientFactory.create(clientConfig(wireMockRuntime));
+ wireMock = wireMockRuntime.getWireMock();
+ fhirContext = FhirContext.forR4();
+
+ step = new FhirPseudonymizerStep(client, config, meterRegistry, fhirContext);
+
+ var bundle =
+ generateOnePatient("transport-id-123", "2024", "http://test.example.com", "test-tid");
+ testBundle = new TransportBundle(bundle, "transfer-session-123");
+ }
+
+ @Test
+ void testDeidentifyWithFhirPseudonymizer() {
+ var deidentifiedBundle = new Bundle();
+ deidentifiedBundle.setType(Bundle.BundleType.COLLECTION);
+ deidentifiedBundle.setId("resolved-bundle-123");
+
+ var deidentifiedBundleJson =
+ fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle);
+
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON))
+ .willReturn(
+ aResponse()
+ .withStatus(200)
+ .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .withBody(deidentifiedBundleJson)));
+
+ var result = step.deidentify(testBundle);
+
+ create(result)
+ .assertNext(
+ bundle -> {
+ assertThat(bundle).isNotNull();
+ assertThat(bundle.getIdPart()).isEqualTo("resolved-bundle-123");
+ assertThat(bundle.getType()).isEqualTo(Bundle.BundleType.COLLECTION);
+ })
+ .verifyComplete();
+
+ wireMock.verifyThat(1, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)));
+ }
+
+ @Test
+ void testDeidentifyServiceUnavailableRetry() {
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .willReturn(
+ aResponse()
+ .withStatus(SERVICE_UNAVAILABLE.value())
+ .withBody("Service temporarily unavailable")));
+
+ var result = step.deidentify(testBundle);
+
+ create(result)
+ .expectErrorSatisfies(
+ error -> {
+ assertThat(error).isNotNull();
+ assertThat(error.getMessage())
+ .satisfiesAnyOf(
+ msg -> assertThat(msg).contains("Service temporarily unavailable"),
+ msg -> assertThat(msg).contains("503"),
+ msg -> assertThat(msg).contains("SERVICE_UNAVAILABLE"),
+ msg -> assertThat(msg).contains("Retries exhausted"));
+ })
+ .verify();
+
+ wireMock.verifyThat(4, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)));
+ }
+
+ @Test
+ void testDeidentifySuccessAfterRetry() {
+ var deidentifiedBundle = new Bundle();
+ deidentifiedBundle.setType(Bundle.BundleType.COLLECTION);
+ deidentifiedBundle.setId("resolved-bundle-retry-success");
+
+ var deidentifiedBundleJson =
+ fhirContext.newJsonParser().encodeResourceToString(deidentifiedBundle);
+
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .inScenario("Retry Scenario")
+ .whenScenarioStateIs("Started")
+ .willReturn(aResponse().withStatus(SERVICE_UNAVAILABLE.value()))
+ .willSetStateTo("First Failure"));
+
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .inScenario("Retry Scenario")
+ .whenScenarioStateIs("First Failure")
+ .willReturn(aResponse().withStatus(SERVICE_UNAVAILABLE.value()))
+ .willSetStateTo("Second Failure"));
+
+ wireMock.register(
+ post(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT))
+ .inScenario("Retry Scenario")
+ .whenScenarioStateIs("Second Failure")
+ .willReturn(
+ aResponse()
+ .withStatus(200)
+ .withHeader(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .withBody(deidentifiedBundleJson)));
+
+ var result = step.deidentify(testBundle);
+
+ create(result)
+ .assertNext(
+ bundle -> {
+ assertThat(bundle).isNotNull();
+ assertThat(bundle.getIdPart()).isEqualTo("resolved-bundle-retry-success");
+ })
+ .verifyComplete();
+
+ wireMock.verifyThat(3, postRequestedFor(urlEqualTo(FHIR_PSEUDONYMIZER_ENDPOINT)));
+ }
+
+ @Test
+ void testConfigurationValues() {
+ assertThat(step).isNotNull();
+ }
+
+ @AfterEach
+ void tearDown() {
+ wireMock.resetMappings();
+ wireMock.resetScenarios();
+ }
+}
diff --git a/research-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml b/research-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml
new file mode 100644
index 000000000..aeaf215b7
--- /dev/null
+++ b/research-domain-agent/src/test/resources/projects/fhir-pseudonymizer-example.yaml
@@ -0,0 +1,15 @@
+# Example project configuration using FHIR Pseudonymizer for transport ID resolution
+# This configuration demonstrates how to use the FhirPseudonymizerStep in RDA context
+# to resolve transport IDs (tIDs) to secure pseudonyms (sIDs) via TCA
+
+deidentificator:
+ fhir-pseudonymizer:
+ server:
+ baseUrl: "http://fhir-pseudonymizer:8080"
+ timeout: 60s
+ maxRetries: 3
+
+bundleSender:
+ fhirStore:
+ fhirServer:
+ baseUrl: "http://rd-hds:8080/fhir"
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/EnticiBackendAdapter.java b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/EnticiBackendAdapter.java
new file mode 100644
index 000000000..c6c002835
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/EnticiBackendAdapter.java
@@ -0,0 +1,47 @@
+package care.smith.fts.tca.adapters;
+
+import care.smith.fts.tca.deidentification.EnticiClient;
+import java.util.Map;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import reactor.core.publisher.Mono;
+
+/**
+ * Backend adapter for Entici pseudonymization service.
+ *
+ * This adapter communicates with an external Entici service to generate pseudonyms. It
+ * implements the {@link PseudonymBackendAdapter} interface for use with the FHIR Pseudonymizer
+ * integration.
+ */
+@Slf4j
+public class EnticiBackendAdapter implements PseudonymBackendAdapter {
+
+ private static final String BACKEND_TYPE = "entici";
+ private final EnticiClient enticiClient;
+
+ public EnticiBackendAdapter(EnticiClient enticiClient) {
+ this.enticiClient = enticiClient;
+ }
+
+ @Override
+ public Mono fetchOrCreatePseudonym(String domain, String originalValue) {
+ log.trace("Fetching pseudonym from Entici: domain={}, originalValue={}", domain, originalValue);
+ return enticiClient
+ .fetchOrCreatePseudonym(domain, originalValue)
+ .doOnSuccess(p -> log.trace("Entici returned pseudonym for {}", originalValue));
+ }
+
+ @Override
+ public Mono> fetchOrCreatePseudonyms(
+ String domain, Set originalValues) {
+ log.trace("Fetching {} pseudonyms from Entici: domain={}", originalValues.size(), domain);
+ return enticiClient
+ .fetchOrCreatePseudonyms(domain, originalValues)
+ .doOnSuccess(m -> log.trace("Entici returned {} pseudonyms", m.size()));
+ }
+
+ @Override
+ public String getBackendType() {
+ return BACKEND_TYPE;
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/GpasBackendAdapter.java b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/GpasBackendAdapter.java
new file mode 100644
index 000000000..dac56023b
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/GpasBackendAdapter.java
@@ -0,0 +1,59 @@
+package care.smith.fts.tca.adapters;
+
+import care.smith.fts.tca.deidentification.GpasClient;
+import java.util.Map;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import reactor.core.publisher.Mono;
+
+/**
+ * Backend adapter for gPAS (generic Pseudonym Administration Service).
+ *
+ * This adapter wraps the existing {@link GpasClient} to implement the {@link
+ * PseudonymBackendAdapter} interface, enabling gPAS to be used as a backend for the FHIR
+ * Pseudonymizer integration.
+ *
+ *
The adapter reuses all existing gPAS configuration and batching logic from GpasClient.
+ */
+@Slf4j
+public class GpasBackendAdapter implements PseudonymBackendAdapter {
+
+ private static final String BACKEND_TYPE = "gpas";
+
+ private final GpasClient gpasClient;
+
+ public GpasBackendAdapter(GpasClient gpasClient) {
+ this.gpasClient = gpasClient;
+ }
+
+ @Override
+ public Mono fetchOrCreatePseudonym(String domain, String originalValue) {
+ log.trace("Fetching pseudonym from gPAS: domain={}, originalValue={}", domain, originalValue);
+ return gpasClient
+ .fetchOrCreatePseudonyms(domain, Set.of(originalValue))
+ .map(mappings -> mappings.get(originalValue))
+ .doOnSuccess(
+ pseudonym ->
+ log.trace(
+ "Received pseudonym from gPAS: original={} -> pseudonym={}",
+ originalValue,
+ pseudonym));
+ }
+
+ @Override
+ public Mono> fetchOrCreatePseudonyms(
+ String domain, Set originalValues) {
+ log.trace("Fetching {} pseudonyms from gPAS: domain={}", originalValues.size(), domain);
+ return gpasClient
+ .fetchOrCreatePseudonyms(domain, originalValues)
+ .doOnSuccess(
+ mappings ->
+ log.trace(
+ "Received {} pseudonyms from gPAS for domain={}", mappings.size(), domain));
+ }
+
+ @Override
+ public String getBackendType() {
+ return BACKEND_TYPE;
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/PseudonymBackendAdapter.java b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/PseudonymBackendAdapter.java
new file mode 100644
index 000000000..697664d21
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/PseudonymBackendAdapter.java
@@ -0,0 +1,45 @@
+package care.smith.fts.tca.adapters;
+
+import java.util.Map;
+import java.util.Set;
+import reactor.core.publisher.Mono;
+
+/**
+ * Interface for pseudonymization backend adapters.
+ *
+ * This interface defines the contract for adapters that communicate with pseudonymization
+ * backends such as gPAS, Vfps, or entici. Each adapter implementation translates requests to the
+ * backend-specific protocol and returns the generated pseudonyms.
+ *
+ *
All operations are reactive and return {@link Mono} to support non-blocking processing.
+ */
+public interface PseudonymBackendAdapter {
+
+ /**
+ * Fetches or creates a single pseudonym for the given original value in the specified domain.
+ *
+ * @param domain the pseudonymization domain/namespace
+ * @param originalValue the original value to pseudonymize
+ * @return a Mono emitting the generated pseudonym (sID)
+ */
+ Mono fetchOrCreatePseudonym(String domain, String originalValue);
+
+ /**
+ * Fetches or creates pseudonyms for multiple original values in the specified domain.
+ *
+ * This batch operation is more efficient than calling {@link #fetchOrCreatePseudonym} multiple
+ * times when processing many identifiers.
+ *
+ * @param domain the pseudonymization domain/namespace
+ * @param originalValues the set of original values to pseudonymize
+ * @return a Mono emitting a map from original value to generated pseudonym (sID)
+ */
+ Mono> fetchOrCreatePseudonyms(String domain, Set originalValues);
+
+ /**
+ * Returns the backend type identifier for this adapter.
+ *
+ * @return the backend type (e.g., "gpas", "vfps", "entici")
+ */
+ String getBackendType();
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/VfpsBackendAdapter.java b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/VfpsBackendAdapter.java
new file mode 100644
index 000000000..618fb71ad
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/adapters/VfpsBackendAdapter.java
@@ -0,0 +1,46 @@
+package care.smith.fts.tca.adapters;
+
+import care.smith.fts.tca.deidentification.VfpsClient;
+import java.util.Map;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import reactor.core.publisher.Mono;
+
+/**
+ * Backend adapter for Vfps (Very Fast Pseudonym Service).
+ *
+ * This adapter communicates with an external Vfps service to generate pseudonyms. It implements
+ * the {@link PseudonymBackendAdapter} interface for use with the FHIR Pseudonymizer integration.
+ */
+@Slf4j
+public class VfpsBackendAdapter implements PseudonymBackendAdapter {
+
+ private static final String BACKEND_TYPE = "vfps";
+ private final VfpsClient vfpsClient;
+
+ public VfpsBackendAdapter(VfpsClient vfpsClient) {
+ this.vfpsClient = vfpsClient;
+ }
+
+ @Override
+ public Mono fetchOrCreatePseudonym(String domain, String originalValue) {
+ log.trace("Fetching pseudonym from Vfps: domain={}, originalValue={}", domain, originalValue);
+ return vfpsClient
+ .fetchOrCreatePseudonym(domain, originalValue)
+ .doOnSuccess(p -> log.trace("Vfps returned pseudonym for {}", originalValue));
+ }
+
+ @Override
+ public Mono> fetchOrCreatePseudonyms(
+ String domain, Set originalValues) {
+ log.trace("Fetching {} pseudonyms from Vfps: domain={}", originalValues.size(), domain);
+ return vfpsClient
+ .fetchOrCreatePseudonyms(domain, originalValues)
+ .doOnSuccess(m -> log.trace("Vfps returned {} pseudonyms", m.size()));
+ }
+
+ @Override
+ public String getBackendType() {
+ return BACKEND_TYPE;
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/config/BackendAdapterConfig.java b/trust-center-agent/src/main/java/care/smith/fts/tca/config/BackendAdapterConfig.java
new file mode 100644
index 000000000..8f1d43c07
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/config/BackendAdapterConfig.java
@@ -0,0 +1,103 @@
+package care.smith.fts.tca.config;
+
+import care.smith.fts.util.HttpClientConfig;
+import jakarta.validation.constraints.NotNull;
+import java.util.Optional;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Configuration for pseudonymization backend adapter selection.
+ *
+ * This configuration allows selection between different pseudonymization backends: gPAS, Vfps,
+ * or entici. Only one backend can be active at a time.
+ *
+ *
Example configuration:
+ *
+ *
+ * de-identification:
+ * backend:
+ * type: gpas # or: vfps, entici
+ * gpas:
+ * fhir:
+ * baseUrl: http://gpas:8080/ttp-fhir/fhir/gpas
+ * vfps:
+ * address: dns:///vfps:8081
+ * entici:
+ * baseUrl: http://entici:8080
+ *
+ */
+@Configuration
+@ConfigurationProperties(prefix = "de-identification.backend")
+@Getter
+@Setter
+public class BackendAdapterConfig {
+
+ /** The type of backend to use for pseudonymization. */
+ @NotNull private BackendType type = BackendType.GPAS;
+
+ /** gPAS-specific configuration (used when type=gpas). */
+ private GpasConfig gpas;
+
+ /** Vfps-specific configuration (used when type=vfps). */
+ private VfpsConfig vfps;
+
+ /** entici-specific configuration (used when type=entici). */
+ private EnticiConfig entici;
+
+ /** Supported backend types for pseudonymization. */
+ public enum BackendType {
+ GPAS,
+ VFPS,
+ ENTICI
+ }
+
+ /**
+ * Configuration for gPAS backend.
+ *
+ * Note: gPAS configuration is already handled by GpasDeIdentificationConfiguration. This
+ * provides an alternative path for the backend adapter pattern.
+ */
+ @Getter
+ @Setter
+ public static class GpasConfig {
+ private HttpClientConfig fhir;
+ }
+
+ /** Configuration for Vfps (Very Fast Pseudonym Service) backend. */
+ @Getter
+ @Setter
+ public static class VfpsConfig {
+ /** The gRPC or REST address of the Vfps service. */
+ private String address;
+
+ /** Optional authentication configuration. */
+ private HttpClientConfig auth;
+ }
+
+ /** Configuration for entici backend. */
+ @Getter
+ @Setter
+ public static class EnticiConfig {
+ /** The base URL of the entici service. */
+ private String baseUrl;
+
+ /** Optional HTTP client configuration. */
+ private HttpClientConfig server;
+ }
+
+ /**
+ * Gets the active backend configuration based on the selected type.
+ *
+ * @return Optional containing the active backend config, or empty if not configured
+ */
+ public Optional getActiveBackendConfig() {
+ return switch (type) {
+ case GPAS -> Optional.ofNullable(gpas);
+ case VFPS -> Optional.ofNullable(vfps);
+ case ENTICI -> Optional.ofNullable(entici);
+ };
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiClient.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiClient.java
new file mode 100644
index 000000000..20a14be8e
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiClient.java
@@ -0,0 +1,129 @@
+package care.smith.fts.tca.deidentification;
+
+import static care.smith.fts.tca.TtpFhirGatewayUtil.handle4xxError;
+import static care.smith.fts.tca.TtpFhirGatewayUtil.handleError;
+import static care.smith.fts.tca.deidentification.configuration.EnticiDeIdentificationConfiguration.ENTICI_OPERATIONS;
+import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON;
+import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy;
+import static java.util.List.of;
+
+import care.smith.fts.tca.deidentification.configuration.EnticiDeIdentificationConfiguration;
+import io.micrometer.core.instrument.MeterRegistry;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+/**
+ * Client for communicating with Entici pseudonymization service.
+ *
+ * This client calls the Entici FHIR API to create pseudonyms for identifiers. Since Entici does
+ * not support native batch operations, batch requests are processed using client-side
+ * parallelization.
+ */
+@Slf4j
+public class EnticiClient {
+
+ private final WebClient enticiClient;
+ private final MeterRegistry meterRegistry;
+ private final int concurrency;
+ private final String resourceType;
+ private final String project;
+
+ public EnticiClient(
+ WebClient enticiClient,
+ MeterRegistry meterRegistry,
+ EnticiDeIdentificationConfiguration config) {
+ this.enticiClient = enticiClient;
+ this.meterRegistry = meterRegistry;
+ this.concurrency = config.getConcurrency();
+ this.resourceType = config.getResourceType();
+ this.project = config.getProject();
+ }
+
+ /**
+ * Fetches or creates a pseudonym for a single identifier.
+ *
+ * @param domain the Entici domain (maps to identifier.system)
+ * @param originalValue the original identifier value
+ * @return Mono containing the pseudonym
+ */
+ public Mono fetchOrCreatePseudonym(String domain, String originalValue) {
+ log.trace("Fetching pseudonym from Entici: domain={}, originalValue={}", domain, originalValue);
+
+ var requestBody = createRequest(domain, originalValue);
+
+ return enticiClient
+ .post()
+ .uri("/$pseudonymize")
+ .headers(h -> h.setContentType(APPLICATION_FHIR_JSON))
+ .bodyValue(requestBody)
+ .headers(h -> h.setAccept(of(APPLICATION_FHIR_JSON)))
+ .retrieve()
+ .onStatus(
+ HttpStatusCode::is4xxClientError,
+ r -> handle4xxError("Entici", enticiClient, ENTICI_OPERATIONS, r))
+ .bodyToMono(EnticiParameterResponse.class)
+ .retryWhen(defaultRetryStrategy(meterRegistry, "fetchOrCreatePseudonymOnEntici"))
+ .onErrorResume(e -> handleError("Entici", e))
+ .doOnError(e -> log.error("Unable to fetch pseudonym from Entici: {}", e.getMessage()))
+ .doOnNext(r -> log.trace("$pseudonymize response: {}", r.parameter()))
+ .map(EnticiParameterResponse::getPseudonymValue);
+ }
+
+ /**
+ * Fetches or creates pseudonyms for multiple identifiers.
+ *
+ * Since Entici does not support native batch operations, this method processes requests in
+ * parallel with configurable concurrency.
+ *
+ * @param domain the Entici domain (maps to identifier.system)
+ * @param originalValues the set of original identifier values
+ * @return Mono of a map from original value to pseudonym
+ */
+ public Mono> fetchOrCreatePseudonyms(
+ String domain, Set originalValues) {
+ if (originalValues.isEmpty()) {
+ return Mono.just(Map.of());
+ }
+
+ log.trace("Fetching {} pseudonyms from Entici: domain={}", originalValues.size(), domain);
+
+ return Flux.fromIterable(originalValues)
+ .flatMap(
+ original ->
+ fetchOrCreatePseudonym(domain, original)
+ .map(pseudonym -> Map.entry(original, pseudonym)),
+ concurrency)
+ .collectMap(Map.Entry::getKey, Map.Entry::getValue);
+ }
+
+ private Map createRequest(String domain, String originalValue) {
+ List> params = new ArrayList<>();
+
+ // Add identifier parameter
+ params.add(
+ Map.of(
+ "name",
+ "identifier",
+ "valueIdentifier",
+ Map.of(
+ "system", domain,
+ "value", originalValue)));
+
+ // Add resourceType parameter
+ params.add(Map.of("name", "resourceType", "valueString", resourceType));
+
+ // Add project parameter if configured
+ if (project != null && !project.isBlank()) {
+ params.add(Map.of("name", "project", "valueString", project));
+ }
+
+ return Map.of("resourceType", "Parameters", "parameter", params);
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiParameterResponse.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiParameterResponse.java
new file mode 100644
index 000000000..752a65df1
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/EnticiParameterResponse.java
@@ -0,0 +1,42 @@
+package care.smith.fts.tca.deidentification;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import java.util.List;
+
+/**
+ * Record to deserialize Entici response from $pseudonymize operation.
+ *
+ * Response format:
+ *
+ *
+ * {
+ * "resourceType": "Parameters",
+ * "parameter": [
+ * {
+ * "name": "pseudonym",
+ * "valueIdentifier": {
+ * "system": "domain-namespace-url",
+ * "value": "generated-pseudonym"
+ * }
+ * }
+ * ]
+ * }
+ *
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record EnticiParameterResponse(String resourceType, List parameter) {
+
+ public String getPseudonymValue() {
+ return parameter.stream()
+ .filter(p -> "pseudonym".equals(p.name()))
+ .findFirst()
+ .map(p -> p.valueIdentifier().value())
+ .orElseThrow(() -> new IllegalStateException("No pseudonym in Entici response"));
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record Parameter(String name, String valueString, ValueIdentifier valueIdentifier) {}
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record ValueIdentifier(String system, String value) {}
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsClient.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsClient.java
new file mode 100644
index 000000000..3c10f10d3
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsClient.java
@@ -0,0 +1,110 @@
+package care.smith.fts.tca.deidentification;
+
+import static care.smith.fts.tca.TtpFhirGatewayUtil.handle4xxError;
+import static care.smith.fts.tca.TtpFhirGatewayUtil.handleError;
+import static care.smith.fts.tca.deidentification.configuration.VfpsDeIdentificationConfiguration.VFPS_OPERATIONS;
+import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON;
+import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy;
+import static java.util.List.of;
+
+import care.smith.fts.tca.deidentification.configuration.VfpsDeIdentificationConfiguration;
+import io.micrometer.core.instrument.MeterRegistry;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+/**
+ * Client for communicating with Vfps (Very Fast Pseudonym Service).
+ *
+ * This client calls the Vfps REST API to create pseudonyms for identifiers. Since Vfps does not
+ * support native batch operations, batch requests are processed using client-side parallelization.
+ */
+@Slf4j
+public class VfpsClient {
+
+ private final WebClient vfpsClient;
+ private final MeterRegistry meterRegistry;
+ private final int concurrency;
+
+ public VfpsClient(
+ WebClient vfpsClient, MeterRegistry meterRegistry, VfpsDeIdentificationConfiguration config) {
+ this.vfpsClient = vfpsClient;
+ this.meterRegistry = meterRegistry;
+ this.concurrency = config.getConcurrency();
+ }
+
+ /**
+ * Fetches or creates a pseudonym for a single identifier.
+ *
+ * @param namespace the Vfps namespace (domain)
+ * @param originalValue the original identifier value
+ * @return Mono containing the pseudonym
+ */
+ public Mono fetchOrCreatePseudonym(String namespace, String originalValue) {
+ log.trace(
+ "Fetching pseudonym from Vfps: namespace={}, originalValue={}", namespace, originalValue);
+
+ var requestBody = createRequest(namespace, originalValue);
+
+ return vfpsClient
+ .post()
+ .uri("/$create-pseudonym")
+ .headers(h -> h.setContentType(APPLICATION_FHIR_JSON))
+ .bodyValue(requestBody)
+ .headers(h -> h.setAccept(of(APPLICATION_FHIR_JSON)))
+ .retrieve()
+ .onStatus(
+ HttpStatusCode::is4xxClientError,
+ r -> handle4xxError("Vfps", vfpsClient, VFPS_OPERATIONS, r))
+ .bodyToMono(VfpsParameterResponse.class)
+ .retryWhen(defaultRetryStrategy(meterRegistry, "fetchOrCreatePseudonymOnVfps"))
+ .onErrorResume(e -> handleError("Vfps", e))
+ .doOnError(e -> log.error("Unable to fetch pseudonym from Vfps: {}", e.getMessage()))
+ .doOnNext(r -> log.trace("$create-pseudonym response: {}", r.parameter()))
+ .map(VfpsParameterResponse::getPseudonymValue);
+ }
+
+ /**
+ * Fetches or creates pseudonyms for multiple identifiers.
+ *
+ * Since Vfps does not support native batch operations, this method processes requests in
+ * parallel with configurable concurrency.
+ *
+ * @param namespace the Vfps namespace (domain)
+ * @param originalValues the set of original identifier values
+ * @return Mono of a map from original value to pseudonym
+ */
+ public Mono> fetchOrCreatePseudonyms(
+ String namespace, Set originalValues) {
+ if (originalValues.isEmpty()) {
+ return Mono.just(Map.of());
+ }
+
+ log.trace("Fetching {} pseudonyms from Vfps: namespace={}", originalValues.size(), namespace);
+
+ return Flux.fromIterable(originalValues)
+ .flatMap(
+ original ->
+ fetchOrCreatePseudonym(namespace, original)
+ .map(pseudonym -> Map.entry(original, pseudonym)),
+ concurrency)
+ .collectMap(Map.Entry::getKey, Map.Entry::getValue);
+ }
+
+ private static Map createRequest(String namespace, String originalValue) {
+ return Map.of(
+ "resourceType",
+ "Parameters",
+ "parameter",
+ List.of(param("namespace", namespace), param("originalValue", originalValue)));
+ }
+
+ private static Map param(String name, String value) {
+ return Map.of("name", name, "valueString", value);
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsParameterResponse.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsParameterResponse.java
new file mode 100644
index 000000000..d41759e09
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/VfpsParameterResponse.java
@@ -0,0 +1,46 @@
+package care.smith.fts.tca.deidentification;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import java.util.List;
+
+/**
+ * Record to deserialize Vfps response from $create-pseudonym operation.
+ *
+ * Response format:
+ *
+ *
+ * {
+ * "resourceType": "Parameters",
+ * "parameter": [
+ * {"name": "namespace", "valueString": "domain-name"},
+ * {"name": "originalValue", "valueString": "patient-123"},
+ * {"name": "pseudonymValue", "valueString": "generated-pseudonym"}
+ * ]
+ * }
+ *
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record VfpsParameterResponse(String resourceType, List parameter) {
+
+ public String getPseudonymValue() {
+ return parameter.stream()
+ .filter(p -> "pseudonymValue".equals(p.name()))
+ .findFirst()
+ .map(Parameter::valueString)
+ .orElseThrow(() -> new IllegalStateException("No pseudonymValue in Vfps response"));
+ }
+
+ public String getOriginalValue() {
+ return parameter.stream()
+ .filter(p -> "originalValue".equals(p.name()))
+ .findFirst()
+ .map(Parameter::valueString)
+ .orElseThrow(() -> new IllegalStateException("No originalValue in Vfps response"));
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record Parameter(String name, String valueString, ValueIdentifier valueIdentifier) {}
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record ValueIdentifier(String system, String value) {}
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/EnticiDeIdentificationConfiguration.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/EnticiDeIdentificationConfiguration.java
new file mode 100644
index 000000000..c46b3f488
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/EnticiDeIdentificationConfiguration.java
@@ -0,0 +1,66 @@
+package care.smith.fts.tca.deidentification.configuration;
+
+import static care.smith.fts.util.fhir.FhirClientUtils.fetchCapabilityStatementOperations;
+import static care.smith.fts.util.fhir.FhirClientUtils.requireOperations;
+
+import care.smith.fts.util.HttpClientConfig;
+import care.smith.fts.util.LogUtil;
+import care.smith.fts.util.WebClientFactory;
+import java.util.List;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.reactive.function.client.WebClient;
+
+@Slf4j
+@Configuration
+@ConfigurationProperties(prefix = "de-identification.entici")
+@Data
+public class EnticiDeIdentificationConfiguration {
+
+ public static final List ENTICI_OPERATIONS = List.of("pseudonymize");
+
+ private HttpClientConfig fhir;
+
+ /** Maximum number of concurrent requests to Entici for batch processing. */
+ private int concurrency = 4;
+
+ /** Resource type to use in pseudonymization requests (default: Patient). */
+ private String resourceType = "Patient";
+
+ /** Optional project name for Entici requests. */
+ private String project;
+
+ @Bean("enticiFhirHttpClient")
+ @ConditionalOnProperty(name = "de-identification.backend.type", havingValue = "entici")
+ public WebClient enticiClient(WebClientFactory clientFactory) {
+ return clientFactory.create(fhir);
+ }
+
+ @Bean("enticiApplicationRunner")
+ @ConditionalOnProperty(name = "de-identification.backend.type", havingValue = "entici")
+ ApplicationRunner runner(@Qualifier("enticiFhirHttpClient") WebClient enticiClient) {
+ return args ->
+ fetchCapabilityStatementOperations(enticiClient)
+ .flatMap(c -> requireOperations(c, ENTICI_OPERATIONS))
+ .doOnNext(i -> log.info("Entici available"))
+ .doOnError(EnticiDeIdentificationConfiguration::logWarning)
+ .onErrorComplete()
+ .block();
+ }
+
+ private static void logWarning(Throwable e) {
+ var msg =
+ """
+ Connection to Entici could not be established on agent startup. \
+ The agent will continue startup anyway, in case Entici connection will be \
+ available later on.\
+ """;
+ LogUtil.warnWithDebugException(log, msg, e);
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/VfpsDeIdentificationConfiguration.java b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/VfpsDeIdentificationConfiguration.java
new file mode 100644
index 000000000..42eaa7846
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/deidentification/configuration/VfpsDeIdentificationConfiguration.java
@@ -0,0 +1,60 @@
+package care.smith.fts.tca.deidentification.configuration;
+
+import static care.smith.fts.util.fhir.FhirClientUtils.fetchCapabilityStatementOperations;
+import static care.smith.fts.util.fhir.FhirClientUtils.requireOperations;
+
+import care.smith.fts.util.HttpClientConfig;
+import care.smith.fts.util.LogUtil;
+import care.smith.fts.util.WebClientFactory;
+import java.util.List;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.reactive.function.client.WebClient;
+
+@Slf4j
+@Configuration
+@ConfigurationProperties(prefix = "de-identification.vfps")
+@Data
+public class VfpsDeIdentificationConfiguration {
+
+ public static final List VFPS_OPERATIONS = List.of("create-pseudonym");
+
+ private HttpClientConfig fhir;
+
+ /** Maximum number of concurrent requests to Vfps for batch processing. */
+ private int concurrency = 4;
+
+ @Bean("vfpsFhirHttpClient")
+ @ConditionalOnProperty(name = "de-identification.backend.type", havingValue = "vfps")
+ public WebClient vfpsClient(WebClientFactory clientFactory) {
+ return clientFactory.create(fhir);
+ }
+
+ @Bean("vfpsApplicationRunner")
+ @ConditionalOnProperty(name = "de-identification.backend.type", havingValue = "vfps")
+ ApplicationRunner runner(@Qualifier("vfpsFhirHttpClient") WebClient vfpsClient) {
+ return args ->
+ fetchCapabilityStatementOperations(vfpsClient)
+ .flatMap(c -> requireOperations(c, VFPS_OPERATIONS))
+ .doOnNext(i -> log.info("Vfps available"))
+ .doOnError(VfpsDeIdentificationConfiguration::logWarning)
+ .onErrorComplete()
+ .block();
+ }
+
+ private static void logWarning(Throwable e) {
+ var msg =
+ """
+ Connection to Vfps could not be established on agent startup. \
+ The agent will continue startup anyway, in case Vfps connection will be \
+ available later on.\
+ """;
+ LogUtil.warnWithDebugException(log, msg, e);
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerController.java b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerController.java
new file mode 100644
index 000000000..c39882c6a
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerController.java
@@ -0,0 +1,281 @@
+package care.smith.fts.tca.rest;
+
+import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON_VALUE;
+
+import care.smith.fts.tca.adapters.PseudonymBackendAdapter;
+import care.smith.fts.tca.rest.dto.VfpsPseudonymizeRequest;
+import care.smith.fts.tca.rest.dto.VfpsPseudonymizeResponse;
+import care.smith.fts.tca.rest.dto.VfpsPseudonymizeResponse.PseudonymEntry;
+import care.smith.fts.tca.services.BackendAdapterFactory;
+import care.smith.fts.tca.services.TransportIdService;
+import care.smith.fts.util.error.ErrorResponseUtil;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import jakarta.validation.Valid;
+import java.util.HashSet;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.hl7.fhir.r4.model.Base;
+import org.hl7.fhir.r4.model.OperationOutcome;
+import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity;
+import org.hl7.fhir.r4.model.OperationOutcome.IssueType;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
+import org.hl7.fhir.r4.model.StringType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+/**
+ * REST controller providing Vfps-compatible FHIR endpoint for CDA's FHIR Pseudonymizer.
+ *
+ * This controller exposes a Vfps-compatible {@code $create-pseudonym} endpoint that:
+ *
+ *
+ * Receives pseudonymization requests from CDA's FHIR Pseudonymizer
+ * Fetches real pseudonyms (sIDs) from the configured backend (gPAS/Vfps/entici)
+ * Generates transport IDs (tIDs) as temporary replacements
+ * Stores tID→sID mappings in Redis for later resolution by RDA
+ * Returns transport IDs (NOT real pseudonyms) to the FHIR Pseudonymizer
+ *
+ *
+ * This maintains data isolation: clinical data never reaches TCA, only identifiers flow through.
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v2/cd-agent/fhir")
+@Validated
+public class CdAgentFhirPseudonymizerController {
+
+ private final TransportIdService transportIdService;
+ private final PseudonymBackendAdapter backendAdapter;
+
+ public CdAgentFhirPseudonymizerController(
+ TransportIdService transportIdService, BackendAdapterFactory adapterFactory) {
+ this.transportIdService = transportIdService;
+ this.backendAdapter = adapterFactory.createAdapter();
+ }
+
+ /**
+ * Vfps-compatible endpoint to create pseudonyms (returns transport IDs).
+ *
+ *
This endpoint mimics Vfps's $create-pseudonym operation but returns transport IDs instead of
+ * real pseudonyms, maintaining data isolation between domains.
+ *
+ * @param requestParams FHIR Parameters with namespace and originalValue(s)
+ * @return FHIR Parameters with namespace, originalValue, and pseudonymValue (transport ID)
+ */
+ @PostMapping(
+ value = "/$create-pseudonym",
+ consumes = APPLICATION_FHIR_JSON_VALUE,
+ produces = APPLICATION_FHIR_JSON_VALUE)
+ @Operation(
+ summary = "Create pseudonyms (Vfps-compatible, returns transport IDs)",
+ description =
+ "Accepts Vfps-format FHIR Parameters with namespace and original values, "
+ + "returns transport IDs (NOT real pseudonyms) for data isolation.\n\n"
+ + "The transport IDs can be resolved to real pseudonyms via the RDA endpoint.",
+ requestBody =
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(
+ content =
+ @Content(
+ mediaType = APPLICATION_FHIR_JSON_VALUE,
+ schema = @Schema(implementation = Parameters.class),
+ examples =
+ @ExampleObject(
+ value =
+ """
+ {
+ "resourceType": "Parameters",
+ "parameter": [
+ {"name": "namespace", "valueString": "clinical-domain"},
+ {"name": "originalValue", "valueString": "patient-123"}
+ ]
+ }
+ """))),
+ responses = {
+ @ApiResponse(
+ responseCode = "200",
+ description = "Transport IDs generated successfully",
+ content =
+ @Content(
+ mediaType = APPLICATION_FHIR_JSON_VALUE,
+ schema = @Schema(implementation = Parameters.class),
+ examples =
+ @ExampleObject(
+ value =
+ """
+ {
+ "resourceType": "Parameters",
+ "parameter": [
+ {"name": "namespace", "valueString": "clinical-domain"},
+ {"name": "originalValue", "valueString": "patient-123"},
+ {"name": "pseudonymValue", "valueString": "tID-abc123xyz..."}
+ ]
+ }
+ """))),
+ @ApiResponse(
+ responseCode = "400",
+ description = "Invalid request (missing namespace or originalValue)",
+ content = @Content(mediaType = APPLICATION_FHIR_JSON_VALUE)),
+ @ApiResponse(
+ responseCode = "502",
+ description = "Backend service unavailable",
+ content = @Content(mediaType = APPLICATION_FHIR_JSON_VALUE))
+ })
+ public Mono> createPseudonym(
+ @Valid @RequestBody Parameters requestParams) {
+
+ log.debug("Received Vfps $create-pseudonym request from CDA");
+
+ return Mono.fromCallable(() -> parseRequest(requestParams))
+ .flatMap(this::processRequest)
+ .map(this::buildResponse)
+ .map(ResponseEntity::ok)
+ .onErrorResume(this::handleError);
+ }
+
+ private VfpsPseudonymizeRequest parseRequest(Parameters params) {
+ // Extract namespace
+ String namespace =
+ params.getParameter().stream()
+ .filter(p -> "namespace".equals(p.getName()))
+ .findFirst()
+ .map(ParametersParameterComponent::getValue)
+ .map(Base::primitiveValue)
+ .orElseThrow(
+ () -> new IllegalArgumentException("Missing required parameter 'namespace'"));
+
+ if (namespace.isBlank()) {
+ throw new IllegalArgumentException("Parameter 'namespace' must not be empty");
+ }
+
+ // Extract original values
+ List originals =
+ params.getParameter().stream()
+ .filter(p -> "originalValue".equals(p.getName()))
+ .map(ParametersParameterComponent::getValue)
+ .map(Base::primitiveValue)
+ .toList();
+
+ if (originals.isEmpty()) {
+ throw new IllegalArgumentException("At least one 'originalValue' parameter is required");
+ }
+
+ // Generate a transfer ID for this batch
+ var transferId = transportIdService.generateTransferId();
+
+ log.debug(
+ "Parsed request: namespace={}, originalCount={}, transferId={}",
+ namespace,
+ originals.size(),
+ transferId);
+
+ return new VfpsPseudonymizeRequest(namespace, originals, transferId);
+ }
+
+ private Mono processRequest(VfpsPseudonymizeRequest request) {
+ var transferId = request.transferId();
+ var namespace = request.namespace();
+ var ttl = transportIdService.getDefaultTtl();
+
+ log.debug(
+ "Processing {} identifiers for namespace={}, transferId={}",
+ request.originals().size(),
+ namespace,
+ transferId);
+
+ // Fetch real pseudonyms from backend and generate transport IDs
+ return backendAdapter
+ .fetchOrCreatePseudonyms(namespace, new HashSet<>(request.originals()))
+ .flatMap(
+ sIdMap ->
+ Flux.fromIterable(sIdMap.entrySet())
+ .flatMap(
+ entry -> {
+ var original = entry.getKey();
+ var sId = entry.getValue();
+ var tId = transportIdService.generateTransportId();
+
+ return transportIdService
+ .storeMapping(transferId, tId, sId, namespace, ttl)
+ .map(storedTId -> new PseudonymEntry(namespace, original, storedTId));
+ })
+ .collectList()
+ .map(VfpsPseudonymizeResponse::new))
+ .doOnSuccess(
+ response ->
+ log.debug(
+ "Generated {} transport IDs for transferId={}",
+ response.pseudonyms().size(),
+ transferId));
+ }
+
+ private Parameters buildResponse(VfpsPseudonymizeResponse response) {
+ var fhirParams = new Parameters();
+
+ for (var entry : response.pseudonyms()) {
+ // For single-value responses, use flat structure
+ if (response.pseudonyms().size() == 1) {
+ fhirParams.addParameter().setName("namespace").setValue(new StringType(entry.namespace()));
+ fhirParams
+ .addParameter()
+ .setName("originalValue")
+ .setValue(new StringType(entry.original()));
+ fhirParams
+ .addParameter()
+ .setName("pseudonymValue")
+ .setValue(new StringType(entry.pseudonym()));
+ } else {
+ // For batch responses, use nested structure
+ var pseudonymParam = new ParametersParameterComponent();
+ pseudonymParam.setName("pseudonym");
+
+ pseudonymParam.addPart().setName("namespace").setValue(new StringType(entry.namespace()));
+ pseudonymParam
+ .addPart()
+ .setName("originalValue")
+ .setValue(new StringType(entry.original()));
+ pseudonymParam
+ .addPart()
+ .setName("pseudonymValue")
+ .setValue(new StringType(entry.pseudonym()));
+
+ fhirParams.addParameter(pseudonymParam);
+ }
+ }
+
+ log.trace("Built FHIR Parameters response with {} entries", response.pseudonyms().size());
+ return fhirParams;
+ }
+
+ private Mono> handleError(Throwable error) {
+ log.warn("Error processing $create-pseudonym request: {}", error.getMessage());
+
+ if (error instanceof IllegalArgumentException) {
+ return Mono.just(
+ ResponseEntity.badRequest()
+ .body(buildOperationOutcome(error.getMessage(), IssueType.INVALID)));
+ }
+
+ return ErrorResponseUtil.internalServerError(error);
+ }
+
+ private Parameters buildOperationOutcome(String message, IssueType issueType) {
+ // Return error as FHIR OperationOutcome wrapped in Parameters for protocol compatibility
+ var outcome = new OperationOutcome();
+ outcome.addIssue().setSeverity(IssueSeverity.ERROR).setCode(issueType).setDiagnostics(message);
+
+ var params = new Parameters();
+ params.addParameter().setName("outcome").setResource(outcome);
+ return params;
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerController.java b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerController.java
new file mode 100644
index 000000000..4c868c36d
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerController.java
@@ -0,0 +1,292 @@
+package care.smith.fts.tca.rest;
+
+import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON_VALUE;
+
+import care.smith.fts.tca.rest.dto.VfpsPseudonymizeResponse;
+import care.smith.fts.tca.rest.dto.VfpsPseudonymizeResponse.PseudonymEntry;
+import care.smith.fts.tca.services.TransportIdService;
+import care.smith.fts.util.error.ErrorResponseUtil;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.ExampleObject;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import jakarta.validation.Valid;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.hl7.fhir.r4.model.Base;
+import org.hl7.fhir.r4.model.OperationOutcome;
+import org.hl7.fhir.r4.model.OperationOutcome.IssueSeverity;
+import org.hl7.fhir.r4.model.OperationOutcome.IssueType;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
+import org.hl7.fhir.r4.model.StringType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import reactor.core.publisher.Mono;
+
+/**
+ * REST controller providing Vfps-compatible FHIR endpoint for RDA's FHIR Pseudonymizer.
+ *
+ * This controller exposes a Vfps-compatible {@code $create-pseudonym} endpoint that resolves
+ * transport IDs (tIDs) to their corresponding secure pseudonyms (sIDs):
+ *
+ *
+ * Receives resolution requests from RDA's FHIR Pseudonymizer
+ * Looks up tID→sID mappings in Redis (stored by CDA requests)
+ * Returns real pseudonyms (sIDs) to the FHIR Pseudonymizer
+ *
+ *
+ * The RDA endpoint returns actual pseudonyms (unlike CDA endpoint which returns transport IDs),
+ * completing the data isolation architecture where clinical data flows CDA→RDA but identifiers are
+ * resolved via TCA.
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/v2/rd-agent/fhir")
+@Validated
+public class RdAgentFhirPseudonymizerController {
+
+ private final TransportIdService transportIdService;
+
+ public RdAgentFhirPseudonymizerController(TransportIdService transportIdService) {
+ this.transportIdService = transportIdService;
+ }
+
+ /**
+ * Vfps-compatible endpoint to resolve transport IDs to secure pseudonyms.
+ *
+ *
This endpoint accepts transport IDs (returned by CDA endpoint) and resolves them to their
+ * corresponding secure pseudonyms (sIDs) stored in Redis.
+ *
+ * @param requestParams FHIR Parameters with namespace and originalValue(s) containing transport
+ * IDs
+ * @return FHIR Parameters with namespace, originalValue, and pseudonymValue (real sID)
+ */
+ @PostMapping(
+ value = "/$create-pseudonym",
+ consumes = APPLICATION_FHIR_JSON_VALUE,
+ produces = APPLICATION_FHIR_JSON_VALUE)
+ @Operation(
+ summary = "Resolve transport IDs to secure pseudonyms (Vfps-compatible)",
+ description =
+ "Accepts Vfps-format FHIR Parameters with transport IDs, "
+ + "returns the corresponding secure pseudonyms (sIDs) from Redis.\n\n"
+ + "This endpoint is used by RDA's FHIR Pseudonymizer to resolve transport IDs "
+ + "received from CDA bundles to their final pseudonyms.",
+ requestBody =
+ @io.swagger.v3.oas.annotations.parameters.RequestBody(
+ content =
+ @Content(
+ mediaType = APPLICATION_FHIR_JSON_VALUE,
+ schema = @Schema(implementation = Parameters.class),
+ examples =
+ @ExampleObject(
+ value =
+ """
+ {
+ "resourceType": "Parameters",
+ "parameter": [
+ {"name": "namespace", "valueString": "clinical-domain"},
+ {"name": "originalValue", "valueString": "tID-abc123xyz..."}
+ ]
+ }
+ """))),
+ responses = {
+ @ApiResponse(
+ responseCode = "200",
+ description = "Secure pseudonyms resolved successfully",
+ content =
+ @Content(
+ mediaType = APPLICATION_FHIR_JSON_VALUE,
+ schema = @Schema(implementation = Parameters.class),
+ examples =
+ @ExampleObject(
+ value =
+ """
+ {
+ "resourceType": "Parameters",
+ "parameter": [
+ {"name": "namespace", "valueString": "clinical-domain"},
+ {"name": "originalValue", "valueString": "tID-abc123xyz..."},
+ {"name": "pseudonymValue", "valueString": "sID-real-pseudonym"}
+ ]
+ }
+ """))),
+ @ApiResponse(
+ responseCode = "400",
+ description = "Invalid request (missing namespace or originalValue)",
+ content = @Content(mediaType = APPLICATION_FHIR_JSON_VALUE)),
+ @ApiResponse(
+ responseCode = "404",
+ description = "Transport ID not found (may have expired)",
+ content = @Content(mediaType = APPLICATION_FHIR_JSON_VALUE))
+ })
+ public Mono> resolvePseudonyms(
+ @Valid @RequestBody Parameters requestParams) {
+
+ log.debug("Received Vfps $create-pseudonym request from RDA");
+
+ return Mono.fromCallable(() -> parseRequest(requestParams))
+ .flatMap(this::resolveTransportIds)
+ .map(this::buildResponse)
+ .map(ResponseEntity::ok)
+ .onErrorResume(this::handleError);
+ }
+
+ private record ResolutionRequest(
+ String namespace, List transportIds, String transferId) {}
+
+ private ResolutionRequest parseRequest(Parameters params) {
+ // Extract namespace
+ String namespace =
+ params.getParameter().stream()
+ .filter(p -> "namespace".equals(p.getName()))
+ .findFirst()
+ .map(ParametersParameterComponent::getValue)
+ .map(Base::primitiveValue)
+ .orElseThrow(
+ () -> new IllegalArgumentException("Missing required parameter 'namespace'"));
+
+ if (namespace.isBlank()) {
+ throw new IllegalArgumentException("Parameter 'namespace' must not be empty");
+ }
+
+ // Extract transport IDs (originalValue parameters contain tIDs to resolve)
+ List transportIds =
+ params.getParameter().stream()
+ .filter(p -> "originalValue".equals(p.getName()))
+ .map(ParametersParameterComponent::getValue)
+ .map(Base::primitiveValue)
+ .toList();
+
+ if (transportIds.isEmpty()) {
+ throw new IllegalArgumentException("At least one 'originalValue' parameter is required");
+ }
+
+ // Extract transferId if provided (for scoped lookups)
+ String transferId =
+ params.getParameter().stream()
+ .filter(p -> "transferId".equals(p.getName()))
+ .findFirst()
+ .map(ParametersParameterComponent::getValue)
+ .map(Base::primitiveValue)
+ .orElse(null);
+
+ log.debug(
+ "Parsed RDA request: namespace={}, transportIdCount={}, transferId={}",
+ namespace,
+ transportIds.size(),
+ transferId);
+
+ return new ResolutionRequest(namespace, transportIds, transferId);
+ }
+
+ private Mono resolveTransportIds(ResolutionRequest request) {
+ var namespace = request.namespace();
+ var transportIds = new HashSet<>(request.transportIds());
+ var transferId = request.transferId();
+
+ log.debug(
+ "Resolving {} transport IDs for namespace={}, transferId={}",
+ transportIds.size(),
+ namespace,
+ transferId);
+
+ if (transferId == null) {
+ // Without transferId, we can't resolve (need to know which session the tIDs belong to)
+ return Mono.error(
+ new IllegalArgumentException("Parameter 'transferId' is required for RDA resolution"));
+ }
+
+ return transportIdService
+ .resolveMappings(transferId, transportIds)
+ .map(
+ resolvedMappings -> {
+ List entries = new ArrayList<>();
+ for (var transportId : request.transportIds()) {
+ var sId = resolvedMappings.get(transportId);
+ if (sId != null) {
+ entries.add(new PseudonymEntry(namespace, transportId, sId));
+ } else {
+ log.warn(
+ "Transport ID not found: tId={}, transferId={}", transportId, transferId);
+ // Return the tID as-is if not found (or could throw error)
+ entries.add(new PseudonymEntry(namespace, transportId, transportId));
+ }
+ }
+ return new VfpsPseudonymizeResponse(entries);
+ })
+ .doOnSuccess(
+ response ->
+ log.debug(
+ "Resolved {} transport IDs for transferId={}",
+ response.pseudonyms().size(),
+ transferId));
+ }
+
+ private Parameters buildResponse(VfpsPseudonymizeResponse response) {
+ var fhirParams = new Parameters();
+
+ for (var entry : response.pseudonyms()) {
+ // For single-value responses, use flat structure
+ if (response.pseudonyms().size() == 1) {
+ fhirParams.addParameter().setName("namespace").setValue(new StringType(entry.namespace()));
+ fhirParams
+ .addParameter()
+ .setName("originalValue")
+ .setValue(new StringType(entry.original()));
+ fhirParams
+ .addParameter()
+ .setName("pseudonymValue")
+ .setValue(new StringType(entry.pseudonym()));
+ } else {
+ // For batch responses, use nested structure
+ var pseudonymParam = new ParametersParameterComponent();
+ pseudonymParam.setName("pseudonym");
+
+ pseudonymParam.addPart().setName("namespace").setValue(new StringType(entry.namespace()));
+ pseudonymParam
+ .addPart()
+ .setName("originalValue")
+ .setValue(new StringType(entry.original()));
+ pseudonymParam
+ .addPart()
+ .setName("pseudonymValue")
+ .setValue(new StringType(entry.pseudonym()));
+
+ fhirParams.addParameter(pseudonymParam);
+ }
+ }
+
+ log.trace("Built FHIR Parameters response with {} entries", response.pseudonyms().size());
+ return fhirParams;
+ }
+
+ private Mono> handleError(Throwable error) {
+ log.warn("Error processing RDA $create-pseudonym request: {}", error.getMessage());
+
+ if (error instanceof IllegalArgumentException) {
+ return Mono.just(
+ ResponseEntity.badRequest()
+ .body(buildOperationOutcome(error.getMessage(), IssueType.INVALID)));
+ }
+
+ return ErrorResponseUtil.internalServerError(error);
+ }
+
+ private Parameters buildOperationOutcome(String message, IssueType issueType) {
+ var outcome = new OperationOutcome();
+ outcome.addIssue().setSeverity(IssueSeverity.ERROR).setCode(issueType).setDiagnostics(message);
+
+ var params = new Parameters();
+ params.addParameter().setName("outcome").setResource(outcome);
+ return params;
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequest.java b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequest.java
new file mode 100644
index 000000000..382884685
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequest.java
@@ -0,0 +1,34 @@
+package care.smith.fts.tca.rest.dto;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Vfps-compatible pseudonymization request parsed from FHIR Parameters.
+ *
+ * This DTO represents the internal representation of a Vfps $create-pseudonym request after
+ * parsing the FHIR Parameters resource. The original request contains:
+ *
+ *
+ * namespace - The domain/namespace for pseudonym generation
+ * originalValue - One or more original values to pseudonymize
+ *
+ *
+ * @param namespace The domain/namespace for pseudonym generation (non-blank)
+ * @param originals The list of original values to pseudonymize (at least one)
+ * @param transferId The transfer session identifier for grouping mappings
+ */
+public record VfpsPseudonymizeRequest(String namespace, List originals, String transferId) {
+
+ public VfpsPseudonymizeRequest {
+ Objects.requireNonNull(namespace, "namespace is required");
+ if (namespace.isBlank()) {
+ throw new IllegalArgumentException("namespace must not be blank");
+ }
+ if (originals == null || originals.isEmpty()) {
+ throw new IllegalArgumentException("at least one original value required");
+ }
+ Objects.requireNonNull(transferId, "transferId is required");
+ originals = List.copyOf(originals);
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponse.java b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponse.java
new file mode 100644
index 000000000..4d7e8a854
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponse.java
@@ -0,0 +1,33 @@
+package care.smith.fts.tca.rest.dto;
+
+import java.util.List;
+
+/**
+ * Vfps-compatible pseudonymization response to be converted to FHIR Parameters.
+ *
+ * This DTO represents the internal representation of a Vfps $create-pseudonym response before
+ * serialization to FHIR Parameters. The response contains pseudonym entries with:
+ *
+ *
+ * namespace - The original namespace from the request
+ * originalValue - The original value that was pseudonymized
+ * pseudonymValue - The generated pseudonym (transport ID for CDA, real sID for RDA)
+ *
+ *
+ * @param pseudonyms List of pseudonym entries
+ */
+public record VfpsPseudonymizeResponse(List pseudonyms) {
+
+ public VfpsPseudonymizeResponse {
+ pseudonyms = pseudonyms != null ? List.copyOf(pseudonyms) : List.of();
+ }
+
+ /**
+ * A single pseudonym mapping entry.
+ *
+ * @param namespace The domain/namespace
+ * @param original The original value
+ * @param pseudonym The generated pseudonym (tID or sID depending on endpoint)
+ */
+ public record PseudonymEntry(String namespace, String original, String pseudonym) {}
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/services/BackendAdapterFactory.java b/trust-center-agent/src/main/java/care/smith/fts/tca/services/BackendAdapterFactory.java
new file mode 100644
index 000000000..7e5b5af58
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/services/BackendAdapterFactory.java
@@ -0,0 +1,106 @@
+package care.smith.fts.tca.services;
+
+import care.smith.fts.tca.adapters.EnticiBackendAdapter;
+import care.smith.fts.tca.adapters.GpasBackendAdapter;
+import care.smith.fts.tca.adapters.PseudonymBackendAdapter;
+import care.smith.fts.tca.adapters.VfpsBackendAdapter;
+import care.smith.fts.tca.config.BackendAdapterConfig;
+import care.smith.fts.tca.config.BackendAdapterConfig.BackendType;
+import care.smith.fts.tca.deidentification.EnticiClient;
+import care.smith.fts.tca.deidentification.GpasClient;
+import care.smith.fts.tca.deidentification.VfpsClient;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * Factory for creating pseudonymization backend adapters.
+ *
+ * This factory creates the appropriate {@link PseudonymBackendAdapter} based on the configured
+ * backend type. Currently supports:
+ *
+ *
+ * gPAS - Uses existing {@link GpasClient} implementation
+ * Vfps - Uses {@link VfpsClient} for Very Fast Pseudonym Service
+ * entici - Uses {@link EnticiClient} for Entici pseudonymization service
+ *
+ *
+ * The factory is configured via {@link BackendAdapterConfig} which reads from application.yaml:
+ *
+ *
+ * de-identification:
+ * backend:
+ * type: gpas # or: vfps, entici
+ *
+ */
+@Slf4j
+@Component
+public class BackendAdapterFactory {
+
+ private final BackendAdapterConfig config;
+ private final GpasClient gpasClient;
+ private final VfpsClient vfpsClient;
+ private final EnticiClient enticiClient;
+
+ public BackendAdapterFactory(
+ BackendAdapterConfig config,
+ GpasClient gpasClient,
+ @Autowired(required = false) VfpsClient vfpsClient,
+ @Autowired(required = false) EnticiClient enticiClient) {
+ this.config = config;
+ this.gpasClient = gpasClient;
+ this.vfpsClient = vfpsClient;
+ this.enticiClient = enticiClient;
+ }
+
+ /**
+ * Creates a backend adapter based on the configured type.
+ *
+ * @return the configured PseudonymBackendAdapter
+ * @throws IllegalStateException if the required client is not configured
+ */
+ public PseudonymBackendAdapter createAdapter() {
+ var type = config.getType();
+ log.info("Creating backend adapter for type: {}", type);
+
+ return switch (type) {
+ case GPAS -> createGpasAdapter();
+ case VFPS -> createVfpsAdapter();
+ case ENTICI -> createEnticiAdapter();
+ };
+ }
+
+ private PseudonymBackendAdapter createGpasAdapter() {
+ log.debug("Initializing gPAS backend adapter");
+ return new GpasBackendAdapter(gpasClient);
+ }
+
+ private PseudonymBackendAdapter createVfpsAdapter() {
+ if (vfpsClient == null) {
+ throw new IllegalStateException(
+ "Vfps backend selected but VfpsClient is not configured. "
+ + "Add de-identification.vfps.fhir.base-url to configuration.");
+ }
+ log.debug("Initializing Vfps backend adapter");
+ return new VfpsBackendAdapter(vfpsClient);
+ }
+
+ private PseudonymBackendAdapter createEnticiAdapter() {
+ if (enticiClient == null) {
+ throw new IllegalStateException(
+ "Entici backend selected but EnticiClient is not configured. "
+ + "Add de-identification.entici.fhir.base-url to configuration.");
+ }
+ log.debug("Initializing Entici backend adapter");
+ return new EnticiBackendAdapter(enticiClient);
+ }
+
+ /**
+ * Gets the currently configured backend type.
+ *
+ * @return the backend type
+ */
+ public BackendType getConfiguredBackendType() {
+ return config.getType();
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdMapping.java b/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdMapping.java
new file mode 100644
index 000000000..91824f56e
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdMapping.java
@@ -0,0 +1,25 @@
+package care.smith.fts.tca.services;
+
+import java.util.Objects;
+
+/**
+ * Mapping between a transport ID and its corresponding secure pseudonym.
+ *
+ * This record represents a single tID→sID mapping stored in Redis for later resolution by the
+ * Research Domain Agent.
+ *
+ * @param transportId The transport ID (tID) - temporary identifier returned to CDA
+ * @param securePseudonym The real pseudonym (sID) from the backend (gPAS/Vfps/entici)
+ * @param domain The pseudonymization domain/namespace
+ * @param transferId The session grouping identifier
+ */
+public record TransportIdMapping(
+ String transportId, String securePseudonym, String domain, String transferId) {
+
+ public TransportIdMapping {
+ Objects.requireNonNull(transportId, "transportId is required");
+ Objects.requireNonNull(securePseudonym, "securePseudonym is required");
+ Objects.requireNonNull(domain, "domain is required");
+ Objects.requireNonNull(transferId, "transferId is required");
+ }
+}
diff --git a/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdService.java b/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdService.java
new file mode 100644
index 000000000..b0f51ae88
--- /dev/null
+++ b/trust-center-agent/src/main/java/care/smith/fts/tca/services/TransportIdService.java
@@ -0,0 +1,244 @@
+package care.smith.fts.tca.services;
+
+import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy;
+
+import care.smith.fts.tca.deidentification.configuration.TransportMappingConfiguration;
+import io.micrometer.core.instrument.MeterRegistry;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RMapCacheReactive;
+import org.redisson.api.RedissonClient;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+/**
+ * Service for generating and managing transport IDs.
+ *
+ *
Transport IDs (tIDs) are temporary identifiers used to replace real pseudonyms during data
+ * transfer from CDA to RDA. The tID→sID mappings are stored in Redis with a configurable TTL.
+ *
+ *
Key responsibilities:
+ *
+ *
+ * Generate cryptographically secure transport IDs (32 chars, Base64URL)
+ * Store tID→sID mappings in Redis grouped by transfer session
+ * Resolve transport IDs back to secure pseudonyms for RDA
+ * Manage date shift values per transfer session
+ *
+ */
+@Slf4j
+@Service
+public class TransportIdService {
+
+ private static final int ID_BYTES = 24; // 24 bytes = 32 Base64URL chars
+ private static final String DATE_SHIFT_KEY = "_dateShiftMillis";
+ private static final String KEY_PREFIX = "transport-mapping:";
+
+ private final SecureRandom secureRandom;
+ private final RedissonClient redisClient;
+ private final Duration defaultTtl;
+ private final MeterRegistry meterRegistry;
+
+ public TransportIdService(
+ RedissonClient redisClient,
+ TransportMappingConfiguration config,
+ MeterRegistry meterRegistry) {
+ this.secureRandom = new SecureRandom();
+ this.redisClient = redisClient;
+ this.defaultTtl = config.getTtl();
+ this.meterRegistry = meterRegistry;
+ }
+
+ /**
+ * Generates a cryptographically secure transport ID.
+ *
+ * @return a 32-character Base64URL-encoded transport ID
+ */
+ public String generateTransportId() {
+ byte[] bytes = new byte[ID_BYTES];
+ secureRandom.nextBytes(bytes);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
+
+ /**
+ * Generates a new transfer session ID.
+ *
+ * @return a unique transfer session identifier
+ */
+ public String generateTransferId() {
+ return generateTransportId();
+ }
+
+ /**
+ * Stores a single tID→sID mapping in Redis.
+ *
+ * @param transferId the transfer session identifier
+ * @param transportId the transport ID (tID)
+ * @param securePseudonym the real pseudonym (sID)
+ * @param domain the pseudonymization domain
+ * @param ttl time-to-live for this mapping
+ * @return Mono emitting the stored transport ID
+ */
+ public Mono storeMapping(
+ String transferId, String transportId, String securePseudonym, String domain, Duration ttl) {
+ var mapCache = getMapCache(transferId);
+ return mapCache
+ .fastPut(transportId, securePseudonym, ttl.toMillis(), TimeUnit.MILLISECONDS)
+ .retryWhen(defaultRetryStrategy(meterRegistry, "storeTransportMapping"))
+ .doOnSuccess(
+ v ->
+ log.trace(
+ "Stored mapping: transferId={}, tID={} (domain={})",
+ transferId,
+ transportId,
+ domain))
+ .thenReturn(transportId);
+ }
+
+ /**
+ * Stores multiple tID→sID mappings in Redis.
+ *
+ * @param transferId the transfer session identifier
+ * @param mappings map from transport ID to secure pseudonym
+ * @param domain the pseudonymization domain
+ * @param ttl time-to-live for these mappings
+ * @return Mono emitting the stored mappings
+ */
+ public Mono> storeMappings(
+ String transferId, Map mappings, String domain, Duration ttl) {
+ if (mappings.isEmpty()) {
+ return Mono.just(Map.of());
+ }
+
+ var mapCache = getMapCache(transferId);
+ return Flux.fromIterable(mappings.entrySet())
+ .flatMap(
+ entry ->
+ mapCache
+ .fastPut(
+ entry.getKey(), entry.getValue(), ttl.toMillis(), TimeUnit.MILLISECONDS)
+ .thenReturn(entry))
+ .collectMap(Map.Entry::getKey, Map.Entry::getValue)
+ .retryWhen(defaultRetryStrategy(meterRegistry, "storeTransportMappings"))
+ .doOnSuccess(
+ m ->
+ log.trace(
+ "Stored {} mappings: transferId={} (domain={})", m.size(), transferId, domain));
+ }
+
+ /**
+ * Resolves transport IDs to their corresponding secure pseudonyms.
+ *
+ * @param transferId the transfer session identifier
+ * @param transportIds the set of transport IDs to resolve
+ * @return Mono emitting a map from tID to sID (only for found mappings)
+ */
+ public Mono> resolveMappings(String transferId, Set transportIds) {
+ if (transportIds.isEmpty()) {
+ return Mono.just(Map.of());
+ }
+
+ var mapCache = getMapCache(transferId);
+ return Flux.fromIterable(transportIds)
+ .flatMap(
+ tId ->
+ mapCache
+ .get(tId)
+ .map(sId -> Map.entry(tId, sId))
+ .defaultIfEmpty(Map.entry(tId, "")))
+ .filter(entry -> !entry.getValue().isEmpty())
+ .collectMap(Map.Entry::getKey, Map.Entry::getValue)
+ .retryWhen(defaultRetryStrategy(meterRegistry, "resolveTransportMappings"))
+ .doOnSuccess(
+ m ->
+ log.trace(
+ "Resolved {} of {} mappings for transferId={}",
+ m.size(),
+ transportIds.size(),
+ transferId));
+ }
+
+ /**
+ * Retrieves all mappings for a transfer session.
+ *
+ * @param transferId the transfer session identifier
+ * @return Mono emitting all tID→sID mappings for this session
+ */
+ public Mono> getAllMappings(String transferId) {
+ var mapCache = getMapCache(transferId);
+ return mapCache
+ .readAllMap()
+ .retryWhen(defaultRetryStrategy(meterRegistry, "getAllTransportMappings"))
+ .map(
+ m -> {
+ var result = new HashMap<>(m);
+ result.remove(DATE_SHIFT_KEY); // Exclude metadata
+ return Map.copyOf(result);
+ })
+ .doOnSuccess(
+ m -> log.trace("Retrieved {} mappings for transferId={}", m.size(), transferId));
+ }
+
+ /**
+ * Stores the date shift value for a transfer session.
+ *
+ * @param transferId the transfer session identifier
+ * @param dateShiftMillis the date shift value in milliseconds
+ * @param ttl time-to-live for this value
+ * @return Mono emitting the stored date shift value
+ */
+ public Mono storeDateShiftValue(String transferId, long dateShiftMillis, Duration ttl) {
+ var mapCache = getMapCache(transferId);
+ return mapCache
+ .fastPut(
+ DATE_SHIFT_KEY, String.valueOf(dateShiftMillis), ttl.toMillis(), TimeUnit.MILLISECONDS)
+ .retryWhen(defaultRetryStrategy(meterRegistry, "storeDateShiftValue"))
+ .doOnSuccess(
+ v ->
+ log.trace("Stored dateShift: transferId={}, value={}", transferId, dateShiftMillis))
+ .thenReturn(dateShiftMillis);
+ }
+
+ /**
+ * Retrieves the date shift value for a transfer session.
+ *
+ * @param transferId the transfer session identifier
+ * @return Mono emitting the date shift value, or empty if not found
+ */
+ public Mono getDateShiftValue(String transferId) {
+ var mapCache = getMapCache(transferId);
+ return mapCache
+ .get(DATE_SHIFT_KEY)
+ .flatMap(
+ value -> {
+ try {
+ return Mono.just(Long.parseLong(value));
+ } catch (NumberFormatException e) {
+ log.warn("Invalid dateShift value for transferId={}: {}", transferId, value);
+ return Mono.empty();
+ }
+ })
+ .retryWhen(defaultRetryStrategy(meterRegistry, "getDateShiftValue"))
+ .doOnSuccess(v -> log.trace("Retrieved dateShift: transferId={}, value={}", transferId, v));
+ }
+
+ /**
+ * Gets the default TTL for transport mappings.
+ *
+ * @return the default TTL duration
+ */
+ public Duration getDefaultTtl() {
+ return defaultTtl;
+ }
+
+ private RMapCacheReactive getMapCache(String transferId) {
+ return redisClient.reactive().getMapCache(KEY_PREFIX + transferId);
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/EnticiBackendAdapterTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/EnticiBackendAdapterTest.java
new file mode 100644
index 000000000..3a986fda7
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/EnticiBackendAdapterTest.java
@@ -0,0 +1,81 @@
+package care.smith.fts.tca.adapters;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+import care.smith.fts.tca.deidentification.EnticiClient;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+@ExtendWith(MockitoExtension.class)
+class EnticiBackendAdapterTest {
+
+ @Mock private EnticiClient enticiClient;
+
+ private EnticiBackendAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ adapter = new EnticiBackendAdapter(enticiClient);
+ }
+
+ @Test
+ void getBackendTypeReturnsEntici() {
+ assertThat(adapter.getBackendType()).isEqualTo("entici");
+ }
+
+ @Test
+ void fetchOrCreatePseudonymDelegatesToEnticiClient() {
+ var domain = "test-domain";
+ var originalValue = "patient-123";
+ var expectedPseudonym = "pseudo-456";
+
+ when(enticiClient.fetchOrCreatePseudonym(domain, originalValue))
+ .thenReturn(Mono.just(expectedPseudonym));
+
+ var result = adapter.fetchOrCreatePseudonym(domain, originalValue);
+
+ StepVerifier.create(result).expectNext(expectedPseudonym).verifyComplete();
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsDelegatesToEnticiClient() {
+ var domain = "test-domain";
+ var originals = Set.of("patient-1", "patient-2");
+ var expected = Map.of("patient-1", "pseudo-1", "patient-2", "pseudo-2");
+
+ when(enticiClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(expected));
+
+ var result = adapter.fetchOrCreatePseudonyms(domain, originals);
+
+ StepVerifier.create(result)
+ .assertNext(
+ mappings -> {
+ assertThat(mappings).hasSize(2);
+ assertThat(mappings).containsEntry("patient-1", "pseudo-1");
+ assertThat(mappings).containsEntry("patient-2", "pseudo-2");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsHandlesEmptySet() {
+ var domain = "test-domain";
+ var originals = Set.of();
+
+ when(enticiClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(Map.of()));
+
+ var result = adapter.fetchOrCreatePseudonyms(domain, originals);
+
+ StepVerifier.create(result)
+ .assertNext(mappings -> assertThat(mappings).isEmpty())
+ .verifyComplete();
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/GpasBackendAdapterTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/GpasBackendAdapterTest.java
new file mode 100644
index 000000000..84545389c
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/GpasBackendAdapterTest.java
@@ -0,0 +1,81 @@
+package care.smith.fts.tca.adapters;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+import care.smith.fts.tca.deidentification.GpasClient;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+@ExtendWith(MockitoExtension.class)
+class GpasBackendAdapterTest {
+
+ @Mock private GpasClient gpasClient;
+
+ private GpasBackendAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ adapter = new GpasBackendAdapter(gpasClient);
+ }
+
+ @Test
+ void getBackendTypeReturnsGpas() {
+ assertThat(adapter.getBackendType()).isEqualTo("gpas");
+ }
+
+ @Test
+ void fetchOrCreatePseudonymDelegatesToGpasClient() {
+ var domain = "test-domain";
+ var originalValue = "patient-123";
+ var expectedPseudonym = "pseudo-456";
+
+ when(gpasClient.fetchOrCreatePseudonyms(domain, Set.of(originalValue)))
+ .thenReturn(Mono.just(Map.of(originalValue, expectedPseudonym)));
+
+ var result = adapter.fetchOrCreatePseudonym(domain, originalValue);
+
+ StepVerifier.create(result).expectNext(expectedPseudonym).verifyComplete();
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsDelegatesToGpasClient() {
+ var domain = "test-domain";
+ var originals = Set.of("patient-1", "patient-2");
+ var expected = Map.of("patient-1", "pseudo-1", "patient-2", "pseudo-2");
+
+ when(gpasClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(expected));
+
+ var result = adapter.fetchOrCreatePseudonyms(domain, originals);
+
+ StepVerifier.create(result)
+ .assertNext(
+ mappings -> {
+ assertThat(mappings).hasSize(2);
+ assertThat(mappings).containsEntry("patient-1", "pseudo-1");
+ assertThat(mappings).containsEntry("patient-2", "pseudo-2");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsHandlesEmptySet() {
+ var domain = "test-domain";
+ var originals = Set.of();
+
+ when(gpasClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(Map.of()));
+
+ var result = adapter.fetchOrCreatePseudonyms(domain, originals);
+
+ StepVerifier.create(result)
+ .assertNext(mappings -> assertThat(mappings).isEmpty())
+ .verifyComplete();
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/VfpsBackendAdapterTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/VfpsBackendAdapterTest.java
new file mode 100644
index 000000000..36730d078
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/adapters/VfpsBackendAdapterTest.java
@@ -0,0 +1,81 @@
+package care.smith.fts.tca.adapters;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+import care.smith.fts.tca.deidentification.VfpsClient;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+@ExtendWith(MockitoExtension.class)
+class VfpsBackendAdapterTest {
+
+ @Mock private VfpsClient vfpsClient;
+
+ private VfpsBackendAdapter adapter;
+
+ @BeforeEach
+ void setUp() {
+ adapter = new VfpsBackendAdapter(vfpsClient);
+ }
+
+ @Test
+ void getBackendTypeReturnsVfps() {
+ assertThat(adapter.getBackendType()).isEqualTo("vfps");
+ }
+
+ @Test
+ void fetchOrCreatePseudonymDelegatesToVfpsClient() {
+ var domain = "test-domain";
+ var originalValue = "patient-123";
+ var expectedPseudonym = "pseudo-456";
+
+ when(vfpsClient.fetchOrCreatePseudonym(domain, originalValue))
+ .thenReturn(Mono.just(expectedPseudonym));
+
+ var result = adapter.fetchOrCreatePseudonym(domain, originalValue);
+
+ StepVerifier.create(result).expectNext(expectedPseudonym).verifyComplete();
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsDelegatesToVfpsClient() {
+ var domain = "test-domain";
+ var originals = Set.of("patient-1", "patient-2");
+ var expected = Map.of("patient-1", "pseudo-1", "patient-2", "pseudo-2");
+
+ when(vfpsClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(expected));
+
+ var result = adapter.fetchOrCreatePseudonyms(domain, originals);
+
+ StepVerifier.create(result)
+ .assertNext(
+ mappings -> {
+ assertThat(mappings).hasSize(2);
+ assertThat(mappings).containsEntry("patient-1", "pseudo-1");
+ assertThat(mappings).containsEntry("patient-2", "pseudo-2");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsHandlesEmptySet() {
+ var domain = "test-domain";
+ var originals = Set.of();
+
+ when(vfpsClient.fetchOrCreatePseudonyms(domain, originals)).thenReturn(Mono.just(Map.of()));
+
+ var result = adapter.fetchOrCreatePseudonyms(domain, originals);
+
+ StepVerifier.create(result)
+ .assertNext(mappings -> assertThat(mappings).isEmpty())
+ .verifyComplete();
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/config/BackendAdapterConfigTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/config/BackendAdapterConfigTest.java
new file mode 100644
index 000000000..5286a4cb9
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/config/BackendAdapterConfigTest.java
@@ -0,0 +1,119 @@
+package care.smith.fts.tca.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import care.smith.fts.tca.config.BackendAdapterConfig.BackendType;
+import care.smith.fts.tca.config.BackendAdapterConfig.EnticiConfig;
+import care.smith.fts.tca.config.BackendAdapterConfig.GpasConfig;
+import care.smith.fts.tca.config.BackendAdapterConfig.VfpsConfig;
+import care.smith.fts.util.HttpClientConfig;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class BackendAdapterConfigTest {
+
+ private BackendAdapterConfig config;
+
+ @BeforeEach
+ void setUp() {
+ config = new BackendAdapterConfig();
+ }
+
+ @Test
+ void defaultTypeIsGpas() {
+ assertThat(config.getType()).isEqualTo(BackendType.GPAS);
+ }
+
+ @Test
+ void setAndGetType() {
+ config.setType(BackendType.VFPS);
+ assertThat(config.getType()).isEqualTo(BackendType.VFPS);
+
+ config.setType(BackendType.ENTICI);
+ assertThat(config.getType()).isEqualTo(BackendType.ENTICI);
+ }
+
+ @Test
+ void getActiveBackendConfigReturnsGpasWhenConfigured() {
+ var gpasConfig = new GpasConfig();
+ gpasConfig.setFhir(new HttpClientConfig("http://gpas:8080"));
+ config.setGpas(gpasConfig);
+ config.setType(BackendType.GPAS);
+
+ var active = config.getActiveBackendConfig();
+
+ assertThat(active).isPresent();
+ assertThat(active.get()).isInstanceOf(GpasConfig.class);
+ }
+
+ @Test
+ void getActiveBackendConfigReturnsVfpsWhenConfigured() {
+ var vfpsConfig = new VfpsConfig();
+ vfpsConfig.setAddress("dns:///vfps:8081");
+ config.setVfps(vfpsConfig);
+ config.setType(BackendType.VFPS);
+
+ var active = config.getActiveBackendConfig();
+
+ assertThat(active).isPresent();
+ assertThat(active.get()).isInstanceOf(VfpsConfig.class);
+ }
+
+ @Test
+ void getActiveBackendConfigReturnsEnticiWhenConfigured() {
+ var enticiConfig = new EnticiConfig();
+ enticiConfig.setBaseUrl("http://entici:8080");
+ config.setEntici(enticiConfig);
+ config.setType(BackendType.ENTICI);
+
+ var active = config.getActiveBackendConfig();
+
+ assertThat(active).isPresent();
+ assertThat(active.get()).isInstanceOf(EnticiConfig.class);
+ }
+
+ @Test
+ void getActiveBackendConfigReturnsEmptyWhenNotConfigured() {
+ config.setType(BackendType.GPAS);
+ config.setGpas(null);
+
+ var active = config.getActiveBackendConfig();
+
+ assertThat(active).isEmpty();
+ }
+
+ @Test
+ void gpasConfigCanSetFhir() {
+ var gpasConfig = new GpasConfig();
+ var httpConfig = new HttpClientConfig("http://gpas:8080");
+ gpasConfig.setFhir(httpConfig);
+
+ assertThat(gpasConfig.getFhir()).isEqualTo(httpConfig);
+ }
+
+ @Test
+ void vfpsConfigCanSetAddressAndAuth() {
+ var vfpsConfig = new VfpsConfig();
+ vfpsConfig.setAddress("dns:///vfps:8081");
+ vfpsConfig.setAuth(new HttpClientConfig("http://auth:8080"));
+
+ assertThat(vfpsConfig.getAddress()).isEqualTo("dns:///vfps:8081");
+ assertThat(vfpsConfig.getAuth()).isNotNull();
+ }
+
+ @Test
+ void enticiConfigCanSetBaseUrlAndServer() {
+ var enticiConfig = new EnticiConfig();
+ enticiConfig.setBaseUrl("http://entici:8080");
+ enticiConfig.setServer(new HttpClientConfig("http://server:8080"));
+
+ assertThat(enticiConfig.getBaseUrl()).isEqualTo("http://entici:8080");
+ assertThat(enticiConfig.getServer()).isNotNull();
+ }
+
+ @Test
+ void backendTypeEnumHasAllValues() {
+ assertThat(BackendType.values())
+ .containsExactly(BackendType.GPAS, BackendType.VFPS, BackendType.ENTICI);
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientIT.java
new file mode 100644
index 000000000..6429ec667
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientIT.java
@@ -0,0 +1,110 @@
+package care.smith.fts.tca.deidentification;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static java.util.Set.of;
+import static org.assertj.core.api.Assertions.assertThat;
+import static reactor.test.StepVerifier.create;
+
+import care.smith.fts.tca.AbstractFhirClientIT;
+import care.smith.fts.tca.deidentification.configuration.EnticiDeIdentificationConfiguration;
+import com.github.tomakehurst.wiremock.client.MappingBuilder;
+import io.micrometer.core.instrument.MeterRegistry;
+import java.util.Map;
+import java.util.Set;
+import org.hl7.fhir.r4.model.CapabilityStatement;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+@SpringBootTest
+public class EnticiClientIT
+ extends AbstractFhirClientIT> {
+
+ @Autowired WebClient.Builder httpClientBuilder;
+
+ @Autowired MeterRegistry meterRegistry;
+
+ @MockitoBean RedissonClient redisClient; // Mock redisClient to allow tests to start
+
+ @BeforeEach
+ void setUpDependencies() {
+ init(httpClientBuilder, meterRegistry);
+ }
+
+ private static final String REQUEST_BODY =
+ """
+ {
+ "resourceType": "Parameters",
+ "parameter": [
+ {
+ "name": "identifier",
+ "valueIdentifier": {
+ "system": "domain",
+ "value": "id"
+ }
+ },
+ {
+ "name": "resourceType",
+ "valueString": "Patient"
+ }
+ ]
+ }
+ """;
+
+ @Override
+ protected EnticiClient createClient(String baseUrl) {
+ var config = new EnticiDeIdentificationConfiguration();
+ config.setResourceType("Patient");
+ return new EnticiClient(httpClientBuilder.baseUrl(baseUrl).build(), meterRegistry, config);
+ }
+
+ @Override
+ protected MappingBuilder getRequestMappingBuilder() {
+ return post(urlPathEqualTo("/$pseudonymize")).withRequestBody(equalToJson(REQUEST_BODY));
+ }
+
+ @Override
+ protected CapabilityStatement getMockCapabilityStatement() {
+ var capabilities = new CapabilityStatement();
+ var rest = capabilities.addRest();
+ rest.addOperation().setName("pseudonymize");
+ return capabilities;
+ }
+
+ @Override
+ protected Mono> executeRequest(String request) {
+ String[] parts = request.split(":");
+ return client.fetchOrCreatePseudonyms(parts[0], of(parts[1]));
+ }
+
+ @Override
+ protected String getServerName() {
+ return "Entici";
+ }
+
+ @Override
+ protected String getDefaultRequest() {
+ return "domain:id";
+ }
+
+ @Override
+ protected Mono> executeRequestWithClient(
+ EnticiClient specificClient, String request) {
+ String[] parts = request.split(":");
+ return specificClient.fetchOrCreatePseudonyms(parts[0], of(parts[1]));
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsReturnsEmptyMapForEmptyInput() {
+ create(client.fetchOrCreatePseudonyms("domain", Set.of()))
+ .assertNext(result -> assertThat(result).isEmpty())
+ .verifyComplete();
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientTest.java
new file mode 100644
index 000000000..0567fbbbb
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/EnticiClientTest.java
@@ -0,0 +1,118 @@
+package care.smith.fts.tca.deidentification;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import care.smith.fts.tca.deidentification.configuration.EnticiDeIdentificationConfiguration;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+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.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec;
+import org.springframework.web.reactive.function.client.WebClient.RequestBodyUriSpec;
+import org.springframework.web.reactive.function.client.WebClient.RequestHeadersSpec;
+import org.springframework.web.reactive.function.client.WebClient.ResponseSpec;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+@ExtendWith(MockitoExtension.class)
+class EnticiClientTest {
+
+ @Mock private WebClient webClient;
+ @Mock private RequestBodyUriSpec requestBodyUriSpec;
+ @Mock private RequestBodySpec requestBodySpec;
+ @Mock private RequestHeadersSpec requestHeadersSpec;
+ @Mock private ResponseSpec responseSpec;
+
+ private MeterRegistry meterRegistry;
+ private EnticiDeIdentificationConfiguration config;
+ private EnticiClient client;
+
+ @BeforeEach
+ void setUp() {
+ meterRegistry = new SimpleMeterRegistry();
+ config = new EnticiDeIdentificationConfiguration();
+ config.setConcurrency(4);
+ config.setResourceType("Patient");
+ client = new EnticiClient(webClient, meterRegistry, config);
+ }
+
+ @Test
+ void fetchOrCreatePseudonymReturnsPseudonymValue() {
+ var domain = "http://test-domain";
+ var originalValue = "patient-123";
+ var expectedPseudonym = "pseudo-abc";
+
+ var response =
+ new EnticiParameterResponse(
+ "Parameters",
+ java.util.List.of(
+ new EnticiParameterResponse.Parameter(
+ "pseudonym",
+ null,
+ new EnticiParameterResponse.ValueIdentifier(domain, expectedPseudonym))));
+
+ setupWebClientMock(response);
+
+ var result = client.fetchOrCreatePseudonym(domain, originalValue);
+
+ StepVerifier.create(result).expectNext(expectedPseudonym).verifyComplete();
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsReturnsMappings() {
+ var domain = "http://test-domain";
+ var originals = Set.of("patient-1", "patient-2");
+
+ var response =
+ new EnticiParameterResponse(
+ "Parameters",
+ java.util.List.of(
+ new EnticiParameterResponse.Parameter(
+ "pseudonym",
+ null,
+ new EnticiParameterResponse.ValueIdentifier(domain, "pseudo-value"))));
+
+ setupWebClientMock(response);
+
+ var result = client.fetchOrCreatePseudonyms(domain, originals);
+
+ StepVerifier.create(result)
+ .assertNext(
+ mappings -> {
+ assertThat(mappings).hasSize(2);
+ assertThat(mappings).containsKey("patient-1");
+ assertThat(mappings).containsKey("patient-2");
+ assertThat(mappings.get("patient-1")).isEqualTo("pseudo-value");
+ assertThat(mappings.get("patient-2")).isEqualTo("pseudo-value");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsReturnsEmptyMapForEmptyInput() {
+ var result = client.fetchOrCreatePseudonyms("domain", Set.of());
+
+ StepVerifier.create(result)
+ .assertNext(mappings -> assertThat(mappings).isEmpty())
+ .verifyComplete();
+ }
+
+ @SuppressWarnings("unchecked")
+ private void setupWebClientMock(EnticiParameterResponse response) {
+ when(webClient.post()).thenReturn(requestBodyUriSpec);
+ when(requestBodyUriSpec.uri(any(String.class))).thenReturn(requestBodySpec);
+ when(requestBodySpec.headers(any())).thenReturn(requestBodySpec);
+ when(requestBodySpec.bodyValue(any())).thenReturn(requestHeadersSpec);
+ when(requestHeadersSpec.headers(any())).thenReturn(requestHeadersSpec);
+ when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
+ when(responseSpec.onStatus(any(), any())).thenReturn(responseSpec);
+ when(responseSpec.bodyToMono(EnticiParameterResponse.class)).thenReturn(Mono.just(response));
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientIT.java
new file mode 100644
index 000000000..43474a8d1
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientIT.java
@@ -0,0 +1,99 @@
+package care.smith.fts.tca.deidentification;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static java.util.Set.of;
+import static org.assertj.core.api.Assertions.assertThat;
+import static reactor.test.StepVerifier.create;
+
+import care.smith.fts.tca.AbstractFhirClientIT;
+import care.smith.fts.tca.deidentification.configuration.VfpsDeIdentificationConfiguration;
+import com.github.tomakehurst.wiremock.client.MappingBuilder;
+import io.micrometer.core.instrument.MeterRegistry;
+import java.util.Map;
+import java.util.Set;
+import org.hl7.fhir.r4.model.CapabilityStatement;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+@SpringBootTest
+public class VfpsClientIT extends AbstractFhirClientIT> {
+
+ @Autowired WebClient.Builder httpClientBuilder;
+
+ @Autowired MeterRegistry meterRegistry;
+
+ @MockitoBean RedissonClient redisClient; // Mock redisClient to allow tests to start
+
+ @BeforeEach
+ void setUpDependencies() {
+ init(httpClientBuilder, meterRegistry);
+ }
+
+ private static final String REQUEST_BODY =
+ """
+ {
+ "resourceType": "Parameters",
+ "parameter": [
+ {"name": "namespace", "valueString": "domain"},
+ {"name": "originalValue", "valueString": "id"}
+ ]
+ }
+ """;
+
+ @Override
+ protected VfpsClient createClient(String baseUrl) {
+ var config = new VfpsDeIdentificationConfiguration();
+ return new VfpsClient(httpClientBuilder.baseUrl(baseUrl).build(), meterRegistry, config);
+ }
+
+ @Override
+ protected MappingBuilder getRequestMappingBuilder() {
+ return post(urlPathEqualTo("/$create-pseudonym")).withRequestBody(equalToJson(REQUEST_BODY));
+ }
+
+ @Override
+ protected CapabilityStatement getMockCapabilityStatement() {
+ var capabilities = new CapabilityStatement();
+ var rest = capabilities.addRest();
+ rest.addOperation().setName("create-pseudonym");
+ return capabilities;
+ }
+
+ @Override
+ protected Mono> executeRequest(String request) {
+ String[] parts = request.split(":");
+ return client.fetchOrCreatePseudonyms(parts[0], of(parts[1]));
+ }
+
+ @Override
+ protected String getServerName() {
+ return "Vfps";
+ }
+
+ @Override
+ protected String getDefaultRequest() {
+ return "domain:id";
+ }
+
+ @Override
+ protected Mono> executeRequestWithClient(
+ VfpsClient specificClient, String request) {
+ String[] parts = request.split(":");
+ return specificClient.fetchOrCreatePseudonyms(parts[0], of(parts[1]));
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsReturnsEmptyMapForEmptyInput() {
+ create(client.fetchOrCreatePseudonyms("domain", Set.of()))
+ .assertNext(result -> assertThat(result).isEmpty())
+ .verifyComplete();
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientTest.java
new file mode 100644
index 000000000..04e9b7bd6
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/deidentification/VfpsClientTest.java
@@ -0,0 +1,119 @@
+package care.smith.fts.tca.deidentification;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import care.smith.fts.tca.deidentification.configuration.VfpsDeIdentificationConfiguration;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import java.util.Set;
+import org.junit.jupiter.api.BeforeEach;
+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.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.reactive.function.client.WebClient.RequestBodySpec;
+import org.springframework.web.reactive.function.client.WebClient.RequestBodyUriSpec;
+import org.springframework.web.reactive.function.client.WebClient.RequestHeadersSpec;
+import org.springframework.web.reactive.function.client.WebClient.ResponseSpec;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+@ExtendWith(MockitoExtension.class)
+class VfpsClientTest {
+
+ @Mock private WebClient webClient;
+ @Mock private RequestBodyUriSpec requestBodyUriSpec;
+ @Mock private RequestBodySpec requestBodySpec;
+ @Mock private RequestHeadersSpec requestHeadersSpec;
+ @Mock private ResponseSpec responseSpec;
+
+ private MeterRegistry meterRegistry;
+ private VfpsDeIdentificationConfiguration config;
+ private VfpsClient client;
+
+ @BeforeEach
+ void setUp() {
+ meterRegistry = new SimpleMeterRegistry();
+ config = new VfpsDeIdentificationConfiguration();
+ config.setConcurrency(4);
+ client = new VfpsClient(webClient, meterRegistry, config);
+ }
+
+ @Test
+ void fetchOrCreatePseudonymReturnsPseudonymValue() {
+ var namespace = "test-namespace";
+ var originalValue = "patient-123";
+ var expectedPseudonym = "pseudo-abc";
+
+ var response =
+ new VfpsParameterResponse(
+ "Parameters",
+ java.util.List.of(
+ new VfpsParameterResponse.Parameter("namespace", namespace, null),
+ new VfpsParameterResponse.Parameter("originalValue", originalValue, null),
+ new VfpsParameterResponse.Parameter("pseudonymValue", expectedPseudonym, null)));
+
+ setupWebClientMock(response);
+
+ var result = client.fetchOrCreatePseudonym(namespace, originalValue);
+
+ StepVerifier.create(result).expectNext(expectedPseudonym).verifyComplete();
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsReturnsMappings() {
+ var namespace = "test-namespace";
+ var originals = Set.of("patient-1", "patient-2");
+
+ // Use a single response that returns the same pseudonym pattern
+ // The mapping uses originalValue from request, so we just need consistent pseudonym generation
+ var response =
+ new VfpsParameterResponse(
+ "Parameters",
+ java.util.List.of(
+ new VfpsParameterResponse.Parameter("namespace", namespace, null),
+ new VfpsParameterResponse.Parameter("originalValue", "any-value", null),
+ new VfpsParameterResponse.Parameter("pseudonymValue", "pseudo-value", null)));
+
+ setupWebClientMock(response);
+
+ var result = client.fetchOrCreatePseudonyms(namespace, originals);
+
+ StepVerifier.create(result)
+ .assertNext(
+ mappings -> {
+ assertThat(mappings).hasSize(2);
+ // Both entries map to the same pseudonym since we use a single mock response
+ assertThat(mappings).containsKey("patient-1");
+ assertThat(mappings).containsKey("patient-2");
+ // Values should be the pseudonym from response
+ assertThat(mappings.get("patient-1")).isEqualTo("pseudo-value");
+ assertThat(mappings.get("patient-2")).isEqualTo("pseudo-value");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void fetchOrCreatePseudonymsReturnsEmptyMapForEmptyInput() {
+ var result = client.fetchOrCreatePseudonyms("namespace", Set.of());
+
+ StepVerifier.create(result)
+ .assertNext(mappings -> assertThat(mappings).isEmpty())
+ .verifyComplete();
+ }
+
+ @SuppressWarnings("unchecked")
+ private void setupWebClientMock(VfpsParameterResponse response) {
+ when(webClient.post()).thenReturn(requestBodyUriSpec);
+ when(requestBodyUriSpec.uri(any(String.class))).thenReturn(requestBodySpec);
+ when(requestBodySpec.headers(any())).thenReturn(requestBodySpec);
+ when(requestBodySpec.bodyValue(any())).thenReturn(requestHeadersSpec);
+ when(requestHeadersSpec.headers(any())).thenReturn(requestHeadersSpec);
+ when(requestHeadersSpec.retrieve()).thenReturn(responseSpec);
+ when(responseSpec.onStatus(any(), any())).thenReturn(responseSpec);
+ when(responseSpec.bodyToMono(VfpsParameterResponse.class)).thenReturn(Mono.just(response));
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerIT.java
new file mode 100644
index 000000000..e8caa1aa5
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerIT.java
@@ -0,0 +1,283 @@
+package care.smith.fts.tca.rest;
+
+import static care.smith.fts.test.FhirGenerators.fromList;
+import static care.smith.fts.test.FhirGenerators.gpasGetOrCreateResponse;
+import static care.smith.fts.test.MockServerUtil.APPLICATION_FHIR_JSON;
+import static care.smith.fts.test.MockServerUtil.fhirResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
+import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
+import static reactor.test.StepVerifier.create;
+
+import care.smith.fts.tca.BaseIT;
+import care.smith.fts.test.TestWebClientFactory;
+import java.io.IOException;
+import java.util.List;
+import lombok.extern.slf4j.Slf4j;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.StringType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.reactive.function.client.WebClientResponseException;
+
+/**
+ * Integration tests for CdAgentFhirPseudonymizerController.
+ *
+ * Tests the Vfps-compatible endpoint that generates transport IDs for CDA requests.
+ */
+@Slf4j
+@SpringBootTest(webEnvironment = RANDOM_PORT)
+@Import(TestWebClientFactory.class)
+class CdAgentFhirPseudonymizerControllerIT extends BaseIT {
+
+ private static final String VFPS_ENDPOINT = "/api/v2/cd-agent/fhir/$create-pseudonym";
+
+ @Autowired private RedissonClient redisClient;
+ private WebClient cdClient;
+
+ @BeforeEach
+ void setUp(@LocalServerPort int port, @Autowired TestWebClientFactory factory) {
+ cdClient = factory.webClient("https://localhost:" + port, "cd-agent");
+ // Clean up Redis before each test
+ redisClient.getKeys().deleteByPattern("transport-mapping:*");
+ }
+
+ @AfterEach
+ void tearDown() {
+ gpas().resetMappings();
+ }
+
+ @Test
+ void createPseudonym_shouldReturnTransportId() throws IOException {
+ // Setup gPAS mock to return a real pseudonym
+ var fhirGenerator =
+ gpasGetOrCreateResponse(
+ fromList(List.of("patient-123")), fromList(List.of("sID-real-pseudonym-abc")));
+
+ gpas()
+ .register(
+ post(urlEqualTo("/ttp-fhir/fhir/gpas/$pseudonymizeAllowCreate"))
+ .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON))
+ .willReturn(fhirResponse(fhirGenerator.generateString())));
+
+ // Build Vfps-format request
+ var requestParams = buildVfpsRequest("clinical-domain", "patient-123");
+
+ // Send request
+ var response =
+ cdClient
+ .post()
+ .uri(VFPS_ENDPOINT)
+ .header(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .header("Accept", APPLICATION_FHIR_JSON)
+ .bodyValue(requestParams)
+ .retrieve()
+ .bodyToMono(Parameters.class);
+
+ create(response)
+ .assertNext(
+ params -> {
+ assertThat(params).isNotNull();
+ // Single value response has 3 flat parameters: namespace, originalValue,
+ // pseudonymValue
+ assertThat(params.getParameter()).hasSize(3);
+
+ // The response should contain a pseudonymValue that is a transport ID (not the real
+ // sID)
+ var pseudonymValue = extractPseudonymValue(params);
+ assertThat(pseudonymValue)
+ .isNotNull()
+ .isNotEqualTo("sID-real-pseudonym-abc") // Must NOT be the real pseudonym
+ .hasSize(32) // Transport IDs are 32 chars (24 bytes Base64URL)
+ .matches(s -> s.matches("^[A-Za-z0-9_-]+$"), "should be Base64URL encoded");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void createPseudonym_shouldStoreMapping() throws IOException {
+ // Setup gPAS mock
+ var fhirGenerator =
+ gpasGetOrCreateResponse(
+ fromList(List.of("patient-456")), fromList(List.of("sID-stored-pseudonym")));
+
+ gpas()
+ .register(
+ post(urlEqualTo("/ttp-fhir/fhir/gpas/$pseudonymizeAllowCreate"))
+ .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON))
+ .willReturn(fhirResponse(fhirGenerator.generateString())));
+
+ // Build and send request
+ var requestParams = buildVfpsRequest("clinical-domain", "patient-456");
+
+ var transportId =
+ cdClient
+ .post()
+ .uri(VFPS_ENDPOINT)
+ .header(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .header("Accept", APPLICATION_FHIR_JSON)
+ .bodyValue(requestParams)
+ .retrieve()
+ .bodyToMono(Parameters.class)
+ .map(this::extractPseudonymValue)
+ .block();
+
+ // Verify mapping was stored in Redis
+ var keys = redisClient.getKeys().getKeysByPattern("transport-mapping:*");
+ assertThat(keys).isNotEmpty();
+
+ // The mapping should contain the transport ID -> real pseudonym
+ var transferId = keys.iterator().next().replace("transport-mapping:", "");
+ var mapping = redisClient.getMapCache("transport-mapping:" + transferId);
+ assertThat(mapping.get(transportId)).isEqualTo("sID-stored-pseudonym");
+ }
+
+ @Test
+ void createPseudonym_withMultipleOriginals_shouldReturnMultipleTransportIds() throws IOException {
+ // Setup gPAS mock for batch processing
+ var fhirGenerator =
+ gpasGetOrCreateResponse(
+ fromList(List.of("patient-1", "patient-2", "patient-3")),
+ fromList(List.of("sID-1", "sID-2", "sID-3")));
+
+ gpas()
+ .register(
+ post(urlEqualTo("/ttp-fhir/fhir/gpas/$pseudonymizeAllowCreate"))
+ .withHeader(CONTENT_TYPE, equalTo(APPLICATION_FHIR_JSON))
+ .willReturn(fhirResponse(fhirGenerator.generateString())));
+
+ // Build request with multiple originals
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("namespace").setValue(new StringType("clinical-domain"));
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-1"));
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-2"));
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-3"));
+
+ var response =
+ cdClient
+ .post()
+ .uri(VFPS_ENDPOINT)
+ .header(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .header("Accept", APPLICATION_FHIR_JSON)
+ .bodyValue(requestParams)
+ .retrieve()
+ .bodyToMono(Parameters.class);
+
+ create(response)
+ .assertNext(
+ params -> {
+ assertThat(params).isNotNull();
+ // For batch response (>1 original), should have nested "pseudonym" parameters
+ // Total parameters should be 3 (one for each original)
+ var pseudonymParams =
+ params.getParameter().stream()
+ .filter(p -> "pseudonym".equals(p.getName()))
+ .toList();
+
+ // If we have nested structure, pseudonymParams should have 3 entries
+ // If not (e.g., flat structure reused), we need to count differently
+ if (pseudonymParams.isEmpty()) {
+ // Check if flat structure was used (should not happen for batch)
+ assertThat(params.getParameter()).hasSizeGreaterThanOrEqualTo(3);
+ } else {
+ assertThat(pseudonymParams).hasSize(3);
+
+ // All should have unique transport IDs
+ var transportIds =
+ pseudonymParams.stream().map(this::extractPseudonymValueFromPart).toList();
+ assertThat(transportIds).hasSize(3).doesNotHaveDuplicates();
+ }
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void createPseudonym_withMissingNamespace_shouldReturn400() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-123"));
+
+ var response =
+ cdClient
+ .post()
+ .uri(VFPS_ENDPOINT)
+ .header(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .header("Accept", APPLICATION_FHIR_JSON)
+ .bodyValue(requestParams)
+ .retrieve()
+ .toBodilessEntity();
+
+ create(response)
+ .expectErrorSatisfies(
+ e -> {
+ assertThat(e).isInstanceOf(WebClientResponseException.class);
+ assertThat(((WebClientResponseException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST);
+ })
+ .verify();
+ }
+
+ @Test
+ void createPseudonym_withMissingOriginalValue_shouldReturn400() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("namespace").setValue(new StringType("clinical-domain"));
+
+ var response =
+ cdClient
+ .post()
+ .uri(VFPS_ENDPOINT)
+ .header(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .header("Accept", APPLICATION_FHIR_JSON)
+ .bodyValue(requestParams)
+ .retrieve()
+ .toBodilessEntity();
+
+ create(response)
+ .expectErrorSatisfies(
+ e -> {
+ assertThat(e).isInstanceOf(WebClientResponseException.class);
+ assertThat(((WebClientResponseException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST);
+ })
+ .verify();
+ }
+
+ private Parameters buildVfpsRequest(String namespace, String originalValue) {
+ var params = new Parameters();
+ params.addParameter().setName("namespace").setValue(new StringType(namespace));
+ params.addParameter().setName("originalValue").setValue(new StringType(originalValue));
+ return params;
+ }
+
+ private String extractPseudonymValue(Parameters params) {
+ return params.getParameter().stream()
+ .filter(p -> "pseudonymValue".equals(p.getName()))
+ .findFirst()
+ .map(p -> p.getValue().primitiveValue())
+ .orElseGet(
+ () ->
+ params.getParameter().stream()
+ .filter(p -> "pseudonym".equals(p.getName()))
+ .findFirst()
+ .map(this::extractPseudonymValueFromPart)
+ .orElse(null));
+ }
+
+ private String extractPseudonymValueFromPart(Parameters.ParametersParameterComponent param) {
+ return param.getPart().stream()
+ .filter(p -> "pseudonymValue".equals(p.getName()) || "pseudonym".equals(p.getName()))
+ .findFirst()
+ .map(p -> p.getValue().primitiveValue())
+ .orElse(null);
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerTest.java
new file mode 100644
index 000000000..e610bf841
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/CdAgentFhirPseudonymizerControllerTest.java
@@ -0,0 +1,206 @@
+package care.smith.fts.tca.rest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+import care.smith.fts.tca.adapters.PseudonymBackendAdapter;
+import care.smith.fts.tca.services.BackendAdapterFactory;
+import care.smith.fts.tca.services.TransportIdService;
+import java.time.Duration;
+import java.util.Map;
+import org.hl7.fhir.r4.model.OperationOutcome;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.StringType;
+import org.junit.jupiter.api.BeforeEach;
+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.springframework.http.HttpStatus;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+@ExtendWith(MockitoExtension.class)
+class CdAgentFhirPseudonymizerControllerTest {
+
+ @Mock private TransportIdService transportIdService;
+ @Mock private BackendAdapterFactory adapterFactory;
+ @Mock private PseudonymBackendAdapter backendAdapter;
+
+ private CdAgentFhirPseudonymizerController controller;
+
+ @BeforeEach
+ void setUp() {
+ when(adapterFactory.createAdapter()).thenReturn(backendAdapter);
+ controller = new CdAgentFhirPseudonymizerController(transportIdService, adapterFactory);
+ }
+
+ @Test
+ void createPseudonymSuccessfullyReturnsSingleEntry() {
+ var requestParams = createSingleValueRequest("test-domain", "patient-123");
+ var ttl = Duration.ofMinutes(10);
+
+ when(transportIdService.generateTransferId()).thenReturn("transfer-id-1");
+ when(transportIdService.getDefaultTtl()).thenReturn(ttl);
+ when(transportIdService.generateTransportId()).thenReturn("tId-abc123");
+ when(transportIdService.storeMapping(
+ eq("transfer-id-1"), eq("tId-abc123"), eq("sId-456"), eq("test-domain"), eq(ttl)))
+ .thenReturn(Mono.just("tId-abc123"));
+ when(backendAdapter.fetchOrCreatePseudonyms(eq("test-domain"), anySet()))
+ .thenReturn(Mono.just(Map.of("patient-123", "sId-456")));
+
+ var result = controller.createPseudonym(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+
+ var namespace = findParameterValue(params, "namespace");
+ var originalValue = findParameterValue(params, "originalValue");
+ var pseudonymValue = findParameterValue(params, "pseudonymValue");
+
+ assertThat(namespace).isEqualTo("test-domain");
+ assertThat(originalValue).isEqualTo("patient-123");
+ assertThat(pseudonymValue).isEqualTo("tId-abc123");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void createPseudonymSuccessfullyReturnsMultipleEntries() {
+ var requestParams = createMultiValueRequest("test-domain", "patient-1", "patient-2");
+ var ttl = Duration.ofMinutes(10);
+
+ when(transportIdService.generateTransferId()).thenReturn("transfer-id-1");
+ when(transportIdService.getDefaultTtl()).thenReturn(ttl);
+ when(transportIdService.generateTransportId()).thenReturn("tId-1", "tId-2");
+ when(transportIdService.storeMapping(anyString(), anyString(), anyString(), anyString(), any()))
+ .thenAnswer(invocation -> Mono.just(invocation.getArgument(1)));
+ when(backendAdapter.fetchOrCreatePseudonyms(eq("test-domain"), anySet()))
+ .thenReturn(Mono.just(Map.of("patient-1", "sId-1", "patient-2", "sId-2")));
+
+ var result = controller.createPseudonym(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+ assertThat(params.getParameter()).hasSize(2);
+
+ var firstPseudonym = params.getParameter().get(0);
+ assertThat(firstPseudonym.getName()).isEqualTo("pseudonym");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void createPseudonymReturnsBadRequestForMissingNamespace() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-123"));
+
+ var result = controller.createPseudonym(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+ var outcome = (OperationOutcome) params.getParameter().get(0).getResource();
+ assertThat(outcome.getIssueFirstRep().getDiagnostics())
+ .contains("Missing required parameter 'namespace'");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void createPseudonymReturnsBadRequestForEmptyNamespace() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("namespace").setValue(new StringType(" "));
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("patient-123"));
+
+ var result = controller.createPseudonym(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+ var outcome = (OperationOutcome) params.getParameter().get(0).getResource();
+ assertThat(outcome.getIssueFirstRep().getDiagnostics()).contains("must not be empty");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void createPseudonymReturnsBadRequestForMissingOriginalValue() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("namespace").setValue(new StringType("test-domain"));
+
+ var result = controller.createPseudonym(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+ var outcome = (OperationOutcome) params.getParameter().get(0).getResource();
+ assertThat(outcome.getIssueFirstRep().getDiagnostics())
+ .contains("At least one 'originalValue' parameter is required");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void createPseudonymReturnsInternalServerErrorOnBackendFailure() {
+ var requestParams = createSingleValueRequest("test-domain", "patient-123");
+
+ when(transportIdService.generateTransferId()).thenReturn("transfer-id-1");
+ when(backendAdapter.fetchOrCreatePseudonyms(eq("test-domain"), anySet()))
+ .thenReturn(Mono.error(new RuntimeException("Backend connection failed")));
+
+ var result = controller.createPseudonym(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ })
+ .verifyComplete();
+ }
+
+ private Parameters createSingleValueRequest(String namespace, String originalValue) {
+ var params = new Parameters();
+ params.addParameter().setName("namespace").setValue(new StringType(namespace));
+ params.addParameter().setName("originalValue").setValue(new StringType(originalValue));
+ return params;
+ }
+
+ private Parameters createMultiValueRequest(String namespace, String... originalValues) {
+ var params = new Parameters();
+ params.addParameter().setName("namespace").setValue(new StringType(namespace));
+ for (String value : originalValues) {
+ params.addParameter().setName("originalValue").setValue(new StringType(value));
+ }
+ return params;
+ }
+
+ private String findParameterValue(Parameters params, String name) {
+ return params.getParameter().stream()
+ .filter(p -> name.equals(p.getName()))
+ .findFirst()
+ .map(p -> p.getValue().primitiveValue())
+ .orElse(null);
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerIT.java
new file mode 100644
index 000000000..3999da67b
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerIT.java
@@ -0,0 +1,253 @@
+package care.smith.fts.tca.rest;
+
+import static care.smith.fts.test.MockServerUtil.APPLICATION_FHIR_JSON;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
+import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
+import static reactor.test.StepVerifier.create;
+
+import care.smith.fts.tca.BaseIT;
+import care.smith.fts.tca.services.TransportIdService;
+import care.smith.fts.test.TestWebClientFactory;
+import java.time.Duration;
+import lombok.extern.slf4j.Slf4j;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.StringType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.reactive.function.client.WebClient;
+import org.springframework.web.reactive.function.client.WebClientResponseException;
+
+/**
+ * Integration tests for RdAgentFhirPseudonymizerController.
+ *
+ * Tests the Vfps-compatible endpoint that resolves transport IDs to secure pseudonyms (sIDs).
+ */
+@Slf4j
+@SpringBootTest(webEnvironment = RANDOM_PORT)
+@Import(TestWebClientFactory.class)
+class RdAgentFhirPseudonymizerControllerIT extends BaseIT {
+
+ private static final String VFPS_ENDPOINT = "/api/v2/rd-agent/fhir/$create-pseudonym";
+
+ @Autowired private RedissonClient redisClient;
+ @Autowired private TransportIdService transportIdService;
+ private WebClient rdClient;
+
+ @BeforeEach
+ void setUp(@LocalServerPort int port, @Autowired TestWebClientFactory factory) {
+ rdClient = factory.webClient("https://localhost:" + port, "rd-agent");
+ // Clean up Redis before each test
+ redisClient.getKeys().deleteByPattern("transport-mapping:*");
+ }
+
+ @AfterEach
+ void tearDown() {
+ redisClient.getKeys().deleteByPattern("transport-mapping:*");
+ }
+
+ @Test
+ void resolvePseudonyms_shouldReturnSecurePseudonym() {
+ // First, store a mapping (simulating what CDA endpoint would do)
+ var transferId = transportIdService.generateTransferId();
+ var tId = "test-transport-id-resolve";
+ var sId = "secure-pseudonym-final";
+ var domain = "test-domain";
+
+ transportIdService.storeMapping(transferId, tId, sId, domain, Duration.ofMinutes(5)).block();
+
+ // Build Vfps-format request with the tID
+ var requestParams = buildVfpsRequest("test-domain", tId, transferId);
+
+ // Send request to resolve
+ var response =
+ rdClient
+ .post()
+ .uri(VFPS_ENDPOINT)
+ .header(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .header("Accept", APPLICATION_FHIR_JSON)
+ .bodyValue(requestParams)
+ .retrieve()
+ .bodyToMono(Parameters.class);
+
+ create(response)
+ .assertNext(
+ params -> {
+ assertThat(params).isNotNull();
+ // Single value response has 3 flat parameters
+ assertThat(params.getParameter()).hasSize(3);
+
+ // Verify the resolved pseudonym is the sID, not the tID
+ var pseudonymValue = extractPseudonymValue(params);
+ assertThat(pseudonymValue)
+ .isNotNull()
+ .isEqualTo(sId) // Should be the real secure pseudonym
+ .isNotEqualTo(tId); // NOT the transport ID
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolvePseudonyms_withUnknownTransportId_shouldReturnOriginal() {
+ // Create a transfer session without storing mappings
+ var transferId = transportIdService.generateTransferId();
+ var unknownTId = "unknown-transport-id";
+
+ // Need to store at least something to make the transfer exist
+ // (Otherwise the request should still work, returning tID as-is)
+ var requestParams = buildVfpsRequest("test-domain", unknownTId, transferId);
+
+ var response =
+ rdClient
+ .post()
+ .uri(VFPS_ENDPOINT)
+ .header(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .header("Accept", APPLICATION_FHIR_JSON)
+ .bodyValue(requestParams)
+ .retrieve()
+ .bodyToMono(Parameters.class);
+
+ create(response)
+ .assertNext(
+ params -> {
+ assertThat(params).isNotNull();
+ // Unknown tID should return the tID itself (not fail)
+ var pseudonymValue = extractPseudonymValue(params);
+ assertThat(pseudonymValue).isEqualTo(unknownTId);
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolvePseudonyms_withMissingTransferId_shouldReturn400() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("namespace").setValue(new StringType("test-domain"));
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("some-tid"));
+ // Missing transferId
+
+ var response =
+ rdClient
+ .post()
+ .uri(VFPS_ENDPOINT)
+ .header(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .header("Accept", APPLICATION_FHIR_JSON)
+ .bodyValue(requestParams)
+ .retrieve()
+ .toBodilessEntity();
+
+ create(response)
+ .expectErrorSatisfies(
+ e -> {
+ assertThat(e).isInstanceOf(WebClientResponseException.class);
+ assertThat(((WebClientResponseException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST);
+ })
+ .verify();
+ }
+
+ @Test
+ void resolvePseudonyms_withMissingNamespace_shouldReturn400() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("some-tid"));
+ requestParams.addParameter().setName("transferId").setValue(new StringType("some-transfer"));
+
+ var response =
+ rdClient
+ .post()
+ .uri(VFPS_ENDPOINT)
+ .header(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .header("Accept", APPLICATION_FHIR_JSON)
+ .bodyValue(requestParams)
+ .retrieve()
+ .toBodilessEntity();
+
+ create(response)
+ .expectErrorSatisfies(
+ e -> {
+ assertThat(e).isInstanceOf(WebClientResponseException.class);
+ assertThat(((WebClientResponseException) e).getStatusCode())
+ .isEqualTo(HttpStatus.BAD_REQUEST);
+ })
+ .verify();
+ }
+
+ @Test
+ void resolvePseudonyms_multipleMappings_shouldResolveAll() {
+ // Store multiple mappings
+ var transferId = transportIdService.generateTransferId();
+ var domain = "test-domain";
+ var ttl = Duration.ofMinutes(5);
+
+ transportIdService.storeMapping(transferId, "tId-1", "sId-1", domain, ttl).block();
+ transportIdService.storeMapping(transferId, "tId-2", "sId-2", domain, ttl).block();
+ transportIdService.storeMapping(transferId, "tId-3", "sId-3", domain, ttl).block();
+
+ // Build request with multiple tIDs
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("namespace").setValue(new StringType(domain));
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-1"));
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-2"));
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-3"));
+ requestParams.addParameter().setName("transferId").setValue(new StringType(transferId));
+
+ var response =
+ rdClient
+ .post()
+ .uri(VFPS_ENDPOINT)
+ .header(CONTENT_TYPE, APPLICATION_FHIR_JSON)
+ .header("Accept", APPLICATION_FHIR_JSON)
+ .bodyValue(requestParams)
+ .retrieve()
+ .bodyToMono(Parameters.class);
+
+ create(response)
+ .assertNext(
+ params -> {
+ assertThat(params).isNotNull();
+ // Should have 3 nested pseudonym entries
+ var pseudonymParams =
+ params.getParameter().stream()
+ .filter(p -> "pseudonym".equals(p.getName()))
+ .toList();
+ assertThat(pseudonymParams).hasSize(3);
+ })
+ .verifyComplete();
+ }
+
+ private Parameters buildVfpsRequest(String namespace, String transportId, String transferId) {
+ var params = new Parameters();
+ params.addParameter().setName("namespace").setValue(new StringType(namespace));
+ params.addParameter().setName("originalValue").setValue(new StringType(transportId));
+ params.addParameter().setName("transferId").setValue(new StringType(transferId));
+ return params;
+ }
+
+ private String extractPseudonymValue(Parameters params) {
+ return params.getParameter().stream()
+ .filter(p -> "pseudonymValue".equals(p.getName()))
+ .findFirst()
+ .map(p -> p.getValue().primitiveValue())
+ .orElseGet(
+ () ->
+ params.getParameter().stream()
+ .filter(p -> "pseudonym".equals(p.getName()))
+ .findFirst()
+ .map(this::extractPseudonymValueFromPart)
+ .orElse(null));
+ }
+
+ private String extractPseudonymValueFromPart(Parameters.ParametersParameterComponent param) {
+ return param.getPart().stream()
+ .filter(p -> "pseudonymValue".equals(p.getName()))
+ .findFirst()
+ .map(p -> p.getValue().primitiveValue())
+ .orElse(null);
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerTest.java
new file mode 100644
index 000000000..64898e10c
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/RdAgentFhirPseudonymizerControllerTest.java
@@ -0,0 +1,234 @@
+package care.smith.fts.tca.rest;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+import care.smith.fts.tca.services.TransportIdService;
+import java.util.Map;
+import org.hl7.fhir.r4.model.OperationOutcome;
+import org.hl7.fhir.r4.model.Parameters;
+import org.hl7.fhir.r4.model.StringType;
+import org.junit.jupiter.api.BeforeEach;
+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.springframework.http.HttpStatus;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+@ExtendWith(MockitoExtension.class)
+class RdAgentFhirPseudonymizerControllerTest {
+
+ @Mock private TransportIdService transportIdService;
+
+ private RdAgentFhirPseudonymizerController controller;
+
+ @BeforeEach
+ void setUp() {
+ controller = new RdAgentFhirPseudonymizerController(transportIdService);
+ }
+
+ @Test
+ void resolvePseudonymsSuccessfullyReturnsSingleEntry() {
+ var requestParams = createSingleValueRequest("test-domain", "tId-123", "transfer-id-1");
+
+ when(transportIdService.resolveMappings(eq("transfer-id-1"), anySet()))
+ .thenReturn(Mono.just(Map.of("tId-123", "sId-456")));
+
+ var result = controller.resolvePseudonyms(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+
+ var namespace = findParameterValue(params, "namespace");
+ var originalValue = findParameterValue(params, "originalValue");
+ var pseudonymValue = findParameterValue(params, "pseudonymValue");
+
+ assertThat(namespace).isEqualTo("test-domain");
+ assertThat(originalValue).isEqualTo("tId-123");
+ assertThat(pseudonymValue).isEqualTo("sId-456");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolvePseudonymsSuccessfullyReturnsMultipleEntries() {
+ var requestParams = createMultiValueRequest("test-domain", "transfer-id-1", "tId-1", "tId-2");
+
+ when(transportIdService.resolveMappings(eq("transfer-id-1"), anySet()))
+ .thenReturn(Mono.just(Map.of("tId-1", "sId-1", "tId-2", "sId-2")));
+
+ var result = controller.resolvePseudonyms(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+ assertThat(params.getParameter()).hasSize(2);
+
+ var firstPseudonym = params.getParameter().get(0);
+ assertThat(firstPseudonym.getName()).isEqualTo("pseudonym");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolvePseudonymsReturnsTidWhenNotFound() {
+ var requestParams = createSingleValueRequest("test-domain", "tId-missing", "transfer-id-1");
+
+ when(transportIdService.resolveMappings(eq("transfer-id-1"), anySet()))
+ .thenReturn(Mono.just(Map.of()));
+
+ var result = controller.resolvePseudonyms(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+
+ var pseudonymValue = findParameterValue(params, "pseudonymValue");
+ assertThat(pseudonymValue).isEqualTo("tId-missing");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolvePseudonymsReturnsBadRequestForMissingNamespace() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-123"));
+ requestParams.addParameter().setName("transferId").setValue(new StringType("transfer-id-1"));
+
+ var result = controller.resolvePseudonyms(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+ var outcome = (OperationOutcome) params.getParameter().get(0).getResource();
+ assertThat(outcome.getIssueFirstRep().getDiagnostics())
+ .contains("Missing required parameter 'namespace'");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolvePseudonymsReturnsBadRequestForEmptyNamespace() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("namespace").setValue(new StringType(" "));
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-123"));
+ requestParams.addParameter().setName("transferId").setValue(new StringType("transfer-id-1"));
+
+ var result = controller.resolvePseudonyms(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+ var outcome = (OperationOutcome) params.getParameter().get(0).getResource();
+ assertThat(outcome.getIssueFirstRep().getDiagnostics()).contains("must not be empty");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolvePseudonymsReturnsBadRequestForMissingOriginalValue() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("namespace").setValue(new StringType("test-domain"));
+ requestParams.addParameter().setName("transferId").setValue(new StringType("transfer-id-1"));
+
+ var result = controller.resolvePseudonyms(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+ var outcome = (OperationOutcome) params.getParameter().get(0).getResource();
+ assertThat(outcome.getIssueFirstRep().getDiagnostics())
+ .contains("At least one 'originalValue' parameter is required");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolvePseudonymsReturnsBadRequestForMissingTransferId() {
+ var requestParams = new Parameters();
+ requestParams.addParameter().setName("namespace").setValue(new StringType("test-domain"));
+ requestParams.addParameter().setName("originalValue").setValue(new StringType("tId-123"));
+
+ var result = controller.resolvePseudonyms(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ var params = response.getBody();
+ assertThat(params).isNotNull();
+ var outcome = (OperationOutcome) params.getParameter().get(0).getResource();
+ assertThat(outcome.getIssueFirstRep().getDiagnostics())
+ .contains("'transferId' is required");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolvePseudonymsReturnsInternalServerErrorOnServiceFailure() {
+ var requestParams = createSingleValueRequest("test-domain", "tId-123", "transfer-id-1");
+
+ when(transportIdService.resolveMappings(eq("transfer-id-1"), anySet()))
+ .thenReturn(Mono.error(new RuntimeException("Redis connection failed")));
+
+ var result = controller.resolvePseudonyms(requestParams);
+
+ StepVerifier.create(result)
+ .assertNext(
+ response -> {
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
+ })
+ .verifyComplete();
+ }
+
+ private Parameters createSingleValueRequest(
+ String namespace, String originalValue, String transferId) {
+ var params = new Parameters();
+ params.addParameter().setName("namespace").setValue(new StringType(namespace));
+ params.addParameter().setName("originalValue").setValue(new StringType(originalValue));
+ params.addParameter().setName("transferId").setValue(new StringType(transferId));
+ return params;
+ }
+
+ private Parameters createMultiValueRequest(
+ String namespace, String transferId, String... originalValues) {
+ var params = new Parameters();
+ params.addParameter().setName("namespace").setValue(new StringType(namespace));
+ params.addParameter().setName("transferId").setValue(new StringType(transferId));
+ for (String value : originalValues) {
+ params.addParameter().setName("originalValue").setValue(new StringType(value));
+ }
+ return params;
+ }
+
+ private String findParameterValue(Parameters params, String name) {
+ return params.getParameter().stream()
+ .filter(p -> name.equals(p.getName()))
+ .findFirst()
+ .map(p -> p.getValue().primitiveValue())
+ .orElse(null);
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequestTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequestTest.java
new file mode 100644
index 000000000..424d7274e
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeRequestTest.java
@@ -0,0 +1,72 @@
+package care.smith.fts.tca.rest.dto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class VfpsPseudonymizeRequestTest {
+
+ @Test
+ void validRequestCreatesImmutableCopy() {
+ var originals = List.of("original1", "original2");
+ var request = new VfpsPseudonymizeRequest("namespace", originals, "transfer-123");
+
+ assertThat(request.namespace()).isEqualTo("namespace");
+ assertThat(request.originals()).containsExactly("original1", "original2");
+ assertThat(request.transferId()).isEqualTo("transfer-123");
+ }
+
+ @Test
+ void nullNamespaceThrowsNullPointerException() {
+ assertThatThrownBy(() -> new VfpsPseudonymizeRequest(null, List.of("original"), "transfer-123"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("namespace is required");
+ }
+
+ @Test
+ void blankNamespaceThrowsIllegalArgumentException() {
+ assertThatThrownBy(() -> new VfpsPseudonymizeRequest(" ", List.of("original"), "transfer-123"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("namespace must not be blank");
+ }
+
+ @Test
+ void emptyNamespaceThrowsIllegalArgumentException() {
+ assertThatThrownBy(() -> new VfpsPseudonymizeRequest("", List.of("original"), "transfer-123"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("namespace must not be blank");
+ }
+
+ @Test
+ void nullOriginalsThrowsIllegalArgumentException() {
+ assertThatThrownBy(() -> new VfpsPseudonymizeRequest("namespace", null, "transfer-123"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("at least one original value required");
+ }
+
+ @Test
+ void emptyOriginalsThrowsIllegalArgumentException() {
+ assertThatThrownBy(() -> new VfpsPseudonymizeRequest("namespace", List.of(), "transfer-123"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("at least one original value required");
+ }
+
+ @Test
+ void nullTransferIdThrowsNullPointerException() {
+ assertThatThrownBy(() -> new VfpsPseudonymizeRequest("namespace", List.of("original"), null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("transferId is required");
+ }
+
+ @Test
+ void originalsListIsDefensivelyCopied() {
+ var mutableList = new java.util.ArrayList<>(List.of("original1"));
+ var request = new VfpsPseudonymizeRequest("namespace", mutableList, "transfer-123");
+
+ mutableList.add("original2");
+
+ assertThat(request.originals()).containsExactly("original1");
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponseTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponseTest.java
new file mode 100644
index 000000000..8ea20ea21
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/rest/dto/VfpsPseudonymizeResponseTest.java
@@ -0,0 +1,58 @@
+package care.smith.fts.tca.rest.dto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import care.smith.fts.tca.rest.dto.VfpsPseudonymizeResponse.PseudonymEntry;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class VfpsPseudonymizeResponseTest {
+
+ @Test
+ void validResponseWithPseudonyms() {
+ var entries =
+ List.of(
+ new PseudonymEntry("namespace", "original1", "pseudo1"),
+ new PseudonymEntry("namespace", "original2", "pseudo2"));
+ var response = new VfpsPseudonymizeResponse(entries);
+
+ assertThat(response.pseudonyms()).hasSize(2);
+ assertThat(response.pseudonyms().get(0).original()).isEqualTo("original1");
+ assertThat(response.pseudonyms().get(1).pseudonym()).isEqualTo("pseudo2");
+ }
+
+ @Test
+ void nullPseudonymsCreatesEmptyList() {
+ var response = new VfpsPseudonymizeResponse(null);
+
+ assertThat(response.pseudonyms()).isNotNull();
+ assertThat(response.pseudonyms()).isEmpty();
+ }
+
+ @Test
+ void emptyPseudonymsListIsAllowed() {
+ var response = new VfpsPseudonymizeResponse(List.of());
+
+ assertThat(response.pseudonyms()).isEmpty();
+ }
+
+ @Test
+ void pseudonymsListIsDefensivelyCopied() {
+ var mutableList =
+ new java.util.ArrayList<>(List.of(new PseudonymEntry("namespace", "original", "pseudo")));
+ var response = new VfpsPseudonymizeResponse(mutableList);
+
+ mutableList.add(new PseudonymEntry("namespace", "original2", "pseudo2"));
+
+ assertThat(response.pseudonyms()).hasSize(1);
+ }
+
+ @Test
+ void pseudonymEntryRecordStoresValues() {
+ var entry = new PseudonymEntry("test-namespace", "original-value", "pseudonym-value");
+
+ assertThat(entry.namespace()).isEqualTo("test-namespace");
+ assertThat(entry.original()).isEqualTo("original-value");
+ assertThat(entry.pseudonym()).isEqualTo("pseudonym-value");
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/services/BackendAdapterFactoryTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/services/BackendAdapterFactoryTest.java
new file mode 100644
index 000000000..7c3eb7173
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/services/BackendAdapterFactoryTest.java
@@ -0,0 +1,99 @@
+package care.smith.fts.tca.services;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.when;
+
+import care.smith.fts.tca.adapters.EnticiBackendAdapter;
+import care.smith.fts.tca.adapters.GpasBackendAdapter;
+import care.smith.fts.tca.adapters.VfpsBackendAdapter;
+import care.smith.fts.tca.config.BackendAdapterConfig;
+import care.smith.fts.tca.config.BackendAdapterConfig.BackendType;
+import care.smith.fts.tca.deidentification.EnticiClient;
+import care.smith.fts.tca.deidentification.GpasClient;
+import care.smith.fts.tca.deidentification.VfpsClient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class BackendAdapterFactoryTest {
+
+ @Mock private BackendAdapterConfig config;
+ @Mock private GpasClient gpasClient;
+ @Mock private VfpsClient vfpsClient;
+ @Mock private EnticiClient enticiClient;
+
+ private BackendAdapterFactory factory;
+
+ @BeforeEach
+ void setUp() {
+ factory = new BackendAdapterFactory(config, gpasClient, vfpsClient, enticiClient);
+ }
+
+ @Test
+ void createAdapterReturnsGpasAdapter() {
+ when(config.getType()).thenReturn(BackendType.GPAS);
+
+ var adapter = factory.createAdapter();
+
+ assertThat(adapter).isInstanceOf(GpasBackendAdapter.class);
+ assertThat(adapter.getBackendType()).isEqualTo("gpas");
+ }
+
+ @Test
+ void createAdapterReturnsVfpsAdapter() {
+ when(config.getType()).thenReturn(BackendType.VFPS);
+
+ var adapter = factory.createAdapter();
+
+ assertThat(adapter).isInstanceOf(VfpsBackendAdapter.class);
+ assertThat(adapter.getBackendType()).isEqualTo("vfps");
+ }
+
+ @Test
+ void createAdapterReturnsEnticiAdapter() {
+ when(config.getType()).thenReturn(BackendType.ENTICI);
+
+ var adapter = factory.createAdapter();
+
+ assertThat(adapter).isInstanceOf(EnticiBackendAdapter.class);
+ assertThat(adapter.getBackendType()).isEqualTo("entici");
+ }
+
+ @Test
+ void createAdapterThrowsForVfpsWhenClientNotConfigured() {
+ var factoryWithoutVfps = new BackendAdapterFactory(config, gpasClient, null, enticiClient);
+ when(config.getType()).thenReturn(BackendType.VFPS);
+
+ assertThatThrownBy(factoryWithoutVfps::createAdapter)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("VfpsClient is not configured");
+ }
+
+ @Test
+ void createAdapterThrowsForEnticiWhenClientNotConfigured() {
+ var factoryWithoutEntici = new BackendAdapterFactory(config, gpasClient, vfpsClient, null);
+ when(config.getType()).thenReturn(BackendType.ENTICI);
+
+ assertThatThrownBy(factoryWithoutEntici::createAdapter)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("EnticiClient is not configured");
+ }
+
+ @Test
+ void getConfiguredBackendTypeReturnsConfiguredType() {
+ when(config.getType()).thenReturn(BackendType.GPAS);
+
+ assertThat(factory.getConfiguredBackendType()).isEqualTo(BackendType.GPAS);
+ }
+
+ @Test
+ void getConfiguredBackendTypeReflectsConfigChanges() {
+ when(config.getType()).thenReturn(BackendType.VFPS);
+
+ assertThat(factory.getConfiguredBackendType()).isEqualTo(BackendType.VFPS);
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdMappingTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdMappingTest.java
new file mode 100644
index 000000000..ef8e0f5ec
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdMappingTest.java
@@ -0,0 +1,47 @@
+package care.smith.fts.tca.services;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.jupiter.api.Test;
+
+class TransportIdMappingTest {
+
+ @Test
+ void createValidMapping() {
+ var mapping = new TransportIdMapping("tId-123", "sId-456", "domain", "transfer-1");
+
+ assertThat(mapping.transportId()).isEqualTo("tId-123");
+ assertThat(mapping.securePseudonym()).isEqualTo("sId-456");
+ assertThat(mapping.domain()).isEqualTo("domain");
+ assertThat(mapping.transferId()).isEqualTo("transfer-1");
+ }
+
+ @Test
+ void nullTransportIdThrowsException() {
+ assertThatThrownBy(() -> new TransportIdMapping(null, "sId", "domain", "transfer"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("transportId is required");
+ }
+
+ @Test
+ void nullSecurePseudonymThrowsException() {
+ assertThatThrownBy(() -> new TransportIdMapping("tId", null, "domain", "transfer"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("securePseudonym is required");
+ }
+
+ @Test
+ void nullDomainThrowsException() {
+ assertThatThrownBy(() -> new TransportIdMapping("tId", "sId", null, "transfer"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("domain is required");
+ }
+
+ @Test
+ void nullTransferIdThrowsException() {
+ assertThatThrownBy(() -> new TransportIdMapping("tId", "sId", "domain", null))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessage("transferId is required");
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceIT.java b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceIT.java
new file mode 100644
index 000000000..82658642d
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceIT.java
@@ -0,0 +1,165 @@
+package care.smith.fts.tca.services;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
+import static reactor.test.StepVerifier.create;
+
+import care.smith.fts.tca.BaseIT;
+import care.smith.fts.test.TestWebClientFactory;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Set;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+
+/**
+ * Integration tests for TransportIdService.
+ *
+ *
Tests transport ID generation, Redis storage, and resolution functionality.
+ */
+@Slf4j
+@SpringBootTest(webEnvironment = RANDOM_PORT)
+@Import(TestWebClientFactory.class)
+class TransportIdServiceIT extends BaseIT {
+
+ @Autowired private TransportIdService transportIdService;
+ @Autowired private RedissonClient redisClient;
+
+ @BeforeEach
+ void setUp() {
+ // Clean up any existing test data
+ redisClient.getKeys().deleteByPattern("transport-mapping:*");
+ }
+
+ @Test
+ void generateTransportId_shouldReturnBase64UrlEncodedString() {
+ var tId = transportIdService.generateTransportId();
+
+ assertThat(tId)
+ .isNotNull()
+ .hasSize(32)
+ .matches(s -> s.matches("^[A-Za-z0-9_-]+$"), "should be Base64URL encoded");
+ }
+
+ @Test
+ void generateTransportId_shouldBeUnique() {
+ var id1 = transportIdService.generateTransportId();
+ var id2 = transportIdService.generateTransportId();
+ var id3 = transportIdService.generateTransportId();
+
+ assertThat(id1).isNotEqualTo(id2).isNotEqualTo(id3);
+ assertThat(id2).isNotEqualTo(id3);
+ }
+
+ @Test
+ void storeAndRetrieveMapping_shouldWork() {
+ var transferId = "test-transfer-123";
+ var tId = "test-transport-id-abc";
+ var sId = "secure-pseudonym-xyz";
+ var domain = "test-domain";
+
+ // Store mapping
+ var storeMono =
+ transportIdService.storeMapping(transferId, tId, sId, domain, Duration.ofMinutes(5));
+
+ create(storeMono).expectNext(tId).verifyComplete();
+
+ // Retrieve mapping
+ var retrieveMono = transportIdService.resolveMappings(transferId, Set.of(tId));
+
+ create(retrieveMono)
+ .assertNext(
+ mappings -> {
+ assertThat(mappings).containsEntry(tId, sId);
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void storeMultipleMappings_shouldWork() {
+ var transferId = "test-transfer-multi";
+ var mappings =
+ Map.of(
+ "tId-1", "sId-1",
+ "tId-2", "sId-2",
+ "tId-3", "sId-3");
+ var domain = "test-domain";
+
+ // Store all mappings
+ var storeMono =
+ transportIdService.storeMappings(transferId, mappings, domain, Duration.ofMinutes(5));
+
+ create(storeMono)
+ .assertNext(
+ stored -> {
+ assertThat(stored).containsExactlyInAnyOrderEntriesOf(mappings);
+ })
+ .verifyComplete();
+
+ // Retrieve all mappings
+ var retrieveMono = transportIdService.resolveMappings(transferId, mappings.keySet());
+
+ create(retrieveMono)
+ .assertNext(
+ resolved -> {
+ assertThat(resolved).containsExactlyInAnyOrderEntriesOf(mappings);
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolveMappings_withUnknownTransferId_shouldReturnEmpty() {
+ var retrieveMono =
+ transportIdService.resolveMappings("non-existent-transfer", Set.of("some-tid"));
+
+ create(retrieveMono).assertNext(mappings -> assertThat(mappings).isEmpty()).verifyComplete();
+ }
+
+ @Test
+ void resolveMappings_withPartialMatch_shouldReturnOnlyKnownMappings() {
+ var transferId = "test-transfer-partial";
+ var tId = "known-tid";
+ var sId = "known-sid";
+ var domain = "test-domain";
+
+ // Store one mapping
+ transportIdService.storeMapping(transferId, tId, sId, domain, Duration.ofMinutes(5)).block();
+
+ // Try to resolve known and unknown tIDs
+ var retrieveMono = transportIdService.resolveMappings(transferId, Set.of(tId, "unknown-tid"));
+
+ create(retrieveMono)
+ .assertNext(
+ mappings -> {
+ assertThat(mappings).hasSize(1).containsEntry(tId, sId);
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void storeDateShiftValue_shouldPersistAndRetrieve() {
+ var transferId = "test-transfer-dateshift";
+ var dateShiftMillis = 86400000L; // 1 day
+
+ // Store date shift value
+ var storeMono =
+ transportIdService.storeDateShiftValue(transferId, dateShiftMillis, Duration.ofMinutes(5));
+
+ create(storeMono).expectNext(dateShiftMillis).verifyComplete();
+
+ // Retrieve date shift value
+ var retrieveMono = transportIdService.getDateShiftValue(transferId);
+
+ create(retrieveMono)
+ .assertNext(
+ value -> {
+ assertThat(value).isEqualTo(dateShiftMillis);
+ })
+ .verifyComplete();
+ }
+}
diff --git a/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceTest.java b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceTest.java
new file mode 100644
index 000000000..8929bec25
--- /dev/null
+++ b/trust-center-agent/src/test/java/care/smith/fts/tca/services/TransportIdServiceTest.java
@@ -0,0 +1,216 @@
+package care.smith.fts.tca.services;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
+
+import care.smith.fts.tca.deidentification.configuration.TransportMappingConfiguration;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import java.time.Duration;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.BeforeEach;
+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.redisson.api.RMapCacheReactive;
+import org.redisson.api.RedissonClient;
+import org.redisson.api.RedissonReactiveClient;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+@ExtendWith(MockitoExtension.class)
+class TransportIdServiceTest {
+
+ @Mock private RedissonClient redisClient;
+ @Mock private RedissonReactiveClient reactiveClient;
+ @Mock private RMapCacheReactive mapCache;
+
+ private TransportIdService service;
+ private MeterRegistry meterRegistry;
+ private Duration defaultTtl;
+
+ @BeforeEach
+ void setUp() {
+ meterRegistry = new SimpleMeterRegistry();
+ defaultTtl = Duration.ofMinutes(10);
+
+ var config = new TransportMappingConfiguration();
+ config.setTtl(defaultTtl);
+
+ lenient().when(redisClient.reactive()).thenReturn(reactiveClient);
+ lenient().when(reactiveClient.getMapCache(anyString())).thenReturn(mapCache);
+
+ service = new TransportIdService(redisClient, config, meterRegistry);
+ }
+
+ @Test
+ void generateTransportIdReturns32CharBase64() {
+ var transportId = service.generateTransportId();
+
+ assertThat(transportId).hasSize(32);
+ assertThat(transportId).matches("[A-Za-z0-9_-]+");
+ }
+
+ @Test
+ void generateTransferIdReturns32CharBase64() {
+ var transferId = service.generateTransferId();
+
+ assertThat(transferId).hasSize(32);
+ assertThat(transferId).matches("[A-Za-z0-9_-]+");
+ }
+
+ @Test
+ void generatedIdsAreUnique() {
+ var id1 = service.generateTransportId();
+ var id2 = service.generateTransportId();
+ var id3 = service.generateTransferId();
+
+ assertThat(id1).isNotEqualTo(id2);
+ assertThat(id1).isNotEqualTo(id3);
+ assertThat(id2).isNotEqualTo(id3);
+ }
+
+ @Test
+ void getDefaultTtlReturnsConfiguredValue() {
+ assertThat(service.getDefaultTtl()).isEqualTo(defaultTtl);
+ }
+
+ @Test
+ void storeMappingStoresInRedis() {
+ when(mapCache.fastPut(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
+ .thenReturn(Mono.just(true));
+
+ var result = service.storeMapping("transfer-1", "tId-123", "sId-456", "domain", defaultTtl);
+
+ StepVerifier.create(result).expectNext("tId-123").verifyComplete();
+ }
+
+ @Test
+ void storeMappingsWithEmptyMapReturnsEmpty() {
+ var result = service.storeMappings("transfer-1", Map.of(), "domain", defaultTtl);
+
+ StepVerifier.create(result).expectNext(Map.of()).verifyComplete();
+ }
+
+ @Test
+ void storeMappingsStoresMultipleInRedis() {
+ when(mapCache.fastPut(anyString(), anyString(), anyLong(), any(TimeUnit.class)))
+ .thenReturn(Mono.just(true));
+
+ var mappings = Map.of("tId-1", "sId-1", "tId-2", "sId-2");
+ var result = service.storeMappings("transfer-1", mappings, "domain", defaultTtl);
+
+ StepVerifier.create(result)
+ .assertNext(
+ stored -> {
+ assertThat(stored).hasSize(2);
+ assertThat(stored).containsEntry("tId-1", "sId-1");
+ assertThat(stored).containsEntry("tId-2", "sId-2");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolveMappingsWithEmptySetReturnsEmpty() {
+ var result = service.resolveMappings("transfer-1", Set.of());
+
+ StepVerifier.create(result).expectNext(Map.of()).verifyComplete();
+ }
+
+ @Test
+ void resolveMappingsRetrievesFromRedis() {
+ when(mapCache.get("tId-1")).thenReturn(Mono.just("sId-1"));
+ when(mapCache.get("tId-2")).thenReturn(Mono.just("sId-2"));
+
+ var result = service.resolveMappings("transfer-1", Set.of("tId-1", "tId-2"));
+
+ StepVerifier.create(result)
+ .assertNext(
+ resolved -> {
+ assertThat(resolved).hasSize(2);
+ assertThat(resolved).containsEntry("tId-1", "sId-1");
+ assertThat(resolved).containsEntry("tId-2", "sId-2");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void resolveMappingsFiltersNotFound() {
+ when(mapCache.get("tId-1")).thenReturn(Mono.just("sId-1"));
+ when(mapCache.get("tId-missing")).thenReturn(Mono.empty());
+
+ var result = service.resolveMappings("transfer-1", Set.of("tId-1", "tId-missing"));
+
+ StepVerifier.create(result)
+ .assertNext(
+ resolved -> {
+ assertThat(resolved).hasSize(1);
+ assertThat(resolved).containsEntry("tId-1", "sId-1");
+ assertThat(resolved).doesNotContainKey("tId-missing");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void getAllMappingsRetrievesAllFromRedis() {
+ var allMappings = Map.of("tId-1", "sId-1", "tId-2", "sId-2", "_dateShiftMillis", "1000");
+ when(mapCache.readAllMap()).thenReturn(Mono.just(allMappings));
+
+ var result = service.getAllMappings("transfer-1");
+
+ StepVerifier.create(result)
+ .assertNext(
+ retrieved -> {
+ assertThat(retrieved).hasSize(2);
+ assertThat(retrieved).containsEntry("tId-1", "sId-1");
+ assertThat(retrieved).containsEntry("tId-2", "sId-2");
+ assertThat(retrieved).doesNotContainKey("_dateShiftMillis");
+ })
+ .verifyComplete();
+ }
+
+ @Test
+ void storeDateShiftValueStoresInRedis() {
+ when(mapCache.fastPut(eq("_dateShiftMillis"), eq("5000"), anyLong(), any(TimeUnit.class)))
+ .thenReturn(Mono.just(true));
+
+ var result = service.storeDateShiftValue("transfer-1", 5000L, defaultTtl);
+
+ StepVerifier.create(result).expectNext(5000L).verifyComplete();
+ }
+
+ @Test
+ void getDateShiftValueRetrievesFromRedis() {
+ when(mapCache.get("_dateShiftMillis")).thenReturn(Mono.just("3000"));
+
+ var result = service.getDateShiftValue("transfer-1");
+
+ StepVerifier.create(result).expectNext(3000L).verifyComplete();
+ }
+
+ @Test
+ void getDateShiftValueReturnsEmptyForNotFound() {
+ when(mapCache.get("_dateShiftMillis")).thenReturn(Mono.empty());
+
+ var result = service.getDateShiftValue("transfer-1");
+
+ StepVerifier.create(result).verifyComplete();
+ }
+
+ @Test
+ void getDateShiftValueReturnsEmptyForInvalidNumber() {
+ when(mapCache.get("_dateShiftMillis")).thenReturn(Mono.just("not-a-number"));
+
+ var result = service.getDateShiftValue("transfer-1");
+
+ StepVerifier.create(result).verifyComplete();
+ }
+}