From 3dada0fcd1b2aaafb3ab736f5861cde247522c6f Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Mon, 27 Jul 2026 23:42:45 -0300 Subject: [PATCH 1/4] fix(content-drive): resolve keyword/title search in DB for read-your-writes #36688 --- .../com/dotcms/browser/BrowserAPIImpl.java | 22 +- .../java/com/dotcms/browser/BrowserQuery.java | 27 ++ .../rest/api/v1/drive/ContentDriveHelper.java | 7 +- .../drive/ContentDriveKeywordSearchTest.java | 249 ++++++++++++++++++ 4 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java index a0feb2038cc1..03af56f53cb2 100644 --- a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java @@ -1179,7 +1179,10 @@ private List> createChunks(List list, int chunkSize) { String buildBaseESQuery(final BrowserQuery browserQuery) { final StringBuilder textGroup = new StringBuilder(); - if (UtilMethods.isSet(browserQuery.filter)) { + // When the keyword is DB-resolved (resolveTextInDb), skip the free-text group here — otherwise + // ES would re-filter on the lagging index and drop just-saved items. The index-routed field + // clauses (below) still apply. + if (UtilMethods.isSet(browserQuery.filter) && !browserQuery.resolveTextInDb) { final String titleFilters = String.format( "title:%s* OR title:'%s'^15 OR title_dotraw:*%s*^5 OR +catchall:*%s*^10", browserQuery.filter, @@ -1484,12 +1487,14 @@ private String buildMultiValueOrClause(final String fieldName, final List criteria.getBucket() == FieldSearchCriteria.RoutingBucket.INDEX); - return browserQuery.useElasticsearchFiltering && (hasTextFilter || hasIndexFieldCriteria); + return browserQuery.useElasticsearchFiltering && (textNeedsEs || hasIndexFieldCriteria); } /** @@ -1891,11 +1896,16 @@ private SelectQuery selectQuery(final BrowserQuery browserQuery) { : resolveArchiveTargetSteps(browserQuery.workflowStepIds); appendWorkflowQuery(selectQuery, browserQuery.workflowSchemeIds, browserQuery.workflowStepIds, archiveStepIds, parameters); - //We only build the filtering bits of the SQL Query if we're not using ES - if (!browserQuery.useElasticsearchFiltering) { + // Free-text keyword: resolved in the DB when ES filtering is off OR when resolveTextInDb is + // set (Content Drive keyword search — read-your-writes per ADR-0018). This keeps the text + // predicate in the candidate SQL even while the ES path narrows by index-routed field clauses. + if (!browserQuery.useElasticsearchFiltering || browserQuery.resolveTextInDb) { if (UtilMethods.isSet(browserQuery.filter)) { appendFilterQuery(selectQuery, browserQuery.filter, parameters); } + } + // fileName stays on the legacy ES-gated path (only resolved in the DB when ES is off). + if (!browserQuery.useElasticsearchFiltering) { if (UtilMethods.isSet(browserQuery.fileName)) { appendFileNameQuery(selectQuery, browserQuery.fileName, parameters); } diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java index 2ff59d77b1a6..26edbcc84486 100644 --- a/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java +++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java @@ -61,6 +61,15 @@ public class BrowserQuery { final boolean showShorties; final boolean showDefaultLangItems; final boolean useElasticsearchFiltering; + /** + * When {@code true} the free-text {@code filter} is resolved in the database (via + * {@code appendFilterQuery}) even if Elasticsearch filtering is on for other criteria. This + * preserves read-your-writes for keyword/title search (ADR-0018): a just-saved item is found by + * name immediately, without waiting for ES indexing. The ES narrowing then carries only the + * index-routed field clauses, never the text group. Defaults to {@code false} so the legacy + * Site Browser path is unchanged. + */ + final boolean resolveTextInDb; final boolean filterFolderNames; final Set languageIds; final String luceneQuery; @@ -140,6 +149,7 @@ private BrowserQuery(final Builder builder) { final Tuple2 siteAndFolder = getParents(builder.hostFolderId,this.user, builder.hostIdSystemFolder); this.filter = builder.filter; this.useElasticsearchFiltering = builder.useElasticsearchFiltering; + this.resolveTextInDb = builder.resolveTextInDb; this.skipFolder = builder.skipFolder; this.ignoreSiteForFolders = builder.ignoreSiteForFolders; this.filterFolderNames = builder.filterFolderNames; @@ -261,6 +271,7 @@ public static final class Builder { private int folderCursor = 0; private User user; private boolean useElasticsearchFiltering = false; + private boolean resolveTextInDb = false; private boolean filterFolderNames = false; private String filter = null; private String fileName = null; @@ -302,6 +313,7 @@ private Builder(BrowserQuery browserQuery) { ? browserQuery.site.getIdentifier() : browserQuery.folder.getInode(); this.useElasticsearchFiltering = browserQuery.useElasticsearchFiltering; + this.resolveTextInDb = browserQuery.resolveTextInDb; this.forceSystemHost = browserQuery.forceSystemHost; this.skipFolder = browserQuery.skipFolder; this.ignoreSiteForFolders = browserQuery.ignoreSiteForFolders; @@ -401,6 +413,21 @@ public Builder useElasticsearchFiltering(boolean useElasticsearchFiltering) { return this; } + /** + * Resolves the free-text {@code filter} in the database instead of Elasticsearch, preserving + * read-your-writes for keyword/title search (ADR-0018). When {@code true}, the text predicate + * is applied in the DB {@code selectQuery} even if ES filtering is on for index-routed field + * criteria, and the ES narrowing omits the text group. Leave {@code false} for the legacy + * Site Browser path. + * + * @param resolveTextInDb flag + * @return this + */ + public Builder resolveTextInDb(boolean resolveTextInDb) { + this.resolveTextInDb = resolveTextInDb; + return this; + } + /** * if we want to filter folder names when searching with Text filters * @param filterFolderNames flag diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java index e9f7aa3602e5..4a8b53314ed0 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java @@ -167,9 +167,12 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U //This ensures that despite the site passed systemHost will be included too builder.forceSystemHost(requestForm.includeSystemHost()); - // Enable Elasticsearch filtering for text search when filter is provided + // Keyword/title search resolves in the DB (read-your-writes, ADR-0018): a just-saved item is + // findable by name immediately, without waiting for ES indexing. Only index-routed field + // filters (below) flip on Elasticsearch; the text term is carried by resolveTextInDb so it + // still resolves in the DB even when the ES path runs for those field clauses. if (null != requestForm.filters() && UtilMethods.isSet(requestForm.filters().text())) { - builder.useElasticsearchFiltering(true) // Rely on ES for enhanced text filtering + builder.resolveTextInDb(true) .filterFolderNames(requestForm.filters().filterFolders()) .withFilter(requestForm.filters().text()); } diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java new file mode 100644 index 000000000000..de788895ae7d --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java @@ -0,0 +1,249 @@ +package com.dotcms.rest.api.v1.drive; + +import com.dotcms.DataProviderWeldRunner; +import com.dotcms.IntegrationTestBase; +import com.dotcms.browser.BrowserAPIImpl.PaginatedContents; +import com.dotcms.contenttype.model.field.TextField; +import com.dotcms.contenttype.model.type.BaseContentType; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.ContentTypeDataGen; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FieldDataGen; +import com.dotcms.datagen.FileAssetDataGen; +import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.util.IntegrationTestInitService; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.contentlet.model.IndexPolicy; +import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.util.Logger; +import com.liferay.portal.model.User; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import javax.enterprise.context.ApplicationScoped; +import java.io.File; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Regression test for issue #36688 — Content Drive keyword/title search. + * + *

