Skip to content

Commit 9c348f6

Browse files
luis100claude
authored andcommitted
Nested documents UI
Phase 0: EmailArchive nested-document metadata schema (#3660) Introduces the EmailArchive metadata type as the reference implementation for Solr nested-document support in RODA — fully config-driven, zero Java. - emailarchive.xsd: XML schema (parent mailbox + child email elements) - emailarchive.xslt: ingest crosswalk producing nested Solr child docs via <field name="emails"><doc>…</doc></field> blocks; follows rakenskapsinfo pattern - Register type in roda-wui.properties and i18n ServerMessages.properties - EmailArchiveCrosswalkTest: 12 TestNG tests (full/minimal/no-emails fixtures) covering parent fields, date fields, child count, multi-value recipients, and absent-optional-field assertions Part of: #3382 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> feat(search): Phase 1 — Advanced AIP Search nested filter groups Adds `nestedType` and `nestedParentType` properties to SearchField so config-declared fields can carry a Solr block-join context. `SearchPanel.buildSearchFilter()` groups all nested-type fields by their (nestedType, nestedParentType) pair and wraps each group in a `ParentWhichFilterParameter` — following the RepresentationInformation pattern — instead of emitting flat filter parameters. Config registers three EmailArchive child search fields (emailSubject, emailSender, emailSentDate) as the reference example; any future nested metadata type benefits automatically through `roda-wui.properties` alone — no Java changes required. Note: the implementation uses a `nestedType` property on SearchField rather than the `nested_group` field type originally proposed in #3661. This is simpler (no new GWT widget), equally expressive, and avoids adding a UI rendering path that would be unused for all current types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> feat(emailarchive): add title/level to ingest XSLT and HTML dissemination crosswalk Ingest XSLT now emits `title` (custodian name, emailAddress as fallback) and a fixed `level = item` so emailarchive AIPs display correctly in the browse list alongside other AIP types. New HTML dissemination crosswalk renders all mailbox-level fields with i18n labels and shows the indexed email messages as a compact table (subject, sender, sent date, folder). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> fix(emailarchive): use composite title in ingest XSLT Format: "Custodian <email> (dateStart / dateEnd)" Date range is omitted when both dates are absent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> fix(emailarchive): add state=ACTIVE to nested email child documents Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Adding test for nested documents indexing and search fix(search): add missing appendANDOperator in block-join filter builders Without the AND operator prefix, combining AllFilterParameter (*:*) with ParentWhichFilterParameter or ChildOfFilterParameter produced invalid Solr syntax (*:*{!parent which=...}) that returned 0 results. Also corrects inner parseFilterParameter calls to use false since inner builders are empty. Update CLAUDE.md to reflect that tests use Testcontainers — no docker compose setup or environment variables required to run tests, only Docker on the host. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> fix(i18n): add missing GWT plural forms across locale files Add [one] forms alongside existing [=1] forms for de_AT, de_DE, es, pt_PT, sv_SE — required by GWT for locales where 'one' is a mandatory plural category. Add [one] and [few] forms for hr (Croatian), which also requires 'few'. Fix hu (Hungarian) by renaming [one] to [=1] since Hungarian uses DefaultRule which has no 'one' plural category. Affected keys: selected, representationInformation*, retentionPeriod, liftDisposalHoldDialogMessage, disposalHoldAssociatedFromValue, applyDisposalHoldDialogMessage, clearDisposalHoldDialogMessage, disassociateDisposalHoldDialogMessage, disassociateDisposalScheduleDialogMessage, disposalPolicySchedule{Day,Month,Year}Summary. Also adds missing Swedish translation for liftDisposalHoldDialog and Croatian translation for liftDisposalHoldDialog. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> fix(search): remove space in block-join queries to fix q.op=AND interaction Solr's standard query parser tokenizes {!parent which=...} and the child query as SEPARATE clauses when whitespace separates them. With q.op=AND, the child query becomes a required top-level clause matching child documents directly, returning 0 AIP results. Removing the space makes the child/parent filter directly adjacent to the closing brace, so Solr correctly treats it as the sub-query for the local params parser: {!parent which=<parentFilter>}<childFilter>. Extend NestedDocumentSearchTest with a layer-5 assertion that uses AllFilterParameter + ParentWhichFilterParameter (the production scenario), covering the *:* AND {!parent ...}<children> query form. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> fix(search): fix block-join query format for q.op=AND compatibility - Use double-quoted which= local param: `which="field:value"` instead of `which=(field:"value")` - Use required-operator (+) prefix in child/parent sub-queries: `(+field:"value")` - Skip AllFilterParameter when block-join params are present to avoid spurious *:* AND prefix - Add dedicated buildBlockJoinMask() and buildBlockJoinSubQuery() helpers to generate correct Solr block-join syntax without relying on the standard filter-to-query path - Add string-level query assertion in NestedDocumentSearchTest (Layer 4) to lock in format - Update Layer 5 to assert AllFilterParameter is suppressed when block-join is present Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> fix(emailarchive): index dateStart/dateEnd as standard dateInitial/dateFinal fields Map the email archive date range fields to the standard AIP date fields so they appear in the existing date range search facet without needing custom Solr fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> revert: remove AllFilterParameter suppression from parseFilter block-join path The *:* AND prefix produced by AllFilterParameter alongside a block-join query is functionally harmless — *:* matches every document so intersecting with it leaves the block-join result unchanged. The specialization was unnecessary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> fix(search): handle wildcard values in block-join child sub-query builder Wildcards (* ?) cannot appear inside quoted phrases in Solr's standard query parser. When a BasicSearchFilterParameter value contains wildcards, emit each whitespace-separated token as an unquoted +field:token* clause instead of the phrase-quoted form that was causing SyntaxError. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> fix(search): fix email sent date filter format and selector type - Change emailSentDate search field type from 'date' to 'date_interval' so the UI uses a proper from/to date picker and creates DateIntervalFilterParameter with Date objects rather than SimpleFilterParameter with Date.toString() (which produces 'Mon Mar 15 00:00:00 GMT+000 2021' — invalid for Solr) - Add explicit DateIntervalFilterParameter, DateRangeFilterParameter and LongRangeFilterParameter handlers in buildBlockJoinSubQueryClause to emit clean +field:[from TO to] range clauses with proper ISO8601 dates instead of going through the verbose appendRangeInterval fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Update Spanish translations fix(search): exclude nested child documents from normal AIP queries Solr block-join nested documents (e.g. email children inside an EmailArchive AIP) were leaking into the normal AIP catalogue search, showing up as ghost rows with no title/level and navigating to broken child UUIDs on click. Adds a -_nest_path_:* filter query to all IndexedAIP find/cursor/suggest paths when ChildOfFilterParameter is not present. Behaviour is also exposed as a configurable flag: FindRequest.includeNestedDocuments (default: false) Clients can set ui.lists.<listId>.includeNestedDocuments = true in roda-wui.properties to opt-in to receiving nested documents in a specific list (used by the upcoming virtual catalogue for EmailArchive). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> feat(catalogue): add virtual catalogue for email archives and individual emails fix(catalogue): fix email column rendering and enable advanced search in virtual catalogues - Fix LIST rendering hint bug: recursive call passed LIST hint causing ClassCastException when inner values are strings; now passes null - Apply LIST rendering hint to subject_txt so multivalued subjects display cleanly instead of as Java array toString ([value]) - Apply DATETIME_FORMAT_SIMPLE rendering hint to sentDate_dt for human-readable date formatting - Enable advanced search for Search_emailarchive and Search_emails virtual catalogue tabs - Add explicit search fields for Search_emailarchive (title, description, dates) to avoid inheriting nestedType email fields from the IndexedAIP scope fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> fix(crosswalk): emit dateStart_dt and dateEnd_dt alongside dateInitial and dateFinal in emailarchive crosswalk Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 860c08f commit 9c348f6

39 files changed

Lines changed: 2346 additions & 947 deletions

CLAUDE.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ roda/
8484

8585
1. **Java 21** (Oracle JDK) — strictly required for compilation
8686
2. **Maven 3.8.6+** — build tool
87-
3. **Docker & Docker Compose** — for running Solr, PostgreSQL, LDAP, etc.
87+
3. **Docker**required for running the application locally and for tests (via Testcontainers — no manual `docker compose` needed for tests)
8888
4. **GitHub account with PAT** — required for GitHub Packages dependency resolution
8989

9090
**Configure Maven for GitHub Packages** (`~/.m2/settings.xml`):
@@ -112,6 +112,10 @@ The PAT must have `read:packages` permission. Without this, the build will fail
112112

113113
### Starting Development Dependencies
114114

115+
**For running tests:** No manual setup needed — tests use **Testcontainers** (`TestContainersManager`) which automatically starts ZooKeeper, Solr, PostgreSQL, Mailpit, ClamAV, and Siegfried as ephemeral Docker containers. The `RodaContainersLifecycleListener` TestNG suite listener wires this up before any test class loads. Docker must be running on the host, but no `docker compose` command is required.
116+
117+
**For running the application locally:**
118+
115119
```bash
116120
# Create required data directories
117121
mkdir -p $HOME/.roda/data/{storage,staging-storage}
@@ -120,7 +124,7 @@ mkdir -p $HOME/.roda/data/{storage,staging-storage}
120124
docker compose -f deploys/standalone/docker-compose-dev.yaml up -d
121125
```
122126

123-
Services and ports:
127+
Services and ports (for local app, not tests):
124128
- ZooKeeper: `2181`
125129
- Apache Solr: `8983`
126130
- PostgreSQL: `5432`
@@ -196,25 +200,23 @@ mvn -f dev/codeserver gwt:codeserver -DrodaPath=$(pwd)
196200

197201
### Running Tests
198202

203+
Tests use **Testcontainers** — no environment variables or `docker compose` setup required. Docker must be available on the host.
204+
199205
```bash
200-
# All tests (requires Docker services running)
201-
mvn clean test
206+
# All tests
207+
mvn clean test -Pcore
202208

203209
# CI subset only (faster)
204-
mvn -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent test
210+
mvn -Dtestng.groups="travis" -Denforcer.skip=true clean org.jacoco:jacoco-maven-plugin:prepare-agent test -Pcore
211+
212+
# Specific test class
213+
mvn -pl roda-core/roda-core-tests -am test -Dtest=NestedDocumentSearchTest -Dtestng.groups=dev -Denforcer.skip=true -DfailIfNoTests=false
205214

206215
# Skip tests
207216
mvn clean package -Dmaven.test.skip=true
208217
```
209218

210-
### Required Environment Variables (for tests matching CI)
211-
212-
```
213-
RODA_CORE_SOLR_TYPE=CLOUD
214-
SIEGFRIED_MODE=standalone
215-
```
216-
217-
See `.github/workflows/CI.yml` for the full CI test environment configuration.
219+
See `.github/workflows/CI.yml` for the full CI configuration.
218220

219221
### Key Test Classes
220222

roda-common/roda-common-data/src/main/java/org/roda/core/data/common/RodaConstants.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,15 @@ public enum DateGranularity {
176176

177177
public static final String UI_LISTS_PAGE_SIZE_INITIAL = "pageSize.initial";
178178
public static final String UI_LISTS_PAGE_SIZE_INCREMENT = "pageSize.increment";
179+
public static final String UI_LISTS_INCLUDE_NESTED_DOCUMENTS = "includeNestedDocuments";
180+
181+
public static final String UI_CATALOGUE_VIRTUAL_PROPERTY = "ui.catalogue.virtual";
182+
public static final String UI_LISTS_CATALOGUE_LABEL_I18N_PROPERTY = "catalogue.label.i18n";
183+
public static final String UI_LISTS_CATALOGUE_ICON_PROPERTY = "catalogue.icon";
184+
public static final String UI_LISTS_CATALOGUE_FILTER = "catalogue.filter";
185+
public static final String UI_LISTS_CATALOGUE_CHILDOF_FILTER = "catalogue.childOf.filter";
186+
public static final String UI_LISTS_CATALOGUE_CLICK_ACTION = "catalogue.click_action";
187+
public static final String UI_LISTS_CATALOGUE_CLICK_ACTION_BROWSE_PARENT = "browse_parent";
179188

180189
public static final String UI_ICONS_CLASS = "ui.icons.class";
181190
public static final String UI_SERVICE_DROPFOLDER_URL = "ui.service.dropfolder.url";
@@ -1980,6 +1989,8 @@ public String toString() {
19801989
public static final String SEARCH_FIELD_TYPE_SUGGEST_FIELD = "suggestField";
19811990
public static final String SEARCH_FIELD_TYPE_SUGGEST_PARTIAL = "suggestPartial";
19821991
public static final String SEARCH_FIELD_TYPE_CONTROLLED = "controlled";
1992+
public static final String SEARCH_FIELD_NESTED_TYPE = "nestedType";
1993+
public static final String SEARCH_FIELD_NESTED_PARENT_TYPE = "nestedParentType";
19831994
public static final String SEARCH_WITH_PREFILTER_HANDLER = "$prefilter";
19841995
public static final String SEARCH_WITH_SAVED_HANDLER = "$savedSearch";
19851996

roda-common/roda-common-data/src/main/java/org/roda/core/data/v2/index/FindRequest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ public class FindRequest extends CountRequest {
6262
private Long childrenLimit;
6363
@JsonProperty("childrenFilter")
6464
private Filter childrenFilter;
65+
@JsonProperty("includeNestedDocuments")
66+
private boolean includeNestedDocuments;
6567

6668
public FindRequest() {
6769
super();
@@ -85,6 +87,7 @@ private FindRequest(FindRequestBuilder builder) {
8587
this.childrenFieldsToReturn = builder.childrenFieldsToReturn;
8688
this.childrenLimit = builder.childrenLimit;
8789
this.childrenFilter = builder.childrenFilter;
90+
this.includeNestedDocuments = builder.includeNestedDocuments;
8891
}
8992

9093
@JsonCreator
@@ -176,6 +179,10 @@ public Filter getChildrenFilter() {
176179
return childrenFilter;
177180
}
178181

182+
public boolean isIncludeNestedDocuments() {
183+
return includeNestedDocuments;
184+
}
185+
179186
public void setChildrenFilter(Filter childrenFilter) {
180187
this.childrenFilter = childrenFilter;
181188
}
@@ -195,6 +202,7 @@ public static class FindRequestBuilder {
195202
private List<String> childrenFieldsToReturn;
196203
private Long childrenLimit;
197204
private Filter childrenFilter;
205+
private boolean includeNestedDocuments;
198206

199207
public FindRequestBuilder(@JsonProperty("filter") final Filter filter,
200208
@JsonProperty("onlyActive") boolean onlyActive) {
@@ -209,6 +217,7 @@ public FindRequestBuilder(@JsonProperty("filter") final Filter filter,
209217
this.fieldsToReturn = Collections.emptyList();
210218
this.collapse = null;
211219
this.children = false;
220+
this.includeNestedDocuments = false;
212221
}
213222

214223
public FindRequest build() {
@@ -269,5 +278,10 @@ public FindRequestBuilder withChildrenFilter(Filter childrenFilter) {
269278
this.childrenFilter = childrenFilter;
270279
return this;
271280
}
281+
282+
public FindRequestBuilder withIncludeNestedDocuments(boolean includeNestedDocuments) {
283+
this.includeNestedDocuments = includeNestedDocuments;
284+
return this;
285+
}
272286
}
273287
}

roda-core/roda-core-tests/src/main/java/org/roda/core/CorporaConstants.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ public final class CorporaConstants {
9494

9595
public static final String SOURCE_DESC_METADATA_CONTAINER = "DescriptiveMetadata";
9696
public static final String STRANGE_DESC_METADATA_FILE = "strange.xml";
97+
public static final String EMAIL_ARCHIVE_FULL_FILE = "emailarchive_full.xml";
98+
public static final String EMAIL_ARCHIVE_MINIMAL_FILE = "emailarchive_minimal.xml";
99+
public static final String EMAIL_ARCHIVE_NO_EMAILS_FILE = "emailarchive_no_emails.xml";
100+
public static final String EMAIL_ARCHIVE_METADATA_TYPE = "emailarchive";
97101

98102
public static final String TEXT_XML = "text/xml";
99103
public static final String REPRESENTATION_1_PREMIS_EVENT_ID = "urn:roda:premis:event:roda_398";
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
/**
2+
* The contents of this file are subject to the license and copyright
3+
* detailed in the LICENSE file at the root of the source
4+
* tree and available online at
5+
*
6+
* https://github.com/keeps/roda
7+
*/
8+
package org.roda.core.index;
9+
10+
import static org.testng.AssertJUnit.assertEquals;
11+
import static org.testng.AssertJUnit.assertNotNull;
12+
import static org.testng.AssertJUnit.assertNull;
13+
14+
import java.io.IOException;
15+
import java.net.URISyntaxException;
16+
import java.net.URL;
17+
import java.nio.file.Path;
18+
import java.nio.file.Paths;
19+
import java.util.Collection;
20+
21+
import org.apache.solr.common.SolrInputDocument;
22+
import org.apache.solr.common.SolrInputField;
23+
import org.roda.core.CorporaConstants;
24+
import org.roda.core.RodaCoreFactory;
25+
import org.roda.core.data.common.RodaConstants;
26+
import org.roda.core.data.exceptions.GenericException;
27+
import org.roda.core.data.exceptions.NotFoundException;
28+
import org.roda.core.data.exceptions.RODAException;
29+
import org.roda.core.index.utils.SolrUtils;
30+
import org.roda.core.storage.Binary;
31+
import org.roda.core.storage.DefaultStoragePath;
32+
import org.roda.core.storage.StorageService;
33+
import org.roda.core.storage.fs.FileStorageService;
34+
import org.testng.Assert;
35+
import org.testng.annotations.AfterMethod;
36+
import org.testng.annotations.BeforeClass;
37+
import org.testng.annotations.BeforeMethod;
38+
import org.testng.annotations.Test;
39+
40+
@Test(groups = {RodaConstants.TEST_GROUP_ALL, RodaConstants.TEST_GROUP_DEV, RodaConstants.TEST_GROUP_TRAVIS})
41+
public class EmailArchiveCrosswalkTest {
42+
43+
private static StorageService corporaService;
44+
45+
@BeforeClass
46+
public static void setUp() throws URISyntaxException, GenericException {
47+
URL corporaURL = IndexServiceTest.class.getResource("/corpora");
48+
Path corporaPath = Paths.get(corporaURL.toURI());
49+
corporaService = new FileStorageService(corporaPath);
50+
}
51+
52+
@BeforeMethod
53+
public void init() {
54+
RodaCoreFactory.instantiateTest(false, false, false, false, false, false, false);
55+
}
56+
57+
@AfterMethod
58+
public void cleanup() throws NotFoundException, GenericException, IOException {
59+
RodaCoreFactory.shutdown();
60+
}
61+
62+
// ---------------------------------------------------------------------------
63+
// Full fixture — 3 emails
64+
// ---------------------------------------------------------------------------
65+
66+
@Test
67+
public void testFullCrosswalkProducesParentFields() throws RODAException {
68+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_FULL_FILE);
69+
70+
assertNotNull(doc);
71+
assertFieldValue(doc, "custodian_txt", "João Silva");
72+
assertFieldValue(doc, "emailAddress_s", "joao.silva@empresa.pt");
73+
assertFieldValue(doc, "totalMessages_i", "3");
74+
assertFieldValue(doc, "originalFormat_s", "PST");
75+
assertFieldValue(doc, "archivingMotive_txt", "Offboarding");
76+
assertFieldValue(doc, "content_type", "emailarchive");
77+
}
78+
79+
@Test
80+
public void testFullCrosswalkProducesDateFields() throws RODAException {
81+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_FULL_FILE);
82+
83+
assertNotNull(doc);
84+
assertFieldValue(doc, "dateStart_dt", "2020-01-01T00:00:00Z");
85+
assertFieldValue(doc, "dateEnd_dt", "2023-12-31T00:00:00Z");
86+
}
87+
88+
@Test
89+
public void testFullCrosswalkProducesThreeChildDocuments() throws RODAException {
90+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_FULL_FILE);
91+
92+
assertNotNull(doc);
93+
SolrInputField emailsField = doc.getField("emails");
94+
assertNotNull("'emails' field must be present for nested children", emailsField);
95+
96+
Collection<SolrInputDocument> children = getChildDocuments(emailsField);
97+
assertEquals("Expected 3 child email documents", 3, children.size());
98+
}
99+
100+
@Test
101+
public void testFullCrosswalkFirstChildFields() throws RODAException {
102+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_FULL_FILE);
103+
SolrInputDocument first = getChildAt(doc, 0);
104+
105+
assertFieldValue(first, "content_type", "email");
106+
assertFieldValue(first, "messageId_s", "<msg001@empresa.pt>");
107+
assertFieldValue(first, "subject_txt", "Quarterly Report Q1 2021");
108+
assertFieldValue(first, "sender_s", "joao.silva@empresa.pt");
109+
assertFieldValue(first, "sentDate_dt", "2021-03-15T09:42:00Z");
110+
assertFieldValue(first, "folderPath_s", "Inbox/Projects");
111+
assertFieldValue(first, "hasAttachments_b", "true");
112+
assertFieldValue(first, "filePath_s", "Inbox/Projects/msg_001.eml");
113+
}
114+
115+
@Test
116+
public void testFullCrosswalkMultipleRecipients() throws RODAException {
117+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_FULL_FILE);
118+
SolrInputDocument first = getChildAt(doc, 0);
119+
120+
// First email has two recipients: ana.costa and rui.pinto
121+
SolrInputField recipientsField = first.getField("recipients_txt");
122+
assertNotNull("recipients_txt field must be present", recipientsField);
123+
Collection<?> values = recipientsField.getValues();
124+
assertNotNull(values);
125+
assertEquals("Expected 2 recipient values", 2, values.size());
126+
}
127+
128+
@Test
129+
public void testFullCrosswalkThirdChildFields() throws RODAException {
130+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_FULL_FILE);
131+
SolrInputDocument third = getChildAt(doc, 2);
132+
133+
assertFieldValue(third, "subject_txt", "Budget Approval Request");
134+
assertFieldValue(third, "folderPath_s", "Sent");
135+
assertFieldValue(third, "filePath_s", "Sent/msg_003.eml");
136+
}
137+
138+
// ---------------------------------------------------------------------------
139+
// Minimal fixture — 1 email, only required fields
140+
// ---------------------------------------------------------------------------
141+
142+
@Test
143+
public void testMinimalCrosswalkProducesRequiredFields() throws RODAException {
144+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_MINIMAL_FILE);
145+
146+
assertNotNull(doc);
147+
assertFieldValue(doc, "custodian_txt", "Jane Doe");
148+
assertFieldValue(doc, "emailAddress_s", "jane.doe@example.org");
149+
assertFieldValue(doc, "content_type", "emailarchive");
150+
}
151+
152+
@Test
153+
public void testMinimalCrosswalkOmitsAbsentOptionalFields() throws RODAException {
154+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_MINIMAL_FILE);
155+
156+
assertNotNull(doc);
157+
assertNull("dateStart_dt should be absent when not in source", doc.getField("dateStart_dt"));
158+
assertNull("dateEnd_dt should be absent when not in source", doc.getField("dateEnd_dt"));
159+
assertNull("totalMessages_i should be absent when not in source", doc.getField("totalMessages_i"));
160+
assertNull("originalFormat_s should be absent when not in source", doc.getField("originalFormat_s"));
161+
assertNull("archivingMotive_txt should be absent when not in source", doc.getField("archivingMotive_txt"));
162+
}
163+
164+
@Test
165+
public void testMinimalCrosswalkProducesOneChild() throws RODAException {
166+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_MINIMAL_FILE);
167+
SolrInputField emailsField = doc.getField("emails");
168+
assertNotNull(emailsField);
169+
assertEquals(1, getChildDocuments(emailsField).size());
170+
}
171+
172+
@Test
173+
public void testMinimalCrosswalkChildHasRequiredFields() throws RODAException {
174+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_MINIMAL_FILE);
175+
SolrInputDocument child = getChildAt(doc, 0);
176+
177+
assertFieldValue(child, "content_type", "email");
178+
assertFieldValue(child, "messageId_s", "<only-email@example.org>");
179+
assertFieldValue(child, "subject_txt", "Hello World");
180+
assertFieldValue(child, "hasAttachments_b", "false");
181+
}
182+
183+
// ---------------------------------------------------------------------------
184+
// No-emails fixture — mailbox with zero email records
185+
// ---------------------------------------------------------------------------
186+
187+
@Test
188+
public void testNoEmailsCrosswalkProducesParentFieldsOnly() throws RODAException {
189+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_NO_EMAILS_FILE);
190+
191+
assertNotNull(doc);
192+
assertFieldValue(doc, "custodian_txt", "Empty Mailbox User");
193+
assertFieldValue(doc, "content_type", "emailarchive");
194+
assertFieldValue(doc, "totalMessages_i", "0");
195+
}
196+
197+
@Test
198+
public void testNoEmailsCrosswalkProducesNoChildDocumentsField() throws RODAException {
199+
SolrInputDocument doc = getCrosswalkResult(CorporaConstants.EMAIL_ARCHIVE_NO_EMAILS_FILE);
200+
assertNull("'emails' field must be absent when there are no child emails", doc.getField("emails"));
201+
}
202+
203+
// ---------------------------------------------------------------------------
204+
// Helpers
205+
// ---------------------------------------------------------------------------
206+
207+
private SolrInputDocument getCrosswalkResult(String filename) throws RODAException {
208+
try {
209+
DefaultStoragePath path = DefaultStoragePath.parse(
210+
CorporaConstants.SOURCE_DESC_METADATA_CONTAINER, filename);
211+
Binary binary = corporaService.getBinary(path);
212+
return SolrUtils.getDescriptiveMetadataFields(binary, CorporaConstants.EMAIL_ARCHIVE_METADATA_TYPE, null);
213+
} catch (Exception e) {
214+
Assert.fail("Unexpected exception loading fixture '" + filename + "': " + e.getMessage());
215+
return null;
216+
}
217+
}
218+
219+
private void assertFieldValue(SolrInputDocument doc, String fieldName, String expectedValue) {
220+
SolrInputField field = doc.getField(fieldName);
221+
assertNotNull("Field '" + fieldName + "' must be present", field);
222+
assertEquals("Field '" + fieldName + "' value mismatch", expectedValue, field.getValue().toString());
223+
}
224+
225+
@SuppressWarnings("unchecked")
226+
private Collection<SolrInputDocument> getChildDocuments(SolrInputField emailsField) {
227+
Object value = emailsField.getValue();
228+
assertNotNull("'emails' field value must not be null", value);
229+
return (Collection<SolrInputDocument>) value;
230+
}
231+
232+
private SolrInputDocument getChildAt(SolrInputDocument parent, int index) {
233+
SolrInputField emailsField = parent.getField("emails");
234+
assertNotNull(emailsField);
235+
Collection<SolrInputDocument> children = getChildDocuments(emailsField);
236+
return children.stream().skip(index).findFirst()
237+
.orElseThrow(() -> new AssertionError("No child document at index " + index));
238+
}
239+
}

0 commit comments

Comments
 (0)