diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java index a0feb2038cc1..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; @@ -1180,13 +1181,16 @@ String buildBaseESQuery(final BrowserQuery browserQuery) { final StringBuilder textGroup = new StringBuilder(); if (UtilMethods.isSet(browserQuery.filter)) { - 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); + // 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)) { 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, 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..3519877b9c0f 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; @@ -1223,7 +1225,7 @@ public void test_buildBaseESQuery_withDifferentFilterCombinations() { assertNotNull("Result should not be null", result); assertTrue("Should contain title search", result.contains("title:test*")); assertTrue("Should contain quoted title search", result.contains("title:'test'^15")); - assertTrue("Should contain dotraw title search", result.contains("title_dotraw:*test*^5")); + assertTrue("Should contain dotraw title search", result.contains("title_dotraw:*test*")); assertTrue("Should be wrapped with mandatory group", result.startsWith(" +(") && result.endsWith(")")); assertFalse("Should not contain metadata search", result.contains("metadata.name")); @@ -1304,8 +1306,43 @@ public void test_buildBaseESQuery_withDifferentFilterCombinations() { /** * + */ + @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, + // unscoped leading-wildcard catchall (slow, matched unrelated body text). Whether the + // strategy itself uses ' OR ' internally is its own concern — the assertEquals above + // already pins this method to whatever it produces. + assertFalse("Must not use a leading-wildcard catchall clause", + result.contains("catchall:*")); + } + + /** + * */ @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 new file mode 100644 index 000000000000..934b419336f3 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveKeywordSearchTest.java @@ -0,0 +1,242 @@ +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.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; + +import javax.enterprise.context.ApplicationScoped; +import java.io.File; +import java.nio.file.Files; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Regression test for issue #36688 — Content Drive keyword/title search. + * + *

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 — 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) +public class ContentDriveKeywordSearchTest extends IntegrationTestBase { + + private static final ContentDriveHelper contentDriveHelper = new ContentDriveHelper(); + + private static User systemUser; + 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; + 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() + ""; + 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). + 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(defaultLanguageId) + .setPolicy(IndexPolicy.WAIT_FOR) + .nextPersisted(); + fileInode = fileAsset.getInode(); + + // Content type + item for the text + field-filter 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_TERM + " report " + uniqueId) + .setProperty(TOPIC_VAR, COMPOSE_TERM) + .folder(testFolder) + .languageId(defaultLanguageId) + .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)); + } + + /** + * 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() + .filters(QueryFilters.builder().text(term).build()) + .build(), 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()); + } + + /** + * 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}. + */ + @DataProvider + public static Object[] matchingKeywordTerms() { + return new String[]{"IMG", "img", "Img", "IMG_1004", "jpeg"}; + } + + /** + * 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 + @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)); + } + + /** + * 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 { + + final PaginatedContents match = contentDriveHelper.driveSearch( + baseRequest() + .contentTypes(List.of(composeType.variable())) + .filters(QueryFilters.builder().text(COMPOSE_TERM).build()) + .userSearchable(java.util.Map.of(TOPIC_VAR, COMPOSE_TERM)) + .build(), + systemUser); + assertTrue("Text + matching field filter must return the item", contains(match, composeInode)); + + final PaginatedContents noMatch = contentDriveHelper.driveSearch( + baseRequest() + .contentTypes(List.of(composeType.variable())) + .filters(QueryFilters.builder().text(COMPOSE_TERM).build()) + .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", + contains(noMatch, composeInode)); + } +}