The fix routes the toolbar keyword/title search to the database (case-insensitive, tokenized + * {@code ILIKE}) instead of Elasticsearch, so a just-saved item is findable by name immediately + * (read-your-writes, ADR-0018). Elasticsearch is still used for index-routed {@code userSearchable} + * field filters, and the two compose.

+ * + *

Covers: (1) the reported scenario — a FileAsset named {@code IMG_1004.jpeg} found by any + * case/substring of its name (including the boundary-spanning {@code 1004.jpeg} that failed before); + * (2) read-your-writes — an item absent from the ES index is still found; (3) composition of a text + * keyword (DB) with a {@code userSearchable} field filter (ES).

+ */ +@ApplicationScoped +@RunWith(DataProviderWeldRunner.class) +public class ContentDriveKeywordSearchTest extends IntegrationTestBase { + + private static final ContentDriveHelper contentDriveHelper = new ContentDriveHelper(); + + private static User systemUser; + private static Host testSite; + private static Folder testFolder; + private static String assetPath; + + /** The exact file name reported in the issue/screencast. */ + private static final String FILE_NAME = "IMG_1004.jpeg"; + private static String fileInode; + + // Compose scenario: a content type with a searchable/indexed text field. + private static ContentType composeType; + private static final String TOPIC_VAR = "topic"; + private static String composeInode; + // A distinctive value stored in the topic field; the DB keyword search matches it via the + // contentlet JSON, and the userSearchable field filter matches it in ES. + private static final String COMPOSE_TERM = "angularcompose"; + + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + systemUser = APILocator.getUserAPI().getSystemUser(); + + final String uniqueId = System.currentTimeMillis() + ""; + testSite = new SiteDataGen().name("kw-search-" + uniqueId + ".local").nextPersisted(); + testFolder = new FolderDataGen().name("kwFolder_" + uniqueId).site(testSite).nextPersisted(); + assetPath = "//" + testSite.getHostname() + testFolder.getPath(); + + // FileAsset with the EXACT name IMG_1004.jpeg (File.createTempFile would inject random + // digits, so build the file inside a temp dir to control the name precisely). + final File tmpDir = Files.createTempDirectory("kw-" + uniqueId).toFile(); + final File imgFile = new File(tmpDir, FILE_NAME); + Files.writeString(imgFile.toPath(), "keyword search reproduction test content"); + final Contentlet fileAsset = new FileAssetDataGen(testFolder, imgFile) + .languageId(1) + .setPolicy(IndexPolicy.WAIT_FOR) + .nextPersisted(); + fileInode = fileAsset.getInode(); + + // Content type + item for the text(DB)+field(ES) composition test. + composeType = new ContentTypeDataGen() + .name("KwComposeType_" + uniqueId) + .velocityVarName("kwComposeType_" + uniqueId) + .baseContentType(BaseContentType.CONTENT) + .host(testSite) + .nextPersisted(); + new FieldDataGen().type(TextField.class).name(TOPIC_VAR).velocityVarName(TOPIC_VAR) + .contentTypeId(composeType.id()).searchable(true).indexed(true).nextPersisted(); + final Contentlet composeItem = new ContentletDataGen(composeType.id()) + .setProperty("title", "Compose item " + uniqueId) + .setProperty(TOPIC_VAR, COMPOSE_TERM) + .folder(testFolder) + .setPolicy(IndexPolicy.WAIT_FOR) + .nextPersisted(); + composeInode = composeItem.getInode(); + + Logger.info(ContentDriveKeywordSearchTest.class, String.format( + "Seeded FileAsset '%s' (inode %s) and compose item (inode %s) under %s", + FILE_NAME, fileInode, composeInode, assetPath)); + } + + /** Runs a plain keyword search through the Content Drive endpoint path. */ + private PaginatedContents search(final String term) throws DotDataException, DotSecurityException { + final DriveRequestForm request = baseRequest() + .filters(QueryFilters.builder().text(term).build()) + .build(); + return contentDriveHelper.driveSearch(request, systemUser); + } + + private DriveRequestForm.Builder baseRequest() { + return DriveRequestForm.builder() + .assetPath(assetPath) + .showFolders(false) + .live(false) // working content + .archived(false) + .offset(0) + .maxResults(100); + } + + private static boolean contains(final PaginatedContents results, final String inode) { + return results.list.stream() + .map(item -> (String) item.get("inode")) + .anyMatch(inode::equals); + } + + private static List names(final PaginatedContents results) { + return results.list.stream() + .map(item -> String.valueOf(item.get("title"))) + .collect(Collectors.toList()); + } + + /** + * The reported scenario: keyword search finds {@code IMG_1004.jpeg} for any case and any + * distinctive substring of the name, including the boundary-spanning {@code 1004.jpeg} and + * multi-word queries that the previous ES path could not match. + */ + @Test + public void keywordSearch_findsFileAsset_caseInsensitive_anySubstring() + throws DotDataException, DotSecurityException { + + final List terms = List.of( + "IMG", "1004", "img", "Img", // exact screencast inputs + case variants + "IMG_1004", "img_1004", "jpeg", // substrings + "1004.jpeg", // boundary-spanning (failed before the fix) + "IMG 1004", "1004 jpeg"); // multi-word (tokenized, AND) + + final List failures = new ArrayList<>(); + for (final String term : terms) { + final PaginatedContents results = search(term); + final boolean found = contains(results, fileInode); + Logger.info(this.getClass(), String.format( + "term='%s' → found=%b, %d result(s): %s", + term, found, results.list.size(), names(results))); + if (!found) { + failures.add(term); + } + } + + if (!failures.isEmpty()) { + fail(String.format("Keyword search did not return '%s' for term(s): %s", + FILE_NAME, failures)); + } + } + + @Test + public void keywordSearch_uppercaseIMG_findsFile() throws DotDataException, DotSecurityException { + assertTrue("Searching 'IMG' must return " + FILE_NAME, contains(search("IMG"), fileInode)); + } + + @Test + public void keywordSearch_digits1004_findsFile() throws DotDataException, DotSecurityException { + assertTrue("Searching '1004' must return " + FILE_NAME, contains(search("1004"), fileInode)); + } + + /** + * Read-your-writes: an item that is NOT in the Elasticsearch index must still be found by keyword + * search, proving the search resolves in the database (no ES dependency for the text term). We + * seed an item, remove it from the index, and search by its name. + */ + @Test + public void keywordSearch_findsItemMissingFromElasticsearchIndex_readYourWrites() + throws Exception { + + final String uniqueName = "readyourwrites" + System.currentTimeMillis(); + final File tmpDir = Files.createTempDirectory("kw-ryw").toFile(); + final File file = new File(tmpDir, uniqueName + ".txt"); + Files.writeString(file.toPath(), "read your writes content"); + final Contentlet asset = new FileAssetDataGen(testFolder, file) + .languageId(1) + .setPolicy(IndexPolicy.WAIT_FOR) + .nextPersisted(); + + // Drop it from the ES index — the keyword search must not depend on it. + APILocator.getContentletIndexAPI().removeContentFromIndex(asset); + + final PaginatedContents results = search(uniqueName); + assertTrue("Item absent from the ES index must still be found by keyword search (read-your-writes)", + contains(results, asset.getInode())); + } + + /** + * Composition: a text keyword (resolved in the DB) AND a {@code userSearchable} field filter + * (resolved in ES) combine — the item is returned only when both match. + */ + @Test + public void keywordText_composesWith_userSearchableFieldFilter() + throws DotDataException, DotSecurityException { + + // Text matches (DB) AND the field filter matches (ES) → returned. + final PaginatedContents match = contentDriveHelper.driveSearch( + baseRequest() + .contentTypes(List.of(composeType.variable())) + .filters(QueryFilters.builder().text(COMPOSE_TERM).build()) + .userSearchable(Map.of(TOPIC_VAR, COMPOSE_TERM)) + .build(), + systemUser); + assertTrue("Text (DB) + matching field filter (ES) must return the item", + contains(match, composeInode)); + + // Text matches (DB) but the field filter does NOT → excluded (AND semantics). + final PaginatedContents noMatch = contentDriveHelper.driveSearch( + baseRequest() + .contentTypes(List.of(composeType.variable())) + .filters(QueryFilters.builder().text(COMPOSE_TERM).build()) + .userSearchable(Map.of(TOPIC_VAR, "reactnomatch")) + .build(), + systemUser); + assertFalse("A non-matching field filter must exclude the item even when the text matches", + contains(noMatch, composeInode)); + } +} From 964e7a283dfc09f835b8e7c17ca4bce653521fc0 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Tue, 28 Jul 2026 17:43:48 -0300 Subject: [PATCH 2/4] fix(content-drive): align keyword search with Content Search index query #36688 --- .../com/dotcms/browser/BrowserAPIImpl.java | 40 +++--- .../java/com/dotcms/browser/BrowserQuery.java | 27 ---- .../rest/api/v1/drive/ContentDriveHelper.java | 7 +- .../drive/ContentDriveKeywordSearchTest.java | 129 +++++++----------- 4 files changed, 65 insertions(+), 138 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java index 03af56f53cb2..df6d58202b3b 100644 --- a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java @@ -16,6 +16,7 @@ import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.rest.api.v1.content.search.handlers.FieldContext; import com.dotcms.rest.api.v1.content.search.handlers.FieldHandlerRegistry; +import com.dotcms.rest.api.v1.content.search.strategies.GlobalSearchAttributeStrategy; import com.dotcms.content.index.SearchAPI; import com.dotcms.uuid.shorty.ShortyIdAPI; import com.dotmarketing.beans.Host; @@ -1179,17 +1180,17 @@ private List> createChunks(List list, int chunkSize) { String buildBaseESQuery(final BrowserQuery browserQuery) { final StringBuilder textGroup = new StringBuilder(); - // When the keyword is DB-resolved (resolveTextInDb), skip the free-text group here — otherwise - // ES would re-filter on the lagging index and drop just-saved items. The index-routed field - // clauses (below) still apply. - if (UtilMethods.isSet(browserQuery.filter) && !browserQuery.resolveTextInDb) { - final String titleFilters = String.format( - "title:%s* OR title:'%s'^15 OR title_dotraw:*%s*^5 OR +catchall:*%s*^10", - browserQuery.filter, - browserQuery.filter, - browserQuery.filter, - browserQuery.filter); - textGroup.append(titleFilters); + if (UtilMethods.isSet(browserQuery.filter)) { + // Reuse the Content Search global-search strategy so Content Drive keyword search stays + // consistent with the Search portlet (issue #36688). It builds a selective mandatory + // "+catchall:*" prefix plus tokenized, escaped title boosts — replacing the previous + // broad "catchall:**" leading wildcard, which returned unrelated body matches and + // scanned slowly on large, indexed datasets. + final FieldContext globalSearchContext = new FieldContext.Builder() + .withFieldName("title") + .withFieldValue(browserQuery.filter) + .build(); + textGroup.append(new GlobalSearchAttributeStrategy().generateQuery(globalSearchContext)); } if (UtilMethods.isSet(browserQuery.fileName)) { @@ -1487,14 +1488,12 @@ private String buildMultiValueOrClause(final String fieldName, final List criteria.getBucket() == FieldSearchCriteria.RoutingBucket.INDEX); - return browserQuery.useElasticsearchFiltering && (textNeedsEs || hasIndexFieldCriteria); + return browserQuery.useElasticsearchFiltering && (hasTextFilter || hasIndexFieldCriteria); } /** @@ -1896,16 +1895,11 @@ private SelectQuery selectQuery(final BrowserQuery browserQuery) { : resolveArchiveTargetSteps(browserQuery.workflowStepIds); appendWorkflowQuery(selectQuery, browserQuery.workflowSchemeIds, browserQuery.workflowStepIds, archiveStepIds, parameters); - // Free-text keyword: resolved in the DB when ES filtering is off OR when resolveTextInDb is - // set (Content Drive keyword search — read-your-writes per ADR-0018). This keeps the text - // predicate in the candidate SQL even while the ES path narrows by index-routed field clauses. - if (!browserQuery.useElasticsearchFiltering || browserQuery.resolveTextInDb) { + //We only build the filtering bits of the SQL Query if we're not using ES + if (!browserQuery.useElasticsearchFiltering) { if (UtilMethods.isSet(browserQuery.filter)) { appendFilterQuery(selectQuery, browserQuery.filter, parameters); } - } - // fileName stays on the legacy ES-gated path (only resolved in the DB when ES is off). - if (!browserQuery.useElasticsearchFiltering) { if (UtilMethods.isSet(browserQuery.fileName)) { appendFileNameQuery(selectQuery, browserQuery.fileName, parameters); } diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java index 26edbcc84486..2ff59d77b1a6 100644 --- a/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java +++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java @@ -61,15 +61,6 @@ public class BrowserQuery { final boolean showShorties; final boolean showDefaultLangItems; final boolean useElasticsearchFiltering; - /** - * When {@code true} the free-text {@code filter} is resolved in the database (via - * {@code appendFilterQuery}) even if Elasticsearch filtering is on for other criteria. This - * preserves read-your-writes for keyword/title search (ADR-0018): a just-saved item is found by - * name immediately, without waiting for ES indexing. The ES narrowing then carries only the - * index-routed field clauses, never the text group. Defaults to {@code false} so the legacy - * Site Browser path is unchanged. - */ - final boolean resolveTextInDb; final boolean filterFolderNames; final Set languageIds; final String luceneQuery; @@ -149,7 +140,6 @@ private BrowserQuery(final Builder builder) { final Tuple2 siteAndFolder = getParents(builder.hostFolderId,this.user, builder.hostIdSystemFolder); this.filter = builder.filter; this.useElasticsearchFiltering = builder.useElasticsearchFiltering; - this.resolveTextInDb = builder.resolveTextInDb; this.skipFolder = builder.skipFolder; this.ignoreSiteForFolders = builder.ignoreSiteForFolders; this.filterFolderNames = builder.filterFolderNames; @@ -271,7 +261,6 @@ public static final class Builder { private int folderCursor = 0; private User user; private boolean useElasticsearchFiltering = false; - private boolean resolveTextInDb = false; private boolean filterFolderNames = false; private String filter = null; private String fileName = null; @@ -313,7 +302,6 @@ private Builder(BrowserQuery browserQuery) { ? browserQuery.site.getIdentifier() : browserQuery.folder.getInode(); this.useElasticsearchFiltering = browserQuery.useElasticsearchFiltering; - this.resolveTextInDb = browserQuery.resolveTextInDb; this.forceSystemHost = browserQuery.forceSystemHost; this.skipFolder = browserQuery.skipFolder; this.ignoreSiteForFolders = browserQuery.ignoreSiteForFolders; @@ -413,21 +401,6 @@ public Builder useElasticsearchFiltering(boolean useElasticsearchFiltering) { return this; } - /** - * Resolves the free-text {@code filter} in the database instead of Elasticsearch, preserving - * read-your-writes for keyword/title search (ADR-0018). When {@code true}, the text predicate - * is applied in the DB {@code selectQuery} even if ES filtering is on for index-routed field - * criteria, and the ES narrowing omits the text group. Leave {@code false} for the legacy - * Site Browser path. - * - * @param resolveTextInDb flag - * @return this - */ - public Builder resolveTextInDb(boolean resolveTextInDb) { - this.resolveTextInDb = resolveTextInDb; - return this; - } - /** * if we want to filter folder names when searching with Text filters * @param filterFolderNames flag diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java index 4a8b53314ed0..e9f7aa3602e5 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java @@ -167,12 +167,9 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U //This ensures that despite the site passed systemHost will be included too builder.forceSystemHost(requestForm.includeSystemHost()); - // Keyword/title search resolves in the DB (read-your-writes, ADR-0018): a just-saved item is - // findable by name immediately, without waiting for ES indexing. Only index-routed field - // filters (below) flip on Elasticsearch; the text term is carried by resolveTextInDb so it - // still resolves in the DB even when the ES path runs for those field clauses. + // Enable Elasticsearch filtering for text search when filter is provided if (null != requestForm.filters() && UtilMethods.isSet(requestForm.filters().text())) { - builder.resolveTextInDb(true) + builder.useElasticsearchFiltering(true) // Rely on ES for enhanced text filtering .filterFolderNames(requestForm.filters().filterFolders()) .withFilter(requestForm.filters().text()); } diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java index de788895ae7d..5614d4a5c4c8 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java @@ -29,9 +29,7 @@ import javax.enterprise.context.ApplicationScoped; import java.io.File; import java.nio.file.Files; -import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; import static org.junit.Assert.assertFalse; @@ -41,15 +39,16 @@ /** * Regression test for issue #36688 — Content Drive keyword/title search. * - *

The fix routes the toolbar keyword/title search to the database (case-insensitive, tokenized - * {@code ILIKE}) instead of Elasticsearch, so a just-saved item is findable by name immediately - * (read-your-writes, ADR-0018). Elasticsearch is still used for index-routed {@code userSearchable} - * field filters, and the two compose.

+ *

Per the team decision (ADR-0018: text search stays on the index), the fix makes Content Drive's + * keyword search build the same Elasticsearch query as the Content Search portlet — by reusing + * {@code GlobalSearchAttributeStrategy}. This replaces the previous broad {@code catchall:*kw*} + * leading-wildcard (which returned unrelated body matches and scanned slowly on large indexed + * datasets) with a selective {@code +catchall:kw*} prefix plus tokenized, escaped title boosts.

* - *

Covers: (1) the reported scenario — a FileAsset named {@code IMG_1004.jpeg} found by any - * case/substring of its name (including the boundary-spanning {@code 1004.jpeg} that failed before); - * (2) read-your-writes — an item absent from the ES index is still found; (3) composition of a text - * keyword (DB) with a {@code userSearchable} field filter (ES).

+ *

Covers: (1) the reported scenario — the FileAsset {@code IMG_1004.jpeg} is found by its name and + * case variants; (2) composition of a text keyword with a {@code userSearchable} field filter. The + * per-term log documents the exact matching behavior (now prefix-based, consistent with Content + * Search).

*/ @ApplicationScoped @RunWith(DataProviderWeldRunner.class) @@ -58,8 +57,6 @@ public class ContentDriveKeywordSearchTest extends IntegrationTestBase { private static final ContentDriveHelper contentDriveHelper = new ContentDriveHelper(); private static User systemUser; - private static Host testSite; - private static Folder testFolder; private static String assetPath; /** The exact file name reported in the issue/screencast. */ @@ -70,8 +67,6 @@ public class ContentDriveKeywordSearchTest extends IntegrationTestBase { private static ContentType composeType; private static final String TOPIC_VAR = "topic"; private static String composeInode; - // A distinctive value stored in the topic field; the DB keyword search matches it via the - // contentlet JSON, and the userSearchable field filter matches it in ES. private static final String COMPOSE_TERM = "angularcompose"; @BeforeClass @@ -80,8 +75,8 @@ public static void prepare() throws Exception { systemUser = APILocator.getUserAPI().getSystemUser(); final String uniqueId = System.currentTimeMillis() + ""; - testSite = new SiteDataGen().name("kw-search-" + uniqueId + ".local").nextPersisted(); - testFolder = new FolderDataGen().name("kwFolder_" + uniqueId).site(testSite).nextPersisted(); + final Host testSite = new SiteDataGen().name("kw-search-" + uniqueId + ".local").nextPersisted(); + final Folder testFolder = new FolderDataGen().name("kwFolder_" + uniqueId).site(testSite).nextPersisted(); assetPath = "//" + testSite.getHostname() + testFolder.getPath(); // FileAsset with the EXACT name IMG_1004.jpeg (File.createTempFile would inject random @@ -95,7 +90,7 @@ public static void prepare() throws Exception { .nextPersisted(); fileInode = fileAsset.getInode(); - // Content type + item for the text(DB)+field(ES) composition test. + // Content type + item for the text + field-filter composition test. composeType = new ContentTypeDataGen() .name("KwComposeType_" + uniqueId) .velocityVarName("kwComposeType_" + uniqueId) @@ -105,7 +100,7 @@ public static void prepare() throws Exception { new FieldDataGen().type(TextField.class).name(TOPIC_VAR).velocityVarName(TOPIC_VAR) .contentTypeId(composeType.id()).searchable(true).indexed(true).nextPersisted(); final Contentlet composeItem = new ContentletDataGen(composeType.id()) - .setProperty("title", "Compose item " + uniqueId) + .setProperty("title", COMPOSE_TERM + " report " + uniqueId) .setProperty(TOPIC_VAR, COMPOSE_TERM) .folder(testFolder) .setPolicy(IndexPolicy.WAIT_FOR) @@ -119,10 +114,9 @@ public static void prepare() throws Exception { /** Runs a plain keyword search through the Content Drive endpoint path. */ private PaginatedContents search(final String term) throws DotDataException, DotSecurityException { - final DriveRequestForm request = baseRequest() + return contentDriveHelper.driveSearch(baseRequest() .filters(QueryFilters.builder().text(term).build()) - .build(); - return contentDriveHelper.driveSearch(request, systemUser); + .build(), systemUser); } private DriveRequestForm.Builder baseRequest() { @@ -148,99 +142,68 @@ private static List names(final PaginatedContents results) { } /** - * The reported scenario: keyword search finds {@code IMG_1004.jpeg} for any case and any - * distinctive substring of the name, including the boundary-spanning {@code 1004.jpeg} and - * multi-word queries that the previous ES path could not match. + * The reported scenario: keyword search finds {@code IMG_1004.jpeg} by its name (and case + * variants). Matching is prefix-based per {@code GlobalSearchAttributeStrategy}, consistent with + * the Content Search portlet. The per-term log documents the full behavior for the record. */ @Test - public void keywordSearch_findsFileAsset_caseInsensitive_anySubstring() - throws DotDataException, DotSecurityException { + public void keywordSearch_findsFileAsset_byName() throws DotDataException, DotSecurityException { + + // Prefix-style terms that must find the file (a token in title/catchall starts with them). + final List mustFind = List.of("IMG", "img", "Img", "IMG_1004", "jpeg"); + // Characterization only (logged, not asserted): mid-token / boundary-spanning terms whose + // matching depends on prefix semantics — documents how the search now behaves. + final List characterize = List.of("1004", "1004.jpeg", "IMG_1004.jpeg"); + + for (final String term : characterize) { + final PaginatedContents r = search(term); + Logger.info(this.getClass(), String.format("[characterize] term='%s' → found=%b, %d result(s): %s", + term, contains(r, fileInode), r.list.size(), names(r))); + } - final List terms = List.of( - "IMG", "1004", "img", "Img", // exact screencast inputs + case variants - "IMG_1004", "img_1004", "jpeg", // substrings - "1004.jpeg", // boundary-spanning (failed before the fix) - "IMG 1004", "1004 jpeg"); // multi-word (tokenized, AND) - - final List failures = new ArrayList<>(); - for (final String term : terms) { - final PaginatedContents results = search(term); - final boolean found = contains(results, fileInode); - Logger.info(this.getClass(), String.format( - "term='%s' → found=%b, %d result(s): %s", - term, found, results.list.size(), names(results))); + final StringBuilder failures = new StringBuilder(); + for (final String term : mustFind) { + final PaginatedContents r = search(term); + final boolean found = contains(r, fileInode); + Logger.info(this.getClass(), String.format("[mustFind] term='%s' → found=%b, %d result(s): %s", + term, found, r.list.size(), names(r))); if (!found) { - failures.add(term); + failures.append(term).append(' '); } } - - if (!failures.isEmpty()) { - fail(String.format("Keyword search did not return '%s' for term(s): %s", - FILE_NAME, failures)); + if (failures.length() > 0) { + fail("Keyword search did not return " + FILE_NAME + " for term(s): " + failures.toString().trim()); } } + /** The exact reported input, asserted on its own for a precise failure message. */ @Test public void keywordSearch_uppercaseIMG_findsFile() throws DotDataException, DotSecurityException { assertTrue("Searching 'IMG' must return " + FILE_NAME, contains(search("IMG"), fileInode)); } - @Test - public void keywordSearch_digits1004_findsFile() throws DotDataException, DotSecurityException { - assertTrue("Searching '1004' must return " + FILE_NAME, contains(search("1004"), fileInode)); - } - - /** - * Read-your-writes: an item that is NOT in the Elasticsearch index must still be found by keyword - * search, proving the search resolves in the database (no ES dependency for the text term). We - * seed an item, remove it from the index, and search by its name. - */ - @Test - public void keywordSearch_findsItemMissingFromElasticsearchIndex_readYourWrites() - throws Exception { - - final String uniqueName = "readyourwrites" + System.currentTimeMillis(); - final File tmpDir = Files.createTempDirectory("kw-ryw").toFile(); - final File file = new File(tmpDir, uniqueName + ".txt"); - Files.writeString(file.toPath(), "read your writes content"); - final Contentlet asset = new FileAssetDataGen(testFolder, file) - .languageId(1) - .setPolicy(IndexPolicy.WAIT_FOR) - .nextPersisted(); - - // Drop it from the ES index — the keyword search must not depend on it. - APILocator.getContentletIndexAPI().removeContentFromIndex(asset); - - final PaginatedContents results = search(uniqueName); - assertTrue("Item absent from the ES index must still be found by keyword search (read-your-writes)", - contains(results, asset.getInode())); - } - /** - * Composition: a text keyword (resolved in the DB) AND a {@code userSearchable} field filter - * (resolved in ES) combine — the item is returned only when both match. + * Composition: a text keyword and a {@code userSearchable} field filter combine — the item is + * returned only when both match (AND semantics). Both resolve through Elasticsearch. */ @Test public void keywordText_composesWith_userSearchableFieldFilter() throws DotDataException, DotSecurityException { - // Text matches (DB) AND the field filter matches (ES) → returned. final PaginatedContents match = contentDriveHelper.driveSearch( baseRequest() .contentTypes(List.of(composeType.variable())) .filters(QueryFilters.builder().text(COMPOSE_TERM).build()) - .userSearchable(Map.of(TOPIC_VAR, COMPOSE_TERM)) + .userSearchable(java.util.Map.of(TOPIC_VAR, COMPOSE_TERM)) .build(), systemUser); - assertTrue("Text (DB) + matching field filter (ES) must return the item", - contains(match, composeInode)); + assertTrue("Text + matching field filter must return the item", contains(match, composeInode)); - // Text matches (DB) but the field filter does NOT → excluded (AND semantics). final PaginatedContents noMatch = contentDriveHelper.driveSearch( baseRequest() .contentTypes(List.of(composeType.variable())) .filters(QueryFilters.builder().text(COMPOSE_TERM).build()) - .userSearchable(Map.of(TOPIC_VAR, "reactnomatch")) + .userSearchable(java.util.Map.of(TOPIC_VAR, "reactnomatch")) .build(), systemUser); assertFalse("A non-matching field filter must exclude the item even when the text matches", From 8b7fb696988d2b4004be03f2e848bedb0e40eb07 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 29 Jul 2026 18:54:36 -0300 Subject: [PATCH 3/4] test(content-drive): register ContentDriveKeywordSearchTest in MainSuite3a #36688 --- dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java index b2ee664a14c7..061f1f064576 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java @@ -10,6 +10,7 @@ import com.dotcms.publisher.business.PublisherQueueJobTest; import com.dotcms.rest.api.v1.drive.ContentDriveFieldFilterTest; import com.dotcms.rest.api.v1.drive.ContentDriveHelperContentletAPIComparisonTest; +import com.dotcms.rest.api.v1.drive.ContentDriveKeywordSearchTest; import com.dotcms.rest.api.v1.drive.ContentDriveWorkflowArchiveStepTest; import com.dotcms.rest.api.v1.drive.ContentDriveWorkflowFilterTest; import com.dotcms.security.apps.AppsAPIImplTest; @@ -72,6 +73,7 @@ OpenAIVisionAPIImplTest.class, ContentDriveFieldFilterTest.class, ContentDriveHelperContentletAPIComparisonTest.class, + ContentDriveKeywordSearchTest.class, ContentDriveWorkflowArchiveStepTest.class, ContentDriveWorkflowFilterTest.class, AppsAPIImplTest.class, From a16b563439d9de8168c6008621d26bd06962013d Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 30 Jul 2026 12:56:49 -0300 Subject: [PATCH 4/4] test(content-drive): per-term data provider, fixture cleanup and strategy delegation guard #36688 --- .../com/dotcms/browser/BrowserAPITest.java | 42 ++++++- .../drive/ContentDriveKeywordSearchTest.java | 106 +++++++++++------- 2 files changed, 108 insertions(+), 40 deletions(-) diff --git a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java index ad5b2f04a3df..fdf717e137d6 100644 --- a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java @@ -18,6 +18,8 @@ import com.dotcms.datagen.HTMLPageDataGen; import com.dotcms.datagen.LanguageDataGen; import com.dotcms.datagen.LinkDataGen; +import com.dotcms.rest.api.v1.content.search.handlers.FieldContext; +import com.dotcms.rest.api.v1.content.search.strategies.GlobalSearchAttributeStrategy; import com.dotcms.datagen.RoleDataGen; import com.dotcms.datagen.SiteDataGen; import com.dotcms.datagen.TestDataUtils; @@ -1304,8 +1306,44 @@ public void test_buildBaseESQuery_withDifferentFilterCombinations() { /** *
    *
  • Method to Test: {@link BrowserAPIImpl#buildBaseESQuery(BrowserQuery)}
  • - *
  • Given Scenario: Test query structure and Lucene syntax compliance.
  • - *
  • Expected Result: Generated queries should follow proper Lucene query syntax.
  • + *
  • Given Scenario: A free-text filter is provided.
  • + *
  • Expected Result: The text clause is produced by the shared + * {@link GlobalSearchAttributeStrategy} — the same one the Content Search portlet uses — and + * no longer by a hand-rolled query string.
  • + *
+ */ + @Test + public void test_buildBaseESQuery_delegatesToSharedGlobalSearchStrategy() { + final BrowserAPIImpl browserAPIImpl = new BrowserAPIImpl(); + final String filter = "searchterm"; + + final String result = browserAPIImpl.buildBaseESQuery( + BrowserQuery.builder().withFilter(filter).build()); + + // The free-text clause must be byte-identical to what the Content Search portlet builds, + // so both surfaces always query the index the same way (issue #36688). + final String expectedTextGroup = new GlobalSearchAttributeStrategy().generateQuery( + new FieldContext.Builder() + .withFieldName("title") + .withFieldValue(filter) + .build()); + assertEquals("Text group must be exactly what the shared global-search strategy produces", + " +(" + expectedTextGroup + ")", result); + + // Guards against regressing to the previous hand-rolled query, which used a broad + // leading-wildcard catchall (slow, matched unrelated body text) and mixed an explicit OR + // with a '+' modifier inside the same group. + assertFalse("Must not use a leading-wildcard catchall clause", + result.contains("catchall:*")); + assertFalse("Must not mix explicit OR with '+' operators in the text group", + result.contains(" OR ")); + } + + /** + *
    + *
  • Method to Test: {@link BrowserAPIImpl#buildBaseESQuery(BrowserQuery)}
  • + *
  • Given Scenario: Verify the generated query complies with Lucene syntax.
  • + *
  • Expected Result: Query uses valid Lucene field:value syntax.
  • *
*/ @Test diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java index 5614d4a5c4c8..934b419336f3 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java @@ -20,8 +20,12 @@ import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.contentlet.model.IndexPolicy; import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.util.FileUtil; import com.dotmarketing.util.Logger; import com.liferay.portal.model.User; +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.UseDataProvider; +import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; @@ -34,7 +38,6 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; /** * Regression test for issue #36688 — Content Drive keyword/title search. @@ -69,23 +72,28 @@ public class ContentDriveKeywordSearchTest extends IntegrationTestBase { private static String composeInode; private static final String COMPOSE_TERM = "angularcompose"; + // Kept for @AfterClass cleanup so the suite doesn't accumulate sites/types/temp dirs. + private static Host testSite; + private static File tmpDir; + @BeforeClass public static void prepare() throws Exception { IntegrationTestInitService.getInstance().init(); systemUser = APILocator.getUserAPI().getSystemUser(); + final long defaultLanguageId = APILocator.getLanguageAPI().getDefaultLanguage().getId(); final String uniqueId = System.currentTimeMillis() + ""; - final Host testSite = new SiteDataGen().name("kw-search-" + uniqueId + ".local").nextPersisted(); + testSite = new SiteDataGen().name("kw-search-" + uniqueId + ".local").nextPersisted(); final Folder testFolder = new FolderDataGen().name("kwFolder_" + uniqueId).site(testSite).nextPersisted(); assetPath = "//" + testSite.getHostname() + testFolder.getPath(); // FileAsset with the EXACT name IMG_1004.jpeg (File.createTempFile would inject random // digits, so build the file inside a temp dir to control the name precisely). - final File tmpDir = Files.createTempDirectory("kw-" + uniqueId).toFile(); + tmpDir = Files.createTempDirectory("kw-" + uniqueId).toFile(); final File imgFile = new File(tmpDir, FILE_NAME); Files.writeString(imgFile.toPath(), "keyword search reproduction test content"); final Contentlet fileAsset = new FileAssetDataGen(testFolder, imgFile) - .languageId(1) + .languageId(defaultLanguageId) .setPolicy(IndexPolicy.WAIT_FOR) .nextPersisted(); fileInode = fileAsset.getInode(); @@ -103,6 +111,7 @@ public static void prepare() throws Exception { .setProperty("title", COMPOSE_TERM + " report " + uniqueId) .setProperty(TOPIC_VAR, COMPOSE_TERM) .folder(testFolder) + .languageId(defaultLanguageId) .setPolicy(IndexPolicy.WAIT_FOR) .nextPersisted(); composeInode = composeItem.getInode(); @@ -112,6 +121,39 @@ public static void prepare() throws Exception { FILE_NAME, fileInode, composeInode, assetPath)); } + /** + * Removes the fixtures this class created so the rest of the suite runs against a clean state. + * Failures here must not fail the test run — they are logged and swallowed. + */ + @AfterClass + public static void cleanup() { + try { + if (null != composeType) { + APILocator.getContentTypeAPI(systemUser).delete(composeType); + } + } catch (final Exception e) { + Logger.warn(ContentDriveKeywordSearchTest.class, + "Could not delete test content type: " + e.getMessage()); + } + try { + if (null != testSite) { + APILocator.getHostAPI().archive(testSite, systemUser, false); + APILocator.getHostAPI().delete(testSite, systemUser, false); + } + } catch (final Exception e) { + Logger.warn(ContentDriveKeywordSearchTest.class, + "Could not delete test site: " + e.getMessage()); + } + try { + if (null != tmpDir) { + FileUtil.deleteDir(tmpDir.getAbsolutePath()); + } + } catch (final Exception e) { + Logger.warn(ContentDriveKeywordSearchTest.class, + "Could not delete temp dir: " + e.getMessage()); + } + } + /** Runs a plain keyword search through the Content Drive endpoint path. */ private PaginatedContents search(final String term) throws DotDataException, DotSecurityException { return contentDriveHelper.driveSearch(baseRequest() @@ -142,44 +184,32 @@ private static List names(final PaginatedContents results) { } /** - * The reported scenario: keyword search finds {@code IMG_1004.jpeg} by its name (and case - * variants). Matching is prefix-based per {@code GlobalSearchAttributeStrategy}, consistent with - * the Content Search portlet. The per-term log documents the full behavior for the record. + * Terms that must return {@code IMG_1004.jpeg} — each one is a genuine token prefix of the + * indexed name (tokens: {@code img_1004}, {@code jpeg}), in assorted casing. + *

+ * Mid-token ({@code 1004}), boundary-spanning ({@code 1004.jpeg}) and exact-full-name + * ({@code IMG_1004.jpeg}) terms are deliberately absent: prefix matching — the behavior this PR + * aligns with the Content Search portlet — cannot match them. That shared limitation is fixed + * separately in #36791, and covered there by {@code GlobalSearchAttributeStrategyMatchingTest}. */ - @Test - public void keywordSearch_findsFileAsset_byName() throws DotDataException, DotSecurityException { - - // Prefix-style terms that must find the file (a token in title/catchall starts with them). - final List mustFind = List.of("IMG", "img", "Img", "IMG_1004", "jpeg"); - // Characterization only (logged, not asserted): mid-token / boundary-spanning terms whose - // matching depends on prefix semantics — documents how the search now behaves. - final List characterize = List.of("1004", "1004.jpeg", "IMG_1004.jpeg"); - - for (final String term : characterize) { - final PaginatedContents r = search(term); - Logger.info(this.getClass(), String.format("[characterize] term='%s' → found=%b, %d result(s): %s", - term, contains(r, fileInode), r.list.size(), names(r))); - } - - final StringBuilder failures = new StringBuilder(); - for (final String term : mustFind) { - final PaginatedContents r = search(term); - final boolean found = contains(r, fileInode); - Logger.info(this.getClass(), String.format("[mustFind] term='%s' → found=%b, %d result(s): %s", - term, found, r.list.size(), names(r))); - if (!found) { - failures.append(term).append(' '); - } - } - if (failures.length() > 0) { - fail("Keyword search did not return " + FILE_NAME + " for term(s): " + failures.toString().trim()); - } + @DataProvider + public static Object[] matchingKeywordTerms() { + return new String[]{"IMG", "img", "Img", "IMG_1004", "jpeg"}; } - /** The exact reported input, asserted on its own for a precise failure message. */ + /** + * The reported scenario: keyword search finds {@code IMG_1004.jpeg} by its name. Run per term so + * a failure names the exact keyword instead of collapsing every term into one assertion. + */ @Test - public void keywordSearch_uppercaseIMG_findsFile() throws DotDataException, DotSecurityException { - assertTrue("Searching 'IMG' must return " + FILE_NAME, contains(search("IMG"), fileInode)); + @UseDataProvider("matchingKeywordTerms") + public void keywordSearch_findsFileAsset_byName(final String term) + throws DotDataException, DotSecurityException { + final PaginatedContents results = search(term); + Logger.info(this.getClass(), String.format("term='%s' → %d result(s): %s", + term, results.list.size(), names(results))); + assertTrue("Searching '" + term + "' must return " + FILE_NAME, + contains(results, fileInode)); } /**