diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json
index b15987671b..678d45ebe8 100644
--- a/descriptors/ModuleDescriptor-template.json
+++ b/descriptors/ModuleDescriptor-template.json
@@ -76,6 +76,24 @@
}
]
},
+ {
+ "id": "request-anonymization",
+ "version": "0.1",
+ "handlers": [
+ {
+ "methods": [
+ "POST"
+ ],
+ "pathPattern": "/request-anonymization/{requestId}",
+ "permissionsRequired": [
+ "circulation.requests.anonymize.item.post"
+ ],
+ "modulePermissions": [
+ "modperms.circulation.requests.anonymize.item"
+ ]
+ }
+ ]
+ },
{
"id": "request-move",
"version": "0.7",
@@ -1509,6 +1527,11 @@
"displayName": "circulation - anonymize loans",
"description": "anonymize loans"
},
+ {
+ "permissionName": "circulation.requests.anonymize.item.post",
+ "displayName": "circulation - anonymize request",
+ "description": "anonymize request"
+ },
{
"permissionName": "circulation.loans.item.delete",
"displayName": "circulation - delete individual loan",
@@ -2305,6 +2328,21 @@
],
"visible": false
},
+ {
+ "permissionName": "modperms.circulation.requests.anonymize.item",
+ "displayName": "module permissions for single request anonymization",
+ "description": "Permissions needed to anonymize a single closed request",
+ "subPermissions": [
+ "circulation.request-anonymization.item.post",
+ "circulation-storage.requests.item.get",
+ "inventory-storage.items.item.get",
+ "inventory-storage.holdings-records.item.get",
+ "inventory-storage.instances.item.get",
+ "service-points.item.get",
+ "pubsub.publish.post"
+ ],
+ "visible": false
+ },
{
"permissionName": "modperms.circulation.requests.collection.get",
"displayName": "module permissions for one op",
diff --git a/ramls/request-anonymization.raml b/ramls/request-anonymization.raml
new file mode 100644
index 0000000000..52ecd8c182
--- /dev/null
+++ b/ramls/request-anonymization.raml
@@ -0,0 +1,66 @@
+#%RAML 1.0
+title: Circulation
+version: v0.1
+protocols: [ HTTP, HTTPS ]
+baseUri: http://localhost:9130
+
+documentation:
+ - title: Request Anonymization API
+ content: Request Anonymization API
+
+types:
+ anonymize-request-response: !include schema/anonymize-single-request-response.json
+ errors: !include raml-util/schemas/errors.schema
+
+traits:
+ validate: !include raml-util/traits/validation.raml
+
+/request-anonymization:
+ /{requestId}:
+ uriParameters:
+ requestId:
+ type: string
+ description: Request UUID to anonymize
+ example: 9fd9b9d8-1b1a-4a7a-9f54-5e7c5f5e0b2e
+ pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
+ post:
+ is: [ validate ]
+ description: Anonymize a single closed request by its requestId.
+ responses:
+ 200:
+ description: The request has been successfully anonymized.
+ body:
+ application/json:
+ type: anonymize-request-response
+ example:
+ requestId: 9fd9b9d8-1b1a-4a7a-9f54-5e7c5f5e0b2e
+ anonymized: true
+
+ 404:
+ description: Request with the given requestId was not found.
+ body:
+ application/json:
+ type: errors
+ example:
+ errors:
+ - message: Request not found
+
+ 422:
+ description: Validation error — request is not closed or another rule prevents anonymization.
+ body:
+ application/json:
+ type: errors
+ example:
+ errors:
+ - message: requestNotClosed
+
+ 500:
+ description: Internal server error.
+ body:
+ application/json:
+ type: errors
+ example:
+ errors:
+ - message: Internal server error
+
+
diff --git a/ramls/schema/anonymize-single-request-response.json b/ramls/schema/anonymize-single-request-response.json
new file mode 100644
index 0000000000..ea8cdcda2d
--- /dev/null
+++ b/ramls/schema/anonymize-single-request-response.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "description": "Single Request Anonymization Response",
+ "type": "object",
+ "properties": {
+ "requestId": {
+ "type": "string",
+ "format": "UUID",
+ "description": "UUID of the request that was processed."
+ },
+ "anonymized": {
+ "type": "boolean",
+ "description": "True if the request is anonymized after this call (PII removed or already absent)."
+ }
+ },
+ "required": ["requestId", "anonymized"],
+ "additionalProperties": false
+}
diff --git a/src/main/java/org/folio/circulation/CirculationVerticle.java b/src/main/java/org/folio/circulation/CirculationVerticle.java
index f0606dbb8c..132a92a573 100644
--- a/src/main/java/org/folio/circulation/CirculationVerticle.java
+++ b/src/main/java/org/folio/circulation/CirculationVerticle.java
@@ -56,6 +56,7 @@
import org.folio.circulation.resources.foruseatlocation.PickupByBarcodeResource;
import org.folio.circulation.support.logging.LogHelper;
import org.folio.circulation.support.logging.Logging;
+import org.folio.circulation.resources.RequestAnonymizationResource;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Promise;
@@ -145,6 +146,7 @@ public void start(Promise startFuture) {
new ScheduledDigitalRemindersProcessingResource(client).register(router);
new DueDateNotRealTimeScheduledNoticeProcessingResource(client).register(router);
new RequestScheduledNoticeProcessingResource(client).register(router);
+ new RequestAnonymizationResource(client).register(router);
new FeeFineScheduledNoticeProcessingResource(client).register(router);
new FeeFineNotRealTimeScheduledNoticeProcessingResource(client).register(router);
new OverdueFineScheduledNoticeProcessingResource(client).register(router);
diff --git a/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java b/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java
index c78301c543..98b09179f7 100644
--- a/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java
+++ b/src/main/java/org/folio/circulation/domain/representations/logs/LogEventType.java
@@ -11,7 +11,8 @@ public enum LogEventType {
REQUEST_CREATED_THROUGH_OVERRIDE("REQUEST_CREATED_THROUGH_OVERRIDE_EVENT"),
REQUEST_UPDATED("REQUEST_UPDATED_EVENT"),
REQUEST_MOVED("REQUEST_MOVED_EVENT"),
- REQUEST_REORDERED("REQUEST_REORDERED_EVENT");
+ REQUEST_REORDERED("REQUEST_REORDERED_EVENT"),
+ REQUEST_ANONYMIZED("REQUEST_ANONYMIZED_EVENT");
private final String value;
diff --git a/src/main/java/org/folio/circulation/infrastructure/storage/requests/RequestRepository.java b/src/main/java/org/folio/circulation/infrastructure/storage/requests/RequestRepository.java
index 9d9a74edef..6d564b3c4a 100644
--- a/src/main/java/org/folio/circulation/infrastructure/storage/requests/RequestRepository.java
+++ b/src/main/java/org/folio/circulation/infrastructure/storage/requests/RequestRepository.java
@@ -20,7 +20,9 @@
import java.lang.invoke.MethodHandles;
import java.util.Collection;
+import java.util.UUID;
import java.util.concurrent.CompletableFuture;
+import java.util.function.DoubleUnaryOperator;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
diff --git a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
new file mode 100644
index 0000000000..c271886275
--- /dev/null
+++ b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
@@ -0,0 +1,43 @@
+package org.folio.circulation.resources;
+
+import io.vertx.core.json.JsonObject;
+import org.folio.circulation.services.EventPublisher;
+import org.folio.circulation.services.RequestAnonymizationService;
+import org.folio.circulation.support.Clients;
+import org.folio.circulation.support.RouteRegistration;
+import org.folio.circulation.support.http.server.JsonHttpResponse;
+import org.folio.circulation.support.http.server.WebContext;
+
+import io.vertx.core.http.HttpClient;
+import io.vertx.ext.web.Router;
+import io.vertx.ext.web.RoutingContext;
+
+public class RequestAnonymizationResource extends Resource {
+ public RequestAnonymizationResource(HttpClient client) {
+ super(client);
+ }
+
+ @Override
+ public void register(Router router) {
+ new RouteRegistration("/request-anonymization/:requestId", router)
+ .create(this::anonymizeRequest);
+ }
+
+ public void anonymizeRequest(RoutingContext routingContext) {
+ final WebContext context = new WebContext(routingContext);
+ final Clients clients = Clients.create(context, client);
+
+ final String requestId = routingContext.request().getParam("requestId");
+
+ final var eventPublisher = new EventPublisher(clients);
+ final var requestAnonymizationService = new RequestAnonymizationService(clients, eventPublisher);
+
+ requestAnonymizationService.anonymizeSingle(requestId)
+ .thenApply(r -> r.map(id ->
+ JsonHttpResponse.ok(new JsonObject()
+ .put("requestId", id)
+ .put("anonymized", true))
+ ))
+ .thenAccept(context::writeResultToHttpResponse);
+ }
+}
diff --git a/src/main/java/org/folio/circulation/services/EventPublisher.java b/src/main/java/org/folio/circulation/services/EventPublisher.java
index 551a01a677..ec3c546679 100644
--- a/src/main/java/org/folio/circulation/services/EventPublisher.java
+++ b/src/main/java/org/folio/circulation/services/EventPublisher.java
@@ -35,6 +35,8 @@
import static org.folio.circulation.support.utils.DateFormatUtil.formatDateTimeOptional;
import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import org.apache.logging.log4j.LogManager;
@@ -450,6 +452,26 @@ private CompletableFuture> publishDueDateChangedEvent(Loan loan, Us
return completedFuture(succeeded(null));
}
+ public CompletableFuture> publishRequestAnonymizedLog(Request req) {
+ final Item item = req.getItem();
+ final JsonObject linkToIds = new JsonObject()
+ .put("requestId", req.getId());
+ final JsonObject items = new JsonObject()
+ .put("itemBarcode", item != null ? item.getBarcode() : null)
+ .put("itemId", item != null ? item.getItemId() : null)
+ .put("instanceId", item != null ? item.getInstanceId(): req.getInstanceId())
+ .put("holdingsId", item != null ? item.getHoldingsRecordId() : req.getHoldingsRecordId());
+ final JsonObject context = new JsonObject()
+ .put("object", "Request")
+ .put("action", "anonymizeRequest")
+ .put("date", ZonedDateTime.now(ZoneOffset.UTC).toInstant().toString())
+ .put("userBarcode", "-")
+ .put("linkToIds", linkToIds)
+ .put("items", items);
+
+ return publishLogRecord(context, LogEventType.REQUEST_ANONYMIZED);
+ }
+
private String getLoanActionCommentLog(Loan loan) {
return format(ACTION_COMMENT_TEMPLATE, loan.getActionComment());
}
diff --git a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
new file mode 100644
index 0000000000..5541787b4b
--- /dev/null
+++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
@@ -0,0 +1,119 @@
+package org.folio.circulation.services;
+
+import static org.folio.circulation.support.results.Result.failed;
+import static org.folio.circulation.support.results.Result.succeeded;
+import static org.folio.circulation.support.results.ResultBinding.flatMapResult;
+import static org.folio.circulation.support.results.ResultBinding.mapResult;
+
+import java.util.EnumSet;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+import org.folio.circulation.domain.Request;
+import org.folio.circulation.domain.RequestFulfillmentPreference;
+import org.folio.circulation.domain.RequestStatus;
+import org.folio.circulation.support.Clients;
+import org.folio.circulation.support.ValidationErrorFailure;
+import org.folio.circulation.support.http.server.ValidationError;
+import org.folio.circulation.support.results.Result;
+import org.folio.circulation.infrastructure.storage.inventory.ItemRepository;
+import org.folio.circulation.infrastructure.storage.loans.LoanRepository;
+import org.folio.circulation.infrastructure.storage.users.UserRepository;
+import org.folio.circulation.infrastructure.storage.requests.RequestRepository;
+import org.folio.util.UuidUtil;
+import io.vertx.core.json.JsonObject;
+
+public class RequestAnonymizationService {
+ private static final Set ALLOWED_STATUSES = EnumSet.of(
+ RequestStatus.CLOSED_FILLED,
+ RequestStatus.CLOSED_CANCELLED,
+ RequestStatus.CLOSED_PICKUP_EXPIRED,
+ RequestStatus.CLOSED_UNFILLED
+ );
+
+ private static final Set OPEN_STATUSES = EnumSet.of(
+ RequestStatus.OPEN_AWAITING_PICKUP,
+ RequestStatus.OPEN_IN_TRANSIT,
+ RequestStatus.OPEN_NOT_YET_FILLED
+ );
+
+ private final RequestRepository requestRepository;
+ private final EventPublisher eventPublisher;
+
+ public RequestAnonymizationService(Clients clients, EventPublisher eventPublisher) {
+ ItemRepository itemRepository = new ItemRepository(clients);
+ UserRepository userRepository = new UserRepository(clients);
+ LoanRepository loanRepository = new LoanRepository(clients, itemRepository, userRepository);
+
+ this.requestRepository = RequestRepository.using(clients, itemRepository, userRepository, loanRepository);
+
+ this.eventPublisher = eventPublisher;
+ }
+
+ public RequestAnonymizationService(RequestRepository requestRepository,
+ EventPublisher eventPublisher) {
+
+ this.requestRepository = requestRepository;
+ this.eventPublisher = eventPublisher;
+ }
+
+ public CompletableFuture> anonymizeSingle(String requestId) {
+ if (!UuidUtil.isUuid(requestId)) {
+ return CompletableFuture.completedFuture(
+ ValidationErrorFailure.failedValidation("invalidRequestId", "requestId", requestId)
+ );
+ }
+
+ return fetchRequest(requestId)
+ .thenApply(flatMapResult(req -> validateStatus(req, requestId)))
+ .thenApply(mapResult(this::scrubPii))
+ .thenCompose(r -> r.after(requestRepository::update))
+ .thenCompose(r -> r.after(this::publishLog))
+ .thenApply(mapResult(updated -> requestId));
+ }
+
+ private CompletableFuture> fetchRequest(String requestId) {
+ return requestRepository.getById(requestId);
+ }
+
+ private Result validateStatus(Request request, String id) {
+ final RequestStatus status = request.getStatus();
+
+ if (ALLOWED_STATUSES.contains(status)) {
+ return succeeded(request);
+ }
+ if (OPEN_STATUSES.contains(status)) {
+ return failed(new ValidationErrorFailure((new ValidationError("requestNotClosed", "requestId", id))));
+ }
+ return failed(new ValidationErrorFailure((new ValidationError("requestNotEligibleForAnonymization", "requestId", id))));
+ }
+
+ private Request scrubPii(Request req) {
+ final JsonObject rep = req.asJson();
+
+ final boolean hadRequester = rep.containsKey("requester") || rep.containsKey("requesterId");
+ final boolean hadProxy = rep.containsKey("proxy") || rep.containsKey("proxyUserId");
+ final boolean isDelivery = req.getfulfillmentPreference() == RequestFulfillmentPreference.DELIVERY;
+ final boolean hadDelivery = isDelivery && (rep.containsKey("deliveryAddress") || rep.containsKey("deliveryAddressTypeId"));
+
+ if (!hadRequester && !hadProxy && (!isDelivery || !hadDelivery)) {
+ return req;
+ }
+
+ rep.putNull("requesterId");
+ rep.putNull("proxyUserId");
+ rep.remove("requester");
+ rep.remove("proxy");
+
+ if (isDelivery) {
+ rep.remove("deliveryAddress");
+ rep.remove("deliveryAddressTypeId");
+ }
+
+ return Request.from(rep);
+ }
+
+ private CompletableFuture> publishLog(Request req) {
+ return eventPublisher.publishRequestAnonymizedLog(req);
+ }
+}
diff --git a/src/test/java/api/requests/RequestAnonymizationApiTests.java b/src/test/java/api/requests/RequestAnonymizationApiTests.java
new file mode 100644
index 0000000000..5acc8c08cd
--- /dev/null
+++ b/src/test/java/api/requests/RequestAnonymizationApiTests.java
@@ -0,0 +1,62 @@
+package api.requests;
+
+import static api.support.http.InterfaceUrls.circulationRequestAnonymizationUrl;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.util.UUID;
+
+import org.folio.circulation.support.http.client.Response;
+import org.junit.jupiter.api.Test;
+
+import api.support.APITests;
+import api.support.builders.RequestBuilder;
+import api.support.http.IndividualResource;
+import io.vertx.core.json.JsonObject;
+
+class RequestAnonymizationApiTests extends APITests {
+
+ @Test
+ void anonymizeSingleRequestRemovesPIIAndReturns200() {
+ String requestId = createClosedDeliveryRequest();
+
+ Response response = restAssuredClient.post(
+ circulationRequestAnonymizationUrl(requestId),
+ "anonymize-request-" + requestId
+ );
+
+ assertEquals(200, response.getStatusCode());
+
+ Response updated =
+ requestsStorageClient.getById(UUID.fromString(requestId));
+ JsonObject stored = updated.getJson();
+
+ assertTrue(stored.containsKey("requesterId"));
+ assertNull(stored.getValue("requesterId"));
+ assertTrue(stored.containsKey("proxyUserId"));
+ assertNull(stored.getValue("proxyUserId"));
+
+ assertFalse(stored.containsKey("requester"));
+ assertFalse(stored.containsKey("proxy"));
+ assertFalse(stored.containsKey("deliveryAddressTypeId"));
+ }
+
+ private String createClosedDeliveryRequest() {
+ var user = usersFixture.james();
+ var item = itemsFixture.basedUponSmallAngryPlanet();
+
+ IndividualResource request = requestsStorageClient.create(
+ new RequestBuilder()
+ .forItem(item)
+ .by(user)
+ .fulfilled()
+ .withFulfillmentPreference("Delivery")
+ .withDeliveryAddressType(UUID.randomUUID())
+ .create()
+ );
+
+ return request.getId().toString();
+ }
+}
diff --git a/src/test/java/api/requests/RequestAnonymizationResourceTests.java b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
new file mode 100644
index 0000000000..5a7f5bcc54
--- /dev/null
+++ b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
@@ -0,0 +1,40 @@
+package api.requests;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import org.folio.circulation.resources.RequestAnonymizationResource;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import api.support.APITests;
+import io.vertx.core.http.HttpClient;
+import io.vertx.core.http.impl.HttpClientInternal;
+import io.vertx.ext.web.Route;
+import io.vertx.ext.web.Router;
+
+@Nested
+class RequestAnonymizationResourceTests extends APITests {
+
+ @Test
+ void registerDoesNotThrowAndRegistersRoute() {
+ HttpClientInternal internalClient = mock(HttpClientInternal.class);
+ HttpClient httpClient = internalClient;
+
+ RequestAnonymizationResource resource = new RequestAnonymizationResource(httpClient);
+
+ Router router = mock(Router.class);
+ Route route = mock(Route.class);
+
+ when(router.post(anyString())).thenReturn(route);
+ when(route.handler(any())).thenReturn(route);
+
+ assertDoesNotThrow(() -> resource.register(router));
+
+ verify(router).post("/request-anonymization/:requestId");
+ }
+}
diff --git a/src/test/java/api/requests/RequestAnonymizationServiceTest.java b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
new file mode 100644
index 0000000000..c5ab219727
--- /dev/null
+++ b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
@@ -0,0 +1,166 @@
+package api.requests;
+
+import static java.util.concurrent.CompletableFuture.completedFuture;
+import static org.folio.circulation.support.results.Result.failed;
+import static org.junit.Assert.assertNull;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.UUID;
+
+import org.folio.circulation.domain.*;
+import org.folio.circulation.infrastructure.storage.requests.RequestRepository;
+import org.folio.circulation.services.EventPublisher;
+import org.folio.circulation.services.RequestAnonymizationService;
+import org.folio.circulation.support.Clients;
+import org.folio.circulation.support.RecordNotFoundFailure;
+import org.folio.circulation.support.results.Result;
+import org.junit.Assert;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import api.support.APITests;
+import io.vertx.core.json.JsonObject;
+
+
+class RequestAnonymizationServiceTest extends APITests {
+
+ private Clients clients;
+ private RequestRepository requestRepository;
+ private EventPublisher eventPublisher;
+ private RequestAnonymizationService service;
+
+ @BeforeEach
+ void setUp() {
+ clients = mock(Clients.class);
+ requestRepository = mock(RequestRepository.class);
+ eventPublisher = mock(EventPublisher.class);
+
+ when(clients.requestsStorage()).thenReturn(null);
+
+ service = new RequestAnonymizationService(requestRepository, eventPublisher);
+ }
+
+ private static Request closedFilledRequest(String id) {
+ JsonObject json = new JsonObject()
+ .put("id", id)
+ .put("status", RequestStatus.CLOSED_FILLED.getValue())
+ .put("requesterId", "r1")
+ .put("proxyUserId", "p1")
+ .put("requester", new io.vertx.core.json.JsonObject().put("barcode", "rb"))
+ .put("proxy", new io.vertx.core.json.JsonObject().put("barcode", "pb"))
+ .put("fulfillmentPreference", RequestFulfillmentPreference.HOLD_SHELF.getValue());
+ return Request.from(json);
+ }
+
+ private static Request closedDeliveryRequest(String id) {
+ var json = new io.vertx.core.json.JsonObject()
+ .put("id", id)
+ .put("status", RequestStatus.CLOSED_CANCELLED.getValue())
+ .put("requesterId", "r1")
+ .put("proxyUserId", "p1")
+ .put("requester", new io.vertx.core.json.JsonObject())
+ .put("proxy", new io.vertx.core.json.JsonObject())
+ .put("fulfillmentPreference", RequestFulfillmentPreference.DELIVERY.getValue())
+ .put("deliveryAddress", new io.vertx.core.json.JsonObject().put("x", 1))
+ .put("deliveryAddressTypeId", "addrType");
+ return Request.from(json);
+ }
+
+ @Test
+ void anonymizeSingleHappyPathRemovesPIIUpdatesRepoAndLogs() {
+ String id = UUID.randomUUID().toString();
+ var req = closedFilledRequest(id);
+
+ when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(req)));
+ when(requestRepository.update(org.mockito.ArgumentMatchers.any()))
+ .thenAnswer(inv -> completedFuture(Result.succeeded(inv.getArgument(0))));
+ when(eventPublisher.publishRequestAnonymizedLog(org.mockito.ArgumentMatchers.any()))
+ .thenReturn(completedFuture(Result.succeeded(null)));
+
+ var out = service.anonymizeSingle(id).join();
+
+ assertTrue(out.succeeded());
+ assertEquals(id, out.value());
+
+ ArgumentCaptor updatedCap = ArgumentCaptor.forClass(Request.class);
+ verify(requestRepository).update(updatedCap.capture());
+ var updatedJson = updatedCap.getValue().asJson();
+ Assert.assertTrue(updatedJson.containsKey("requesterId"));
+ assertNull(updatedJson.getValue("requesterId"));
+ Assert.assertTrue(updatedJson.containsKey("proxyUserId"));
+ assertNull(updatedJson.getValue("proxyUserId"));
+ assertFalse(updatedJson.containsKey("requester"));
+ assertFalse(updatedJson.containsKey("proxy"));
+
+ verify(eventPublisher).publishRequestAnonymizedLog(any());
+ }
+
+ @Test
+ void anonymizeSingleDeliveryRemovesDeliveryFields() {
+ String id = UUID.randomUUID().toString();
+ var req = closedDeliveryRequest(id);
+
+ when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(req)));
+ when(requestRepository.update((Request) any()))
+ .thenAnswer(inv -> completedFuture(Result.succeeded(inv.getArgument(0))));
+ when(eventPublisher.publishRequestAnonymizedLog(any()))
+ .thenReturn(completedFuture(Result.succeeded(null)));
+
+ var out = service.anonymizeSingle(id).join();
+ assertTrue(out.succeeded());
+
+ ArgumentCaptor updatedCap = ArgumentCaptor.forClass(Request.class);
+ verify(requestRepository).update(updatedCap.capture());
+ var updatedJson = updatedCap.getValue().asJson();
+ assertFalse(updatedJson.containsKey("deliveryAddress"));
+ assertFalse(updatedJson.containsKey("deliveryAddressTypeId"));
+ }
+
+ @Test
+ void anonymizeSingleReturns404WhenRequestNotFound() {
+ String id = UUID.randomUUID().toString();
+ when(requestRepository.getById(id))
+ .thenReturn(completedFuture(
+ failed(new RecordNotFoundFailure("Request", id))
+ ));
+
+ var out = service.anonymizeSingle(id).join();
+
+ assertFalse(out.succeeded());
+ assertTrue(out.cause().toString().contains("cannot be found"));
+ verify(requestRepository, never()).update((Request) any());
+ verify(eventPublisher, never()).publishRequestAnonymizedLog(any());
+ }
+
+ @Test
+void anonymizeSingleReturns422WhenRequestIsOpen() {
+ String id = UUID.randomUUID().toString();
+ var json = new io.vertx.core.json.JsonObject()
+ .put("id", id)
+ .put("status", RequestStatus.OPEN_NOT_YET_FILLED.getValue());
+ var req = Request.from(json);
+
+ when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(req)));
+
+ var out = service.anonymizeSingle(id).join();
+
+ assertFalse(out.succeeded());
+ assertTrue(out.cause().toString().contains("requestNotClosed"));
+ verify(requestRepository, never()).update((Request) any());
+ verify(eventPublisher, never()).publishRequestAnonymizedLog(any());
+ }
+
+ @Test
+ void anonymizeSingleReturns422WhenIdIsNotUuid() {
+ var out = service.anonymizeSingle("not-a-uuid").join();
+ assertFalse(out.succeeded());
+ assertTrue(out.cause().toString().contains("invalidRequestId"));
+ }
+}
diff --git a/src/test/java/api/support/http/InterfaceUrls.java b/src/test/java/api/support/http/InterfaceUrls.java
index 5abfcb039f..9b9b7deb20 100644
--- a/src/test/java/api/support/http/InterfaceUrls.java
+++ b/src/test/java/api/support/http/InterfaceUrls.java
@@ -361,4 +361,9 @@ public static URL circulationSettingsUrl(String subPath) {
public static URL printEventsUrl(String subPath) {
return circulationModuleUrl("/circulation" + subPath);
}
+
+ public static URL circulationRequestAnonymizationUrl(String requestId) {
+ return circulationModuleUrl("/request-anonymization/" + requestId);
+ }
+
}
diff --git a/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java b/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java
new file mode 100644
index 0000000000..ecd886e46f
--- /dev/null
+++ b/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java
@@ -0,0 +1,99 @@
+package org.folio.circulation.domain.anonymization;
+
+import io.vertx.core.json.JsonObject;
+import org.folio.circulation.domain.Request;
+import org.folio.circulation.domain.RequestStatus;
+import org.folio.circulation.domain.RequestFulfillmentPreference;
+import org.folio.circulation.infrastructure.storage.requests.RequestRepository;
+import org.folio.circulation.services.RequestAnonymizationService;
+import org.folio.circulation.services.EventPublisher;
+import org.folio.circulation.support.Clients;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.invocation.InvocationOnMock;
+
+import java.util.UUID;
+
+import static java.util.concurrent.CompletableFuture.completedFuture;
+import static org.folio.circulation.support.results.Result.succeeded;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.assertFalse;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AnonymizeRequestTests {
+
+ @Mock
+ RequestRepository requestRepository;
+
+ @Mock
+ Clients client;
+
+ @Mock
+ EventPublisher eventPublisher;
+
+ RequestAnonymizationService service;
+
+ @BeforeEach
+ void setUp() {
+ service = new RequestAnonymizationService(requestRepository, eventPublisher);
+ }
+
+ @Test
+ void anonymizeSingleHappyPathRemovesPIIUpdatesRepoAndLogs() {
+ // given a closed request with PII
+ String id = UUID.randomUUID().toString();
+
+ JsonObject json = new JsonObject()
+ .put("id", id)
+ .put("status", RequestStatus.CLOSED_FILLED.getValue())
+ .put("fulfillmentPreference",
+ RequestFulfillmentPreference.DELIVERY.getValue())
+ .put("requesterId", "r-1")
+ .put("proxyUserId", "p-1")
+ .put("requester", new JsonObject().put("barcode", "rb"))
+ .put("proxy", new JsonObject().put("barcode", "pb"))
+ .put("deliveryAddressTypeId", "addr-type");
+
+ Request request = Request.from(json);
+
+ when(requestRepository.getById(id))
+ .thenReturn(completedFuture(succeeded(request)));
+
+ when(requestRepository.update(any(Request.class)))
+ .thenAnswer((InvocationOnMock invocation) ->
+ completedFuture(succeeded(invocation.getArgument(0)))
+ );
+
+ when(eventPublisher.publishRequestAnonymizedLog(any(Request.class)))
+ .thenReturn(completedFuture(succeeded(null)));
+
+ var result = service.anonymizeSingle(id).join();
+
+ assertTrue(result.succeeded());
+ assertThat(result.value(), is(id));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Request.class);
+ verify(requestRepository).update(captor.capture());
+
+ JsonObject stored = captor.getValue().asJson();
+
+ assertTrue(stored.containsKey("requesterId"));
+ assertNull(stored.getValue("requesterId"));
+ assertTrue(stored.containsKey("proxyUserId"));
+ assertNull(stored.getValue("proxyUserId"));
+ assertFalse(stored.containsKey("requester"));
+ assertFalse(stored.containsKey("proxy"));
+ assertFalse(stored.containsKey("deliveryAddressTypeId"));
+ }
+}