From ff1b0bc0d5c3f3b7107d09d87477cbb603d7f8e2 Mon Sep 17 00:00:00 2001 From: Matt Kelsey Date: Mon, 29 Jun 2026 21:22:40 -0500 Subject: [PATCH 1/2] Preserve source subject/session labels (#10)) Reimport can now carry the source's XNAT subject and session labels through to the importer instead of letting the destination derive them from DICOM tags (e.g. PatientName/PatientID). Two independent, default-on checkboxes in the Reimport panel select which labels to preserve. The flags flow through the request: BatchTransfer/TransferRequest -> TransferItemRequest (JMS) -> importExperiment, which sets the importer "subject"/"session" overrides (SUBJECT_ID/EXPT_LABEL). If a chosen label is blank or missing, the item fails rather than silently falling back to DICOM-derived labels. Null/absent flags preserve nothing. --- CHANGELOG.md | 14 +- build.gradle | 4 +- docs/ROADMAP.md | 17 +- .../listeners/TransferReimportListener.java | 3 +- .../jms/requests/TransferItemRequest.java | 2 + .../transfer/model/TransferRequest.java | 9 + .../impl/BatchTransferServiceImpl.java | 31 ++- .../plugin/batchTransfer/batchTransfer.js | 16 +- .../plugin/batchTransfer/batchTransfer.css | 39 +++ .../screens/XDATScreen_batch_transfer.vm | 10 + .../impl/BatchTransferServiceImplTest.java | 232 ++++++++++++++++++ 11 files changed, 361 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db50999..257ba78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) --- -## [1.0.1-RC] +## [1.1.0] + +### Added + +- **Preserve source labels on Reimport** ([#10](https://github.com/NrgXnat/batch-transfer-plugin/issues/10)) — the Reimport panel now offers two checkboxes (both on by default) to preserve the source **subject label** and/or **session label**. When enabled, those XNAT labels are passed to the DICOM importer as `SUBJECT_ID` / `EXPT_LABEL` overrides so the destination uses them instead of deriving labels from DICOM tags (e.g. `PatientName` / `PatientID`). + +### Changed + +- Updated target XNAT version to 1.9.3.5 + +--- + +## [1.0.1] A performance and hardening release on top of the 1.0.0 rebrand: Reimport and Clone now run in parallel through JMS queues, mixed-operation batches route correctly, the listing query is parameterized, and workflow history is consistent across all three operations. The `/xapi/transfer` request format and the Java packages are unchanged from 1.0.0. diff --git a/build.gradle b/build.gradle index d0828ed..82e782a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ buildscript { ext { - vXnat = "1.9.3.3" + vXnat = "1.9.3.5" pluginAppName = "Batch Transfer Plugin" } } @@ -15,7 +15,7 @@ plugins { } group "org.nrg.xnatx.plugins" -version "1.0.1" +version "1.1.0-SNAPSHOT" description "XNAT Batch Transfer Plugin" sourceCompatibility = 1.8 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4a55ebf..126dfcb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,8 +1,8 @@ # Batch Transfer Plugin — Roadmap -**Plugin Version**: 1.0.1-RC (rebranded from Batch Share Plugin 2.0.0-SNAPSHOT; version line reset at 1.0.0) -**Target XNAT**: 1.9.3.3 -**Last Updated**: 2026-06-22 +**Plugin Version**: 1.1.0 (rebranded from Batch Share Plugin 2.0.0-SNAPSHOT; version line reset at 1.0.0) +**Target XNAT**: 1.9.3.5 +**Last Updated**: 2026-06-29 The Batch Transfer Plugin enables bulk data operations across XNAT projects. Users can Share, Clone, or Reimport subjects, sessions, and assessors in batch from a single interface. **Share** adds data into a destination project without copying (XNAT's standard sharing relationship); **Clone** duplicates data into the destination, producing an independent editable copy; **Reimport** re-ingests image sessions through the destination project's anonymization pipeline. @@ -10,6 +10,13 @@ The Batch Transfer Plugin enables bulk data operations across XNAT projects. Use ## Completed +## [1.1.0] + +### Added + +- **Preserve source labels on Reimport** ([#10](https://github.com/NrgXnat/batch-transfer-plugin/issues/10)) — the Reimport panel now offers two checkboxes (both on by default) to preserve the source **subject label** and/or **session label**. When enabled, those XNAT labels are passed to the DICOM importer as `subject_ID` / `EXPT_LABEL` overrides so the destination uses them instead of deriving labels from DICOM tags (e.g. `PatientName` / `PatientID`). + + ## 1.0.1 — Performance & hardening - **Parallel processing via JMS queues** — Reimport (per session) and Clone (per subject) dispatched onto in-process (`vm://`) JMS queues instead of a single sequential loop; in-memory `BatchTransferMonitor` fan-in emits one terminal event per batch - **Admin-tunable queue concurrency** — site-admin settings + `GET`/`POST /xapi/batch_transfer/jms_queues`; defaults Reimport 4–8, Clone 1–2 (`min = max = 1` for serial) @@ -63,7 +70,3 @@ The Batch Transfer Plugin enables bulk data operations across XNAT projects. Use - Extend PROJECT_SHARING_FEATURE and PROJECT_COPYING_FEATURE controls to include a separate PROJECT_IMPORTING_FEATURE (feature-flag naming intentionally deferred from the rebrand) - Per-experiment Reimport timeout to bound importer hangs. `BatchTransferServiceImpl.runImporter` invokes `DicomZipImporter.call()` synchronously, with no upper bound — a stuck prearchive write (wedged NFS mount, deadlocked rebuild queue, slow remote disk) parks the batch-loop thread indefinitely and blocks all subsequent requests in the batch. The 5-minute heartbeat logger makes a hang observable, but recovery still requires a Tomcat restart. Possible fix: wrap `importExperiment` in `Future.get(timeoutMs)` on a separate executor; on timeout, cancel the future, emit a Failed event, and let the batch loop continue with the next request. Caveats: `cancel(true)` cannot reliably stop uninterruptible I/O so the underlying thread leaks; the prearchive has no transactional rollback so a cancelled import may leave partial state; choosing a default timeout is environment-specific (large legitimate sessions can run 30+ min). Defer until a customer incident motivates it; if added, expose the timeout as a plugin config and document explicitly that it means "we gave up waiting", not "we cleaned up". -### Testing & Release -- Test admin/owner/member permissions -- Testing for Share/Clone/Reimport operations with assessors -- Scale testing on large project diff --git a/src/main/java/org/nrg/xnatx/plugins/transfer/jms/listeners/TransferReimportListener.java b/src/main/java/org/nrg/xnatx/plugins/transfer/jms/listeners/TransferReimportListener.java index dc9255e..34c3e37 100644 --- a/src/main/java/org/nrg/xnatx/plugins/transfer/jms/listeners/TransferReimportListener.java +++ b/src/main/java/org/nrg/xnatx/plugins/transfer/jms/listeners/TransferReimportListener.java @@ -69,7 +69,8 @@ public void onRequest(final TransferItemRequest request) { try { eventService.triggerEvent(BatchTransferEvent.progress(request.getRequestingUserId(), monitor.currentPercent(trackingId), trackingId, "Reimporting " + itemId + " to " + request.getDestinationProject())); - service.processItem(new TransferRequest(request.getDestinationProject(), itemId, TransferMode.REIMPORT), + service.processItem(new TransferRequest(request.getDestinationProject(), itemId, TransferMode.REIMPORT, + request.getPreserveSubjectLabel(), request.getPreserveSessionLabel()), user, new EventInfo(trackingId, monitor.currentPercent(trackingId))); } catch (Exception e) { log.error("Reimport {} failed", itemId, e); diff --git a/src/main/java/org/nrg/xnatx/plugins/transfer/jms/requests/TransferItemRequest.java b/src/main/java/org/nrg/xnatx/plugins/transfer/jms/requests/TransferItemRequest.java index da21981..36f11fb 100644 --- a/src/main/java/org/nrg/xnatx/plugins/transfer/jms/requests/TransferItemRequest.java +++ b/src/main/java/org/nrg/xnatx/plugins/transfer/jms/requests/TransferItemRequest.java @@ -29,4 +29,6 @@ public class TransferItemRequest implements Serializable { private final String destinationProject; private final String username; private final Integer requestingUserId; + private final Boolean preserveSubjectLabel; + private final Boolean preserveSessionLabel; } diff --git a/src/main/java/org/nrg/xnatx/plugins/transfer/model/TransferRequest.java b/src/main/java/org/nrg/xnatx/plugins/transfer/model/TransferRequest.java index 128facc..fcb1a5e 100644 --- a/src/main/java/org/nrg/xnatx/plugins/transfer/model/TransferRequest.java +++ b/src/main/java/org/nrg/xnatx/plugins/transfer/model/TransferRequest.java @@ -16,4 +16,13 @@ public class TransferRequest { private String destinationProject; private String id; private TransferMode mode; + // Reimport only: preserve the source subject's XNAT label by passing it to the importer + private Boolean preserveSubjectLabel; + // Reimport only: preserve the source session's XNAT label by passing it to the importer + private Boolean preserveSessionLabel; + + // Retain no-preserve-flag default constructor + public TransferRequest(String destinationProject, String id, TransferMode mode) { + this(destinationProject, id, mode, null, null); + } } diff --git a/src/main/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImpl.java b/src/main/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImpl.java index fb27b72..7431ebe 100644 --- a/src/main/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImpl.java +++ b/src/main/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImpl.java @@ -153,7 +153,8 @@ private void enqueueReimport(final String trackingId, final List> uriRef = new AtomicReference<>(); XnatUtils.doActionWithWorkflow(user, sourceExperiment, diff --git a/src/main/resources/META-INF/resources/scripts/xnat/plugin/batchTransfer/batchTransfer.js b/src/main/resources/META-INF/resources/scripts/xnat/plugin/batchTransfer/batchTransfer.js index 736a97b..657b952 100644 --- a/src/main/resources/META-INF/resources/scripts/xnat/plugin/batchTransfer/batchTransfer.js +++ b/src/main/resources/META-INF/resources/scripts/xnat/plugin/batchTransfer/batchTransfer.js @@ -191,6 +191,8 @@ var XNAT = getObject(XNAT || {}); function updateOperationDetail() { $('#bt-op-detail-desc').text(operationDetails[selectedOp] || ''); + // The preserve-source-label checkboxes only apply to Reimport. + $('#bt-reimport-label-option').toggle(selectedOp === 'Reimport'); } // ── Operation Selection ── @@ -672,15 +674,25 @@ var XNAT = getObject(XNAT || {}); return false; } + // Reimport only: whether to preserve the source XNAT subject/session labels (server resolves + // the actual label values). Ignored for Share/Clone. + var preserveSubjectLabel = (selectedOp === 'Reimport') && $('#bt-preserve-subject-label').is(':checked'); + var preserveSessionLabel = (selectedOp === 'Reimport') && $('#bt-preserve-session-label').is(':checked'); + var items = []; $selected.each(function() { var id = $(this).data('id'); if (id) { - items.push({ + var item = { id: id, mode: selectedOp, destination_project: selectedProject - }); + }; + if (selectedOp === 'Reimport') { + item.preserve_subject_label = preserveSubjectLabel; + item.preserve_session_label = preserveSessionLabel; + } + items.push(item); } }); diff --git a/src/main/resources/META-INF/resources/style/xnat/plugin/batchTransfer/batchTransfer.css b/src/main/resources/META-INF/resources/style/xnat/plugin/batchTransfer/batchTransfer.css index dcc4bf3..d83a0ca 100644 --- a/src/main/resources/META-INF/resources/style/xnat/plugin/batchTransfer/batchTransfer.css +++ b/src/main/resources/META-INF/resources/style/xnat/plugin/batchTransfer/batchTransfer.css @@ -1008,3 +1008,42 @@ tr.bt-import-disabled[data-type="subject"]:hover { .bt-proj-locked-name { font-weight: 600; } + +/* Reimport: preserve-source-label checkboxes, laid out side by side (Subject | Session). */ +.bt-label-options { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e8eaed; +} + +.bt-label-options-title { + font-size: 14px; + font-weight: 500; + color: #202124; + margin-bottom: 3px; +} + +.bt-label-options-help { + font-size: 12px; + color: #5f6368; + line-height: 1.45; + margin: 0 0 10px; +} + +.bt-label-checks { + display: flex; + gap: 20px; +} + +.bt-label-checks label { + display: inline-flex; + align-items: center; + font-size: 13px; + color: #3c4043; + cursor: pointer; +} + +.bt-label-checks input[type="checkbox"] { + margin: 0 6px 0 0; + vertical-align: middle; +} diff --git a/src/main/resources/META-INF/resources/templates/screens/XDATScreen_batch_transfer.vm b/src/main/resources/META-INF/resources/templates/screens/XDATScreen_batch_transfer.vm index 6f87d6b..7f64566 100644 --- a/src/main/resources/META-INF/resources/templates/screens/XDATScreen_batch_transfer.vm +++ b/src/main/resources/META-INF/resources/templates/screens/XDATScreen_batch_transfer.vm @@ -82,6 +82,16 @@
Re-anonymize image sessions
+ + + diff --git a/src/test/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImplTest.java b/src/test/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImplTest.java index 5cde05e..da17618 100644 --- a/src/test/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImplTest.java +++ b/src/test/java/org/nrg/xnatx/plugins/transfer/service/impl/BatchTransferServiceImplTest.java @@ -158,6 +158,60 @@ private void stubImageSessionAsSource() throws Exception { .thenReturn(tmp.newFolder("src").getAbsolutePath()); } + private static final String SUBJECT_ID = "SUBJ1"; + private static final String SUBJECT_LABEL = "subjectLabel"; + + /** + * Drives a REIMPORT of the image session with the given preserve-label flags and returns the + * parameter map handed to the (spy-stubbed) {@code runImporter} seam, so a test can assert which + * label overrides ("subject" / "session") were set. {@code subjectLabel} is what the resolved + * source subject reports as its label (pass blank to exercise the "blank ⇒ no override" path). + */ + @SuppressWarnings("unchecked") + private Map reimportCapturingImporterParams(final boolean preserveSubjectLabel, + final boolean preserveSessionLabel, + final String subjectLabel) throws Exception { + stubImageSessionAsSource(); + when(imageSession.getProperty("subject_ID")).thenReturn(SUBJECT_ID); + when(subject.getLabel()).thenReturn(subjectLabel); + + final List importerUris = Collections.singletonList( + "/prearchive/projects/" + DEST_PROJECT + "/20260101_000000/" + LABEL); + doReturn(importerUris).when(service).runImporter( + any(UserI.class), any(FileWriterWrapperI.class), any(Map.class)); + + final ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(Map.class); + + try (MockedStatic utils = mockStatic(XnatUtils.class); + MockedStatic perms = mockStatic(Permissions.class); + MockedStatic feats = mockStatic(Features.class); + MockedStatic prearc = mockStatic(PrearcUtils.class); + MockedConstruction sess = mockConstruction(PrearcSession.class); + MockedConstruction req = + mockConstruction(PrearchiveOperationRequest.class)) { + + utils.when(() -> XnatUtils.getProject(DEST_PROJECT, user)).thenReturn(destinationProjectData); + utils.when(() -> XnatUtils.getArchivableItem(EXP_ID, null)).thenReturn(imageSession); + utils.when(() -> XnatUtils.getSubject(SUBJECT_ID, user)).thenReturn(subject); + utils.when(() -> XnatUtils.doActionWithWorkflow( + any(UserI.class), any(), anyString(), any(Callable.class))) + .thenAnswer(inv -> { + ((Callable) inv.getArgument(3)).call(); + return true; + }); + perms.when(() -> Permissions.canRead(user, imageSession)).thenReturn(true); + perms.when(() -> Permissions.canCreate(eq(user), anyString(), eq(DEST_PROJECT))).thenReturn(true); + feats.when(() -> Features.checkRestrictedFeature(eq(user), anyString(), anyString())).thenReturn(true); + prearc.when(() -> PrearcUtils.parseURI(anyString())).thenReturn(uriProps()); + + service.processItem(new TransferRequest(DEST_PROJECT, EXP_ID, TransferMode.REIMPORT, + preserveSubjectLabel, preserveSessionLabel), user, eventInfo()); + + verify(service).runImporter(eq(user), any(FileWriterWrapperI.class), paramsCaptor.capture()); + return paramsCaptor.getValue(); + } + } + // ----------------------------------------------------------------- // Tests // ----------------------------------------------------------------- @@ -325,6 +379,184 @@ public void importImagesession_happyPath() throws Exception { } } + /** + * Preserve-label flags (issue #10): each flag independently adds a label override to the importer + * params — "subject" (→ SUBJECT_ID) from the source subject label and "session" (→ EXPT_LABEL) from + * the source session label — so the destination uses the source XNAT labels instead of deriving them + * from DICOM tags. The four flag combinations are asserted via the captured runImporter params. + */ + @Test + public void preserveLabels_both_addsSubjectAndSessionOverrides() throws Exception { + final Map params = reimportCapturingImporterParams(true, true, SUBJECT_LABEL); + assertEquals(SUBJECT_LABEL, params.get("subject")); + assertEquals(LABEL, params.get("session")); + } + + @Test + public void preserveLabels_subjectOnly_addsSubjectOverrideOnly() throws Exception { + final Map params = reimportCapturingImporterParams(true, false, SUBJECT_LABEL); + assertEquals(SUBJECT_LABEL, params.get("subject")); + assertTrue("session override must not be set when only subject is preserved", + !params.containsKey("session")); + } + + @Test + public void preserveLabels_sessionOnly_addsSessionOverrideOnly() throws Exception { + final Map params = reimportCapturingImporterParams(false, true, SUBJECT_LABEL); + assertEquals(LABEL, params.get("session")); + assertTrue("subject override must not be set when only session is preserved", + !params.containsKey("subject")); + } + + @Test + public void preserveLabels_none_addsNoOverrides() throws Exception { + final Map params = reimportCapturingImporterParams(false, false, SUBJECT_LABEL); + assertTrue("subject override must not be set", !params.containsKey("subject")); + assertTrue("session override must not be set", !params.containsKey("session")); + } + + /** + * Drives a REIMPORT that preserves only the subject label, with the resolved source subject + * reporting {@code subjectLabel}, and returns the thrown exception (or null). Used for the + * blank/missing-label error cases; asserts the importer is never invoked. + */ + private Exception reimportExpectingSubjectLabelError(final String subjectLabel) throws Exception { + stubImageSessionAsSource(); + when(imageSession.getProperty("subject_ID")).thenReturn(SUBJECT_ID); + when(subject.getLabel()).thenReturn(subjectLabel); + + try (MockedStatic utils = mockStatic(XnatUtils.class); + MockedStatic perms = mockStatic(Permissions.class); + MockedStatic feats = mockStatic(Features.class)) { + + utils.when(() -> XnatUtils.getProject(DEST_PROJECT, user)).thenReturn(destinationProjectData); + utils.when(() -> XnatUtils.getArchivableItem(EXP_ID, null)).thenReturn(imageSession); + utils.when(() -> XnatUtils.getSubject(SUBJECT_ID, user)).thenReturn(subject); + perms.when(() -> Permissions.canRead(user, imageSession)).thenReturn(true); + perms.when(() -> Permissions.canCreate(eq(user), anyString(), eq(DEST_PROJECT))).thenReturn(true); + feats.when(() -> Features.checkRestrictedFeature(eq(user), anyString(), anyString())).thenReturn(true); + + final Exception thrown = processItemCatching( + new TransferRequest(DEST_PROJECT, EXP_ID, TransferMode.REIMPORT, true, false)); + verify(service, never()).runImporter(any(UserI.class), any(FileWriterWrapperI.class), any(Map.class)); + return thrown; + } + } + + /** + * Drives a REIMPORT that preserves only the session label, with the source session reporting + * {@code sessionLabel}, and returns the thrown exception (or null). Asserts the importer is never invoked. + */ + private Exception reimportExpectingSessionLabelError(final String sessionLabel) throws Exception { + when(imageSession.getXSIType()).thenReturn("xnat:mrSessionData"); + when(imageSession.getProject()).thenReturn(SRC_PROJECT); + when(imageSession.getId()).thenReturn(EXP_ID); + when(imageSession.getLabel()).thenReturn(sessionLabel); + when(imageSession.getCurrentSessionFolder(true)).thenReturn(tmp.newFolder().getAbsolutePath()); + + try (MockedStatic utils = mockStatic(XnatUtils.class); + MockedStatic perms = mockStatic(Permissions.class); + MockedStatic feats = mockStatic(Features.class)) { + + utils.when(() -> XnatUtils.getProject(DEST_PROJECT, user)).thenReturn(destinationProjectData); + utils.when(() -> XnatUtils.getArchivableItem(EXP_ID, null)).thenReturn(imageSession); + perms.when(() -> Permissions.canRead(user, imageSession)).thenReturn(true); + perms.when(() -> Permissions.canCreate(eq(user), anyString(), eq(DEST_PROJECT))).thenReturn(true); + feats.when(() -> Features.checkRestrictedFeature(eq(user), anyString(), anyString())).thenReturn(true); + + final Exception thrown = processItemCatching( + new TransferRequest(DEST_PROJECT, EXP_ID, TransferMode.REIMPORT, false, true)); + verify(service, never()).runImporter(any(UserI.class), any(FileWriterWrapperI.class), any(Map.class)); + return thrown; + } + } + + /** + * Preserve subject/session label requested but the source label is blank or missing (null) → the + * substitution has nothing usable, so processItem fails the item rather than skipping it. All four + * combinations (subject/session × null/blank) throw and never reach the importer. + */ + @Test + public void preserveLabels_nullSubjectLabel_throws() throws Exception { + final Exception thrown = reimportExpectingSubjectLabelError(null); + assertNotNull("expected processItem to throw when the source subject label is null", thrown); + assertTrue("message should mention the blank/missing subject label: " + thrown.getMessage(), + thrown.getMessage() != null && thrown.getMessage().contains("subject label is blank or missing")); + } + + @Test + public void preserveLabels_blankSubjectLabel_throws() throws Exception { + final Exception thrown = reimportExpectingSubjectLabelError(" "); + assertNotNull("expected processItem to throw when the source subject label is blank", thrown); + assertTrue("message should mention the blank/missing subject label: " + thrown.getMessage(), + thrown.getMessage() != null && thrown.getMessage().contains("subject label is blank or missing")); + } + + @Test + public void preserveLabels_nullSessionLabel_throws() throws Exception { + final Exception thrown = reimportExpectingSessionLabelError(null); + assertNotNull("expected processItem to throw when the source session label is null", thrown); + assertTrue("message should mention the blank/missing session label: " + thrown.getMessage(), + thrown.getMessage() != null && thrown.getMessage().contains("session label is blank or missing")); + } + + @Test + public void preserveLabels_blankSessionLabel_throws() throws Exception { + final Exception thrown = reimportExpectingSessionLabelError(" "); + assertNotNull("expected processItem to throw when the source session label is blank", thrown); + assertTrue("message should mention the blank/missing session label: " + thrown.getMessage(), + thrown.getMessage() != null && thrown.getMessage().contains("session label is blank or missing")); + } + + /** + * A Reimport whose request omits the preserve-label flags (the 3-arg constructor leaves both + * {@code null} — as a direct API call or pre-flag client would) must not throw and must add no + * label overrides. Guards the {@code Boolean.TRUE.equals(...)} null check in importExperiment: a + * "simplification" to {@code if (preserveSubjectLabel)} would NPE here while passing every other test. + */ + @Test + public void preserveLabels_nullFlags_addsNoOverridesAndDoesNotThrow() throws Exception { + stubImageSessionAsSource(); + + final List importerUris = Collections.singletonList( + "/prearchive/projects/" + DEST_PROJECT + "/20260101_000000/" + LABEL); + doReturn(importerUris).when(service).runImporter( + any(UserI.class), any(FileWriterWrapperI.class), any(Map.class)); + + final ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(Map.class); + + try (MockedStatic utils = mockStatic(XnatUtils.class); + MockedStatic perms = mockStatic(Permissions.class); + MockedStatic feats = mockStatic(Features.class); + MockedStatic prearc = mockStatic(PrearcUtils.class); + MockedConstruction sess = mockConstruction(PrearcSession.class); + MockedConstruction req = + mockConstruction(PrearchiveOperationRequest.class)) { + + utils.when(() -> XnatUtils.getProject(DEST_PROJECT, user)).thenReturn(destinationProjectData); + utils.when(() -> XnatUtils.getArchivableItem(EXP_ID, null)).thenReturn(imageSession); + utils.when(() -> XnatUtils.doActionWithWorkflow( + any(UserI.class), any(), anyString(), any(Callable.class))) + .thenAnswer(inv -> { + ((Callable) inv.getArgument(3)).call(); + return true; + }); + perms.when(() -> Permissions.canRead(user, imageSession)).thenReturn(true); + perms.when(() -> Permissions.canCreate(eq(user), anyString(), eq(DEST_PROJECT))).thenReturn(true); + feats.when(() -> Features.checkRestrictedFeature(eq(user), anyString(), anyString())).thenReturn(true); + prearc.when(() -> PrearcUtils.parseURI(anyString())).thenReturn(uriProps()); + + // reimport(id) uses the 3-arg constructor, so both preserve flags are null. + service.processItem(reimport(EXP_ID), user, eventInfo()); + + verify(service).runImporter(eq(user), any(FileWriterWrapperI.class), paramsCaptor.capture()); + assertTrue("null preserveSubjectLabel must add no subject override", + !paramsCaptor.getValue().containsKey("subject")); + assertTrue("null preserveSessionLabel must add no session override", + !paramsCaptor.getValue().containsKey("session")); + } + } + /** * If runImporter returns an empty URI list, importExperiment throws inside the workflow callable * ("No DICOM files ... nothing to reimport"), so processItem throws. From 48a80422e13da791eb5497610ff46c0d3b43042a Mon Sep 17 00:00:00 2001 From: Matt Kelsey Date: Tue, 30 Jun 2026 09:21:47 -0500 Subject: [PATCH 2/2] Revert vXnat to 1.9.3.3. XNAT 1.9.3.5 works fine, but is not required. --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 82e782a..8d0f6ae 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ buildscript { ext { - vXnat = "1.9.3.5" + vXnat = "1.9.3.3" pluginAppName = "Batch Transfer Plugin" } }