From 1e0271783d26dda56ae350ac55f6b1e93d077de7 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Wed, 12 Nov 2025 13:58:24 -0500
Subject: [PATCH 01/15] Purpose/Overview: Create a
/request-anonymization/{request UUID} Post API for anonymizing one a single
closed request
Requirements/Scope:
Verify that the user has the UI-Requests Request-Anonymize Single Execute capability
Anonymizes request as described in https://folio-org.atlassian.net/browse/CIRC-2292
Returns an error message if anonymization fails
Acceptance Criteria:
Post API for single request anonymization by UUID is created
---
descriptors/ModuleDescriptor-template.json | 15 ++
ramls/request-anonymization.raml | 85 +++++++++
ramls/schema/anonymize-requests-response.json | 11 ++
.../representations/logs/LogEventType.java | 3 +-
.../storage/requests/RequestRepository.java | 6 +-
.../RequestAnonymizationResource.java | 62 +++++++
.../circulation/services/EventPublisher.java | 28 +++
.../services/RequestAnonymizationService.java | 120 +++++++++++++
.../support/ValidationErrorFailure.java | 5 +
.../RequestAnonymizationServiceTest.java | 164 ++++++++++++++++++
10 files changed, 497 insertions(+), 2 deletions(-)
create mode 100644 ramls/request-anonymization.raml
create mode 100644 ramls/schema/anonymize-requests-response.json
create mode 100644 src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
create mode 100644 src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
create mode 100644 src/test/java/api/requests/RequestAnonymizationServiceTest.java
diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json
index 01d4588aa4..b84dcf5a00 100644
--- a/descriptors/ModuleDescriptor-template.json
+++ b/descriptors/ModuleDescriptor-template.json
@@ -76,6 +76,21 @@
}
]
},
+ {
+ "id": "request-anonymization",
+ "version": "0.1",
+ "handlers": [
+ {
+ "methods": [
+ "POST"
+ ],
+ "pathPattern": "/request-anonymization/{requestId}",
+ "modulePermissions": [
+ "pubsub.publish.post"
+ ]
+ }
+ ]
+ },
{
"id": "request-move",
"version": "0.7",
diff --git a/ramls/request-anonymization.raml b/ramls/request-anonymization.raml
new file mode 100644
index 0000000000..bb86cc9ec1
--- /dev/null
+++ b/ramls/request-anonymization.raml
@@ -0,0 +1,85 @@
+#%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-requests-response: !include schema/anonymize-requests-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-]{36}$"
+ post:
+ is: [validate]
+ description: "Note that a 422 error with haveAssociatedFeesAndFines message and key requestIds has a value that is not a JSON array but a JSON string that contains a serialized JSON array of the request ids."
+ responses:
+ 200:
+ description: "Chosen requests have been successfully anonymized"
+ body:
+ application/json:
+ type: anonymize-requests-response
+ 403:
+ description: Missing required capability to anonymize requests (e.g. ui-requests.request-anonymize-single.execute) or insufficient permissions.
+ body:
+ application/json:
+ type: errors
+ example:
+ errors:
+ - message: "Permission denied: missing execute capability"
+ parameters:
+ - key: "permissionRequired"
+ value: "ui-requests.request-anonymize-single.execute"
+
+ 404:
+ description: Request with the given requestId was not found.
+ body:
+ application/json:
+ type: errors
+ example:
+ errors:
+ - message: "Request not found"
+ parameters:
+ - key: "requestId"
+ value: "00000000-0000-0000-0000-000000000000"
+
+ 422:
+ description: Validation error — request is not closed or other business-rule violation prevents anonymization.
+ body:
+ application/json:
+ type: errors
+ example:
+ errors:
+ - message: "requestNotClosed"
+ parameters:
+ - key: "requestId"
+ value: "00000000-0000-0000-0000-000000000000"
+
+ 500:
+ description: Internal server error.
+ body:
+ application/json:
+ type: errors
+ example:
+ errors:
+ - message: "Internal server error"
+
+
+
+
+
+
diff --git a/ramls/schema/anonymize-requests-response.json b/ramls/schema/anonymize-requests-response.json
new file mode 100644
index 0000000000..a2580d8e17
--- /dev/null
+++ b/ramls/schema/anonymize-requests-response.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "description": "Response for single requests anonymization ",
+ "type": "object",
+ "properties": {
+ "requestId": { "type": "string" },
+ "anonymized": { "type": "boolean" }
+ },
+ "required": ["requestId", "anonymized"],
+ "additionalProperties": false
+}
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..3017c8f729 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;
@@ -361,7 +363,9 @@ private SingleRecordFetcher createSingleRequestFetcher(
return new SingleRecordFetcher<>(requestsStorageClient, "request", interpreter);
}
- @AllArgsConstructor
+
+
+ @AllArgsConstructor
@Getter
private static class Clients {
private final CollectionResourceClient requestsStorageClient;
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..32c12b294e
--- /dev/null
+++ b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
@@ -0,0 +1,62 @@
+package org.folio.circulation.resources;
+
+import java.lang.invoke.MethodHandles;
+
+import io.vertx.core.json.JsonObject;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.folio.circulation.domain.anonymization.DefaultLoanAnonymizationService;
+import org.folio.circulation.domain.anonymization.service.AnonymizationCheckersService;
+import org.folio.circulation.domain.anonymization.service.LoansForBorrowerFinder;
+import org.folio.circulation.domain.representations.anonymization.AnonymizeLoansRepresentation;
+import org.folio.circulation.infrastructure.storage.feesandfines.AccountRepository;
+import org.folio.circulation.infrastructure.storage.inventory.ItemRepository;
+import org.folio.circulation.infrastructure.storage.loans.AnonymizeStorageLoansRepository;
+import org.folio.circulation.infrastructure.storage.loans.LoanRepository;
+import org.folio.circulation.infrastructure.storage.users.UserRepository;
+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 {
+ private final Logger log = LogManager.getLogger(MethodHandles.lookup().lookupClass());
+
+
+ public RequestAnonymizationResource(HttpClient client) {
+ super((io.vertx.core.http.HttpClient) client);
+ }
+
+ @Override
+ public void register(Router router) {
+ final RouteRegistration rr =
+ new RouteRegistration("/request-anonymization/:requestId", router);
+ rr.create(this::anonymizeRequest);
+ }
+
+ private 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);
+
+ log.info("anonymizeRequest:: requestId={}, user={}");
+
+ requestAnonymizationService.anonymizeSingle(requestId, context.getUserId())
+ .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..55b0b845f4 100644
--- a/src/main/java/org/folio/circulation/services/EventPublisher.java
+++ b/src/main/java/org/folio/circulation/services/EventPublisher.java
@@ -47,6 +47,7 @@
import org.folio.circulation.domain.representations.logs.LogContextActionResolver;
import org.folio.circulation.domain.representations.logs.LogEventType;
import org.folio.circulation.domain.representations.logs.NoticeLogContext;
+import org.folio.circulation.domain.representations.logs.LogEventType;
import org.folio.circulation.infrastructure.storage.SettingsRepository;
import org.folio.circulation.infrastructure.storage.loans.LoanRepository;
import org.folio.circulation.infrastructure.storage.users.UserRepository;
@@ -450,6 +451,33 @@ private CompletableFuture> publishDueDateChangedEvent(Loan loan, Us
return completedFuture(succeeded(null));
}
+ public CompletableFuture> publishRequestAnonymizedLog(Request req) {
+ // Build the circulation-log payload for a Request action
+ 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", getZonedDateTime())
+ .put("userBarcode", "-")
+ .put("linkToIds", linkToIds)
+ .put("items", items);
+
+
+ final LogEventType type = LogEventType.REQUEST_ANONYMIZED;
+
+ 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..2e8ea19f5d
--- /dev/null
+++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
@@ -0,0 +1,120 @@
+package org.folio.circulation.services;
+
+import static java.lang.invoke.MethodHandles.lookup;
+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.UUID;
+import java.util.concurrent.CompletableFuture;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.folio.circulation.domain.Request;
+import org.folio.circulation.domain.RequestFulfillmentPreference;
+import org.folio.circulation.domain.RequestStatus;
+import org.folio.circulation.infrastructure.storage.requests.RequestRepository;
+import org.folio.circulation.support.Clients;
+import org.folio.circulation.support.RecordNotFoundFailure;
+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.services.EventPublisher;
+
+import io.vertx.core.json.JsonObject;
+
+public class RequestAnonymizationService {
+ private static final Logger log = LogManager.getLogger(lookup().lookupClass());
+
+ 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) {
+ this.requestRepository = new RequestRepository(clients);
+ this.eventPublisher = eventPublisher;
+ }
+
+ public CompletableFuture> anonymizeSingle(String requestId, String performedByUserId) {
+ final UUID id;
+ try {
+ id = UUID.fromString(requestId);
+ } catch (IllegalArgumentException e) {
+ return CompletableFuture.completedFuture(
+ failed(new ValidationErrorFailure(new ValidationError("invalidRequestId", "requestId", requestId)))
+ );
+ }
+
+ return fetchRequest(id)
+ .thenApply(flatMapResult(req -> validateStatus(req, requestId))) // 404 / 422 mapping
+ .thenApply(flatMapResult(this::scrubPii)) // apply 2292 field clearing
+ .thenCompose(r -> r.after(requestRepository::update)) // persist
+ .thenCompose(r -> r.after(updated -> publishLog(updated, performedByUserId))) // audit/event
+ .thenApply(mapResult(updated -> requestId)); // success payload = id
+ }
+
+ private CompletableFuture> fetchRequest(UUID id) {
+ return requestRepository.getById(id.toString())
+ .thenApply(res -> res.failWhen(
+ r -> succeeded(r == null),
+ r -> new RecordNotFoundFailure("Request", id.toString())
+ ));
+ }
+
+ 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 Result scrubPii(Request req) {
+ final JsonObject rep = req.asJson().copy();
+
+ 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 succeeded(req);
+ }
+
+ rep.remove("requesterId");
+ rep.remove("proxyUserId");
+ rep.remove("requester");
+ rep.remove("proxy");
+
+ if (req.getfulfillmentPreference() == RequestFulfillmentPreference.DELIVERY) {
+ rep.remove("deliveryAddress");
+ rep.remove("deliveryAddressTypeId");
+ }
+
+ return succeeded(Request.from(rep));
+ }
+
+ private CompletableFuture> publishLog(Request req, String performedByUserId) {
+ return eventPublisher.publishRequestAnonymizedLog(req)
+ .thenApply(r -> r.map(v -> req));
+ }
+}
diff --git a/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java b/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java
index 4429d29078..3b4c5adeae 100644
--- a/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java
+++ b/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java
@@ -24,6 +24,11 @@ public class ValidationErrorFailure implements HttpFailure {
private final Collection errors = new ArrayList<>();
+ public static Result ValidationErrorFailure(String code, String paramKey, String paramValue) {
+ return failedValidation(new ValidationError(code, paramKey, paramValue));
+ }
+
+
public static Result failedValidation(String reason, String key, String value) {
return failedValidation(new ValidationError(reason, key, value));
}
diff --git a/src/test/java/api/requests/RequestAnonymizationServiceTest.java b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
new file mode 100644
index 0000000000..67d79d2fca
--- /dev/null
+++ b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
@@ -0,0 +1,164 @@
+package api.requests;
+
+import static java.util.concurrent.CompletableFuture.completedFuture;
+import static org.mockito.ArgumentMatchers.any;
+//import static org.junit.Assert.assertEquals;
+//import static org.junit.Assert.assertFalse;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.*;
+
+import java.util.*;
+
+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.results.Result;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import api.support.APITests;
+
+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(clients, eventPublisher) {
+ {
+ try {
+ var f = RequestAnonymizationService.class.getDeclaredField("requestRepository");
+ f.setAccessible(true);
+ f.set(this, requestRepository);
+ } catch (Exception e) { throw new RuntimeException(e); }
+ }
+ };
+ }
+
+ private static Request closedFilledRequest(String id) {
+ var json = new io.vertx.core.json.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 anonymizeSingle_happyPath_removesPII_updates_repo_and_logs() {
+ 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, "user-123").join();
+
+ assertTrue(out.succeeded());
+ assertEquals(id, out.value());
+
+ ArgumentCaptor updatedCap = ArgumentCaptor.forClass(Request.class);
+ verify(requestRepository).update(updatedCap.capture());
+ var updatedJson = updatedCap.getValue().asJson();
+ assertFalse(updatedJson.containsKey("requesterId"));
+ assertFalse(updatedJson.containsKey("proxyUserId"));
+ assertFalse(updatedJson.containsKey("requester"));
+ assertFalse(updatedJson.containsKey("proxy"));
+
+ verify(eventPublisher).publishRequestAnonymizedLog(any());
+ }
+
+
+
+ @Test
+ void anonymizeSingle_delivery_removesDeliveryFields() {
+ 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, "user-123").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 anonymizeSingle_returns404_whenRequestNotFound() {
+ String id = UUID.randomUUID().toString();
+
+ when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(null)));
+
+ var out = service.anonymizeSingle(id, "user-123").join();
+
+ assertFalse(out.succeeded());
+ assertTrue(out.cause().toString().contains("cannot be found")); // RecordNotFoundFailure message
+ verify(requestRepository, never()).update((Request) any());
+ verify(eventPublisher, never()).publishRequestAnonymizedLog(any());
+ }
+
+ @Test
+void anonymizeSingle_returns422_whenRequestIsOpen() {
+ 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, "user-123").join();
+
+ assertFalse(out.succeeded());
+ assertTrue(out.cause().toString().contains("requestNotClosed"));
+ verify(requestRepository, never()).update((Request) any());
+ verify(eventPublisher, never()).publishRequestAnonymizedLog(any());
+ }
+
+ @Test
+ void anonymizeSingle_returns422_whenIdIsNotUuid() {
+ var out = service.anonymizeSingle("not-a-uuid", "user-123").join();
+ assertFalse(out.succeeded());
+ assertTrue(out.cause().toString().contains("invalidRequestId"));
+ }
+}
From 424fb3edd9592c485fed161932c1fe993a144be7 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Wed, 12 Nov 2025 14:35:45 -0500
Subject: [PATCH 02/15] clean the request-anonymization.raml
---
ramls/request-anonymization.raml | 111 ++++++++----------
...=> anonymize-single-request-response.json} | 0
2 files changed, 46 insertions(+), 65 deletions(-)
rename ramls/schema/{anonymize-requests-response.json => anonymize-single-request-response.json} (100%)
diff --git a/ramls/request-anonymization.raml b/ramls/request-anonymization.raml
index bb86cc9ec1..52ecd8c182 100644
--- a/ramls/request-anonymization.raml
+++ b/ramls/request-anonymization.raml
@@ -5,81 +5,62 @@ protocols: [ HTTP, HTTPS ]
baseUri: http://localhost:9130
documentation:
- - title: request Anonymization API
- content: request Anonymization API
+ - title: Request Anonymization API
+ content: Request Anonymization API
types:
- anonymize-requests-response: !include schema/anonymize-requests-response.json
+ 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-]{36}$"
- post:
- is: [validate]
- description: "Note that a 422 error with haveAssociatedFeesAndFines message and key requestIds has a value that is not a JSON array but a JSON string that contains a serialized JSON array of the request ids."
- responses:
- 200:
- description: "Chosen requests have been successfully anonymized"
- body:
- application/json:
- type: anonymize-requests-response
- 403:
- description: Missing required capability to anonymize requests (e.g. ui-requests.request-anonymize-single.execute) or insufficient permissions.
- body:
- application/json:
- type: errors
- example:
- errors:
- - message: "Permission denied: missing execute capability"
- parameters:
- - key: "permissionRequired"
- value: "ui-requests.request-anonymize-single.execute"
-
- 404:
- description: Request with the given requestId was not found.
- body:
- application/json:
- type: errors
- example:
- errors:
- - message: "Request not found"
- parameters:
- - key: "requestId"
- value: "00000000-0000-0000-0000-000000000000"
-
- 422:
- description: Validation error — request is not closed or other business-rule violation prevents anonymization.
- body:
- application/json:
- type: errors
- example:
- errors:
- - message: "requestNotClosed"
- parameters:
- - key: "requestId"
- value: "00000000-0000-0000-0000-000000000000"
-
- 500:
- description: Internal server error.
- body:
- application/json:
- type: errors
- example:
- errors:
- - message: "Internal server error"
-
+ /{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-requests-response.json b/ramls/schema/anonymize-single-request-response.json
similarity index 100%
rename from ramls/schema/anonymize-requests-response.json
rename to ramls/schema/anonymize-single-request-response.json
From 3424f23b9ce040bc5dd917fc0059b219601fa5ca Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Wed, 12 Nov 2025 14:45:34 -0500
Subject: [PATCH 03/15] add description to
anonymize-single-request-response.json
---
ramls/schema/anonymize-single-request-response.json | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/ramls/schema/anonymize-single-request-response.json b/ramls/schema/anonymize-single-request-response.json
index a2580d8e17..4685001f0a 100644
--- a/ramls/schema/anonymize-single-request-response.json
+++ b/ramls/schema/anonymize-single-request-response.json
@@ -1,10 +1,16 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
- "description": "Response for single requests anonymization ",
+ "description": "Single Request Anonymization Response",
"type": "object",
"properties": {
- "requestId": { "type": "string" },
- "anonymized": { "type": "boolean" }
+ "requestId": {
+ "type": "string",
+ "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
From b1eea2e93956ed11ccdc43ab5fd8ac46246243e0 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Thu, 13 Nov 2025 11:38:29 -0500
Subject: [PATCH 04/15] solve issues and increase test coverage
---
.../resources/RequestAnonymizationResource.java | 13 ++-----------
.../folio/circulation/services/EventPublisher.java | 3 ---
.../services/RequestAnonymizationService.java | 6 ------
.../circulation/support/ValidationErrorFailure.java | 2 +-
.../requests/RequestAnonymizationServiceTest.java | 2 --
5 files changed, 3 insertions(+), 23 deletions(-)
diff --git a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
index 32c12b294e..5294460efa 100644
--- a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
+++ b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
@@ -5,15 +5,6 @@
import io.vertx.core.json.JsonObject;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
-import org.folio.circulation.domain.anonymization.DefaultLoanAnonymizationService;
-import org.folio.circulation.domain.anonymization.service.AnonymizationCheckersService;
-import org.folio.circulation.domain.anonymization.service.LoansForBorrowerFinder;
-import org.folio.circulation.domain.representations.anonymization.AnonymizeLoansRepresentation;
-import org.folio.circulation.infrastructure.storage.feesandfines.AccountRepository;
-import org.folio.circulation.infrastructure.storage.inventory.ItemRepository;
-import org.folio.circulation.infrastructure.storage.loans.AnonymizeStorageLoansRepository;
-import org.folio.circulation.infrastructure.storage.loans.LoanRepository;
-import org.folio.circulation.infrastructure.storage.users.UserRepository;
import org.folio.circulation.services.EventPublisher;
import org.folio.circulation.services.RequestAnonymizationService;
import org.folio.circulation.support.Clients;
@@ -29,7 +20,7 @@ public class RequestAnonymizationResource extends Resource {
private final Logger log = LogManager.getLogger(MethodHandles.lookup().lookupClass());
- public RequestAnonymizationResource(HttpClient client) {
+ public RequestAnonymizationResource(HttpClient client, RequestAnonymizationService service) {
super((io.vertx.core.http.HttpClient) client);
}
@@ -40,7 +31,7 @@ public void register(Router router) {
rr.create(this::anonymizeRequest);
}
- private void anonymizeRequest(RoutingContext routingContext) {
+ public void anonymizeRequest(RoutingContext routingContext) {
final WebContext context = new WebContext(routingContext);
final Clients clients = Clients.create(context, client);
diff --git a/src/main/java/org/folio/circulation/services/EventPublisher.java b/src/main/java/org/folio/circulation/services/EventPublisher.java
index 55b0b845f4..f2f89d5517 100644
--- a/src/main/java/org/folio/circulation/services/EventPublisher.java
+++ b/src/main/java/org/folio/circulation/services/EventPublisher.java
@@ -472,9 +472,6 @@ public CompletableFuture> publishRequestAnonymizedLog(Request req)
.put("linkToIds", linkToIds)
.put("items", items);
-
- final LogEventType type = LogEventType.REQUEST_ANONYMIZED;
-
return publishLogRecord(context, LogEventType.REQUEST_ANONYMIZED);
}
diff --git a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
index 2e8ea19f5d..7739973ef3 100644
--- a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
+++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
@@ -1,6 +1,5 @@
package org.folio.circulation.services;
-import static java.lang.invoke.MethodHandles.lookup;
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;
@@ -11,8 +10,6 @@
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
import org.folio.circulation.domain.Request;
import org.folio.circulation.domain.RequestFulfillmentPreference;
import org.folio.circulation.domain.RequestStatus;
@@ -22,13 +19,10 @@
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.services.EventPublisher;
import io.vertx.core.json.JsonObject;
public class RequestAnonymizationService {
- private static final Logger log = LogManager.getLogger(lookup().lookupClass());
-
private static final Set ALLOWED_STATUSES = EnumSet.of(
RequestStatus.CLOSED_FILLED,
RequestStatus.CLOSED_CANCELLED,
diff --git a/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java b/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java
index 3b4c5adeae..372cc9b4ed 100644
--- a/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java
+++ b/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java
@@ -24,7 +24,7 @@ public class ValidationErrorFailure implements HttpFailure {
private final Collection errors = new ArrayList<>();
- public static Result ValidationErrorFailure(String code, String paramKey, String paramValue) {
+ public static Result validationErrorFailure(String code, String paramKey, String paramValue) {
return failedValidation(new ValidationError(code, paramKey, paramValue));
}
diff --git a/src/test/java/api/requests/RequestAnonymizationServiceTest.java b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
index 67d79d2fca..5c50c5a46c 100644
--- a/src/test/java/api/requests/RequestAnonymizationServiceTest.java
+++ b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
@@ -2,8 +2,6 @@
import static java.util.concurrent.CompletableFuture.completedFuture;
import static org.mockito.ArgumentMatchers.any;
-//import static org.junit.Assert.assertEquals;
-//import static org.junit.Assert.assertFalse;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
From 9041de971fe4f5e298e578f96e735c664dc0b4fd Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Thu, 13 Nov 2025 11:38:36 -0500
Subject: [PATCH 05/15] solve issues and increase test coverage
---
.../RequestAnonymizationResourceTests.java | 61 +++++++++++++++++++
1 file changed, 61 insertions(+)
create mode 100644 src/test/java/api/requests/RequestAnonymizationResourceTests.java
diff --git a/src/test/java/api/requests/RequestAnonymizationResourceTests.java b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
new file mode 100644
index 0000000000..7dee06bab7
--- /dev/null
+++ b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
@@ -0,0 +1,61 @@
+package api.requests;
+
+import static java.util.concurrent.CompletableFuture.completedFuture;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.net.URL;
+import java.time.ZonedDateTime;
+import java.util.UUID;
+
+import api.support.APITests;
+import api.support.MultipleJsonRecords;
+import api.support.http.IndividualResource;
+import io.vertx.core.http.HttpClient;
+import io.vertx.core.http.HttpServerRequest;
+import io.vertx.core.http.impl.HttpClientInternal;
+import io.vertx.core.json.JsonObject;
+import io.vertx.ext.web.Route;
+import io.vertx.ext.web.Router;
+import io.vertx.ext.web.RoutingContext;
+import org.folio.circulation.resources.RequestAnonymizationResource;
+import org.folio.circulation.services.RequestAnonymizationService;
+import org.folio.circulation.support.results.Result;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import io.vertx.core.json.JsonObject;
+
+@Nested
+class RequestAnonymizationResourceTest extends APITests {
+
+ @Test
+ void register_doesNotThrowAndRegistersRoute() {
+ HttpClientInternal internalClient = mock(HttpClientInternal.class);
+ HttpClient httpClient = internalClient;
+
+ RequestAnonymizationService service = mock(RequestAnonymizationService.class);
+ RequestAnonymizationResource resource =
+ new RequestAnonymizationResource(httpClient, service);
+
+ Router router = mock(Router.class);
+ Route route = mock(Route.class);
+
+ // RouteRegistration will call router.post(path).handler(handler)
+ when(router.post(anyString())).thenReturn(route);
+ when(route.handler(any())).thenReturn(route);
+
+ assertDoesNotThrow(() -> resource.register(router));
+
+ // Optional sanity check that our path wiring is correct
+ verify(router).post("/request-anonymization/:requestId");
+ }
+}
From 8a45dfe09c5ea44e44591edf262c743267dd23a2 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Fri, 14 Nov 2025 10:24:58 -0500
Subject: [PATCH 06/15] update RequestAnonymizationResource,
RequestAnonymizationService, update wiring, update tests.
---
.../circulation/CirculationVerticle.java | 2 +
.../RequestAnonymizationResource.java | 7 +-
.../circulation/services/EventPublisher.java | 5 +-
.../services/RequestAnonymizationService.java | 28 ++++-
.../RequestAnonymizationApiTests.java | 70 ++++++++++++
.../RequestAnonymizationResourceTests.java | 29 ++---
.../RequestAnonymizationServiceTest.java | 8 +-
.../java/api/support/http/InterfaceUrls.java | 5 +
.../anonymization/AnonymizeRequestTests.java | 100 ++++++++++++++++++
9 files changed, 226 insertions(+), 28 deletions(-)
create mode 100644 src/test/java/api/requests/RequestAnonymizationApiTests.java
create mode 100644 src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java
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/resources/RequestAnonymizationResource.java b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
index 5294460efa..ad04a88608 100644
--- a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
+++ b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
@@ -19,10 +19,15 @@
public class RequestAnonymizationResource extends Resource {
private final Logger log = LogManager.getLogger(MethodHandles.lookup().lookupClass());
-
+/*
public RequestAnonymizationResource(HttpClient client, RequestAnonymizationService service) {
super((io.vertx.core.http.HttpClient) client);
}
+*/
+
+ public RequestAnonymizationResource(HttpClient client) {
+ super(client);
+ }
@Override
public void register(Router router) {
diff --git a/src/main/java/org/folio/circulation/services/EventPublisher.java b/src/main/java/org/folio/circulation/services/EventPublisher.java
index f2f89d5517..643e6b1a83 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;
@@ -454,6 +456,7 @@ private CompletableFuture> publishDueDateChangedEvent(Loan loan, Us
public CompletableFuture> publishRequestAnonymizedLog(Request req) {
// Build the circulation-log payload for a Request action
final Item item = req.getItem();
+ ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
final JsonObject linkToIds = new JsonObject()
.put("requestId", req.getId());
@@ -467,7 +470,7 @@ public CompletableFuture> publishRequestAnonymizedLog(Request req)
final JsonObject context = new JsonObject()
.put("object", "Request")
.put("action", "anonymizeRequest")
- .put("date", getZonedDateTime())
+ .put("date", now.toInstant().toString())
.put("userBarcode", "-")
.put("linkToIds", linkToIds)
.put("items", items);
diff --git a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
index 7739973ef3..b6c83dd028 100644
--- a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
+++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
@@ -4,6 +4,12 @@
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 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.users.PatronGroupRepository;
+import org.folio.circulation.infrastructure.storage.requests.RequestRepository;
+
import java.util.EnumSet;
import java.util.Set;
@@ -40,7 +46,23 @@ public class RequestAnonymizationService {
private final EventPublisher eventPublisher;
public RequestAnonymizationService(Clients clients, EventPublisher eventPublisher) {
- this.requestRepository = new RequestRepository(clients);
+ 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;
}
@@ -94,8 +116,8 @@ private Result scrubPii(Request req) {
return succeeded(req);
}
- rep.remove("requesterId");
- rep.remove("proxyUserId");
+ rep.putNull("requesterId");
+ rep.putNull("proxyUserId");
rep.remove("requester");
rep.remove("proxy");
diff --git a/src/test/java/api/requests/RequestAnonymizationApiTests.java b/src/test/java/api/requests/RequestAnonymizationApiTests.java
new file mode 100644
index 0000000000..e6b6e95d92
--- /dev/null
+++ b/src/test/java/api/requests/RequestAnonymizationApiTests.java
@@ -0,0 +1,70 @@
+package api.requests;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+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 static api.support.http.InterfaceUrls.allowedServicePointsUrl;
+import static api.support.http.InterfaceUrls.circulationRequestAnonymizationUrl;
+
+import java.util.UUID;
+
+import lombok.extern.slf4j.Slf4j;
+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;
+
+@Slf4j
+class RequestAnonymizationApiTests extends APITests {
+
+ @Test
+ void anonymizeSingleRequest_removesPII_andReturns200() {
+
+ String requestId = createClosedDeliveryRequest();
+
+ Response response = restAssuredClient.post(
+ circulationRequestAnonymizationUrl(requestId),
+ "anonymize-request-" + requestId
+ );
+
+
+ assertEquals(200, response.getStatusCode());
+
+ JsonObject body = response.getJson();
+ 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
index 7dee06bab7..a801bba291 100644
--- a/src/test/java/api/requests/RequestAnonymizationResourceTests.java
+++ b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
@@ -1,41 +1,31 @@
package api.requests;
-import static java.util.concurrent.CompletableFuture.completedFuture;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.is;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-
-import java.net.URL;
-import java.time.ZonedDateTime;
-import java.util.UUID;
import api.support.APITests;
-import api.support.MultipleJsonRecords;
-import api.support.http.IndividualResource;
+
import io.vertx.core.http.HttpClient;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.impl.HttpClientInternal;
-import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.Route;
import io.vertx.ext.web.Router;
-import io.vertx.ext.web.RoutingContext;
+
import org.folio.circulation.resources.RequestAnonymizationResource;
import org.folio.circulation.services.RequestAnonymizationService;
-import org.folio.circulation.support.results.Result;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
-import io.vertx.core.json.JsonObject;
+
+
+
@Nested
-class RequestAnonymizationResourceTest extends APITests {
+class RequestAnonymizationResourceTests extends APITests {
@Test
void register_doesNotThrowAndRegistersRoute() {
@@ -43,19 +33,16 @@ void register_doesNotThrowAndRegistersRoute() {
HttpClient httpClient = internalClient;
RequestAnonymizationService service = mock(RequestAnonymizationService.class);
- RequestAnonymizationResource resource =
- new RequestAnonymizationResource(httpClient, service);
+ RequestAnonymizationResource resource = new RequestAnonymizationResource(httpClient);
Router router = mock(Router.class);
Route route = mock(Route.class);
- // RouteRegistration will call router.post(path).handler(handler)
when(router.post(anyString())).thenReturn(route);
when(route.handler(any())).thenReturn(route);
assertDoesNotThrow(() -> resource.register(router));
- // Optional sanity check that our path wiring is correct
verify(router).post("/request-anonymization/:requestId");
}
}
diff --git a/src/test/java/api/requests/RequestAnonymizationServiceTest.java b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
index 5c50c5a46c..38b2540bbe 100644
--- a/src/test/java/api/requests/RequestAnonymizationServiceTest.java
+++ b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
@@ -1,6 +1,7 @@
package api.requests;
import static java.util.concurrent.CompletableFuture.completedFuture;
+import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -14,6 +15,7 @@
import org.folio.circulation.services.RequestAnonymizationService;
import org.folio.circulation.support.Clients;
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;
@@ -90,8 +92,10 @@ void anonymizeSingle_happyPath_removesPII_updates_repo_and_logs() {
ArgumentCaptor updatedCap = ArgumentCaptor.forClass(Request.class);
verify(requestRepository).update(updatedCap.capture());
var updatedJson = updatedCap.getValue().asJson();
- assertFalse(updatedJson.containsKey("requesterId"));
- assertFalse(updatedJson.containsKey("proxyUserId"));
+ 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"));
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..f6ef37b643
--- /dev/null
+++ b/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java
@@ -0,0 +1,100 @@
+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.jupiter.api.Assertions.*;
+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 anonymizeSingle_happyPath_removesPII_updates_repo_and_logs() {
+ // 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, "user-123").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"));
+ }
+}
From c7d91f9ea90db640e737a85c2fb11a4f15445139 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Mon, 17 Nov 2025 10:39:31 -0500
Subject: [PATCH 07/15] tidy up the code and remove duplicates and unused codes
---
.../RequestAnonymizationResource.java | 8 -------
.../circulation/services/EventPublisher.java | 1 -
.../services/RequestAnonymizationService.java | 21 ++++++++-----------
.../RequestAnonymizationApiTests.java | 5 -----
.../RequestAnonymizationResourceTests.java | 2 --
5 files changed, 9 insertions(+), 28 deletions(-)
diff --git a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
index ad04a88608..678d764bef 100644
--- a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
+++ b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
@@ -19,12 +19,6 @@
public class RequestAnonymizationResource extends Resource {
private final Logger log = LogManager.getLogger(MethodHandles.lookup().lookupClass());
-/*
- public RequestAnonymizationResource(HttpClient client, RequestAnonymizationService service) {
- super((io.vertx.core.http.HttpClient) client);
- }
-*/
-
public RequestAnonymizationResource(HttpClient client) {
super(client);
}
@@ -45,8 +39,6 @@ public void anonymizeRequest(RoutingContext routingContext) {
final var eventPublisher = new EventPublisher(clients);
final var requestAnonymizationService = new RequestAnonymizationService(clients, eventPublisher);
- log.info("anonymizeRequest:: requestId={}, user={}");
-
requestAnonymizationService.anonymizeSingle(requestId, context.getUserId())
.thenApply(r -> r.map(id ->
JsonHttpResponse.ok(new JsonObject()
diff --git a/src/main/java/org/folio/circulation/services/EventPublisher.java b/src/main/java/org/folio/circulation/services/EventPublisher.java
index 643e6b1a83..4c3195bbc6 100644
--- a/src/main/java/org/folio/circulation/services/EventPublisher.java
+++ b/src/main/java/org/folio/circulation/services/EventPublisher.java
@@ -49,7 +49,6 @@
import org.folio.circulation.domain.representations.logs.LogContextActionResolver;
import org.folio.circulation.domain.representations.logs.LogEventType;
import org.folio.circulation.domain.representations.logs.NoticeLogContext;
-import org.folio.circulation.domain.representations.logs.LogEventType;
import org.folio.circulation.infrastructure.storage.SettingsRepository;
import org.folio.circulation.infrastructure.storage.loans.LoanRepository;
import org.folio.circulation.infrastructure.storage.users.UserRepository;
diff --git a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
index b6c83dd028..b3a9a65f4d 100644
--- a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
+++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
@@ -4,12 +4,6 @@
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 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.users.PatronGroupRepository;
-import org.folio.circulation.infrastructure.storage.requests.RequestRepository;
-
import java.util.EnumSet;
import java.util.Set;
@@ -19,12 +13,15 @@
import org.folio.circulation.domain.Request;
import org.folio.circulation.domain.RequestFulfillmentPreference;
import org.folio.circulation.domain.RequestStatus;
-import org.folio.circulation.infrastructure.storage.requests.RequestRepository;
import org.folio.circulation.support.Clients;
import org.folio.circulation.support.RecordNotFoundFailure;
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 io.vertx.core.json.JsonObject;
@@ -77,11 +74,11 @@ public CompletableFuture> anonymizeSingle(String requestId, Strin
}
return fetchRequest(id)
- .thenApply(flatMapResult(req -> validateStatus(req, requestId))) // 404 / 422 mapping
- .thenApply(flatMapResult(this::scrubPii)) // apply 2292 field clearing
- .thenCompose(r -> r.after(requestRepository::update)) // persist
- .thenCompose(r -> r.after(updated -> publishLog(updated, performedByUserId))) // audit/event
- .thenApply(mapResult(updated -> requestId)); // success payload = id
+ .thenApply(flatMapResult(req -> validateStatus(req, requestId)))
+ .thenApply(flatMapResult(this::scrubPii))
+ .thenCompose(r -> r.after(requestRepository::update))
+ .thenCompose(r -> r.after(updated -> publishLog(updated, performedByUserId)))
+ .thenApply(mapResult(updated -> requestId));
}
private CompletableFuture> fetchRequest(UUID id) {
diff --git a/src/test/java/api/requests/RequestAnonymizationApiTests.java b/src/test/java/api/requests/RequestAnonymizationApiTests.java
index e6b6e95d92..f50431c1f1 100644
--- a/src/test/java/api/requests/RequestAnonymizationApiTests.java
+++ b/src/test/java/api/requests/RequestAnonymizationApiTests.java
@@ -1,12 +1,9 @@
package api.requests;
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.is;
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 static api.support.http.InterfaceUrls.allowedServicePointsUrl;
import static api.support.http.InterfaceUrls.circulationRequestAnonymizationUrl;
import java.util.UUID;
@@ -33,10 +30,8 @@ void anonymizeSingleRequest_removesPII_andReturns200() {
"anonymize-request-" + requestId
);
-
assertEquals(200, response.getStatusCode());
- JsonObject body = response.getJson();
Response updated =
requestsStorageClient.getById(UUID.fromString(requestId));
JsonObject stored = updated.getJson();
diff --git a/src/test/java/api/requests/RequestAnonymizationResourceTests.java b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
index a801bba291..894a9abd40 100644
--- a/src/test/java/api/requests/RequestAnonymizationResourceTests.java
+++ b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
@@ -11,7 +11,6 @@
import api.support.APITests;
import io.vertx.core.http.HttpClient;
-import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.impl.HttpClientInternal;
import io.vertx.ext.web.Route;
import io.vertx.ext.web.Router;
@@ -32,7 +31,6 @@ void register_doesNotThrowAndRegistersRoute() {
HttpClientInternal internalClient = mock(HttpClientInternal.class);
HttpClient httpClient = internalClient;
- RequestAnonymizationService service = mock(RequestAnonymizationService.class);
RequestAnonymizationResource resource = new RequestAnonymizationResource(httpClient);
Router router = mock(Router.class);
From 6d699526b09cea04152350e58166e0e18caeb6db Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Mon, 17 Nov 2025 12:46:42 -0500
Subject: [PATCH 08/15] remove the unused method parameter. edit permission.
---
descriptors/ModuleDescriptor-template.json | 17 ++++++++++++++++-
.../resources/RequestAnonymizationResource.java | 2 --
.../services/RequestAnonymizationService.java | 4 ++--
.../RequestAnonymizationResourceTests.java | 4 ----
4 files changed, 18 insertions(+), 9 deletions(-)
diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json
index 9cd562e124..3b0da46006 100644
--- a/descriptors/ModuleDescriptor-template.json
+++ b/descriptors/ModuleDescriptor-template.json
@@ -114,7 +114,7 @@
"version": "0.1",
"handlers": [
{
- "methods": ["POST"],
+ "methods":["POST"],
"pathPattern": "/loan-anonymization/by-user/{userId}",
"permissionsRequired": [
"circulation.loans.collection.anonymize.user.post"
@@ -2320,6 +2320,21 @@
],
"visible": false
},
+ {
+ "permissionName": "perms.circulation.requests.anonymize.single.all",
+ "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/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
index 678d764bef..fcb3109440 100644
--- a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
+++ b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
@@ -17,8 +17,6 @@
import io.vertx.ext.web.RoutingContext;
public class RequestAnonymizationResource extends Resource {
- private final Logger log = LogManager.getLogger(MethodHandles.lookup().lookupClass());
-
public RequestAnonymizationResource(HttpClient client) {
super(client);
}
diff --git a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
index b3a9a65f4d..739168ebdf 100644
--- a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
+++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
@@ -77,7 +77,7 @@ public CompletableFuture> anonymizeSingle(String requestId, Strin
.thenApply(flatMapResult(req -> validateStatus(req, requestId)))
.thenApply(flatMapResult(this::scrubPii))
.thenCompose(r -> r.after(requestRepository::update))
- .thenCompose(r -> r.after(updated -> publishLog(updated, performedByUserId)))
+ .thenCompose(r -> r.after(this::publishLog))
.thenApply(mapResult(updated -> requestId));
}
@@ -126,7 +126,7 @@ private Result scrubPii(Request req) {
return succeeded(Request.from(rep));
}
- private CompletableFuture> publishLog(Request req, String performedByUserId) {
+ private CompletableFuture> publishLog(Request req) {
return eventPublisher.publishRequestAnonymizedLog(req)
.thenApply(r -> r.map(v -> req));
}
diff --git a/src/test/java/api/requests/RequestAnonymizationResourceTests.java b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
index 894a9abd40..62fa80d92f 100644
--- a/src/test/java/api/requests/RequestAnonymizationResourceTests.java
+++ b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
@@ -16,13 +16,9 @@
import io.vertx.ext.web.Router;
import org.folio.circulation.resources.RequestAnonymizationResource;
-import org.folio.circulation.services.RequestAnonymizationService;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
-
-
-
@Nested
class RequestAnonymizationResourceTests extends APITests {
From 77b8b8e7784f761b5c2ec8fc399e5756cae40624 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Tue, 18 Nov 2025 11:09:24 -0500
Subject: [PATCH 09/15] code corrections according to code review
---
descriptors/ModuleDescriptor-template.json | 2 +-
.../storage/requests/RequestRepository.java | 4 +---
.../resources/RequestAnonymizationResource.java | 10 ++--------
.../folio/circulation/services/EventPublisher.java | 2 +-
.../services/RequestAnonymizationService.java | 3 ++-
.../api/requests/RequestAnonymizationApiTests.java | 2 +-
.../requests/RequestAnonymizationResourceTests.java | 2 +-
.../requests/RequestAnonymizationServiceTest.java | 12 +++++-------
.../domain/anonymization/AnonymizeRequestTests.java | 5 ++---
9 files changed, 16 insertions(+), 26 deletions(-)
diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json
index 3b0da46006..1d1f5e063f 100644
--- a/descriptors/ModuleDescriptor-template.json
+++ b/descriptors/ModuleDescriptor-template.json
@@ -114,7 +114,7 @@
"version": "0.1",
"handlers": [
{
- "methods":["POST"],
+ "methods": ["POST"],
"pathPattern": "/loan-anonymization/by-user/{userId}",
"permissionsRequired": [
"circulation.loans.collection.anonymize.user.post"
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 3017c8f729..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
@@ -363,9 +363,7 @@ private SingleRecordFetcher createSingleRequestFetcher(
return new SingleRecordFetcher<>(requestsStorageClient, "request", interpreter);
}
-
-
- @AllArgsConstructor
+ @AllArgsConstructor
@Getter
private static class Clients {
private final CollectionResourceClient requestsStorageClient;
diff --git a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
index fcb3109440..b1eb433e77 100644
--- a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
+++ b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
@@ -1,10 +1,6 @@
package org.folio.circulation.resources;
-import java.lang.invoke.MethodHandles;
-
import io.vertx.core.json.JsonObject;
-import org.apache.logging.log4j.LogManager;
-import org.apache.logging.log4j.Logger;
import org.folio.circulation.services.EventPublisher;
import org.folio.circulation.services.RequestAnonymizationService;
import org.folio.circulation.support.Clients;
@@ -23,11 +19,9 @@ public RequestAnonymizationResource(HttpClient client) {
@Override
public void register(Router router) {
- final RouteRegistration rr =
- new RouteRegistration("/request-anonymization/:requestId", router);
- rr.create(this::anonymizeRequest);
+ 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);
diff --git a/src/main/java/org/folio/circulation/services/EventPublisher.java b/src/main/java/org/folio/circulation/services/EventPublisher.java
index 4c3195bbc6..fc6f4c6486 100644
--- a/src/main/java/org/folio/circulation/services/EventPublisher.java
+++ b/src/main/java/org/folio/circulation/services/EventPublisher.java
@@ -469,7 +469,7 @@ public CompletableFuture> publishRequestAnonymizedLog(Request req)
final JsonObject context = new JsonObject()
.put("object", "Request")
.put("action", "anonymizeRequest")
- .put("date", now.toInstant().toString())
+ .put("date", ZonedDateTime.now(ZoneOffset.UTC).toInstant().toString())
.put("userBarcode", "-")
.put("linkToIds", linkToIds)
.put("items", items);
diff --git a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
index 739168ebdf..dd27bc6e0f 100644
--- a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
+++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
@@ -58,7 +58,8 @@ public RequestAnonymizationService(Clients clients, EventPublisher eventPublishe
}
public RequestAnonymizationService(RequestRepository requestRepository,
- EventPublisher eventPublisher) {
+ EventPublisher eventPublisher) {
+
this.requestRepository = requestRepository;
this.eventPublisher = eventPublisher;
}
diff --git a/src/test/java/api/requests/RequestAnonymizationApiTests.java b/src/test/java/api/requests/RequestAnonymizationApiTests.java
index f50431c1f1..75183d79c2 100644
--- a/src/test/java/api/requests/RequestAnonymizationApiTests.java
+++ b/src/test/java/api/requests/RequestAnonymizationApiTests.java
@@ -21,7 +21,7 @@
class RequestAnonymizationApiTests extends APITests {
@Test
- void anonymizeSingleRequest_removesPII_andReturns200() {
+ void anonymizeSingleRequestRemovesPIIAndReturns200() {
String requestId = createClosedDeliveryRequest();
diff --git a/src/test/java/api/requests/RequestAnonymizationResourceTests.java b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
index 62fa80d92f..94dbf75313 100644
--- a/src/test/java/api/requests/RequestAnonymizationResourceTests.java
+++ b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
@@ -23,7 +23,7 @@
class RequestAnonymizationResourceTests extends APITests {
@Test
- void register_doesNotThrowAndRegistersRoute() {
+ void registerDoesNotThrowAndRegistersRoute() {
HttpClientInternal internalClient = mock(HttpClientInternal.class);
HttpClient httpClient = internalClient;
diff --git a/src/test/java/api/requests/RequestAnonymizationServiceTest.java b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
index 38b2540bbe..e783edeeb3 100644
--- a/src/test/java/api/requests/RequestAnonymizationServiceTest.java
+++ b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
@@ -74,7 +74,7 @@ private static Request closedDeliveryRequest(String id) {
}
@Test
- void anonymizeSingle_happyPath_removesPII_updates_repo_and_logs() {
+ void anonymizeSingleHappyPathRemovesPIIUpdatesRepoAndLogs() {
String id = UUID.randomUUID().toString();
var req = closedFilledRequest(id);
@@ -102,10 +102,8 @@ void anonymizeSingle_happyPath_removesPII_updates_repo_and_logs() {
verify(eventPublisher).publishRequestAnonymizedLog(any());
}
-
-
@Test
- void anonymizeSingle_delivery_removesDeliveryFields() {
+ void anonymizeSingleDeliveryRemovesDeliveryFields() {
String id = UUID.randomUUID().toString();
var req = closedDeliveryRequest(id);
@@ -126,7 +124,7 @@ void anonymizeSingle_delivery_removesDeliveryFields() {
}
@Test
- void anonymizeSingle_returns404_whenRequestNotFound() {
+ void anonymizeSingleReturns404WhenRequestNotFound() {
String id = UUID.randomUUID().toString();
when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(null)));
@@ -140,7 +138,7 @@ void anonymizeSingle_returns404_whenRequestNotFound() {
}
@Test
-void anonymizeSingle_returns422_whenRequestIsOpen() {
+void anonymizeSingleReturns422WhenRequestIsOpen() {
String id = UUID.randomUUID().toString();
var json = new io.vertx.core.json.JsonObject()
.put("id", id)
@@ -158,7 +156,7 @@ void anonymizeSingle_returns422_whenRequestIsOpen() {
}
@Test
- void anonymizeSingle_returns422_whenIdIsNotUuid() {
+ void anonymizeSingleReturns422WhenIdIsNotUuid() {
var out = service.anonymizeSingle("not-a-uuid", "user-123").join();
assertFalse(out.succeeded());
assertTrue(out.cause().toString().contains("invalidRequestId"));
diff --git a/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java b/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java
index f6ef37b643..d50371ec01 100644
--- a/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java
+++ b/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java
@@ -23,14 +23,13 @@
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.jupiter.api.Assertions.*;
+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 {
@@ -51,7 +50,7 @@ void setUp() {
}
@Test
- void anonymizeSingle_happyPath_removesPII_updates_repo_and_logs() {
+ void anonymizeSingleHappyPathRemovesPIIUpdatesRepoAndLogs() {
// given a closed request with PII
String id = UUID.randomUUID().toString();
From 8da043245205ec231a0b314ebb1ddf51fa1b9ce1 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Tue, 18 Nov 2025 11:42:58 -0500
Subject: [PATCH 10/15] delete extra space
---
.../java/api/requests/RequestAnonymizationResourceTests.java | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/test/java/api/requests/RequestAnonymizationResourceTests.java b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
index 94dbf75313..d300f219dd 100644
--- a/src/test/java/api/requests/RequestAnonymizationResourceTests.java
+++ b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
@@ -3,7 +3,6 @@
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;
From 435fffd5dbad74fddd4e01e6d9b153ebb8d683d3 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Tue, 18 Nov 2025 12:09:41 -0500
Subject: [PATCH 11/15] cla: retry signature
From 257cced1aca3e4b2e38d9e69550fd0ce199d25bc Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Wed, 19 Nov 2025 09:25:42 -0500
Subject: [PATCH 12/15] remove unused local variable.
---
.../resources/RequestAnonymizationResource.java | 2 +-
.../org/folio/circulation/services/EventPublisher.java | 5 -----
.../services/RequestAnonymizationService.java | 4 ++--
.../api/requests/RequestAnonymizationServiceTest.java | 10 +++++-----
.../domain/anonymization/AnonymizeRequestTests.java | 2 +-
5 files changed, 9 insertions(+), 14 deletions(-)
diff --git a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
index b1eb433e77..2545365599 100644
--- a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
+++ b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
@@ -31,7 +31,7 @@ public void anonymizeRequest(RoutingContext routingContext) {
final var eventPublisher = new EventPublisher(clients);
final var requestAnonymizationService = new RequestAnonymizationService(clients, eventPublisher);
- requestAnonymizationService.anonymizeSingle(requestId, context.getUserId())
+ requestAnonymizationService.anonymizeSingle(requestId)
.thenApply(r -> r.map(id ->
JsonHttpResponse.ok(new JsonObject()
.put("requestId", id)
diff --git a/src/main/java/org/folio/circulation/services/EventPublisher.java b/src/main/java/org/folio/circulation/services/EventPublisher.java
index fc6f4c6486..c65fd6f4c4 100644
--- a/src/main/java/org/folio/circulation/services/EventPublisher.java
+++ b/src/main/java/org/folio/circulation/services/EventPublisher.java
@@ -453,19 +453,14 @@ private CompletableFuture> publishDueDateChangedEvent(Loan loan, Us
}
public CompletableFuture> publishRequestAnonymizedLog(Request req) {
- // Build the circulation-log payload for a Request action
final Item item = req.getItem();
- ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);
-
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")
diff --git a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
index dd27bc6e0f..a08e41b87b 100644
--- a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
+++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
@@ -59,12 +59,12 @@ public RequestAnonymizationService(Clients clients, EventPublisher eventPublishe
public RequestAnonymizationService(RequestRepository requestRepository,
EventPublisher eventPublisher) {
-
+
this.requestRepository = requestRepository;
this.eventPublisher = eventPublisher;
}
- public CompletableFuture> anonymizeSingle(String requestId, String performedByUserId) {
+ public CompletableFuture> anonymizeSingle(String requestId) {
final UUID id;
try {
id = UUID.fromString(requestId);
diff --git a/src/test/java/api/requests/RequestAnonymizationServiceTest.java b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
index e783edeeb3..77e10ffaf0 100644
--- a/src/test/java/api/requests/RequestAnonymizationServiceTest.java
+++ b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
@@ -84,7 +84,7 @@ void anonymizeSingleHappyPathRemovesPIIUpdatesRepoAndLogs() {
when(eventPublisher.publishRequestAnonymizedLog(org.mockito.ArgumentMatchers.any()))
.thenReturn(completedFuture(Result.succeeded(null)));
- var out = service.anonymizeSingle(id, "user-123").join();
+ var out = service.anonymizeSingle(id).join();
assertTrue(out.succeeded());
assertEquals(id, out.value());
@@ -113,7 +113,7 @@ void anonymizeSingleDeliveryRemovesDeliveryFields() {
when(eventPublisher.publishRequestAnonymizedLog(any()))
.thenReturn(completedFuture(Result.succeeded(null)));
- var out = service.anonymizeSingle(id, "user-123").join();
+ var out = service.anonymizeSingle(id).join();
assertTrue(out.succeeded());
ArgumentCaptor updatedCap = ArgumentCaptor.forClass(Request.class);
@@ -129,7 +129,7 @@ void anonymizeSingleReturns404WhenRequestNotFound() {
when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(null)));
- var out = service.anonymizeSingle(id, "user-123").join();
+ var out = service.anonymizeSingle(id).join();
assertFalse(out.succeeded());
assertTrue(out.cause().toString().contains("cannot be found")); // RecordNotFoundFailure message
@@ -147,7 +147,7 @@ void anonymizeSingleReturns422WhenRequestIsOpen() {
when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(req)));
- var out = service.anonymizeSingle(id, "user-123").join();
+ var out = service.anonymizeSingle(id).join();
assertFalse(out.succeeded());
assertTrue(out.cause().toString().contains("requestNotClosed"));
@@ -157,7 +157,7 @@ void anonymizeSingleReturns422WhenRequestIsOpen() {
@Test
void anonymizeSingleReturns422WhenIdIsNotUuid() {
- var out = service.anonymizeSingle("not-a-uuid", "user-123").join();
+ var out = service.anonymizeSingle("not-a-uuid").join();
assertFalse(out.succeeded());
assertTrue(out.cause().toString().contains("invalidRequestId"));
}
diff --git a/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java b/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java
index d50371ec01..ecd886e46f 100644
--- a/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java
+++ b/src/test/java/org/folio/circulation/domain/anonymization/AnonymizeRequestTests.java
@@ -78,7 +78,7 @@ void anonymizeSingleHappyPathRemovesPIIUpdatesRepoAndLogs() {
when(eventPublisher.publishRequestAnonymizedLog(any(Request.class)))
.thenReturn(completedFuture(succeeded(null)));
- var result = service.anonymizeSingle(id, "user-123").join();
+ var result = service.anonymizeSingle(id).join();
assertTrue(result.succeeded());
assertThat(result.value(), is(id));
From 44af37625ab9afaad8426c1f2acacc3e679b12d8 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Fri, 21 Nov 2025 09:55:35 -0500
Subject: [PATCH 13/15] Refine request anonymization endpoint, permissions, and
tests
- Add permissionsRequired and modulePermissions for /request-anonymization/{requestId} handler in ModuleDescriptor-template.json
- Use UuidUtil.isUuid in RequestAnonymizationService and return failed validation when requestId is invalid
- Simplify fetchRequest to delegate to requestRepository.getById without extra failWhen logic
- Change scrubPii to return Request instead of Result and call it via mapResult
- Simplify publishLog to return Result from eventPublisher without remapping
- Fix Result chain to use flatMapResult for validateStatus and after(...) for async update/log
- Remove unused validationError helper that relied on old ValidationError constructor
- Update RequestAnonymizationServiceTest to inject mocks via constructor (remove reflection)
- Adjust not-found test to return failed RecordNotFoundFailure instead of succeeded(null)
- Reorder imports in tests to follow mod-circulation import order convention
---
descriptors/ModuleDescriptor-template.json | 11 ++++-
.../RequestAnonymizationResource.java | 1 +
.../services/RequestAnonymizationService.java | 42 ++++++++-----------
.../support/ValidationErrorFailure.java | 5 ---
.../RequestAnonymizationApiTests.java | 5 +--
.../RequestAnonymizationResourceTests.java | 9 ++--
.../RequestAnonymizationServiceTest.java | 26 ++++++------
7 files changed, 47 insertions(+), 52 deletions(-)
diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json
index 1d1f5e063f..fbe3786fe7 100644
--- a/descriptors/ModuleDescriptor-template.json
+++ b/descriptors/ModuleDescriptor-template.json
@@ -85,7 +85,11 @@
"POST"
],
"pathPattern": "/request-anonymization/{requestId}",
+ "permissionsRequired": [
+ "circulation.requests.anonymize.single.post"
+ ],
"modulePermissions": [
+ "modperms.circulation.requests.anonymize.single",
"pubsub.publish.post"
]
}
@@ -1524,6 +1528,11 @@
"displayName": "circulation - anonymize loans",
"description": "anonymize loans"
},
+ {
+ "permissionName": "circulation.requests.anonymize.single.post",
+ "displayName": "circulation - anonymize requests",
+ "description": "anonymize requests"
+ },
{
"permissionName": "circulation.loans.item.delete",
"displayName": "circulation - delete individual loan",
@@ -2321,7 +2330,7 @@
"visible": false
},
{
- "permissionName": "perms.circulation.requests.anonymize.single.all",
+ "permissionName": "modperms.circulation.requests.anonymize.single.all",
"displayName": "module permissions for single request anonymization",
"description": "Permissions needed to anonymize a single closed request",
"subPermissions": [
diff --git a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
index 2545365599..c271886275 100644
--- a/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
+++ b/src/main/java/org/folio/circulation/resources/RequestAnonymizationResource.java
@@ -22,6 +22,7 @@ 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);
diff --git a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
index a08e41b87b..f8859547ec 100644
--- a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
+++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
@@ -7,14 +7,12 @@
import java.util.EnumSet;
import java.util.Set;
-import java.util.UUID;
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.RecordNotFoundFailure;
import org.folio.circulation.support.ValidationErrorFailure;
import org.folio.circulation.support.http.server.ValidationError;
import org.folio.circulation.support.results.Result;
@@ -22,7 +20,7 @@
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 {
@@ -65,29 +63,26 @@ public RequestAnonymizationService(RequestRepository requestRepository,
}
public CompletableFuture> anonymizeSingle(String requestId) {
- final UUID id;
- try {
- id = UUID.fromString(requestId);
- } catch (IllegalArgumentException e) {
+ if (!UuidUtil.isUuid(requestId)) {
return CompletableFuture.completedFuture(
- failed(new ValidationErrorFailure(new ValidationError("invalidRequestId", "requestId", requestId)))
+ ValidationErrorFailure.failedValidation(
+ "invalidRequestId",
+ "requestId",
+ requestId
+ )
);
}
- return fetchRequest(id)
+ return fetchRequest(requestId)
.thenApply(flatMapResult(req -> validateStatus(req, requestId)))
- .thenApply(flatMapResult(this::scrubPii))
+ .thenApply(mapResult(this::scrubPii))
.thenCompose(r -> r.after(requestRepository::update))
.thenCompose(r -> r.after(this::publishLog))
.thenApply(mapResult(updated -> requestId));
}
- private CompletableFuture> fetchRequest(UUID id) {
- return requestRepository.getById(id.toString())
- .thenApply(res -> res.failWhen(
- r -> succeeded(r == null),
- r -> new RecordNotFoundFailure("Request", id.toString())
- ));
+ private CompletableFuture> fetchRequest(String requestId) {
+ return requestRepository.getById(requestId);
}
private Result validateStatus(Request request, String id) {
@@ -102,8 +97,8 @@ private Result validateStatus(Request request, String id) {
return failed(new ValidationErrorFailure((new ValidationError("requestNotEligibleForAnonymization", "requestId", id))));
}
- private Result scrubPii(Request req) {
- final JsonObject rep = req.asJson().copy();
+ 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");
@@ -111,7 +106,7 @@ private Result scrubPii(Request req) {
final boolean hadDelivery = isDelivery && (rep.containsKey("deliveryAddress") || rep.containsKey("deliveryAddressTypeId"));
if (!hadRequester && !hadProxy && (!isDelivery || !hadDelivery)) {
- return succeeded(req);
+ return req;
}
rep.putNull("requesterId");
@@ -119,16 +114,15 @@ private Result scrubPii(Request req) {
rep.remove("requester");
rep.remove("proxy");
- if (req.getfulfillmentPreference() == RequestFulfillmentPreference.DELIVERY) {
+ if (isDelivery) {
rep.remove("deliveryAddress");
rep.remove("deliveryAddressTypeId");
}
- return succeeded(Request.from(rep));
+ return Request.from(rep);
}
- private CompletableFuture> publishLog(Request req) {
- return eventPublisher.publishRequestAnonymizedLog(req)
- .thenApply(r -> r.map(v -> req));
+ private CompletableFuture> publishLog(Request req) {
+ return eventPublisher.publishRequestAnonymizedLog(req);
}
}
diff --git a/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java b/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java
index 372cc9b4ed..4429d29078 100644
--- a/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java
+++ b/src/main/java/org/folio/circulation/support/ValidationErrorFailure.java
@@ -24,11 +24,6 @@ public class ValidationErrorFailure implements HttpFailure {
private final Collection errors = new ArrayList<>();
- public static Result validationErrorFailure(String code, String paramKey, String paramValue) {
- return failedValidation(new ValidationError(code, paramKey, paramValue));
- }
-
-
public static Result failedValidation(String reason, String key, String value) {
return failedValidation(new ValidationError(reason, key, value));
}
diff --git a/src/test/java/api/requests/RequestAnonymizationApiTests.java b/src/test/java/api/requests/RequestAnonymizationApiTests.java
index 75183d79c2..5acc8c08cd 100644
--- a/src/test/java/api/requests/RequestAnonymizationApiTests.java
+++ b/src/test/java/api/requests/RequestAnonymizationApiTests.java
@@ -1,14 +1,13 @@
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 static api.support.http.InterfaceUrls.circulationRequestAnonymizationUrl;
import java.util.UUID;
-import lombok.extern.slf4j.Slf4j;
import org.folio.circulation.support.http.client.Response;
import org.junit.jupiter.api.Test;
@@ -17,12 +16,10 @@
import api.support.http.IndividualResource;
import io.vertx.core.json.JsonObject;
-@Slf4j
class RequestAnonymizationApiTests extends APITests {
@Test
void anonymizeSingleRequestRemovesPIIAndReturns200() {
-
String requestId = createClosedDeliveryRequest();
Response response = restAssuredClient.post(
diff --git a/src/test/java/api/requests/RequestAnonymizationResourceTests.java b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
index d300f219dd..5a7f5bcc54 100644
--- a/src/test/java/api/requests/RequestAnonymizationResourceTests.java
+++ b/src/test/java/api/requests/RequestAnonymizationResourceTests.java
@@ -7,17 +7,16 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import api.support.APITests;
+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;
-import org.folio.circulation.resources.RequestAnonymizationResource;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-
@Nested
class RequestAnonymizationResourceTests extends APITests {
diff --git a/src/test/java/api/requests/RequestAnonymizationServiceTest.java b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
index 77e10ffaf0..9055aa9c4e 100644
--- a/src/test/java/api/requests/RequestAnonymizationServiceTest.java
+++ b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
@@ -1,19 +1,22 @@
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.mockito.ArgumentMatchers.any;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
-import java.util.*;
+import java.util.UUID;
+import io.vertx.core.json.JsonObject;
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;
@@ -36,19 +39,12 @@ void setUp() {
eventPublisher = mock(EventPublisher.class);
when(clients.requestsStorage()).thenReturn(null);
- service = new RequestAnonymizationService(clients, eventPublisher) {
- {
- try {
- var f = RequestAnonymizationService.class.getDeclaredField("requestRepository");
- f.setAccessible(true);
- f.set(this, requestRepository);
- } catch (Exception e) { throw new RuntimeException(e); }
- }
- };
+
+ service = new RequestAnonymizationService(requestRepository, eventPublisher);
}
private static Request closedFilledRequest(String id) {
- var json = new io.vertx.core.json.JsonObject()
+ JsonObject json = new JsonObject()
.put("id", id)
.put("status", RequestStatus.CLOSED_FILLED.getValue())
.put("requesterId", "r1")
@@ -127,12 +123,16 @@ void anonymizeSingleDeliveryRemovesDeliveryFields() {
void anonymizeSingleReturns404WhenRequestNotFound() {
String id = UUID.randomUUID().toString();
- when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(null)));
+ //when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(null)));
+ 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")); // RecordNotFoundFailure message
+ assertTrue(out.cause().toString().contains("cannot be found"));
verify(requestRepository, never()).update((Request) any());
verify(eventPublisher, never()).publishRequestAnonymizedLog(any());
}
From 7fe764415799e980bb180822e917c7cb5d97c758 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Fri, 21 Nov 2025 10:14:46 -0500
Subject: [PATCH 14/15] CIRC-2364-Anonymize Single Request Post API remove a
unused line
---
src/test/java/api/requests/RequestAnonymizationServiceTest.java | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/test/java/api/requests/RequestAnonymizationServiceTest.java b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
index 9055aa9c4e..47647058f1 100644
--- a/src/test/java/api/requests/RequestAnonymizationServiceTest.java
+++ b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
@@ -122,8 +122,6 @@ void anonymizeSingleDeliveryRemovesDeliveryFields() {
@Test
void anonymizeSingleReturns404WhenRequestNotFound() {
String id = UUID.randomUUID().toString();
-
- //when(requestRepository.getById(id)).thenReturn(completedFuture(Result.succeeded(null)));
when(requestRepository.getById(id))
.thenReturn(completedFuture(
failed(new RecordNotFoundFailure("Request", id))
From 77c306ef87c6fc1826d2e80de877ffa87e0bf440 Mon Sep 17 00:00:00 2001
From: Yuntian Hu <48864579+yuntianhu@users.noreply.github.com>
Date: Mon, 24 Nov 2025 10:45:15 -0500
Subject: [PATCH 15/15] CIRC-2364-Anonymize Single Request Post API
Fix displayName/description wording: "anonymize request" instead of "anonymize requests".
Update permission name to modperms.circulation.requests.anonymize.single.
Remove redundant modulePermission already included in anonymize.single.
Inline RequestRepository.using(...) initializer into single line.
Condense ValidationErrorFailure.failedValidation(...) into one line.
Add UUID format validation to requestId in anonymize-single-request-response.json.
Remove extra spacing alignment in EventPublisher .put() calls.
Replace wildcard imports in RequestAnonymizationServiceTest with explicit imports.
Reorder imports to follow mod-circulation import convention.
Adjust permission naming to follow FOLIO scope convention (item vs collection).
---
descriptors/ModuleDescriptor-template.json | 13 +++++------
.../anonymize-single-request-response.json | 1 +
.../circulation/services/EventPublisher.java | 22 +++++++++----------
.../services/RequestAnonymizationService.java | 13 ++---------
.../RequestAnonymizationServiceTest.java | 10 ++++++---
5 files changed, 27 insertions(+), 32 deletions(-)
diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json
index fbe3786fe7..678d45ebe8 100644
--- a/descriptors/ModuleDescriptor-template.json
+++ b/descriptors/ModuleDescriptor-template.json
@@ -86,11 +86,10 @@
],
"pathPattern": "/request-anonymization/{requestId}",
"permissionsRequired": [
- "circulation.requests.anonymize.single.post"
+ "circulation.requests.anonymize.item.post"
],
"modulePermissions": [
- "modperms.circulation.requests.anonymize.single",
- "pubsub.publish.post"
+ "modperms.circulation.requests.anonymize.item"
]
}
]
@@ -1529,9 +1528,9 @@
"description": "anonymize loans"
},
{
- "permissionName": "circulation.requests.anonymize.single.post",
- "displayName": "circulation - anonymize requests",
- "description": "anonymize requests"
+ "permissionName": "circulation.requests.anonymize.item.post",
+ "displayName": "circulation - anonymize request",
+ "description": "anonymize request"
},
{
"permissionName": "circulation.loans.item.delete",
@@ -2330,7 +2329,7 @@
"visible": false
},
{
- "permissionName": "modperms.circulation.requests.anonymize.single.all",
+ "permissionName": "modperms.circulation.requests.anonymize.item",
"displayName": "module permissions for single request anonymization",
"description": "Permissions needed to anonymize a single closed request",
"subPermissions": [
diff --git a/ramls/schema/anonymize-single-request-response.json b/ramls/schema/anonymize-single-request-response.json
index 4685001f0a..ea8cdcda2d 100644
--- a/ramls/schema/anonymize-single-request-response.json
+++ b/ramls/schema/anonymize-single-request-response.json
@@ -5,6 +5,7 @@
"properties": {
"requestId": {
"type": "string",
+ "format": "UUID",
"description": "UUID of the request that was processed."
},
"anonymized": {
diff --git a/src/main/java/org/folio/circulation/services/EventPublisher.java b/src/main/java/org/folio/circulation/services/EventPublisher.java
index c65fd6f4c4..ec3c546679 100644
--- a/src/main/java/org/folio/circulation/services/EventPublisher.java
+++ b/src/main/java/org/folio/circulation/services/EventPublisher.java
@@ -455,19 +455,19 @@ private CompletableFuture> publishDueDateChangedEvent(Loan loan, Us
public CompletableFuture> publishRequestAnonymizedLog(Request req) {
final Item item = req.getItem();
final JsonObject linkToIds = new JsonObject()
- .put("requestId", req.getId());
+ .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());
+ .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);
+ .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);
}
diff --git a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
index f8859547ec..5541787b4b 100644
--- a/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
+++ b/src/main/java/org/folio/circulation/services/RequestAnonymizationService.java
@@ -45,12 +45,7 @@ public RequestAnonymizationService(Clients clients, EventPublisher eventPublishe
UserRepository userRepository = new UserRepository(clients);
LoanRepository loanRepository = new LoanRepository(clients, itemRepository, userRepository);
- this.requestRepository = RequestRepository.using(
- clients,
- itemRepository,
- userRepository,
- loanRepository
- );
+ this.requestRepository = RequestRepository.using(clients, itemRepository, userRepository, loanRepository);
this.eventPublisher = eventPublisher;
}
@@ -65,11 +60,7 @@ public RequestAnonymizationService(RequestRepository requestRepository,
public CompletableFuture> anonymizeSingle(String requestId) {
if (!UuidUtil.isUuid(requestId)) {
return CompletableFuture.completedFuture(
- ValidationErrorFailure.failedValidation(
- "invalidRequestId",
- "requestId",
- requestId
- )
+ ValidationErrorFailure.failedValidation("invalidRequestId", "requestId", requestId)
);
}
diff --git a/src/test/java/api/requests/RequestAnonymizationServiceTest.java b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
index 47647058f1..c5ab219727 100644
--- a/src/test/java/api/requests/RequestAnonymizationServiceTest.java
+++ b/src/test/java/api/requests/RequestAnonymizationServiceTest.java
@@ -3,13 +3,15 @@
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.mockito.ArgumentMatchers.any;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.*;
+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 io.vertx.core.json.JsonObject;
import org.folio.circulation.domain.*;
import org.folio.circulation.infrastructure.storage.requests.RequestRepository;
@@ -24,6 +26,8 @@
import org.mockito.ArgumentCaptor;
import api.support.APITests;
+import io.vertx.core.json.JsonObject;
+
class RequestAnonymizationServiceTest extends APITests {