From ab1e38507571806d375a34b7594684b6a9a19b58 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 23 Jul 2026 12:43:12 +0200 Subject: [PATCH 1/4] ESB-1133 Introduced SOLR indexing with related UI updates --- README.md | 48 ++- ...stractBaseEntityAttributeConfigAction.java | 19 ++ ...attribute-type-entry-composite-element.jsp | 13 + .../CompositeAttributeConfigActionTest.java | 24 ++ .../port/00000000000001_dataPort_test.xml | 84 +++++ .../jacms/port/clob/test/contents_50.xml | 26 ++ .../jacms/port/clob/test/contents_51.xml | 26 ++ .../jacms/port/clob/test/contents_52.xml | 26 ++ .../jacms/port/clob/test/contents_53.xml | 22 ++ .../jacms/port/clob/test/sysconfig_1.xml | 17 + .../apsadmin/jsp/content/contentFinding.jsp | 4 +- .../services/content/TestContentManager.java | 146 ++++++++- .../entity/TestContentEntityManager.java | 2 +- .../content/TestContentFinderAction.java | 4 +- .../attribute/TestContentLinkAction.java | 8 +- .../TestHypertextAttributeAction.java | 8 +- .../viewer/TestContentFinderViewerAction.java | 8 +- .../entity/TestJacmsEntityManagersAction.java | 8 +- .../ContentControllerIntegrationTest.java | 73 ++++- .../content/TestContentFinderAction.java | 4 +- .../content/TestIntroNewContentAction.java | 2 +- .../common/entity/AbstractEntityDAO.java | 88 +++-- .../entity/NestedBooleanSearchSupport.java | 97 ++++++ .../entity/model/EntitySearchFilter.java | 6 + .../model/attribute/CompositeAttribute.java | 8 +- .../AbstractEntityDAONestedBooleanTest.java | 251 +++++++++++++++ .../NestedBooleanSearchSupportTest.java | 145 +++++++++ .../EntitySearchFilterNestedBooleanTest.java | 114 +++++++ .../content/AdvContentFacetManager.java | 41 +++ .../jpsolr/aps/system/solr/IndexerDAO.java | 38 ++- .../jpsolr/aps/system/solr/SearcherDAO.java | 5 +- .../system/solr/SolrComplexAttributes.java | 87 +++++ .../aps/system/solr/SolrFieldsChecker.java | 77 ++++- .../system/solr/SolrSearchEngineManager.java | 54 +++- .../solr/model/ContentTypeSettings.java | 24 +- .../content/AdvContentFacetManagerTest.java | 72 +++++ .../aps/system/solr/IndexerDAOTest.java | 302 ++++++++++++++++++ .../aps/system/solr/SearcherDAOTest.java | 132 ++++++++ .../system/solr/SolrFieldsCheckerTest.java | 199 +++++++++++- .../solr/model/ContentTypeSettingsTest.java | 207 ++++++++++++ .../AdvContentSearchControllerTest.java | 4 +- 41 files changed, 2411 insertions(+), 112 deletions(-) create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_50.xml create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_51.xml create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_52.xml create mode 100644 cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_53.xml create mode 100644 engine/src/main/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupport.java create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilterNestedBooleanTest.java create mode 100644 solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java create mode 100644 solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java diff --git a/README.md b/README.md index e7113c6878..34f719b7dd 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,52 @@ To execute a specific test: mvn clean test -Ppre-deployment-verification -pl -Dtest= ``` -By default the logging output in tests is minimized. -The general log level is controlled by the variable `ROOT_LOG_LEVEL`, that in tests is set to `WARN` by default. +By default the logging output in tests is minimized. See [Logging](#logging) below for how to get +verbose/`DEBUG` output, both for the running webapp and for test runs (they work differently). + +## Logging + +Logging is configured via `engine/src/main/resources/base.xml` (logback) and driven by two environment +variables: +- `ROOT_LOG_LEVEL` — the root logger level. Defaults to `DEBUG` when running the webapp; overridden to + `WARN` when running tests (see `pom.xml` surefire configuration). +- `LOG_LEVEL` — the console (`STDOUT`) appender threshold. Defaults to `WARN`, regardless of + `ROOT_LOG_LEVEL`. + +To run the webapp locally with `DEBUG` logs printed to the console: + +``` +cd webapp/ +LOG_LEVEL=DEBUG mvn package jetty:run-war -Pjetty-local -Dspring.profiles.active=swagger -DskipTests -DskipLicenseDownload -Pderby -Pkeycloak +``` + +`ROOT_LOG_LEVEL` does not need to be set for this, since it already defaults to `DEBUG` outside of tests; +`LOG_LEVEL` is the variable that actually gates what reaches the console. + +Test runs are different: `entando-engine`'s test-jar ships `logback-test.xml`, which every other module +picks up on its test classpath. It hardcodes `` (with explicit per-package `DEBUG` +overrides only for a couple of Spring test loggers), so **`ROOT_LOG_LEVEL`/`LOG_LEVEL` have no effect on +test runs** — only on the running webapp. To get `DEBUG` output from a test run, point Logback at a +throwaway config instead: + +``` +cat > /tmp/logback-debug.xml <<'EOF' + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + +EOF +mvn clean test -Ppre-deployment-verification -pl -Dtest= \ + -DargLine=-Dlogback.configurationFile=/tmp/logback-debug.xml +``` + +(Alternatively, add a one-off `` line to +`engine/src/test/resources/logback-test.xml` before running the test — cheaper for a quick, throwaway +check, but remember to revert it since it's a shared test resource.) ## Environment Variables List | Group | Name | Value [default] | Description | diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java index 2f6c5560bd..33f3fcf79f 100644 --- a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java +++ b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java @@ -14,6 +14,7 @@ package com.agiletec.apsadmin.system.entity.type; import com.agiletec.aps.system.common.entity.IEntityManager; +import com.agiletec.aps.system.common.entity.NestedBooleanSearchSupport; import com.agiletec.aps.system.common.entity.model.IApsEntity; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; import com.agiletec.aps.system.common.entity.model.attribute.AttributeRole; @@ -266,6 +267,24 @@ public boolean isSearchableOptionSupported(String attributeTypeCode) { } return false; } + + /** + * Whether the given attribute type may be flagged searchable when used as a composite child. + * Only plain boolean children are indexed (under the path key "<composite>_<boolean>") in + * the DB search tables; every other type - including CheckBox and ThreeState - is forced + * non-searchable as a composite child, so the searchable option must not be offered for them. + * @param attributeTypeCode the attribute type code. + * @return true only for the plain boolean type. + */ + public boolean isNestedSearchableOptionSupported(String attributeTypeCode) { + try { + AttributeInterface attribute = this.getAttributePrototype(attributeTypeCode); + return NestedBooleanSearchSupport.isIndexableNestedBoolean(attribute); + } catch (Throwable t) { + _logger.error("error in isNestedSearchableOptionSupported", t); + } + return false; + } public AttributeInterface getAttributePrototype(String typeCode) { IEntityManager entityManager = this.getEntityManager(); diff --git a/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite-element.jsp b/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite-element.jsp index cd92c28cd6..cfb0a7f8da 100644 --- a/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite-element.jsp +++ b/admin-console/src/main/webapp/WEB-INF/apsadmin/jsp/entity/attribute-type-entry-composite-element.jsp @@ -135,6 +135,19 @@ + +
+
+ + +
+
+ +
+
+
diff --git a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java index a9f9ea4b37..ccf50a9396 100644 --- a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java +++ b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java @@ -8,9 +8,12 @@ import com.agiletec.aps.system.common.entity.IEntityManager; import com.agiletec.aps.system.common.entity.model.IApsEntity; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute; import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; import com.agiletec.aps.system.common.entity.model.attribute.TextAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; import com.agiletec.apsadmin.system.ApsAdminSystemConstants; import com.agiletec.apsadmin.system.BaseAction; import org.apache.struts2.action.Action; @@ -98,6 +101,27 @@ void testSaveAttributeElement() { .setAttribute(Mockito.eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); } + @Test + void testNestedSearchableOptionSupportedForBooleanLikes() { + String entityManagerName = "EntityManagerName"; + Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + .thenReturn(entityManagerName); + Map attributeTypes = new HashMap<>(); + attributeTypes.put("Boolean", new BooleanAttribute()); + attributeTypes.put("CheckBox", new CheckBoxAttribute()); + attributeTypes.put("ThreeState", new ThreeStateAttribute()); + attributeTypes.put("Text", new TextAttribute()); + IEntityManager entityManager = Mockito.mock(IEntityManager.class); + Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + Mockito.when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + + // every boolean-like composite child may be flagged searchable; other types may not + Assertions.assertTrue(action.isNestedSearchableOptionSupported("Boolean")); + Assertions.assertTrue(action.isNestedSearchableOptionSupported("CheckBox")); + Assertions.assertTrue(action.isNestedSearchableOptionSupported("ThreeState")); + Assertions.assertFalse(action.isNestedSearchableOptionSupported("Text")); + } + @Test void shouldMethodNotAddAttributeElement() { diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/00000000000001_dataPort_test.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/00000000000001_dataPort_test.xml index 0cd93329f3..84f5deac20 100644 --- a/cms-plugin/src/main/resources/liquibase/jacms/port/00000000000001_dataPort_test.xml +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/00000000000001_dataPort_test.xml @@ -1928,4 +1928,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_50.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_50.xml new file mode 100644 index 0000000000..42e41b3670 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_50.xml @@ -0,0 +1,26 @@ + + + Flag on + + + + + Flag on + + + true + + + true + + + + true + + + true + + + + PUBLIC + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_51.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_51.xml new file mode 100644 index 0000000000..29a98982b0 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_51.xml @@ -0,0 +1,26 @@ + + + Flag off + + + + + Flag off + + + false + + + false + + + + false + + + false + + + + PUBLIC + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_52.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_52.xml new file mode 100644 index 0000000000..87a48cacd0 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_52.xml @@ -0,0 +1,26 @@ + + + Mixed + + + + + Mixed + + + true + + + false + + + + false + + + true + + + + PUBLIC + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_53.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_53.xml new file mode 100644 index 0000000000..30e3c102e1 --- /dev/null +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/contents_53.xml @@ -0,0 +1,22 @@ + + + Composite only + + + + + Composite only + + + false + + + + + true + + + + + PUBLIC + diff --git a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/sysconfig_1.xml b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/sysconfig_1.xml index 5905827856..09bab16fe5 100644 --- a/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/sysconfig_1.xml +++ b/cms-plugin/src/main/resources/liquibase/jacms/port/clob/test/sysconfig_1.xml @@ -286,4 +286,21 @@ + + + + + jacms:title + + + + + + + + + + + + diff --git a/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/apsadmin/jsp/content/contentFinding.jsp b/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/apsadmin/jsp/content/contentFinding.jsp index cc915bb47f..6c01c2e041 100644 --- a/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/apsadmin/jsp/content/contentFinding.jsp +++ b/cms-plugin/src/main/webapp/WEB-INF/plugins/jacms/apsadmin/jsp/content/contentFinding.jsp @@ -188,7 +188,7 @@ - +

@@ -378,7 +378,7 @@ value="%{getSearchFormFieldValue(#numberEndInputFieldName)}" /> + test="#attribute.type == 'Boolean' || #attribute.type == 'ThreeState' || #attribute.type == 'CheckBox'"> _booleanFieldName contentIds = this._contentManager.searchId(null); assertNotNull(contentIds); - assertEquals(25, contentIds.size()); + assertEquals(29, contentIds.size()); EntitySearchFilter creationOrder = new EntitySearchFilter(IContentManager.CONTENT_CREATION_DATE_FILTER_KEY, false); creationOrder.setOrder(EntitySearchFilter.ASC_ORDER); @@ -163,14 +163,14 @@ void testSearchContents_1_2() throws Throwable { EntitySearchFilter[] filters4 = {versionFilter}; contentIds = this._contentManager.searchId(filters4); assertNotNull(contentIds); - assertEquals(22, contentIds.size()); + assertEquals(26, contentIds.size()); } @Test void testSearchContents_1_4() throws Throwable { List contentIds = this._contentManager.searchId(null); assertNotNull(contentIds); - assertEquals(25, contentIds.size()); + assertEquals(29, contentIds.size()); EntitySearchFilter creationOrder = new EntitySearchFilter(IContentManager.CONTENT_CREATION_DATE_FILTER_KEY, false); creationOrder.setOrder(EntitySearchFilter.ASC_ORDER); @@ -198,7 +198,7 @@ void testSearchContents_1_5() throws Throwable { EntitySearchFilter[] filters1 = {creationOrder, descrFilter}; List contentIds = this._contentManager.searchId(filters1); assertNotNull(contentIds); - String[] expected1 = {"ART1", "RAH1", "ART187", "RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122"}; + String[] expected1 = {"ART1", "RAH1", "ART187", "RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122", "BLT4"}; assertEquals(expected1.length, contentIds.size()); this.verifyOrder(contentIds, expected1); @@ -206,7 +206,7 @@ void testSearchContents_1_5() throws Throwable { EntitySearchFilter[] filters2 = {creationOrder, descrFilter}; contentIds = this._contentManager.searchId(filters2); assertNotNull(contentIds); - String[] expected2 = {"RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122"}; + String[] expected2 = {"RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122", "BLT4"}; assertEquals(expected2.length, contentIds.size()); this.verifyOrder(contentIds, expected2); @@ -236,7 +236,7 @@ void testSearchPaginatedContents_1_5() throws Throwable { EntitySearchFilter[] filters1 = {creationOrder, descrFilter, paginationFilter}; SearcherDaoPaginatedResult paginatedContentsId1 = this._contentManager.getPaginatedWorkContentsId(null, true, filters1, groupCodes); assertNotNull(paginatedContentsId1); - String[] totalExpected1 = {"ART1", "RAH1", "ART187", "RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122"}; + String[] totalExpected1 = {"ART1", "RAH1", "ART187", "RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122", "BLT4"}; assertEquals(totalExpected1.length, paginatedContentsId1.getCount().intValue()); assertEquals(4, paginatedContentsId1.getList().size()); for (int i = 0; i < 4; i++) { @@ -248,7 +248,7 @@ void testSearchPaginatedContents_1_5() throws Throwable { EntitySearchFilter[] filters2 = {creationOrder, descrFilter, paginationFilter2}; SearcherDaoPaginatedResult paginatedContentsId2 = this._contentManager.getPaginatedWorkContentsId(null, true, filters2, groupCodes); assertNotNull(paginatedContentsId2); - String[] totalExpected2 = {"RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122"}; + String[] totalExpected2 = {"RAH101", "ART102", "EVN103", "ART104", "ART111", "ART112", "EVN23", "ART120", "ART121", "ART122", "BLT4"}; assertEquals(totalExpected2.length, paginatedContentsId2.getCount().intValue()); assertEquals(6, paginatedContentsId2.getList().size()); for (int i = 3; i < (6+3); i++) { @@ -295,7 +295,9 @@ void testSearchContents_3() throws Throwable { assertNotNull(contentIds); String[] expected = {"ART187", "ART1", "EVN193", "EVN194", "ART180", "RAH1", "EVN191", "EVN192", "RAH101", "EVN103", "ART104", "ART102", "EVN23", - "EVN24", "EVN25", "EVN41", "EVN20", "EVN21", "ART111", "ART120", "ART121", "ART122", "ART112", "ALL4"}; + "EVN24", "EVN25", "EVN41", "EVN20", "EVN21", "ART111", "ART120", "ART121", "ART122", "ART112", "ALL4", + "BLT1", "BLT2", "BLT3", "BLT4"}; + assertEquals(expected.length, contentIds.size()); this.verifyOrder(contentIds, expected); } @@ -352,7 +354,7 @@ void testSearchWorkContents() throws Throwable { groupCodes.add(Group.ADMINS_GROUP_NAME); contents = this._contentManager.loadWorkContentsId(null, groupCodes); assertNotNull(contents); - assertEquals(25, contents.size()); + assertEquals(29, contents.size()); } @Test @@ -592,7 +594,7 @@ void testLoadFullContent() throws Throwable { @Test void testGetContentTypes() { Map smallContentTypes = _contentManager.getSmallContentTypesMap(); - assertEquals(4, smallContentTypes.size()); + assertEquals(5, smallContentTypes.size()); } @Test @@ -642,7 +644,7 @@ void testGetXML() throws Throwable { @Test void testLoadPublicContents() throws EntException { List contents = _contentManager.loadPublicContentsId(null, null, freeGroup); - assertEquals(15, contents.size()); + assertEquals(19, contents.size()); } @Test @@ -1614,6 +1616,128 @@ private void setBooleanValue(Content content, String[] attributeCodes, Boolean v booleanAttribute.setBooleanValue(value); } } + + /** + * End-to-end DB-search test for a plain Boolean nested inside a Composite: once the child is + * flagged searchable, it must be indexed under the path key "Composite_Boolean" and be filterable + * through both the work ({@code workcontentsearch}) and public ({@code contentsearch}) searchers - + * the Solr-disabled counterpart of the AdvContentSearch nested-boolean capability. + */ + @Test + void testLoadContentsByNestedCompositeBooleanAttribute() throws Throwable { + this.setCompositeChildrenSearchable(true); + List addedContents = new ArrayList<>(); + try { + // After the type-config round-trip, every boolean-like composite child (Boolean, CheckBox, + // ThreeState) keeps its inherited searchable flag; non-boolean children are forced false. + Content reloaded = this._contentManager.createContentType("ALL"); + CompositeAttribute reloadedComposite = (CompositeAttribute) reloaded.getAttribute("Composite"); + assertTrue(reloadedComposite.getAttribute("Boolean").isSearchable()); + assertTrue(reloadedComposite.getAttribute("CheckBox").isSearchable()); + assertTrue(reloadedComposite.getAttribute("ThreeState").isSearchable()); + + // feature: the nested boolean, made searchable purely through the content type, is indexed + // under the path key "Composite_Boolean" and filterable through both DB searchers. + String trueId = this.createAllCloneWithNestedBoolean(Boolean.TRUE, addedContents); + String falseId = this.createAllCloneWithNestedBoolean(Boolean.FALSE, addedContents); + + // work table (workcontentsearch) + List workTrue = this.searchByNestedComposite(true, false); + assertTrue(workTrue.contains(trueId)); + assertFalse(workTrue.contains(falseId)); + List workFalse = this.searchByNestedComposite(false, false); + assertTrue(workFalse.contains(falseId)); + assertFalse(workFalse.contains(trueId)); + + // public table (contentsearch) + List onlineTrue = this.searchByNestedComposite(true, true); + assertTrue(onlineTrue.contains(trueId)); + assertFalse(onlineTrue.contains(falseId)); + List onlineFalse = this.searchByNestedComposite(false, true); + assertTrue(onlineFalse.contains(falseId)); + assertFalse(onlineFalse.contains(trueId)); + } finally { + for (String id : addedContents) { + this._contentManager.deleteContent(id); + assertNull(this._contentManager.loadContent(id, false)); + } + this.setCompositeChildrenSearchable(false); + } + } + + @Test + void testBooleanSearchableConfigParityTopLevelAndComposite() throws Throwable { + // A boolean-like is configured the SAME WAY top-level and as a Composite child: the searchable + // flag set on the content type is reported in the type XML (proven by surviving the + // serialize->reload of updateEntityPrototype) and inherited by content instances - identically + // at both nesting levels. No auto-forcing: an unflagged boolean-like stays non-searchable. + Content prototype = this._contentManager.createContentType("ALL"); + prototype.getAttribute("Boolean").setSearchable(true); + ((CompositeAttribute) prototype.getAttribute("Composite")).getAttribute("Boolean").setSearchable(true); + try { + ((IEntityTypesConfigurer) this._contentManager).updateEntityPrototype(prototype); + this._contentManager.reloadEntitiesReferences("ALL"); + super.waitThreads(ApsEntityManager.RELOAD_REFERENCES_THREAD_NAME_PREFIX); + + // reloaded TYPE keeps the flag at both levels -> the type XML reported searchable="true" + Content reloadedType = this._contentManager.createContentType("ALL"); + CompositeAttribute reloadedComposite = (CompositeAttribute) reloadedType.getAttribute("Composite"); + assertTrue(reloadedType.getAttribute("Boolean").isSearchable(), "top-level boolean searchable preserved"); + assertTrue(reloadedComposite.getAttribute("Boolean").isSearchable(), "composite boolean searchable preserved"); + // parity (negative): a boolean-like left unflagged is non-searchable at both levels + assertFalse(reloadedType.getAttribute("CheckBox").isSearchable()); + assertFalse(reloadedComposite.getAttribute("CheckBox").isSearchable()); + + // a CONTENT instance inherits the flag from the type, at both levels + Content content = this._contentManager.loadContent("ALL4", false); + CompositeAttribute contentComposite = (CompositeAttribute) content.getAttribute("Composite"); + assertTrue(content.getAttribute("Boolean").isSearchable(), "top-level boolean inherited by content"); + assertTrue(contentComposite.getAttribute("Boolean").isSearchable(), "composite boolean inherited by content"); + } finally { + Content restore = this._contentManager.createContentType("ALL"); + restore.getAttribute("Boolean").setSearchable(false); + ((CompositeAttribute) restore.getAttribute("Composite")).getAttribute("Boolean").setSearchable(false); + ((IEntityTypesConfigurer) this._contentManager).updateEntityPrototype(restore); + this._contentManager.reloadEntitiesReferences("ALL"); + super.waitThreads(ApsEntityManager.RELOAD_REFERENCES_THREAD_NAME_PREFIX); + } + } + + private void setCompositeChildrenSearchable(boolean searchable) throws Throwable { + Content prototype = this._contentManager.createContentType("ALL"); + CompositeAttribute composite = (CompositeAttribute) prototype.getAttribute("Composite"); + composite.getAttribute("Boolean").setSearchable(searchable); + composite.getAttribute("CheckBox").setSearchable(searchable); + composite.getAttribute("ThreeState").setSearchable(searchable); + ((IEntityTypesConfigurer) this._contentManager).updateEntityPrototype(prototype); + this._contentManager.reloadEntitiesReferences("ALL"); + super.waitThreads(ApsEntityManager.RELOAD_REFERENCES_THREAD_NAME_PREFIX); + } + + private String createAllCloneWithNestedBoolean(Boolean value, List addedContents) throws Throwable { + Content clone = this._contentManager.loadContent("ALL4", false); + clone.setId(null); + CompositeAttribute composite = (CompositeAttribute) clone.getAttribute("Composite"); + BooleanAttribute nestedBoolean = (BooleanAttribute) composite.getAttribute("Boolean"); + nestedBoolean.setBooleanValue(value); + this._contentManager.saveContent(clone); + addedContents.add(clone.getId()); + this._contentManager.insertOnLineContent(clone); + return clone.getId(); + } + + private List searchByNestedComposite(boolean value, boolean online) throws Exception { + List groups = new ArrayList<>(); + groups.add(Group.ADMINS_GROUP_NAME); + EntitySearchFilter typeFilter = new EntitySearchFilter<>( + IContentManager.ENTITY_TYPE_CODE_FILTER_KEY, false, "ALL", false); + EntitySearchFilter nestedFilter = new EntitySearchFilter<>( + "Composite_Boolean", true, Boolean.toString(value), false); + EntitySearchFilter[] filters = {typeFilter, nestedFilter}; + return online + ? this._contentManager.loadPublicContentsId("ALL", null, filters, groups) + : this._contentManager.loadWorkContentsId(filters, groups); + } private void testBooleanAttribute_test4(String booleanAttribute, String[] nullResults, String[] falseResults, String[] trueResults) throws Exception { EntitySearchFilter filterForTrue = new EntitySearchFilter<>(booleanAttribute, true, "true", false); diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/entity/TestContentEntityManager.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/entity/TestContentEntityManager.java index 5b2e5159dd..cda7a0c689 100644 --- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/entity/TestContentEntityManager.java +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/entity/TestContentEntityManager.java @@ -41,7 +41,7 @@ class TestContentEntityManager extends BaseTestCase { void testSearchRecords() throws Throwable { List contents = this._contentManager.searchRecords(null); assertNotNull(contents); - assertEquals(25, contents.size()); + assertEquals(29, contents.size()); EntitySearchFilter typeFilter = new EntitySearchFilter(IContentManager.ENTITY_TYPE_CODE_FILTER_KEY, false, "ART", false); EntitySearchFilter[] filters1 = {typeFilter}; diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentFinderAction.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentFinderAction.java index cf1e9b38ec..81a308b5a0 100644 --- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentFinderAction.java +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentFinderAction.java @@ -36,7 +36,7 @@ void testGetList() throws Throwable { String result = this.executeGetList("admin"); assertEquals(Action.SUCCESS, result); List contents = (List) ((ContentFinderAction)this.getAction()).getContents(); - assertEquals(25, contents.size()); + assertEquals(29, contents.size()); result = this.executeGetList("editorCoach"); assertEquals(Action.SUCCESS, result); @@ -63,7 +63,7 @@ void testPerformSearch_1() throws Throwable { Map params = new HashMap(); this.executeSearch("admin", params); ContentFinderAction action = (ContentFinderAction) this.getAction(); - String[] order1 = {"ALL4", "ART112","ART122","ART121","ART120","ART111","ART179","EVN21", + String[] order1 = {"BLT4", "BLT3", "BLT2", "BLT1", "ALL4", "ART112","ART122","ART121","ART120","ART111","ART179","EVN21", "EVN20","EVN41","EVN25","EVN24","EVN23","ART102","ART104","EVN103", "RAH101","EVN192","EVN191","RAH1","ART180","EVN194","EVN193","ART1","ART187"}; List contents = action.getContents(); diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestContentLinkAction.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestContentLinkAction.java index f93804f224..f9c4628852 100644 --- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestContentLinkAction.java +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestContentLinkAction.java @@ -51,7 +51,7 @@ void testFindContent_1() throws Throwable { ContentLinkAction action = (ContentLinkAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(15, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra + assertEquals(19, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra assertTrue(contentIds.contains("EVN25"));//Contenuto coach abilitato al gruppo free assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free } @@ -68,7 +68,7 @@ void testFindContent_2() throws Throwable { ContentLinkAction action = (ContentLinkAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(24, contentIds.size());//Tutti i contenuti pubblici + assertEquals(28, contentIds.size());//Tutti i contenuti pubblici } @Test @@ -83,7 +83,7 @@ void testFindContent_3() throws Throwable { ContentLinkAction action = (ContentLinkAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(20, contentIds.size());// Contenuti pubblici liberi, o del gruppo customers o altri con customers gruppo extra + assertEquals(24, contentIds.size());// Contenuti pubblici liberi, o del gruppo customers o altri con customers gruppo extra assertTrue(contentIds.contains("ART122"));//Contenuto del gruppo "administrators" abilitato al gruppo customers assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free assertTrue(contentIds.contains("EVN25"));//Contenuto del gruppo "coach" abilitato al gruppo free @@ -102,7 +102,7 @@ void testFindContent_4() throws Throwable { ContentLinkAction action = (ContentLinkAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(20, contentIds.size());// Contenuti pubblici liberi, o del gruppo coach o altri con coach gruppo extra + assertEquals(24, contentIds.size());// Contenuti pubblici liberi, o del gruppo coach o altri con coach gruppo extra assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo coach assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free assertTrue(contentIds.contains("EVN25"));//Contenuto del gruppo "coach" abilitato al gruppo free diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestHypertextAttributeAction.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestHypertextAttributeAction.java index 07f4ce9574..8b5e65c6fd 100644 --- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestHypertextAttributeAction.java +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/TestHypertextAttributeAction.java @@ -38,7 +38,7 @@ void testFindContent_1() throws Throwable { this.initIntroContentLink("admin", "ART1");//Contenuto del gruppo Free ContentLinkAttributeAction action = (ContentLinkAttributeAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(15, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra + assertEquals(19, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra assertTrue(contentIds.contains("EVN25"));//Contenuto coach abilitato al gruppo free assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free } @@ -48,7 +48,7 @@ void testFindContent_2() throws Throwable { this.initIntroContentLink("admin", "ART120");//Contenuto del gruppo degli amministratori ContentLinkAttributeAction action = (ContentLinkAttributeAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(24, contentIds.size());//Tutti i contenuti pubblici + assertEquals(28, contentIds.size());//Tutti i contenuti pubblici } @Test @@ -56,7 +56,7 @@ void testFindContent_3() throws Throwable { this.initIntroContentLink("editorCustomers", "ART102");//Contenuto del gruppo customers ContentLinkAttributeAction action = (ContentLinkAttributeAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(20, contentIds.size());// Contenuti pubblici liberi, o del gruppo customers o altri con customers gruppo extra + assertEquals(24, contentIds.size());// Contenuti pubblici liberi, o del gruppo customers o altri con customers gruppo extra assertTrue(contentIds.contains("ART122"));//Contenuto del gruppo "administrators" abilitato al gruppo customers assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free assertTrue(contentIds.contains("EVN25"));//Contenuto del gruppo "coach" abilitato al gruppo free @@ -68,7 +68,7 @@ void testFindContent_4() throws Throwable { this.initIntroContentLink("admin", "EVN25");//Contenuto del gruppo coach ContentLinkAttributeAction action = (ContentLinkAttributeAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(20, contentIds.size());// Contenuti pubblici liberi, o del gruppo coach o altri con coach gruppo extra + assertEquals(24, contentIds.size());// Contenuti pubblici liberi, o del gruppo coach o altri con coach gruppo extra assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo coach assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free assertTrue(contentIds.contains("EVN25"));//Contenuto del gruppo "coach" abilitato al gruppo free diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/portal/specialwidget/viewer/TestContentFinderViewerAction.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/portal/specialwidget/viewer/TestContentFinderViewerAction.java index bfe6ac2a68..786c65a99f 100644 --- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/portal/specialwidget/viewer/TestContentFinderViewerAction.java +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/portal/specialwidget/viewer/TestContentFinderViewerAction.java @@ -37,7 +37,7 @@ void testFindContent_1() throws Throwable { ContentFinderViewerAction action = (ContentFinderViewerAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(15, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra + assertEquals(19, contentIds.size());//Contenuti pubblici liberi o non liberi con free gruppo extra assertTrue(contentIds.contains("EVN25"));//Contenuto coach abilitato al gruppo free assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free } @@ -49,7 +49,7 @@ void testFindContent_2() throws Throwable { ContentFinderViewerAction action = (ContentFinderViewerAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(24, contentIds.size());//Tutti i contenuti pubblici + assertEquals(28, contentIds.size());//Tutti i contenuti pubblici } @Test @@ -59,7 +59,7 @@ void testFindContent_3() throws Throwable { ContentFinderViewerAction action = (ContentFinderViewerAction) this.getAction(); List contentIds = action.getContents(); - assertEquals(20, contentIds.size());// Contenuti pubblici liberi o non liberi con customers gruppo extra + assertEquals(24, contentIds.size());// Contenuti pubblici liberi o non liberi con customers gruppo extra assertTrue(contentIds.contains("ART122"));//Contenuto del gruppo "administrators" abilitato al gruppo customers assertTrue(contentIds.contains("ART121"));//Contenuto del gruppo "administrators" abilitato al gruppo free assertTrue(contentIds.contains("EVN25"));//Contenuto del gruppo "coach" abilitato al gruppo free @@ -71,7 +71,7 @@ void testPerformSearch() throws Throwable { Map params = new HashMap(); this.executeParametrizedSearchContents("admin", "pagina_11", "1", null);//Pagina Free ContentFinderViewerAction action = (ContentFinderViewerAction) this.getAction(); - String[] order1 = {"ALL4", "ART121", "EVN21", "EVN20", "EVN25", + String[] order1 = {"BLT4", "BLT3", "BLT2", "BLT1", "ALL4", "ART121", "EVN21", "EVN20", "EVN25", "EVN24", "EVN23", "EVN192", "EVN191", "RAH1", "ART180", "EVN194", "EVN193", "ART1", "ART187"}; List contents = action.getContents(); diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/system/entity/TestJacmsEntityManagersAction.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/system/entity/TestJacmsEntityManagersAction.java index 752ff2ffcf..bf4dca44e5 100644 --- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/system/entity/TestJacmsEntityManagersAction.java +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/apsadmin/system/entity/TestJacmsEntityManagersAction.java @@ -57,13 +57,13 @@ void testGetEntityPrototypes() throws Throwable { IEntityTypesAction action = (IEntityTypesAction) this.getAction(); List entityPrototypes = action.getEntityPrototypes(); assertNotNull(entityPrototypes); - assertEquals(4, entityPrototypes.size()); - + assertEquals(5, entityPrototypes.size()); + IApsEntity firstType = entityPrototypes.get(0); assertEquals("ART", firstType.getTypeCode()); assertEquals("Articolo rassegna stampa", firstType.getTypeDescr()); - - IApsEntity lastType = entityPrototypes.get(3); + + IApsEntity lastType = entityPrototypes.get(4); assertEquals("RAH", lastType.getTypeCode()); assertEquals("Tipo_Semplice", lastType.getTypeDescr()); } diff --git a/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java b/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java index e4400fdb98..749514d367 100644 --- a/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java +++ b/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java @@ -35,6 +35,7 @@ import com.agiletec.aps.system.common.entity.IEntityTypesConfigurer; import com.agiletec.aps.system.common.entity.model.EntitySearchFilter; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute; import com.agiletec.aps.system.common.entity.model.attribute.ListAttribute; @@ -2715,6 +2716,66 @@ void testGetReturnsList() throws Exception { Assertions.assertEquals(payloadSize2, payloadSize); } + @Test + void testGetContentsFilteredByNestedCompositeBooleanAttribute() throws Exception { + // Solr-disabled end-to-end: a searchable boolean nested in a Composite is filterable via + // GET /plugins/cms/contents using the path key "_" as entityAttr. + String trueId = null; + String falseId = null; + try { + trueId = this.createPublishedAllWithNestedBoolean(Boolean.TRUE); + falseId = this.createPublishedAllWithNestedBoolean(Boolean.FALSE); + UserDetails user = new OAuth2TestUtils.UserBuilder("jack_bauer", "0x24") + .withAuthorization(Group.FREE_GROUP_NAME, "tempRole", Permission.BACKOFFICE).build(); + String accessToken = mockOAuthInterceptor(user); + ResultActions result = mockMvc + .perform(get("/plugins/cms/contents") + .param("status", IContentService.STATUS_ONLINE) + .param("filters[0].entityAttr", "Composite_Boolean") + .param("filters[0].operator", "eq") + .param("filters[0].value", "true") + .param("filters[0].type", "boolean") + .param("pageSize", "50") + .header("Authorization", "Bearer " + accessToken)); + result.andExpect(status().isOk()); + String body = result.andReturn().getResponse().getContentAsString(); + int size = JsonPath.read(body, "$.payload.size()"); + List ids = new ArrayList<>(); + for (int i = 0; i < size; i++) { + ids.add(JsonPath.read(body, "$.payload[" + i + "].id")); + } + Assertions.assertTrue(ids.contains(trueId)); + Assertions.assertFalse(ids.contains(falseId)); + } finally { + this.deletePublishedContent(trueId); + this.deletePublishedContent(falseId); + } + } + + private String createPublishedAllWithNestedBoolean(Boolean value) throws Exception { + Content clone = this.contentManager.loadContent("ALL4", false); + clone.setId(null); + clone.setMainGroup(Group.FREE_GROUP_NAME); + CompositeAttribute composite = (CompositeAttribute) clone.getAttribute("Composite"); + BooleanAttribute nestedBoolean = (BooleanAttribute) composite.getAttribute("Boolean"); + nestedBoolean.setSearchable(true); + nestedBoolean.setBooleanValue(value); + this.contentManager.saveContent(clone); + this.contentManager.insertOnLineContent(clone); + return clone.getId(); + } + + private void deletePublishedContent(String id) throws Exception { + if (null == id) { + return; + } + Content content = this.contentManager.loadContent(id, false); + if (null != content) { + this.contentManager.removeOnLineContent(content); + this.contentManager.deleteContent(content); + } + } + @Test void testLoadPublicEvents_1() throws Exception { UserDetails user = new OAuth2TestUtils.UserBuilder("jack_bauer", "0x24") @@ -4546,8 +4607,8 @@ void testGetContentsStatus() throws Throwable { UserDetails user = new OAuth2TestUtils.UserBuilder("jack_bauer", "0x24") .withAuthorization(Group.FREE_GROUP_NAME, "tempRole", Permission.BACKOFFICE).build(); String accessToken = mockOAuthInterceptor(user); - String lastModified = "2014-03-21 17:10:07"; - this.checkStatus(accessToken, 1, 6, 18, 25, lastModified); + String lastModified = "2026-01-01 00:00:04"; + this.checkStatus(accessToken, 1, 6, 22, 29, lastModified); List newContentIds = new ArrayList(); try { for (int i = 0; i < 10; i++) { @@ -4557,7 +4618,7 @@ void testGetContentsStatus() throws Throwable { newContentIds.add(content.getId()); } String dateString1 = DateConverter.getFormattedDate(new Date(), SystemConstants.API_DATE_FORMAT); - this.checkStatus(accessToken, 1+10, 6, 18, 25+10, dateString1); + this.checkStatus(accessToken, 1+10, 6, 22, 29+10, dateString1); synchronized (this) { this.wait(1000); @@ -4569,7 +4630,7 @@ void testGetContentsStatus() throws Throwable { } String dateString2 = DateConverter.getFormattedDate(new Date(), SystemConstants.API_DATE_FORMAT); Assertions.assertNotEquals(dateString1, dateString2); - this.checkStatus(accessToken, 1, 6, 18+10, 25+10, dateString2); + this.checkStatus(accessToken, 1, 6, 22+10, 29+10, dateString2); synchronized (this) { this.wait(1000); @@ -4584,7 +4645,7 @@ void testGetContentsStatus() throws Throwable { synchronized (this) { this.wait(1000); } - this.checkStatus(accessToken, 1, 6+10, 18, 25+10, dateString3); + this.checkStatus(accessToken, 1, 6+10, 22, 29+10, dateString3); } catch (Exception e) { throw e; } finally { @@ -4594,7 +4655,7 @@ void testGetContentsStatus() throws Throwable { this.contentManager.removeOnLineContent(content); this.contentManager.deleteContent(id); } - this.checkStatus(accessToken, 1, 6, 18, 25, lastModified); + this.checkStatus(accessToken, 1, 6, 22, 29, lastModified); } } diff --git a/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestContentFinderAction.java b/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestContentFinderAction.java index 38576ea74b..52f969e3c6 100644 --- a/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestContentFinderAction.java +++ b/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestContentFinderAction.java @@ -61,7 +61,7 @@ public void testSearch_1() throws Throwable { this.executeSearch("admin", params); ContentFinderAction action = (ContentFinderAction) this.getAction(); SearcherDaoPaginatedResult result = action.getPaginatedContentsId(10); - assertEquals(25, result.getCount().intValue()); + assertEquals(29, result.getCount().intValue()); assertEquals(10, result.getList().size()); this.executeSearch("editorCoach", params); action = (ContentFinderAction) this.getAction(); @@ -102,7 +102,7 @@ public void testSearch_2() throws Throwable { this.executeSearch("admin", params); ContentFinderAction action = (ContentFinderAction) this.getAction(); List contents = action.getContents(); - assertEquals(25, contents.size()); + assertEquals(29, contents.size()); this.executeSearch("editorCoach", params); action = (ContentFinderAction) this.getAction(); contents = action.getContents(); diff --git a/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestIntroNewContentAction.java b/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestIntroNewContentAction.java index 074111b1f9..29c16e50cc 100644 --- a/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestIntroNewContentAction.java +++ b/contentworkflow-plugin/src/test/java/com/agiletec/plugins/jpcontentworkflow/apsadmin/content/TestIntroNewContentAction.java @@ -67,7 +67,7 @@ public void testOpenNew() throws Throwable { assertEquals(Action.SUCCESS, result); JpCwIntroNewContentAction action = (JpCwIntroNewContentAction) this.getAction(); List contentTypes = action.getContentTypes(); - assertEquals(3, contentTypes.size()); + assertEquals(4, contentTypes.size()); for (int i=0; i infos = currAttribute.getSearchInfos(this.getLangManager().getLangs()); - if (currAttribute.isSearchable() && null != infos) { - for (int i=0; i attributes = entity.getAttributeList(); + for (int i = 0; i < attributes.size(); i++) { + this.addAttributeSearchRecord(id, attributes.get(i), null, false, stat); } stat.executeBatch(); } + + /** + * Recursively add the search records of an attribute. Elementary attributes are indexed exactly as + * before (by their own name, when searchable). Complex attributes are traversed to reach their + * elementary attributes - preserving the historical "flattened" behaviour - with one addition: a + * plain boolean attribute nested inside a Composite is indexed under the path key + * <composite>_<boolean> to avoid name collisions. {@code CheckBoxAttribute} + * and {@code ThreeStateAttribute} are excluded, and a boolean reached through a List/Monolist keeps + * the legacy plain-name behaviour (its path is not built). + * @param id the entity id. + * @param attribute the attribute to process. + * @param path the composite name path accumulated so far ('_'-joined), or null when at top level. + * @param listAncestor true when a List/Monolist is on the ancestry chain (disables path building). + * @param stat the batch statement to fill. + * @throws Throwable in case of error. + */ + private void addAttributeSearchRecord(String id, AttributeInterface attribute, String path, + boolean listAncestor, PreparedStatement stat) throws Throwable { + if (attribute.isSimple()) { + String attrName = (!listAncestor && null != path + && NestedBooleanSearchSupport.isIndexableNestedBoolean(attribute)) + ? path + "_" + attribute.getName() + : attribute.getName(); + List infos = attribute.getSearchInfos(this.getLangManager().getLangs()); + if (attribute.isSearchable() && null != infos) { + this.addAttributeSearchInfoRecords(id, attrName, infos, stat); + } + } else { + List children = ((AbstractComplexAttribute) attribute).getAttributes(); + if (null == children) { + return; + } + boolean composite = (attribute instanceof CompositeAttribute) && !listAncestor; + String childPath = composite + ? ((null == path) ? attribute.getName() : path + "_" + attribute.getName()) + : null; + boolean childListAncestor = listAncestor || !(attribute instanceof CompositeAttribute); + for (int i = 0; i < children.size(); i++) { + this.addAttributeSearchRecord(id, children.get(i), childPath, childListAncestor, stat); + } + } + } + + private void addAttributeSearchInfoRecords(String id, String attrName, + List infos, PreparedStatement stat) throws SQLException { + for (int i = 0; i < infos.size(); i++) { + AttributeSearchInfo searchInfo = infos.get(i); + stat.setString(1, id); + stat.setString(2, attrName); + stat.setString(3, searchInfo.getString()); + if (searchInfo.getDate() != null) { + stat.setTimestamp(4, new java.sql.Timestamp(searchInfo.getDate().getTime())); + } else { + stat.setDate(4, null); + } + stat.setBigDecimal(5, searchInfo.getBigDecimal()); + stat.setString(6, searchInfo.getLangCode()); + stat.addBatch(); + stat.clearParameters(); + } + } protected void addEntityAttributeRoleRecord(String id, IApsEntity entity, Connection conn) { PreparedStatement stat = null; diff --git a/engine/src/main/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupport.java b/engine/src/main/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupport.java new file mode 100644 index 0000000000..116c6a4980 --- /dev/null +++ b/engine/src/main/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupport.java @@ -0,0 +1,97 @@ +/* + * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + */ +package com.agiletec.aps.system.common.entity; + +import java.util.List; + +import com.agiletec.aps.system.common.entity.model.IApsEntity; +import com.agiletec.aps.system.common.entity.model.attribute.AbstractComplexAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; + +/** + * Single source of truth for indexing/searching boolean attributes nested inside a Composite + * attribute in the DB search tables ({@code contentsearch} / {@code workcontentsearch}). + * + *

A boolean nested in a Composite is indexed under the path key {@code _} + * (composite names joined by '_') to avoid name collisions. All boolean-like attributes are eligible - + * {@link BooleanAttribute} and its subclasses {@code CheckBoxAttribute} and {@code ThreeStateAttribute} - + * governed by the {@code searchable} flag inherited from the content type. Only Composite + * ancestry is supported: a boolean reached through a {@code MonoListAttribute}/{@code ListAttribute} is + * not path-indexed.

+ * + *

The write side ({@code AbstractEntityDAO.addEntitySearchRecord}) and the filter-key resolution + * ({@code EntitySearchFilter.getInstance}) share this class so that the key produced by the writer is + * exactly the key the reader resolves. It also gates {@code CompositeAttribute}'s decision to preserve + * the {@code searchable} flag on a composite child.

+ * + * @author Entando + */ +public final class NestedBooleanSearchSupport { + + private NestedBooleanSearchSupport() { + // utility class + } + + /** + * Whether the given attribute is a boolean-like attribute eligible for nested (path-based) DB + * indexing, i.e. a {@link BooleanAttribute} or one of its subclasses (CheckBox, ThreeState). + * @param attribute the attribute to test. + * @return true if the attribute is boolean-like. + */ + public static boolean isIndexableNestedBoolean(AttributeInterface attribute) { + return attribute instanceof BooleanAttribute; + } + + /** + * Resolve a Composite-nested boolean attribute from its path key {@code _}. + * The traversal descends only through Composite children (never lists) and builds the same + * path the writer uses, so resolution matches indexing exactly. + * @param entity the entity (or type prototype) to inspect. + * @param key the underscore path key. + * @return the matching nested plain boolean attribute, or null if none matches. + */ + public static AttributeInterface resolveNestedBooleanByKey(IApsEntity entity, String key) { + if (null == entity || null == key) { + return null; + } + return resolve(entity.getAttributeList(), null, key); + } + + private static AttributeInterface resolve(List attributes, String path, String key) { + if (null == attributes) { + return null; + } + for (int i = 0; i < attributes.size(); i++) { + AttributeInterface attribute = attributes.get(i); + if (attribute.isSimple()) { + if (null != path && isIndexableNestedBoolean(attribute) + && key.equals(path + "_" + attribute.getName())) { + return attribute; + } + } else if (attribute instanceof CompositeAttribute) { + String childPath = (null == path) ? attribute.getName() : path + "_" + attribute.getName(); + AttributeInterface found = resolve(((AbstractComplexAttribute) attribute).getAttributes(), childPath, key); + if (null != found) { + return found; + } + } + // Lists (MonoList/List) and any other complex type are intentionally not descended: + // list-reached booleans are out of scope for path indexing. + } + return null; + } + +} diff --git a/engine/src/main/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilter.java b/engine/src/main/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilter.java index 4686db3c9e..b6b7153162 100644 --- a/engine/src/main/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilter.java +++ b/engine/src/main/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilter.java @@ -28,6 +28,7 @@ import org.entando.entando.ent.util.EntLogging.EntLogFactory; import com.agiletec.aps.system.common.FieldSearchFilter; +import com.agiletec.aps.system.common.entity.NestedBooleanSearchSupport; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute; @@ -371,6 +372,11 @@ public static EntitySearchFilter getInstance(IApsEntity prototype, Properties pr AttributeInterface attr = null; if (null != key) { attr = (AttributeInterface) prototype.getAttribute(key); + if (null == attr) { + // fall back to a Composite-nested boolean referenced by its path key + // '_' (top-level attributes always take precedence) + attr = NestedBooleanSearchSupport.resolveNestedBooleanByKey(prototype, key); + } filter.setKey(key); } else { attr = (AttributeInterface) prototype.getAttributeByRole(roleName); diff --git a/engine/src/main/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttribute.java b/engine/src/main/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttribute.java index 79522df83c..9779a3ff98 100644 --- a/engine/src/main/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttribute.java +++ b/engine/src/main/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttribute.java @@ -13,6 +13,7 @@ */ package com.agiletec.aps.system.common.entity.model.attribute; +import com.agiletec.aps.system.common.entity.NestedBooleanSearchSupport; import com.agiletec.aps.system.common.entity.model.AttributeFieldError; import com.agiletec.aps.system.common.entity.model.AttributeTracer; import com.agiletec.aps.system.common.entity.model.IApsEntity; @@ -183,7 +184,12 @@ private void extractAttributeCompositeElement(Map at } compositeAttrElem = (AttributeInterface) compositeAttrElem.getAttributePrototype(); compositeAttrElem.setAttributeConfig(currentAttrJdomElem); - compositeAttrElem.setSearchable(false); + // Composite children are non-searchable by design (they would collide in the DB search tables + // under their unqualified name), EXCEPT plain boolean children, which are indexed under the + // path key "_" and so may keep their configured searchable flag. + if (!NestedBooleanSearchSupport.isIndexableNestedBoolean(compositeAttrElem)) { + compositeAttrElem.setSearchable(false); + } compositeAttrElem.setDefaultLangCode(this.getDefaultLangCode()); this.addAttribute(compositeAttrElem); } diff --git a/engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java b/engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java new file mode 100644 index 0000000000..67aad31d5c --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java @@ -0,0 +1,251 @@ +/* + * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + */ +package com.agiletec.aps.system.common.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.agiletec.aps.system.common.entity.model.ApsEntity; +import com.agiletec.aps.system.common.entity.model.ApsEntityRecord; +import com.agiletec.aps.system.common.entity.model.IApsEntity; +import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; +import com.agiletec.aps.system.services.lang.ILangManager; +import com.agiletec.aps.system.services.lang.Lang; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Verifies the {@code attrname} values that {@link AbstractEntityDAO#addEntitySearchRecord} writes to + * the DB search tables, focusing on the new behaviour: a plain boolean nested in a Composite is stored + * under the path key {@code _}, while everything else keeps its historical name. + */ +class AbstractEntityDAONestedBooleanTest { + + private TestEntityDAO dao; + private PreparedStatement stat; + + @BeforeEach + void setUp() { + this.dao = new TestEntityDAO(); + ILangManager langManager = mock(ILangManager.class); + Lang en = new Lang(); + en.setCode("en"); + en.setDescr("English"); + when(langManager.getLangs()).thenReturn(Collections.singletonList(en)); + this.dao.setLangManager(langManager); + this.stat = mock(PreparedStatement.class); + } + + @Test + void topLevelSearchableBooleanKeepsPlainName() throws Throwable { + assertEquals(List.of("flag"), + writtenAttrNames(entity(booleanAttr("flag", true, Boolean.TRUE)))); + } + + @Test + void compositeSearchableBooleanUsesPathName() throws Throwable { + assertEquals(List.of("address_certified"), + writtenAttrNames(entity(composite("address", booleanAttr("certified", true, Boolean.TRUE))))); + } + + @Test + void compositeNonSearchableBooleanIsNotIndexed() throws Throwable { + assertEquals(List.of(), + writtenAttrNames(entity(composite("address", booleanAttr("certified", false, Boolean.TRUE))))); + } + + @Test + void deepCompositeBooleanUsesFullPathName() throws Throwable { + assertEquals(List.of("a_b_c"), + writtenAttrNames(entity(composite("a", composite("b", booleanAttr("c", true, Boolean.TRUE)))))); + } + + @Test + void nestedCheckBoxIsPathIndexed() throws Throwable { + // CheckBox is a boolean-like -> path-qualified like Boolean (value null -> "false") + assertEquals(List.of("address_verified"), + writtenAttrNames(entity(composite("address", checkBox("verified", true))))); + } + + @Test + void nestedThreeStateIsPathIndexed() throws Throwable { + ThreeStateAttribute maybe = threeState("maybe", true); + maybe.setBooleanValue(Boolean.TRUE); // non-null so ThreeState produces a search row + assertEquals(List.of("address_maybe"), + writtenAttrNames(entity(composite("address", maybe)))); + } + + @Test + void listReachedBooleanKeepsPlainName() throws Throwable { + assertEquals(List.of("flag"), + writtenAttrNames(entity(monolist("tags", booleanAttr("flag", true, Boolean.TRUE))))); + } + + @Test + void listOfCompositeBooleanKeepsPlainName() throws Throwable { + assertEquals(List.of("active"), + writtenAttrNames(entity(monolist("rows", composite("row", booleanAttr("active", true, Boolean.TRUE)))))); + } + + @Test + void mixedEntityWritesEachAttributeUnderItsExpectedName() throws Throwable { + ApsEntity entity = entity( + booleanAttr("published", true, Boolean.TRUE), + composite("address", booleanAttr("certified", true, Boolean.TRUE))); + assertEquals(List.of("published", "address_certified"), writtenAttrNames(entity)); + } + + private List writtenAttrNames(IApsEntity entity) throws Throwable { + ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); + this.dao.addSearchRecords("ENTITY1", entity, this.stat); + verify(this.stat, atLeast(0)).setString(eq(2), captor.capture()); + return captor.getAllValues(); + } + + // --- fixtures ---------------------------------------------------------- + + private BooleanAttribute booleanAttr(String name, boolean searchable, Boolean value) { + BooleanAttribute a = new BooleanAttribute(); + a.setName(name); + a.setSearchable(searchable); + a.setBooleanValue(value); + return a; + } + + private CheckBoxAttribute checkBox(String name, boolean searchable) { + CheckBoxAttribute a = new CheckBoxAttribute(); + a.setName(name); + a.setSearchable(searchable); + return a; + } + + private ThreeStateAttribute threeState(String name, boolean searchable) { + ThreeStateAttribute a = new ThreeStateAttribute(); + a.setName(name); + a.setSearchable(searchable); + return a; + } + + private CompositeAttribute composite(String name, AttributeInterface... children) { + CompositeAttribute c = new CompositeAttribute(); + c.setName(name); + for (AttributeInterface child : children) { + c.getAttributes().add(child); + } + return c; + } + + private MonoListAttribute monolist(String name, AttributeInterface... elements) { + MonoListAttribute list = new MonoListAttribute(); + list.setName(name); + for (AttributeInterface element : elements) { + list.getAttributes().add(element); + } + return list; + } + + private ApsEntity entity(AttributeInterface... attributes) { + ApsEntity entity = new ApsEntity(); + entity.setTypeCode("TST"); + for (AttributeInterface attribute : attributes) { + entity.addAttribute(attribute); + } + return entity; + } + + /** + * Minimal concrete {@link AbstractEntityDAO} exposing the protected search-record writer. + */ + private static class TestEntityDAO extends AbstractEntityDAO { + + public void addSearchRecords(String id, IApsEntity entity, PreparedStatement stat) throws Throwable { + this.addEntitySearchRecord(id, entity, stat); + } + + @Override + protected String getAddEntityRecordQuery() { + return null; + } + + @Override + protected void buildAddEntityStatement(IApsEntity entity, PreparedStatement stat) throws Throwable { + // no-op + } + + @Override + protected String getDeleteEntityRecordQuery() { + return null; + } + + @Override + protected String getUpdateEntityRecordQuery() { + return null; + } + + @Override + protected void buildUpdateEntityStatement(IApsEntity entity, PreparedStatement stat) throws Throwable { + // no-op + } + + @Override + protected String getLoadEntityRecordQuery() { + return null; + } + + @Override + protected ApsEntityRecord createEntityRecord(ResultSet res) throws Throwable { + return null; + } + + @Override + protected String getAddingSearchRecordQuery() { + return null; + } + + @Override + protected String getAddingAttributeRoleRecordQuery() { + return null; + } + + @Override + protected String getRemovingSearchRecordQuery() { + return null; + } + + @Override + protected String getRemovingAttributeRoleRecordQuery() { + return null; + } + + @Override + protected String getExtractingAllEntityIdQuery() { + return null; + } + } + +} diff --git a/engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java b/engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java new file mode 100644 index 0000000000..b961ee01e7 --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java @@ -0,0 +1,145 @@ +/* + * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + */ +package com.agiletec.aps.system.common.entity; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.agiletec.aps.system.common.entity.model.ApsEntity; +import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.MonoTextAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link NestedBooleanSearchSupport} - the shared logic used to index/resolve + * boolean attributes nested inside a Composite in the DB search tables (Solr-disabled path). + */ +class NestedBooleanSearchSupportTest { + + @Test + void isIndexableNestedBoolean_shouldAcceptAllBooleanLikes() { + assertTrue(NestedBooleanSearchSupport.isIndexableNestedBoolean(booleanAttr("b", true, Boolean.TRUE))); + assertTrue(NestedBooleanSearchSupport.isIndexableNestedBoolean(checkBox("c", true))); + assertTrue(NestedBooleanSearchSupport.isIndexableNestedBoolean(threeState("t", true))); + assertFalse(NestedBooleanSearchSupport.isIndexableNestedBoolean(new MonoTextAttribute())); + assertFalse(NestedBooleanSearchSupport.isIndexableNestedBoolean(null)); + } + + @Test + void shouldResolveCompositeNestedBoolean() { + BooleanAttribute certified = booleanAttr("certified", true, Boolean.TRUE); + ApsEntity entity = entity(composite("address", certified)); + assertSame(certified, NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "address_certified")); + } + + @Test + void shouldResolveDeepCompositePath() { + BooleanAttribute leaf = booleanAttr("c", true, Boolean.TRUE); + ApsEntity entity = entity(composite("a", composite("b", leaf))); + assertSame(leaf, NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "a_b_c")); + } + + @Test + void shouldReturnNullForUnknownKey() { + ApsEntity entity = entity(composite("address", booleanAttr("certified", true, Boolean.TRUE))); + assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "address_missing")); + assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "nope")); + assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(null, "address_certified")); + assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, null)); + } + + @Test + void shouldResolveNestedCheckBoxAndThreeState() { + CheckBoxAttribute verified = checkBox("verified", true); + ThreeStateAttribute maybe = threeState("maybe", true); + ApsEntity entity = entity(composite("address", verified, maybe)); + assertSame(verified, NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "address_verified")); + assertSame(maybe, NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "address_maybe")); + } + + @Test + void shouldNotResolveListReachedBoolean() { + ApsEntity listOfBoolean = entity(monolist("tags", booleanAttr("flag", true, Boolean.TRUE))); + assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(listOfBoolean, "tags_flag")); + + ApsEntity listOfComposite = entity(monolist("rows", composite("row", booleanAttr("active", true, Boolean.TRUE)))); + assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(listOfComposite, "rows_row_active")); + } + + @Test + void shouldNotResolveTopLevelAttribute() { + // the resolver only matches nested booleans; top-level precedence is handled by the caller + ApsEntity entity = entity(booleanAttr("flag", true, Boolean.TRUE)); + assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "flag")); + } + + // --- helpers ----------------------------------------------------------- + + private BooleanAttribute booleanAttr(String name, boolean searchable, Boolean value) { + BooleanAttribute a = new BooleanAttribute(); + a.setName(name); + a.setSearchable(searchable); + a.setBooleanValue(value); + return a; + } + + private CheckBoxAttribute checkBox(String name, boolean searchable) { + CheckBoxAttribute a = new CheckBoxAttribute(); + a.setName(name); + a.setSearchable(searchable); + return a; + } + + private ThreeStateAttribute threeState(String name, boolean searchable) { + ThreeStateAttribute a = new ThreeStateAttribute(); + a.setName(name); + a.setSearchable(searchable); + return a; + } + + private CompositeAttribute composite(String name, AttributeInterface... children) { + CompositeAttribute c = new CompositeAttribute(); + c.setName(name); + for (AttributeInterface child : children) { + c.getAttributes().add(child); + } + return c; + } + + private MonoListAttribute monolist(String name, AttributeInterface... elements) { + MonoListAttribute list = new MonoListAttribute(); + list.setName(name); + for (AttributeInterface element : elements) { + list.getAttributes().add(element); + } + return list; + } + + private ApsEntity entity(AttributeInterface... attributes) { + ApsEntity entity = new ApsEntity(); + entity.setTypeCode("TST"); + for (AttributeInterface attribute : attributes) { + entity.addAttribute(attribute); + } + return entity; + } + +} diff --git a/engine/src/test/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilterNestedBooleanTest.java b/engine/src/test/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilterNestedBooleanTest.java new file mode 100644 index 0000000000..7ffd7b9152 --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/common/entity/model/EntitySearchFilterNestedBooleanTest.java @@ -0,0 +1,114 @@ +/* + * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + */ +package com.agiletec.aps.system.common.entity.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; +import java.util.Properties; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link EntitySearchFilter#getInstance(IApsEntity, Properties)} accepts a + * Composite-nested boolean referenced by its path key {@code _} - the read-side + * half of the Solr-disabled nested-boolean feature. + */ +class EntitySearchFilterNestedBooleanTest { + + @Test + void shouldResolveNestedBooleanPathKey() { + ApsEntity prototype = entity(composite("address", booleanAttr("certified", true, Boolean.TRUE))); + EntitySearchFilter filter = EntitySearchFilter.getInstance(prototype, + attributeFilterProps("address_certified", "true")); + assertNotNull(filter); + assertTrue(filter.isAttributeFilter()); + assertEquals("address_certified", filter.getKey()); + assertEquals("true", filter.getValue()); + } + + @Test + void topLevelAttributeTakesPrecedence() { + ApsEntity prototype = entity(booleanAttr("flag", true, Boolean.TRUE)); + EntitySearchFilter filter = EntitySearchFilter.getInstance(prototype, + attributeFilterProps("flag", "true")); + assertNotNull(filter); + assertEquals("flag", filter.getKey()); + } + + @Test + void shouldRejectUnknownKey() { + ApsEntity prototype = entity(composite("address", booleanAttr("certified", true, Boolean.TRUE))); + assertThrows(RuntimeException.class, () -> EntitySearchFilter.getInstance(prototype, + attributeFilterProps("address_missing", "true"))); + } + + @Test + void shouldNotResolveListReachedBoolean() { + ApsEntity prototype = entity(monolist("tags", booleanAttr("flag", true, Boolean.TRUE))); + assertThrows(RuntimeException.class, () -> EntitySearchFilter.getInstance(prototype, + attributeFilterProps("tags_flag", "true"))); + } + + // --- helpers ----------------------------------------------------------- + + private Properties attributeFilterProps(String key, String value) { + Properties props = new Properties(); + props.setProperty(EntitySearchFilter.KEY_PARAM, key); + props.setProperty(EntitySearchFilter.FILTER_TYPE_PARAM, Boolean.TRUE.toString()); + props.setProperty(EntitySearchFilter.VALUE_PARAM, value); + return props; + } + + private BooleanAttribute booleanAttr(String name, boolean searchable, Boolean value) { + BooleanAttribute a = new BooleanAttribute(); + a.setName(name); + a.setSearchable(searchable); + a.setBooleanValue(value); + return a; + } + + private CompositeAttribute composite(String name, AttributeInterface... children) { + CompositeAttribute c = new CompositeAttribute(); + c.setName(name); + for (AttributeInterface child : children) { + c.getAttributes().add(child); + } + return c; + } + + private MonoListAttribute monolist(String name, AttributeInterface... elements) { + MonoListAttribute list = new MonoListAttribute(); + list.setName(name); + for (AttributeInterface element : elements) { + list.getAttributes().add(element); + } + return list; + } + + private ApsEntity entity(AttributeInterface... attributes) { + ApsEntity entity = new ApsEntity(); + entity.setTypeCode("TST"); + for (AttributeInterface attribute : attributes) { + entity.addAttribute(attribute); + } + return entity; + } + +} diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java index 1644d79623..277146793d 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java @@ -34,6 +34,8 @@ import org.entando.entando.ent.util.EntLogging.EntLogger; import org.entando.entando.web.common.exceptions.ValidationConflictException; import org.entando.entando.web.common.model.Filter; +import org.entando.entando.web.common.model.FilterOperator; +import org.entando.entando.web.common.model.FilterType; import org.entando.entando.plugins.jpsolr.aps.system.solr.ISolrSearchEngineManager; import org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFacetedContentsResult; import org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrSearchEngineFilter; @@ -197,6 +199,45 @@ private void validateFilters(Filter[] filters, String fieldPrefix, rejectIfUnsafeIdentifier(solrFilter.getSearchOption(), fieldPrefix + ".searchOption", bindingResult); } + rejectIfInvalidBooleanFilter(filter, fieldPrefix, bindingResult); + } + } + + /** + * Booleans have no meaningful range and only two valid values. Without this check a range + * operator silently builds a nonsense query (SearcherDAO's string-range fallback), and + * {@code Boolean.parseBoolean} silently coerces any non-"true" string (including a + * three-state "none") to {@code false} instead of failing. + */ + private void rejectIfInvalidBooleanFilter(Filter filter, String fieldPrefix, + BeanPropertyBindingResult bindingResult) { + if (!FilterType.BOOLEAN.getValue().equalsIgnoreCase(filter.getType())) { + return; + } + String operator = filter.getOperator(); + if (FilterOperator.GREATER.getValue().equalsIgnoreCase(operator) + || FilterOperator.LOWER.getValue().equalsIgnoreCase(operator)) { + logger.warn("Rejected range operator '{}' on boolean filter in field '{}'", operator, fieldPrefix); + bindingResult.rejectValue(null, "INVALID_PARAMETER", + new Object[]{fieldPrefix + ".operator"}, "parameter.invalid"); + } + rejectIfNotStrictBoolean(filter.getValue(), fieldPrefix + ".value", bindingResult); + if (null != filter.getAllowedValues()) { + for (String av : filter.getAllowedValues()) { + rejectIfNotStrictBoolean(av, fieldPrefix + ".allowedValues", bindingResult); + } + } + } + + private static void rejectIfNotStrictBoolean(String value, String field, + BeanPropertyBindingResult bindingResult) { + if (StringUtils.isBlank(value)) { + return; + } + if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) { + logger.warn("Rejected non-boolean value in field '{}': '{}'", field, value); + bindingResult.rejectValue(null, "INVALID_PARAMETER", + new Object[]{field}, "parameter.invalid"); } } diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java index 3f7849c78e..199da0edd5 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java @@ -16,8 +16,11 @@ import com.agiletec.aps.system.common.entity.model.IApsEntity; import com.agiletec.aps.system.common.entity.model.attribute.AbstractComplexAttribute; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ListAttributeInterface; import com.agiletec.aps.system.common.entity.model.attribute.NumberAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; import com.agiletec.aps.system.common.searchengine.IndexableAttributeInterface; import com.agiletec.aps.system.common.tree.ITreeNode; import com.agiletec.aps.system.common.tree.ITreeNodeManager; @@ -131,7 +134,10 @@ protected SolrInputDocument createDocument(IApsEntity entity) { } for (AttributeInterface currentAttribute : entity.getAttributeList()) { Object value = currentAttribute.getValue(); - if (null == value) { + // An uninitialized ThreeStateAttribute must still reach indexAttribute so it is + // indexed as the literal "none": its getValue() returns null on purpose (the third, + // unset state), unlike a plain BooleanAttribute which coerces null to false. + if (null == value && !(currentAttribute instanceof ThreeStateAttribute)) { continue; } for (Lang lang : this.getLangManager().getLangs()) { @@ -165,12 +171,13 @@ protected void extractCategoryCodes(ITreeNode category, Set codes) { protected void indexAttribute(SolrInputDocument document, AttributeInterface attribute, Lang lang) { attribute.setRenderingLang(lang.getCode()); if (!attribute.isSimple()) { - this.indexComplexAttribute(document, (AbstractComplexAttribute) attribute, lang); + this.indexComplexAttribute(document, (AbstractComplexAttribute) attribute, lang, + attribute.getName(), false); return; } if (attribute instanceof IndexableAttributeInterface - || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute) - && attribute.isSearchable())) { + || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute + || attribute instanceof BooleanAttribute) && attribute.isSearchable())) { Object valueToIndex = null; if (attribute instanceof DateAttribute) { valueToIndex = ((DateAttribute) attribute).getDate(); @@ -179,6 +186,11 @@ protected void indexAttribute(SolrInputDocument document, AttributeInterface att if (null != valueToIndex) { valueToIndex = ((BigDecimal) valueToIndex).intValue(); } + } else if (attribute instanceof ThreeStateAttribute) { + // Must be tested before BooleanAttribute (ThreeStateAttribute is a subclass). + valueToIndex = SolrComplexAttributes.solrValue(attribute); + } else if (attribute instanceof BooleanAttribute) { + valueToIndex = SolrComplexAttributes.solrValue(attribute); } else { valueToIndex = ((IndexableAttributeInterface) attribute).getIndexeableFieldValue(); } @@ -204,14 +216,27 @@ protected void indexAttribute(SolrInputDocument document, AttributeInterface att } private void indexComplexAttribute(SolrInputDocument document, AbstractComplexAttribute complexAttribute, - Lang lang) { + Lang lang, String namePrefix, boolean withinList) { + // Text children are still routed to the full-text "" field wherever they occur + // (including inside lists). Per-attribute boolean fields are created for Composite children + // only: once the traversal has entered a List/Monolist, booleans are skipped so the indexer + // never writes a field the schema (SolrFieldsChecker) does not create. + boolean insideList = withinList || (complexAttribute instanceof ListAttributeInterface); for (AttributeInterface attribute : complexAttribute.getAttributes()) { attribute.setRenderingLang(lang.getCode()); + String path = SolrComplexAttributes.appendPath(namePrefix, attribute.getName()); if (!attribute.isSimple()) { - this.indexComplexAttribute(document, (AbstractComplexAttribute) attribute, lang); + this.indexComplexAttribute(document, (AbstractComplexAttribute) attribute, lang, path, insideList); } else if (attribute instanceof IndexableAttributeInterface){ String valueToIndex = ((IndexableAttributeInterface) attribute).getIndexeableFieldValue(); this.addFieldForFullTextSearch(document, attribute, lang, valueToIndex); + } else if (!insideList + && SolrComplexAttributes.isIndexableCompositeBoolean(attribute)) { + // solrValue() never returns null: an unset ThreeStateAttribute becomes the + // literal "none", an unset Boolean/CheckBoxAttribute coerces to false. + Object valueToIndex = SolrComplexAttributes.solrValue(attribute); + String fieldName = lang.getCode().toLowerCase() + "_" + path; + this.indexValue(document, fieldName, valueToIndex); } } } @@ -232,6 +257,7 @@ protected void addFieldForFullTextSearch(SolrInputDocument document, AttributeIn private void indexValue(SolrInputDocument document, String fieldName, Object valueToIndex) { fieldName = fieldName.replace(":", "_"); + logger.debug("Indexing attribute field '{}' with value '{}'", fieldName, valueToIndex); document.addField(fieldName, valueToIndex); } diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAO.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAO.java index db41072738..fee28301f8 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAO.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAO.java @@ -375,7 +375,7 @@ private Query createMultipleValuesQuery(SearchEngineFilter filter, String key } //To be improved to manage different type for (Object singleValue : allowedValues) { - if (filter instanceof NumericSearchEngineFilter) { + if (filter instanceof NumericSearchEngineFilter || singleValue instanceof Boolean) { TermQuery term = new TermQuery(new Term(key, singleValue + relevance)); fieldQuery.add(term, BooleanClause.Occur.SHOULD); } else { @@ -457,6 +457,9 @@ private Query createSingleValueQuery(SearchEngineFilter filter, String key, S } else if (value instanceof Number) { TermQuery term = new TermQuery(new Term(key, value + relevance)); fieldQuery.add(term, BooleanClause.Occur.MUST); + } else if (value instanceof Boolean) { + TermQuery term = new TermQuery(new Term(key, value.toString() + relevance)); + fieldQuery.add(term, BooleanClause.Occur.MUST); } return fieldQuery.build(); } diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java new file mode 100644 index 0000000000..c420c53f3d --- /dev/null +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java @@ -0,0 +1,87 @@ +/* + * Copyright 2021-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + */ +package org.entando.entando.plugins.jpsolr.aps.system.solr; + +import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; +import org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFields; + +/** + * Shared rules for indexing boolean-like attributes (Boolean, CheckBox, ThreeState) nested inside + * Composite attributes. + * + *

Kept in one place so the schema side ({@code SolrFieldsChecker}), the document side + * ({@code IndexerDAO}) and the content-type settings report ({@code SolrSearchEngineManager}) build + * identical field names and apply the same "is this boolean indexable" decision. Any divergence + * would create fields the indexer never populates, or make the validity check loop forever.

+ * + *

List and Monolist attributes are intentionally out of scope: booleans reached through a list + * are never indexed as per-attribute fields.

+ * + *

Public (rather than package-private) because {@code ContentTypeSettings}, which needs the + * same type/value dispatch, lives in the {@code .model} sub-package.

+ */ +public final class SolrComplexAttributes { + + private SolrComplexAttributes() { + } + + /** + * A boolean-like attribute ({@code BooleanAttribute} or a subclass — {@code CheckBoxAttribute}, + * {@code ThreeStateAttribute}) nested in a Composite is indexed when its {@code searchable} flag, + * inherited from the content type, is set. This mirrors how top-level boolean-like attributes are + * gated, so nested and top-level behave uniformly. + */ + static boolean isIndexableCompositeBoolean(AttributeInterface attribute) { + return attribute instanceof BooleanAttribute && attribute.isSearchable(); + } + + /** + * The Solr field type for a boolean-like attribute. {@code ThreeStateAttribute} must be tested + * before {@code BooleanAttribute} (it is a subclass): its third "uninitialized" state cannot be + * represented in Solr's two-valued {@code BoolField}, so it is indexed as a non-analyzed {@code + * string} field with the literal values {@code true}/{@code false}/{@code none}; plain {@code + * BooleanAttribute}/{@code CheckBoxAttribute} are indexed as Solr {@code boolean}. + */ + public static String solrType(AttributeInterface attribute) { + if (attribute instanceof ThreeStateAttribute) { + return SolrFields.TYPE_STRING; + } + return SolrFields.TYPE_BOOLEAN; + } + + /** + * The value to write for a boolean-like attribute. {@code ThreeStateAttribute}: {@code null} + * (uninitialized) becomes the literal {@code "none"}, otherwise the lowercase string literal + * {@code "true"}/{@code "false"}. Plain {@code BooleanAttribute}/{@code CheckBoxAttribute}: + * {@code getValue()} already coerces {@code null} to {@code false}. + */ + public static Object solrValue(AttributeInterface attribute) { + if (attribute instanceof ThreeStateAttribute) { + Boolean value = ((ThreeStateAttribute) attribute).getValue(); + return (null == value) ? "none" : value.toString(); + } + return ((BooleanAttribute) attribute).getValue(); + } + + /** + * Extends a composite name path with a child segment. The resulting field name is + * "<lang>_<path>" (built by the callers), e.g. "en_complexAttrName_boolAttrName" for the boolean + * "boolAttrName" nested in the composite "complexAttrName". + */ + static String appendPath(String namePrefix, String name) { + return namePrefix + "_" + name; + } +} diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java index 74a81f268b..fb368fa6ae 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java @@ -4,9 +4,11 @@ import static org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFields.SOLR_FIELD_NAME; import static org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFields.SOLR_FIELD_TYPE; +import com.agiletec.aps.system.common.entity.model.attribute.AbstractComplexAttribute; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ListAttributeInterface; import com.agiletec.aps.system.common.entity.model.attribute.NumberAttribute; import com.agiletec.aps.system.common.searchengine.IndexableAttributeInterface; import com.agiletec.aps.system.services.lang.Lang; @@ -95,33 +97,74 @@ private void checkLangFields() { private void checkAttribute(AttributeInterface attribute, Lang lang) { attribute.setRenderingLang(lang.getCode()); - if (attribute instanceof IndexableAttributeInterface - || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute) - && attribute.isSearchable())) { - String type; - if (attribute instanceof DateAttribute) { - type = SolrFields.TYPE_PDATES; - } else if (attribute instanceof NumberAttribute) { - type = SolrFields.TYPE_PLONGS; - } else if (attribute instanceof BooleanAttribute) { - type = SolrFields.TYPE_BOOLEAN; - } else { - type = SolrFields.TYPE_TEXT_GEN_SORT; - } - String fieldName = lang.getCode().toLowerCase() + "_" + attribute.getName(); - fieldName = fieldName.replace(":", "_"); + if (!attribute.isSimple()) { + this.checkComplexAttributeChildren(attribute, lang, attribute.getName()); + return; + } + if (this.isIndexableAttributeField(attribute)) { + String type = this.getFieldType(attribute); + String fieldName = this.buildFieldName(lang, attribute.getName()); this.checkField(fieldName, type); if (null == attribute.getRoles()) { return; } for (String role : attribute.getRoles()) { - String roleFieldName = lang.getCode().toLowerCase() + "_" + role; - roleFieldName = roleFieldName.replace(":", "_"); + String roleFieldName = this.buildFieldName(lang, role); this.checkField(roleFieldName, type); } } } + private boolean isIndexableAttributeField(AttributeInterface attribute) { + return attribute instanceof IndexableAttributeInterface + || ((attribute instanceof DateAttribute || attribute instanceof NumberAttribute + || attribute instanceof BooleanAttribute) && attribute.isSearchable()); + } + + private String getFieldType(AttributeInterface attribute) { + if (attribute instanceof DateAttribute) { + return SolrFields.TYPE_PDATES; + } else if (attribute instanceof NumberAttribute) { + return SolrFields.TYPE_PLONGS; + } else if (attribute instanceof BooleanAttribute) { + // Covers ThreeStateAttribute too (subclass): SolrComplexAttributes.solrType tests it + // first internally and returns TYPE_STRING for it, TYPE_BOOLEAN otherwise. + return SolrComplexAttributes.solrType(attribute); + } else { + return SolrFields.TYPE_TEXT_GEN_SORT; + } + } + + private String buildFieldName(Lang lang, String name) { + return (lang.getCode().toLowerCase() + "_" + name).replace(":", "_"); + } + + // Nested booleans only, Composite attributes only: List/Monolist attributes are excluded, and + // Date/Number/Text children remain full-text-only (unchanged, matching the baseline Lucene + // engine). The field name carries the full composite path, e.g. "en_complexAttrName_boolAttrName". + private void checkComplexAttributeChildren(AttributeInterface attribute, Lang lang, String namePrefix) { + if (attribute instanceof AbstractComplexAttribute && !(attribute instanceof ListAttributeInterface)) { + for (AttributeInterface child : ((AbstractComplexAttribute) attribute).getAttributes()) { + this.checkNestedAttribute(child, lang, namePrefix); + } + } + } + + private void checkNestedAttribute(AttributeInterface attribute, Lang lang, String namePrefix) { + attribute.setRenderingLang(lang.getCode()); + String path = SolrComplexAttributes.appendPath(namePrefix, attribute.getName()); + if (!attribute.isSimple()) { + this.checkComplexAttributeChildren(attribute, lang, path); + return; + } + if (SolrComplexAttributes.isIndexableCompositeBoolean(attribute)) { + String fieldName = this.buildFieldName(lang, path); + // Single-valued: a Composite occurs at most once per document per lang (Monolist and + // Monolist-of-Composite are excluded above), so the nested field is never repeated. + this.checkField(fieldName, SolrComplexAttributes.solrType(attribute), false); + } + } + private void checkField(String fieldName, String type) { this.checkField(fieldName, type, false); } diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java index 1cc685429c..c4d17317e5 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManager.java @@ -19,7 +19,9 @@ import com.agiletec.aps.system.common.entity.event.EntityTypesChangingObserver; import com.agiletec.aps.system.common.entity.model.IApsEntity; import com.agiletec.aps.system.common.entity.model.SmallEntityType; +import com.agiletec.aps.system.common.entity.model.attribute.AbstractComplexAttribute; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.ListAttributeInterface; import com.agiletec.aps.system.services.lang.ILangManager; import com.agiletec.aps.system.services.lang.Lang; import com.agiletec.aps.util.ApsTenantApplicationUtils; @@ -173,17 +175,15 @@ private List getContentTypesSettings(ISolrSchemaDAO schemaD entityType.getDescription()); list.add(typeSettings); Content prototype = this.getContentManager().createContentType(entityType.getCode()); + List languages = this.langManager.getLangs(); for (AttributeInterface attribute : prototype.getAttributeList()) { - Map> currentConfig = new HashMap<>(); - List languages = this.langManager.getLangs(); - for (Lang lang : languages) { - String fieldName = lang.getCode().toLowerCase() + "_" + attribute.getName(); - fields.stream() - .filter(f -> f.get(SOLR_FIELD_NAME).equals(fieldName)) - .findFirst().ifPresent(currentField -> - currentConfig.put(fieldName, (Map) currentField)); - } + Map> currentConfig = + this.buildCurrentFieldConfig(attribute.getName(), languages, fields); typeSettings.addAttribute(attribute, currentConfig, languages); + if (!attribute.isSimple()) { + this.collectNestedBooleanAttributes(attribute, attribute.getName(), fields, languages, + typeSettings); + } } } } catch (Exception e) { @@ -192,6 +192,42 @@ private List getContentTypesSettings(ISolrSchemaDAO schemaD return list; } + private Map> buildCurrentFieldConfig(String attributeName, + List languages, List> fields) { + Map> currentConfig = new HashMap<>(); + for (Lang lang : languages) { + String fieldName = lang.getCode().toLowerCase() + "_" + attributeName; + fields.stream() + .filter(f -> f.get(SOLR_FIELD_NAME).equals(fieldName)) + .findFirst().ifPresent(currentField -> + currentConfig.put(fieldName, (Map) currentField)); + } + return currentConfig; + } + + // Composite attributes only (List/Monolist excluded), mirroring + // SolrFieldsChecker.checkComplexAttributeChildren: reports boolean children under their full + // composite path (e.g. "en_complexAttrName_boolAttrName") so the admin "content types settings" endpoint and + // the lazy schema-refresh validity check (isValid()) stay in sync with the schema/index. + private void collectNestedBooleanAttributes(AttributeInterface attribute, String namePrefix, + List> fields, List languages, ContentTypeSettings typeSettings) { + if (!(attribute instanceof AbstractComplexAttribute) || attribute instanceof ListAttributeInterface) { + return; + } + for (AttributeInterface child : ((AbstractComplexAttribute) attribute).getAttributes()) { + String childPath = SolrComplexAttributes.appendPath(namePrefix, child.getName()); + if (child.isSimple()) { + if (SolrComplexAttributes.isIndexableCompositeBoolean(child)) { + Map> currentConfig = + this.buildCurrentFieldConfig(childPath, languages, fields); + typeSettings.addNestedBooleanAttribute(child, currentConfig, languages); + } + } else { + this.collectNestedBooleanAttributes(child, childPath, fields, languages, typeSettings); + } + } + } + @Override public void refreshCmsFields() throws EntException { refreshCmsFields(solrProxy.getSolrTenantResources()); diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettings.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettings.java index 7ea42c2633..8a4410a7e3 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettings.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettings.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.entando.entando.plugins.jpsolr.aps.system.solr.SolrComplexAttributes; /** * @author E.Santoboni @@ -72,15 +73,16 @@ public void addAttribute(AttributeInterface attribute, Map> currentField, List languages) { + AttributeSettings settings = new AttributeSettings(attribute, languages); + this.getAttributeSettings().add(settings); + settings.setCurrentConfig(currentField); + Map newField = new HashMap<>(); + newField.put(SOLR_FIELD_TYPE, SolrComplexAttributes.solrType(attribute)); + newField.put(SOLR_FIELD_MULTIVALUED, false); + settings.setExpectedConfig(newField); + } + public boolean isValid() { return this.getAttributeSettings().stream().allMatch(AttributeSettings::isValid); } diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManagerTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManagerTest.java index b73301e9d1..6cbe5af9f1 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManagerTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManagerTest.java @@ -210,6 +210,78 @@ void shouldRejectInjectionInSearchOption() { () -> facetManager.getFacetedContents(request, null)); } + @ParameterizedTest + @ValueSource(strings = {"gt", "lt"}) + void shouldRejectRangeOperatorOnBooleanFilter(String operator) { + AdvRestContentListRequest request = new AdvRestContentListRequest(); + SolrFilter filter = new SolrFilter("myFlag", "true", operator); + filter.setType("boolean"); + request.setFilters(new Filter[]{filter}); + Assertions.assertThrows(ValidationConflictException.class, + () -> facetManager.getFacetedContents(request, null)); + } + + @Test + void shouldRejectNonStrictBooleanValue() { + AdvRestContentListRequest request = new AdvRestContentListRequest(); + // A three-state "none" must not be sent as a boolean value: FilterType.BOOLEAN would + // silently coerce it to false via Boolean.parseBoolean. + SolrFilter filter = new SolrFilter("myFlag", "none", "eq"); + filter.setType("boolean"); + request.setFilters(new Filter[]{filter}); + Assertions.assertThrows(ValidationConflictException.class, + () -> facetManager.getFacetedContents(request, null)); + } + + @Test + void shouldRejectNonStrictBooleanAllowedValue() { + AdvRestContentListRequest request = new AdvRestContentListRequest(); + SolrFilter filter = new SolrFilter(); + filter.setEntityAttr("myFlag"); + filter.setType("boolean"); + filter.setOperator("eq"); + filter.setAllowedValues(new String[]{"true", "maybe"}); + request.setFilters(new Filter[]{filter}); + Assertions.assertThrows(ValidationConflictException.class, + () -> facetManager.getFacetedContents(request, null)); + } + + @Test + void shouldAcceptStrictBooleanValue() throws Exception { + AdvRestContentListRequest request = new AdvRestContentListRequest(); + SolrFilter filter = new SolrFilter("myFlag", "true", "eq"); + filter.setType("boolean"); + filter.setEntityAttr("myFlag"); + request.setFilters(new Filter[]{filter}); + when(langManager.getDefaultLang()).thenReturn(createLang("en")); + when(((ISolrSearchEngineManager) searchEngineManager) + .searchFacetedEntities( + any(SolrSearchEngineFilter[][].class), + any(SolrSearchEngineFilter[].class), + any(List.class))) + .thenReturn(new SolrFacetedContentsResult()); + Assertions.assertDoesNotThrow(() -> facetManager.getFacetedContents(request, null)); + } + + @Test + void shouldAcceptNoValueNotEqualBooleanFilterAsExistenceQuery() throws Exception { + // The only valid recipe for querying a ThreeState "none": no value + not_equal. + AdvRestContentListRequest request = new AdvRestContentListRequest(); + SolrFilter filter = new SolrFilter(); + filter.setEntityAttr("myFlag"); + filter.setType("boolean"); + filter.setOperator("not"); + request.setFilters(new Filter[]{filter}); + when(langManager.getDefaultLang()).thenReturn(createLang("en")); + when(((ISolrSearchEngineManager) searchEngineManager) + .searchFacetedEntities( + any(SolrSearchEngineFilter[][].class), + any(SolrSearchEngineFilter[].class), + any(List.class))) + .thenReturn(new SolrFacetedContentsResult()); + Assertions.assertDoesNotThrow(() -> facetManager.getFacetedContents(request, null)); + } + private static Lang createLang(String code) { Lang lang = new Lang(); lang.setCode(code); diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java new file mode 100644 index 0000000000..e0c466c804 --- /dev/null +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java @@ -0,0 +1,302 @@ +/* + * Copyright 2021-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + */ +package org.entando.entando.plugins.jpsolr.aps.system.solr; + +import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.MonoTextAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; +import com.agiletec.aps.system.common.searchengine.IndexableAttributeInterface; +import com.agiletec.aps.system.services.lang.ILangManager; +import com.agiletec.aps.system.services.lang.Lang; +import com.agiletec.plugins.jacms.aps.system.services.content.model.Content; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.common.SolrInputDocument; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class IndexerDAOTest { + + private IndexerDAO newIndexerDAO() { + IndexerDAO indexerDAO = new IndexerDAO(Mockito.mock(SolrClient.class), "core"); + ILangManager langManager = Mockito.mock(ILangManager.class); + Mockito.when(langManager.getLangs()).thenReturn(List.of(lang("en"))); + indexerDAO.setLangManager(langManager); + return indexerDAO; + } + + private Lang lang(String code) { + Lang lang = new Lang(); + lang.setCode(code); + lang.setDescr(code); + return lang; + } + + private Content newContent() { + Content content = new Content(); + content.setId("1"); + content.setTypeCode("TST"); + content.setMainGroup("free"); + return content; + } + + private BooleanAttribute booleanAttribute(String name, boolean searchable, Boolean value) { + BooleanAttribute attribute = new BooleanAttribute(); + attribute.setName(name); + attribute.setType("Boolean"); + attribute.setSearchable(searchable); + attribute.setBooleanValue(value); + return attribute; + } + + private ThreeStateAttribute threeStateAttribute(String name, boolean searchable, Boolean value) { + ThreeStateAttribute attribute = new ThreeStateAttribute(); + attribute.setName(name); + attribute.setType("ThreeState"); + attribute.setSearchable(searchable); + attribute.setBooleanValue(value); + return attribute; + } + + private CheckBoxAttribute checkBoxAttribute(String name, boolean searchable, Boolean value) { + CheckBoxAttribute attribute = new CheckBoxAttribute(); + attribute.setName(name); + attribute.setType("CheckBox"); + attribute.setSearchable(searchable); + attribute.setBooleanValue(value); + return attribute; + } + + private MonoTextAttribute fullTextMonoText(String name, String text) { + MonoTextAttribute attribute = new MonoTextAttribute(); + attribute.setName(name); + attribute.setType("Monotext"); + attribute.setIndexingType(IndexableAttributeInterface.INDEXING_TYPE_TEXT); + attribute.setText(text); + return attribute; + } + + /** Exposes the protected {@code addAttribute} so tests can build composite fixtures. */ + private static class TestComposite extends CompositeAttribute { + + void addChild(AttributeInterface attribute) { + this.addAttribute(attribute); + } + } + + private TestComposite composite(String name, AttributeInterface... children) { + TestComposite composite = new TestComposite(); + composite.setName(name); + composite.setType("Composite"); + for (AttributeInterface child : children) { + composite.addChild(child); + } + return composite; + } + + // ---- top-level booleans: unchanged, still gated by the "searchable" flag ---- + + @Test + void shouldIndexSearchableTopLevelBooleanAttribute() { + Content content = newContent(); + content.addAttribute(booleanAttribute("myFlag", true, Boolean.TRUE)); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals(Boolean.TRUE, document.getFieldValue("en_myFlag")); + Assertions.assertNull(document.getField("en"), "boolean values must not pollute the full-text field"); + } + + @Test + void shouldIndexFalseTopLevelBooleanAttribute() { + Content content = newContent(); + content.addAttribute(booleanAttribute("myFlag", true, Boolean.FALSE)); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals(Boolean.FALSE, document.getFieldValue("en_myFlag")); + } + + @Test + void shouldNotIndexNonSearchableTopLevelBoolean() { + Content content = newContent(); + content.addAttribute(booleanAttribute("myFlag", false, Boolean.TRUE)); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertNull(document.getField("en_myFlag")); + } + + @Test + void shouldIndexUnsetThreeStateAttributeAsNoneLiteral() { + // Unlike a plain Boolean/CheckBox (which coerces null to false), an unset ThreeState must + // reach the index as the literal "none" string so it stays distinguishable and queryable. + Content content = newContent(); + content.addAttribute(threeStateAttribute("flag3", true, null)); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals("none", document.getFieldValue("en_flag3")); + } + + @Test + void shouldIndexTrueTopLevelThreeStateAttributeAsStringLiteral() { + Content content = newContent(); + content.addAttribute(threeStateAttribute("flag3", true, Boolean.TRUE)); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals("true", document.getFieldValue("en_flag3")); + } + + @Test + void shouldIndexSearchableTopLevelCheckBoxAttribute() { + Content content = newContent(); + content.addAttribute(checkBoxAttribute("myCheck", true, Boolean.TRUE)); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals(Boolean.TRUE, document.getFieldValue("en_myCheck")); + } + + @Test + void shouldIndexSearchableTopLevelThreeStateAttributeWithFalseValue() { + Content content = newContent(); + content.addAttribute(threeStateAttribute("flag3", true, Boolean.FALSE)); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals("false", document.getFieldValue("en_flag3")); + } + + // ---- composite children: qualified path, gated by the inherited searchable flag ---- + + @Test + void shouldIndexSearchableBooleanChildOfCompositeUnderQualifiedName() { + Content content = newContent(); + content.addAttribute(composite("myComposite", booleanAttribute("featured", true, Boolean.TRUE))); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals(Boolean.TRUE, document.getFieldValue("en_myComposite_featured")); + } + + @Test + void shouldNotIndexNonSearchableCompositeChild() { + Content content = newContent(); + content.addAttribute(composite("myComposite", booleanAttribute("featured", false, Boolean.TRUE))); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertNull(document.getField("en_myComposite_featured")); + } + + @Test + void shouldIndexCheckBoxChildOfCompositeUnderQualifiedName() { + Content content = newContent(); + content.addAttribute(composite("myComposite", checkBoxAttribute("featuredCheck", true, Boolean.TRUE))); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals(Boolean.TRUE, document.getFieldValue("en_myComposite_featuredCheck")); + } + + @Test + void shouldIndexValuedThreeStateChildOfCompositeUnderQualifiedName() { + Content content = newContent(); + content.addAttribute(composite("myComposite", threeStateAttribute("featured3", true, Boolean.FALSE))); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals("false", document.getFieldValue("en_myComposite_featured3")); + } + + @Test + void shouldIndexUnsetThreeStateChildOfCompositeAsNoneLiteral() { + Content content = newContent(); + content.addAttribute(composite("myComposite", threeStateAttribute("featured3", true, null))); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals("none", document.getFieldValue("en_myComposite_featured3")); + } + + @Test + void shouldIndexBooleanInNestedCompositeUnderFullPath() { + Content content = newContent(); + TestComposite inner = composite("inner", booleanAttribute("boolAttrName", true, Boolean.TRUE)); + content.addAttribute(composite("outer", inner)); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertEquals(Boolean.TRUE, document.getFieldValue("en_outer_inner_boolAttrName")); + } + + // ---- List / Monolist: excluded from boolean indexing ---- + + @Test + void shouldNotIndexBooleanElementsOfMonolist() { + Content content = newContent(); + MonoListAttribute list = new MonoListAttribute(); + list.setName("myList"); + list.setType("Monolist"); + list.getAttributes().add(booleanAttribute("myList", true, Boolean.TRUE)); + list.getAttributes().add(booleanAttribute("myList", true, Boolean.FALSE)); + content.addAttribute(list); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertNull(document.getField("en_myList")); + } + + @Test + void shouldNotIndexBooleanInCompositeNestedInMonolist() { + Content content = newContent(); + MonoListAttribute list = new MonoListAttribute(); + list.setName("comboList"); + list.setType("Monolist"); + list.getAttributes().add(composite("comboList", booleanAttribute("subFlag", true, Boolean.TRUE))); + content.addAttribute(list); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertNull(document.getField("en_comboList_subFlag")); + Assertions.assertNull(document.getField("en_subFlag")); + } + + @Test + void shouldStillFullTextIndexTextChildrenInsideMonolist() { + // Regression guard: the list exclusion gates only the per-attribute boolean branch; text + // children inside a list must still feed the full-text "" field. + Content content = newContent(); + MonoListAttribute list = new MonoListAttribute(); + list.setName("notes"); + list.setType("Monolist"); + list.getAttributes().add(fullTextMonoText("notes", "hello")); + content.addAttribute(list); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Collection fullText = document.getFieldValues("en"); + Assertions.assertNotNull(fullText, "text children in a list must still feed the full-text field"); + Assertions.assertTrue(new ArrayList<>(fullText).contains("hello"), String.valueOf(fullText)); + } +} diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java index 90ac5337b3..11e486ec9b 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java @@ -1,5 +1,7 @@ package org.entando.entando.plugins.jpsolr.aps.system.solr; +import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; import com.agiletec.aps.system.common.tree.ITreeNode; import com.agiletec.aps.system.common.tree.ITreeNodeManager; import com.agiletec.aps.system.services.group.Group; @@ -310,6 +312,136 @@ void shouldFilterOnRangeOfNumbers() throws Exception { "+(+entity_key:[10 TO 20]) +(entity_group:free)"); } + @Test + void shouldFilterByBooleanSingleValueTrue() throws Exception { + Boolean value = Boolean.TRUE; + SolrSearchEngineFilter filter = new SolrSearchEngineFilter<>("key", value); + + SearchEngineFilter[] filters = new SearchEngineFilter[]{filter}; + SearchEngineFilter[] categories = new SearchEngineFilter[]{}; + List allowedGroups = new ArrayList<>(); + + testSearchFacetedContents(filters, categories, allowedGroups, + "+(+entity_key:true) +(entity_group:free)"); + } + + @Test + void shouldFilterByBooleanSingleValueFalse() throws Exception { + Boolean value = Boolean.FALSE; + SolrSearchEngineFilter filter = new SolrSearchEngineFilter<>("key", value); + + SearchEngineFilter[] filters = new SearchEngineFilter[]{filter}; + SearchEngineFilter[] categories = new SearchEngineFilter[]{}; + List allowedGroups = new ArrayList<>(); + + testSearchFacetedContents(filters, categories, allowedGroups, + "+(+entity_key:false) +(entity_group:free)"); + } + + @Test + void shouldResolveBooleanAttributeFilterToLangQualifiedField() throws Exception { + // Mirrors GET .../contents?filters[0].entityAttr=&operator=eq&value=true. + // An entityAttr filter is an attribute filter, so the key resolves to "_". + // The indexer writes a boolean under that same "_" field whether the boolean + // is top-level, a Composite child (its own name) or a Monolist element (the list's name), + // so addressing a nested boolean by its (leaf) name is fully supported. + SolrSearchEngineFilter filter = new SolrSearchEngineFilter<>("featured", true, Boolean.TRUE); + filter.setLangCode("en"); + + SearchEngineFilter[] filters = new SearchEngineFilter[]{filter}; + SearchEngineFilter[] categories = new SearchEngineFilter[]{}; + List allowedGroups = new ArrayList<>(); + + testSearchFacetedContents(filters, categories, allowedGroups, + "+(+en_featured:true) +(entity_group:free)"); + } + + @Test + void shouldResolveNestedBooleanCompositePathFilter() throws Exception { + // A composite-nested boolean is addressed by its full composite path, e.g. "complexAttrName_boolAttrName", + // which resolves to the "_complexAttrName_boolAttrName" field the indexer/schema build. + SolrSearchEngineFilter filter = new SolrSearchEngineFilter<>("complexAttrName_boolAttrName", true, Boolean.TRUE); + filter.setLangCode("en"); + + SearchEngineFilter[] filters = new SearchEngineFilter[]{filter}; + SearchEngineFilter[] categories = new SearchEngineFilter[]{}; + List allowedGroups = new ArrayList<>(); + + testSearchFacetedContents(filters, categories, allowedGroups, + "+(+en_complexAttrName_boolAttrName:true) +(entity_group:free)"); + } + + @Test + void shouldFilterUsingValueSourcedFromCheckBoxAttribute() throws Exception { + // The query layer never sees the source AttributeInterface, only the Boolean value a + // filter carries - proving CheckBoxAttribute's getValue() flows through identically to + // a plain BooleanAttribute's. + CheckBoxAttribute checkBoxAttribute = new CheckBoxAttribute(); + checkBoxAttribute.setBooleanValue(Boolean.TRUE); + SolrSearchEngineFilter filter = new SolrSearchEngineFilter<>("key", checkBoxAttribute.getValue()); + + SearchEngineFilter[] filters = new SearchEngineFilter[]{filter}; + SearchEngineFilter[] categories = new SearchEngineFilter[]{}; + List allowedGroups = new ArrayList<>(); + + testSearchFacetedContents(filters, categories, allowedGroups, + "+(+entity_key:true) +(entity_group:free)"); + } + + @Test + void shouldFilterUsingValueSourcedFromThreeStateAttribute() throws Exception { + ThreeStateAttribute threeStateAttribute = new ThreeStateAttribute(); + threeStateAttribute.setBooleanValue(Boolean.FALSE); + SolrSearchEngineFilter filter = new SolrSearchEngineFilter<>("key", threeStateAttribute.getValue()); + + SearchEngineFilter[] filters = new SearchEngineFilter[]{filter}; + SearchEngineFilter[] categories = new SearchEngineFilter[]{}; + List allowedGroups = new ArrayList<>(); + + testSearchFacetedContents(filters, categories, allowedGroups, + "+(+entity_key:false) +(entity_group:free)"); + } + + @Test + void shouldHandleBooleanFilterAllowedValues() throws Exception { + mockDefaultLang(); + SearchEngineFilter filter = SearchEngineFilter.createAllowedValuesFilter("key", true, + List.of(Boolean.TRUE, Boolean.FALSE), null); + + SearchEngineFilter[] filters = new SearchEngineFilter[]{filter}; + SearchEngineFilter[] categories = new SearchEngineFilter[]{}; + List allowedGroups = new ArrayList<>(); + + testSearchFacetedContents(filters, categories, allowedGroups, + "+(en_key:true en_key:false) +(entity_group:free)"); + } + + @Test + void shouldNegateBooleanFilter() throws Exception { + Boolean value = Boolean.TRUE; + SolrSearchEngineFilter filter = new SolrSearchEngineFilter<>("key", value); + filter.setNotOption(true); + + SearchEngineFilter[] filters = new SearchEngineFilter[]{filter}; + SearchEngineFilter[] categories = new SearchEngineFilter[]{}; + List allowedGroups = new ArrayList<>(); + + testSearchFacetedContents(filters, categories, allowedGroups, + "-(+entity_key:true) +(entity_group:free)"); + } + + @Test + void shouldFilterOnNullValueBooleanFilterAsExistenceQuery() throws Exception { + SolrSearchEngineFilter filter = new SolrSearchEngineFilter<>("key", true); + filter.setLangCode("en"); + + SearchEngineFilter[] filters = new SearchEngineFilter[]{filter}; + SearchEngineFilter[] categories = new SearchEngineFilter[]{}; + List allowedGroups = new ArrayList<>(); + + testSearchFacetedContents(filters, categories, allowedGroups, "+(+en_key:*) +(entity_group:free)"); + } + @Test void shouldFilterOnRangeOfStrings() throws Exception { SolrSearchEngineFilter filter = new SolrSearchEngineFilter("key", "A", "E"); diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java index 2d3b722d7a..79010433d1 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java @@ -13,9 +13,15 @@ */ package org.entando.entando.plugins.jpsolr.aps.system.solr; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; import com.agiletec.aps.system.services.lang.Lang; import java.util.Collections; import java.util.List; @@ -34,16 +40,18 @@ private Lang lang(String code) { return lang; } + private SolrFieldsChecker checker(List attributes, Lang... langs) { + return new SolrFieldsChecker(Collections.emptyList(), attributes, List.of(langs)); + } + @Test void shouldCreateMainAndAttachmentFieldPerLanguage() { // No pre-existing fields, no content-type attributes: only the base + per-language // fields are generated. Each language must yield BOTH the main "" field and the // "_attachment" field, so a full-text search with includeAttachments=true never // queries a field the schema doesn't know about. - SolrFieldsChecker checker = new SolrFieldsChecker( - Collections.emptyList(), - Collections.emptyList(), - List.of(lang("en"), lang("it"))); + SolrFieldsChecker checker = this.checker( + Collections.emptyList(), lang("en"), lang("it")); List createdFieldNames = checker.checkFields().getFieldsToAdd().stream() .map(field -> (String) field.get(SolrFields.SOLR_FIELD_NAME)) @@ -59,10 +67,8 @@ void shouldCreateMainAndAttachmentFieldPerLanguage() { @Test void attachmentFieldMatchesMainLanguageFieldTypeAndMultiplicity() { - SolrFieldsChecker checker = new SolrFieldsChecker( - Collections.emptyList(), - Collections.emptyList(), - List.of(lang("en"))); + SolrFieldsChecker checker = this.checker( + Collections.emptyList(), lang("en")); List> added = checker.checkFields().getFieldsToAdd(); Map main = added.stream() @@ -76,4 +82,181 @@ void attachmentFieldMatchesMainLanguageFieldTypeAndMultiplicity() { Assertions.assertEquals(main.get(SolrFields.SOLR_FIELD_MULTIVALUED), attachment.get(SolrFields.SOLR_FIELD_MULTIVALUED)); } + + /** Exposes the protected {@code addAttribute} so tests can build composite fixtures. */ + private static class TestComposite extends CompositeAttribute { + + void addChild(AttributeInterface attribute) { + this.addAttribute(attribute); + } + } + + private BooleanAttribute booleanAttribute(String name, boolean searchable) { + BooleanAttribute attribute = new BooleanAttribute(); + attribute.setName(name); + attribute.setType("Boolean"); + attribute.setSearchable(searchable); + return attribute; + } + + private CheckBoxAttribute checkBoxAttribute(String name, boolean searchable) { + CheckBoxAttribute attribute = new CheckBoxAttribute(); + attribute.setName(name); + attribute.setType("CheckBox"); + attribute.setSearchable(searchable); + return attribute; + } + + private ThreeStateAttribute threeStateAttribute(String name, boolean searchable) { + ThreeStateAttribute attribute = new ThreeStateAttribute(); + attribute.setName(name); + attribute.setType("ThreeState"); + attribute.setSearchable(searchable); + return attribute; + } + + private TestComposite composite(String name, AttributeInterface... children) { + TestComposite composite = new TestComposite(); + composite.setName(name); + composite.setType("Composite"); + for (AttributeInterface child : children) { + composite.addChild(child); + } + return composite; + } + + // ---- top-level booleans: unchanged, still gated by the "searchable" flag ---- + + @Test + void shouldCreateSingleValuedBooleanFieldForSearchableTopLevelAttribute() { + SolrFieldsChecker checker = this.checker(List.of(booleanAttribute("myFlag", true)), lang("en")); + + Map field = fieldsToAdd(checker, "en_myFlag"); + Assertions.assertEquals(SolrFields.TYPE_BOOLEAN, field.get(SolrFields.SOLR_FIELD_TYPE)); + Assertions.assertEquals(false, field.get(SolrFields.SOLR_FIELD_MULTIVALUED)); + } + + @Test + void shouldNotCreateFieldForNonSearchableTopLevelBoolean() { + SolrFieldsChecker checker = this.checker(List.of(booleanAttribute("myFlag", false)), lang("en")); + + assertFalse(fieldNames(checker).contains("en_myFlag"), fieldNames(checker).toString()); + } + + @Test + void shouldCreateBooleanFieldForSearchableTopLevelCheckBoxAttribute() { + SolrFieldsChecker checker = this.checker(List.of(checkBoxAttribute("myCheck", true)), lang("en")); + + Map field = fieldsToAdd(checker, "en_myCheck"); + Assertions.assertEquals(SolrFields.TYPE_BOOLEAN, field.get(SolrFields.SOLR_FIELD_TYPE)); + Assertions.assertEquals(false, field.get(SolrFields.SOLR_FIELD_MULTIVALUED)); + } + + @Test + void shouldCreateStringFieldForSearchableTopLevelThreeStateAttribute() { + // ThreeState is indexed as a Solr "string" (not "boolean"): its third, uninitialized state + // cannot be represented in a two-valued BoolField. Pins the ThreeState-before-Boolean + // dispatch order. + SolrFieldsChecker checker = this.checker(List.of(threeStateAttribute("myFlag3", true)), lang("en")); + + Map field = fieldsToAdd(checker, "en_myFlag3"); + Assertions.assertEquals(SolrFields.TYPE_STRING, field.get(SolrFields.SOLR_FIELD_TYPE)); + Assertions.assertEquals(false, field.get(SolrFields.SOLR_FIELD_MULTIVALUED)); + } + + // ---- composite children: qualified path, single-valued, gated by the inherited searchable flag ---- + // Single-valued because a Composite occurs at most once per document per lang: Monolist and + // Monolist-of-Composite ancestry are excluded below, so the field can never repeat. + + @Test + void shouldCreateSingleValuedQualifiedFieldForSearchableCompositeChild() { + TestComposite composite = composite("myComposite", booleanAttribute("featured", true)); + SolrFieldsChecker checker = this.checker(List.of(composite), lang("en")); + + Map field = fieldsToAdd(checker, "en_myComposite_featured"); + Assertions.assertEquals(SolrFields.TYPE_BOOLEAN, field.get(SolrFields.SOLR_FIELD_TYPE)); + Assertions.assertEquals(false, field.get(SolrFields.SOLR_FIELD_MULTIVALUED)); + } + + @Test + void shouldNotCreateFieldForNonSearchableCompositeChild() { + TestComposite composite = composite("myComposite", booleanAttribute("featured", false)); + SolrFieldsChecker checker = this.checker(List.of(composite), lang("en")); + + assertFalse(fieldNames(checker).contains("en_myComposite_featured"), fieldNames(checker).toString()); + } + + @Test + void shouldCreateQualifiedFieldForCompositeChildCheckBoxAttribute() { + TestComposite composite = composite("myComposite", checkBoxAttribute("featuredCheck", true)); + SolrFieldsChecker checker = this.checker(List.of(composite), lang("en")); + + Map field = fieldsToAdd(checker, "en_myComposite_featuredCheck"); + Assertions.assertEquals(SolrFields.TYPE_BOOLEAN, field.get(SolrFields.SOLR_FIELD_TYPE)); + Assertions.assertEquals(false, field.get(SolrFields.SOLR_FIELD_MULTIVALUED)); + } + + @Test + void shouldCreateStringFieldForCompositeChildThreeStateAttribute() { + TestComposite composite = composite("myComposite", threeStateAttribute("featured3", true)); + SolrFieldsChecker checker = this.checker(List.of(composite), lang("en")); + + Map field = fieldsToAdd(checker, "en_myComposite_featured3"); + Assertions.assertEquals(SolrFields.TYPE_STRING, field.get(SolrFields.SOLR_FIELD_TYPE)); + Assertions.assertEquals(false, field.get(SolrFields.SOLR_FIELD_MULTIVALUED)); + } + + @Test + void shouldCreateFullPathFieldForBooleanInNestedComposite() { + TestComposite inner = composite("inner", booleanAttribute("boolAttrName", true)); + TestComposite outer = composite("outer", inner); + SolrFieldsChecker checker = this.checker(List.of(outer), lang("en")); + + Map field = fieldsToAdd(checker, "en_outer_inner_boolAttrName"); + Assertions.assertEquals(SolrFields.TYPE_BOOLEAN, field.get(SolrFields.SOLR_FIELD_TYPE)); + Assertions.assertEquals(false, field.get(SolrFields.SOLR_FIELD_MULTIVALUED)); + } + + // ---- List / Monolist: excluded, no boolean field regardless of the flag ---- + + @Test + void shouldNotCreateFieldForBooleanElementOfMonolist() { + BooleanAttribute nestedType = booleanAttribute("myList", true); + MonoListAttribute list = new MonoListAttribute(); + list.setName("myList"); + list.setType("Monolist"); + list.setNestedAttributeType(nestedType); + + SolrFieldsChecker checker = this.checker(List.of(list), lang("en")); + + assertFalse(fieldNames(checker).contains("en_myList"), fieldNames(checker).toString()); + } + + @Test + void shouldNotCreateFieldForBooleanInsideCompositeNestedInMonolist() { + TestComposite compositePrototype = composite("comboList", booleanAttribute("subFlag", true)); + MonoListAttribute listOfComposite = new MonoListAttribute(); + listOfComposite.setName("comboList"); + listOfComposite.setType("Monolist"); + listOfComposite.setNestedAttributeType(compositePrototype); + + SolrFieldsChecker checker = this.checker(List.of(listOfComposite), lang("en")); + + List names = fieldNames(checker); + assertFalse(names.contains("en_comboList_subFlag"), names.toString()); + assertFalse(names.contains("en_subFlag"), names.toString()); + } + + private List fieldNames(SolrFieldsChecker checker) { + return checker.checkFields().getFieldsToAdd().stream() + .map(field -> (String) field.get(SolrFields.SOLR_FIELD_NAME)) + .collect(Collectors.toList()); + } + + private Map fieldsToAdd(SolrFieldsChecker checker, String fieldName) { + return checker.checkFields().getFieldsToAdd().stream() + .filter(f -> fieldName.equals(f.get(SolrFields.SOLR_FIELD_NAME))) + .findFirst() + .orElseThrow(() -> new AssertionError("Field not created: " + fieldName)); + } } diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java index 3857aa6295..388467ed1f 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java @@ -1,6 +1,9 @@ package org.entando.entando.plugins.jpsolr.aps.system.solr.model; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute; import com.agiletec.aps.system.common.entity.model.attribute.TextAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; import com.agiletec.aps.system.services.lang.Lang; import java.io.Serializable; import java.util.Arrays; @@ -75,6 +78,210 @@ void shouldDetectValidLangField() { Assertions.assertTrue(contentTypeSettings.isValid()); } + @Test + void shouldExpectBooleanTypeForSearchableBooleanAttribute() { + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + BooleanAttribute flagAttribute = new BooleanAttribute(); + flagAttribute.setName("flag"); + flagAttribute.setType("Boolean"); + flagAttribute.setSearchable(true); + + Map> currentField = Map.of("en_flag", Map.of( + "name", "en_flag", + "type", "boolean", + "multiValued", false + )); + + contentTypeSettings.addAttribute(flagAttribute, currentField, getLanguages("en")); + + Assertions.assertTrue(contentTypeSettings.isValid()); + } + + @Test + void shouldDetectFieldTypeMismatchForBooleanAttribute() { + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + BooleanAttribute flagAttribute = new BooleanAttribute(); + flagAttribute.setName("flag"); + flagAttribute.setType("Boolean"); + flagAttribute.setSearchable(true); + + Map> currentField = Map.of("en_flag", Map.of( + "name", "en_flag", + "type", "text_gen_sort", + "multiValued", false + )); + + contentTypeSettings.addAttribute(flagAttribute, currentField, getLanguages("en")); + + Assertions.assertFalse(contentTypeSettings.isValid()); + } + + @Test + void shouldNotExpectAnyFieldForNonSearchableBooleanAttribute() { + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + BooleanAttribute flagAttribute = new BooleanAttribute(); + flagAttribute.setName("flag"); + flagAttribute.setType("Boolean"); + flagAttribute.setSearchable(false); + + contentTypeSettings.addAttribute(flagAttribute, Map.of(), getLanguages("en")); + + Assertions.assertTrue(contentTypeSettings.isValid()); + } + + @Test + void shouldExpectBooleanTypeForSearchableCheckBoxAttribute() { + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + CheckBoxAttribute checkAttribute = new CheckBoxAttribute(); + checkAttribute.setName("check"); + checkAttribute.setType("CheckBox"); + checkAttribute.setSearchable(true); + + Map> currentField = Map.of("en_check", Map.of( + "name", "en_check", + "type", "boolean", + "multiValued", false + )); + + contentTypeSettings.addAttribute(checkAttribute, currentField, getLanguages("en")); + + Assertions.assertTrue(contentTypeSettings.isValid()); + } + + @Test + void shouldExpectStringTypeForSearchableThreeStateAttribute() { + // ThreeState's third, uninitialized state cannot be represented in a two-valued Solr + // BoolField, so it is expected as "string" (true|false|none), not "boolean". + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + ThreeStateAttribute flag3Attribute = new ThreeStateAttribute(); + flag3Attribute.setName("flag3"); + flag3Attribute.setType("ThreeState"); + flag3Attribute.setSearchable(true); + + Map> currentField = Map.of("en_flag3", Map.of( + "name", "en_flag3", + "type", "string", + "multiValued", false + )); + + contentTypeSettings.addAttribute(flag3Attribute, currentField, getLanguages("en")); + + Assertions.assertTrue(contentTypeSettings.isValid()); + } + + @Test + void shouldExpectSingleValuedBooleanTypeForNestedCheckBoxAttribute() { + // Single-valued: a Composite occurs at most once per document per lang (the caller + // excludes List/Monolist ancestry), so the nested field can never repeat. + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + CheckBoxAttribute nestedCheck = new CheckBoxAttribute(); + nestedCheck.setName("featuredCheck"); + nestedCheck.setType("CheckBox"); + nestedCheck.setSearchable(true); + + Map> currentField = Map.of("en_featuredCheck", Map.of( + "name", "en_featuredCheck", + "type", "boolean", + "multiValued", false + )); + + contentTypeSettings.addNestedBooleanAttribute(nestedCheck, currentField, getLanguages("en")); + + Assertions.assertTrue(contentTypeSettings.isValid()); + } + + @Test + void shouldExpectSingleValuedStringTypeForNestedThreeStateAttribute() { + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + ThreeStateAttribute nestedFlag3 = new ThreeStateAttribute(); + nestedFlag3.setName("featured3"); + nestedFlag3.setType("ThreeState"); + nestedFlag3.setSearchable(true); + + Map> currentField = Map.of("en_featured3", Map.of( + "name", "en_featured3", + "type", "string", + "multiValued", false + )); + + contentTypeSettings.addNestedBooleanAttribute(nestedFlag3, currentField, getLanguages("en")); + + Assertions.assertTrue(contentTypeSettings.isValid()); + } + + @Test + void shouldExpectSingleValuedBooleanTypeForNestedBooleanAttribute() { + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + BooleanAttribute nestedFlag = new BooleanAttribute(); + nestedFlag.setName("featured"); + nestedFlag.setType("Boolean"); + nestedFlag.setSearchable(true); + + Map> currentField = Map.of("en_featured", Map.of( + "name", "en_featured", + "type", "boolean", + "multiValued", false + )); + + contentTypeSettings.addNestedBooleanAttribute(nestedFlag, currentField, getLanguages("en")); + + Assertions.assertTrue(contentTypeSettings.isValid()); + } + + @Test + void shouldDetectMissingNestedBooleanField() { + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + BooleanAttribute nestedFlag = new BooleanAttribute(); + nestedFlag.setName("featured"); + nestedFlag.setType("Boolean"); + nestedFlag.setSearchable(true); + + contentTypeSettings.addNestedBooleanAttribute(nestedFlag, Map.of(), getLanguages("en")); + + Assertions.assertFalse(contentTypeSettings.isValid()); + } + + @Test + void shouldDetectMultiValuedNestedBooleanFieldAsInvalid() { + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + BooleanAttribute nestedFlag = new BooleanAttribute(); + nestedFlag.setName("featured"); + nestedFlag.setType("Boolean"); + nestedFlag.setSearchable(true); + + // multiValued=true on a nested boolean field is stale (nested fields are always + // single-valued); the schema must be refreshed to a single-valued field. + Map> currentField = Map.of("en_featured", Map.of( + "name", "en_featured", + "type", "boolean", + "multiValued", true + )); + + contentTypeSettings.addNestedBooleanAttribute(nestedFlag, currentField, getLanguages("en")); + + Assertions.assertFalse(contentTypeSettings.isValid()); + } + private List getLanguages(String... codes) { return Arrays.stream(codes).map(c -> { Lang lang = new Lang(); diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/web/content/AdvContentSearchControllerTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/web/content/AdvContentSearchControllerTest.java index 9f76ba5587..f2f71731d6 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/web/content/AdvContentSearchControllerTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/web/content/AdvContentSearchControllerTest.java @@ -92,7 +92,7 @@ void testGetContents() throws Exception { String bodyResult = result.andReturn().getResponse().getContentAsString(); result.andExpect(status().isOk()); int totalPayloadSize = JsonPath.read(bodyResult, "$.payload.size()"); - Assertions.assertEquals(24, totalPayloadSize); + Assertions.assertEquals(28, totalPayloadSize); result = mockMvc .perform(get("/plugins/advcontentsearch/contents") @@ -346,7 +346,7 @@ void testGetContentsByGuestUser_2() throws Exception { result.andExpect(status().isOk()); System.out.println(bodyResult); int payloadSize = JsonPath.read(bodyResult, "$.payload.size()"); - Assertions.assertEquals(15, payloadSize); + Assertions.assertEquals(19, payloadSize); ResultActions evnResult = mockMvc .perform(get("/plugins/advcontentsearch/contents") From fef0d4ddee63b7b66ba73579445b34723a92c619 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 23 Jul 2026 14:22:45 +0200 Subject: [PATCH 2/4] ESB-1133 Quality gate improvements --- .../CompositeAttributeConfigActionTest.java | 21 ++- .../services/content/ContentManager.java | 6 +- .../services/resource/ResourceManager.java | 11 ++ .../services/content/ContentManagerTest.java | 10 ++ .../resource/ResourceManagerTest.java | 19 +++ .../AbstractEntityDAONestedBooleanTest.java | 51 ++++++ .../NestedBooleanSearchSupportTest.java | 19 +++ .../content/AdvContentFacetManagerTest.java | 20 +++ .../aps/system/solr/IndexerDAOTest.java | 17 ++ .../aps/system/solr/SearcherDAOTest.java | 15 ++ .../system/solr/SolrFieldsCheckerTest.java | 32 ++++ .../solr/SolrSearchEngineManagerTest.java | 145 ++++++++++++++++++ .../solr/model/ContentTypeSettingsTest.java | 19 +++ 13 files changed, 377 insertions(+), 8 deletions(-) diff --git a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java index ccf50a9396..c9f4e39905 100644 --- a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java +++ b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java @@ -2,7 +2,6 @@ import static com.agiletec.apsadmin.system.entity.type.ICompositeAttributeConfigAction.COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM; import static com.agiletec.apsadmin.system.entity.type.IEntityTypeConfigAction.ENTITY_TYPE_ON_EDIT_SESSION_PARAM; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import com.agiletec.aps.system.common.entity.IEntityManager; @@ -16,12 +15,12 @@ import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; import com.agiletec.apsadmin.system.ApsAdminSystemConstants; import com.agiletec.apsadmin.system.BaseAction; -import org.apache.struts2.action.Action; -import org.apache.struts2.text.TextProvider; -import java.util.HashMap; -import java.util.Map; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; +import java.util.HashMap; +import java.util.Map; +import org.apache.struts2.action.Action; +import org.apache.struts2.text.TextProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -122,6 +121,18 @@ void testNestedSearchableOptionSupportedForBooleanLikes() { Assertions.assertFalse(action.isNestedSearchableOptionSupported("Text")); } + @Test + void testNestedSearchableOptionSupportedHandlesException() { + String entityManagerName = "EntityManagerName"; + Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + .thenReturn(entityManagerName); + IEntityManager entityManager = Mockito.mock(IEntityManager.class); + Mockito.when(entityManager.getEntityAttributePrototypes()).thenThrow(new RuntimeException()); + Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + + Assertions.assertFalse(action.isNestedSearchableOptionSupported("Boolean")); + } + @Test void shouldMethodNotAddAttributeElement() { diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java index b8454945aa..7b8578bdb2 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java @@ -145,7 +145,7 @@ public Map getSmallContentTypesMap() { @Override public String getViewPage(String contentId) { Content type = this.getTypeById(contentId); - return type.getViewPage(); + return (null != type) ? type.getViewPage() : null; } /** @@ -157,7 +157,7 @@ public String getViewPage(String contentId) { @Override public String getDefaultModel(String contentId) { Content type = this.getTypeById(contentId); - return type.getDefaultModel(); + return (null != type) ? type.getDefaultModel() : null; } /** @@ -170,7 +170,7 @@ public String getDefaultModel(String contentId) { @Override public String getListModel(String contentId) { Content type = this.getTypeById(contentId); - return type.getListModel(); + return (null != type) ? type.getListModel() : null; } /** diff --git a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManager.java b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManager.java index 7184c476a7..6926e19b41 100644 --- a/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManager.java +++ b/cms-plugin/src/main/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManager.java @@ -268,6 +268,9 @@ protected void generateAndSetResourceId(ResourceInterface resource, String id) t @Override public void updateResource(ResourceDataBean bean) throws EntException { ResourceInterface oldResource = this.loadResource(bean.getResourceId()); + if (null == oldResource) { + throw new EntException("Error updating resource: no resource found with id " + bean.getResourceId()); + } try { if (null == bean.getInputStream()) { oldResource.setDescription(LabelSanitizer.stripMarkup(bean.getDescr())); @@ -563,6 +566,10 @@ protected void startResourceReloaderThread(String resourceTypeCode, int operatio protected void refreshMasterFileNames(String resourceId) { try { ResourceInterface resource = this.loadResource(resourceId); + if (null == resource) { + logger.warn("Resource '{}' not found, skipping master file name refresh", resourceId); + return; + } if (resource.isMultiInstance()) { ResourceInstance instance = ((AbstractMultiInstanceResource) resource).getInstance(0, null); @@ -584,6 +591,10 @@ protected void refreshMasterFileNames(String resourceId) { protected void refreshResourceInstances(String resourceId) { try { ResourceInterface resource = this.loadResource(resourceId); + if (null == resource) { + logger.warn("Resource '{}' not found, skipping instance refresh", resourceId); + return; + } resource.reloadResourceInstances(); this.updateResource(resource); } catch (Throwable t) { diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java index 353e598328..dc37970dbe 100644 --- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java @@ -159,5 +159,15 @@ void failGetDefaultModelById() throws Exception { void getEntityPrototypeShouldReturnNullForNullTypeCode() { Assertions.assertNull(this.contentManager.getEntityPrototype(null)); } + + @Test + void shouldReturnNullFromModelAccessorsWhenTypeNotFound() { + ContentManager spyManager = Mockito.spy(this.contentManager); + Mockito.doReturn(null).when(spyManager).getTypeById(Mockito.anyString()); + + Assertions.assertNull(spyManager.getViewPage("XYZ123")); + Assertions.assertNull(spyManager.getDefaultModel("XYZ123")); + Assertions.assertNull(spyManager.getListModel("XYZ123")); + } } diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManagerTest.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManagerTest.java index 7f08d76e0c..331cc686ad 100644 --- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManagerTest.java +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManagerTest.java @@ -15,10 +15,12 @@ import com.agiletec.plugins.jacms.aps.system.services.resource.cache.IResourceManagerCacheWrapper; import com.agiletec.plugins.jacms.aps.system.services.resource.model.AttachResource; +import com.agiletec.plugins.jacms.aps.system.services.resource.model.BaseResourceDataBean; import com.agiletec.plugins.jacms.aps.system.services.resource.model.ImageResource; import com.agiletec.plugins.jacms.aps.system.services.resource.model.ResourceInterface; import java.util.HashMap; import java.util.Map; +import org.entando.entando.ent.exception.EntException; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -74,4 +76,21 @@ public void createResourceType() { Assertions.assertEquals("Image", type.getType()); } + @Test + void updateResourceShouldThrowWhenResourceNotFound() { + BaseResourceDataBean bean = new BaseResourceDataBean(); + bean.setResourceId("missing-id"); + Assertions.assertThrows(EntException.class, () -> this.resourceManager.updateResource(bean)); + } + + @Test + void refreshMasterFileNamesShouldNotThrowWhenResourceNotFound() { + Assertions.assertDoesNotThrow(() -> this.resourceManager.refreshMasterFileNames("missing-id")); + } + + @Test + void refreshResourceInstancesShouldNotThrowWhenResourceNotFound() { + Assertions.assertDoesNotThrow(() -> this.resourceManager.refreshResourceInstances("missing-id")); + } + } diff --git a/engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java b/engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java index 67aad31d5c..d75aed78e6 100644 --- a/engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java +++ b/engine/src/test/java/com/agiletec/aps/system/common/entity/AbstractEntityDAONestedBooleanTest.java @@ -14,9 +14,12 @@ package com.agiletec.aps.system.common.entity; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -27,13 +30,17 @@ import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute; import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.DateAttribute; import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.TextAttribute; import com.agiletec.aps.system.common.entity.model.attribute.ThreeStateAttribute; import com.agiletec.aps.system.services.lang.ILangManager; import com.agiletec.aps.system.services.lang.Lang; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.Timestamp; import java.util.Collections; +import java.util.Date; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -120,6 +127,34 @@ void mixedEntityWritesEachAttributeUnderItsExpectedName() throws Throwable { assertEquals(List.of("published", "address_certified"), writtenAttrNames(entity)); } + @Test + void nestedNonBooleanSimpleAttributeKeepsPlainName() throws Throwable { + // a non boolean-like attribute nested in a Composite is never path-qualified, even if searchable + assertEquals(List.of("note"), + writtenAttrNames(entity(composite("address", textAttr("note", true, "hello"))))); + } + + @Test + void searchableAttributeWithNoSearchInfosIsNotIndexed() throws Throwable { + // DateAttribute.getSearchInfos() returns null when no date is set + assertEquals(List.of(), writtenAttrNames(entity(dateAttr("published", true, null)))); + } + + @Test + void searchableDateAttributeWritesTimestamp() throws Throwable { + Date date = new Date(); + assertEquals(List.of("published"), writtenAttrNames(entity(dateAttr("published", true, date)))); + verify(this.stat, atLeast(1)).setTimestamp(eq(4), any(Timestamp.class)); + } + + @Test + void nullChildrenListIsSkippedWithoutError() throws Throwable { + CompositeAttribute compositeWithNullChildren = spy(composite("address", booleanAttr("certified", true, Boolean.TRUE))); + when(compositeWithNullChildren.getAttributes()).thenReturn(null); + assertEquals(List.of(), writtenAttrNames(entity(compositeWithNullChildren))); + verify(this.stat, never()).setString(eq(2), any()); + } + private List writtenAttrNames(IApsEntity entity) throws Throwable { ArgumentCaptor captor = ArgumentCaptor.forClass(String.class); this.dao.addSearchRecords("ENTITY1", entity, this.stat); @@ -151,6 +186,22 @@ private ThreeStateAttribute threeState(String name, boolean searchable) { return a; } + private TextAttribute textAttr(String name, boolean searchable, String text) { + TextAttribute a = new TextAttribute(); + a.setName(name); + a.setSearchable(searchable); + a.setText(text, "en"); + return a; + } + + private DateAttribute dateAttr(String name, boolean searchable, Date date) { + DateAttribute a = new DateAttribute(); + a.setName(name); + a.setSearchable(searchable); + a.setDate(date); + return a; + } + private CompositeAttribute composite(String name, AttributeInterface... children) { CompositeAttribute c = new CompositeAttribute(); c.setName(name); diff --git a/engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java b/engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java index b961ee01e7..f26a385be7 100644 --- a/engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java +++ b/engine/src/test/java/com/agiletec/aps/system/common/entity/NestedBooleanSearchSupportTest.java @@ -17,6 +17,8 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; import com.agiletec.aps.system.common.entity.model.ApsEntity; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; @@ -91,6 +93,23 @@ void shouldNotResolveTopLevelAttribute() { assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "flag")); } + @Test + void shouldSkipCompositeWithNullChildrenList() { + CompositeAttribute composite = spy(composite("address", booleanAttr("certified", true, Boolean.TRUE))); + when(composite.getAttributes()).thenReturn(null); + ApsEntity entity = entity(composite); + assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "address_certified")); + } + + @Test + void shouldNotResolveNestedNonBooleanAttribute() { + // a non boolean-like simple attribute nested in a Composite is never eligible, whatever the key + MonoTextAttribute note = new MonoTextAttribute(); + note.setName("note"); + ApsEntity entity = entity(composite("address", note)); + assertNull(NestedBooleanSearchSupport.resolveNestedBooleanByKey(entity, "address_note")); + } + // --- helpers ----------------------------------------------------------- private BooleanAttribute booleanAttr(String name, boolean searchable, Boolean value) { diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManagerTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManagerTest.java index 6cbe5af9f1..86babffd49 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManagerTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManagerTest.java @@ -263,6 +263,26 @@ void shouldAcceptStrictBooleanValue() throws Exception { Assertions.assertDoesNotThrow(() -> facetManager.getFacetedContents(request, null)); } + @Test + void shouldAcceptStrictBooleanFalseValue() throws Exception { + // Mirrors shouldAcceptStrictBooleanValue with "false": closes the remaining branch of + // rejectIfNotStrictBoolean's "!true && !false" check (the value == "false" combination), + // never exercised by the "true"/"maybe"/"none" cases above. + AdvRestContentListRequest request = new AdvRestContentListRequest(); + SolrFilter filter = new SolrFilter("myFlag", "false", "eq"); + filter.setType("boolean"); + filter.setEntityAttr("myFlag"); + request.setFilters(new Filter[]{filter}); + when(langManager.getDefaultLang()).thenReturn(createLang("en")); + when(((ISolrSearchEngineManager) searchEngineManager) + .searchFacetedEntities( + any(SolrSearchEngineFilter[][].class), + any(SolrSearchEngineFilter[].class), + any(List.class))) + .thenReturn(new SolrFacetedContentsResult()); + Assertions.assertDoesNotThrow(() -> facetManager.getFacetedContents(request, null)); + } + @Test void shouldAcceptNoValueNotEqualBooleanFilterAsExistenceQuery() throws Exception { // The only valid recipe for querying a ThreeState "none": no value + not_equal. diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java index e0c466c804..b6b3811f19 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java @@ -187,6 +187,23 @@ void shouldIndexSearchableTopLevelThreeStateAttributeWithFalseValue() { Assertions.assertEquals("false", document.getFieldValue("en_flag3")); } + @Test + void shouldNotIndexTopLevelAttributeOfUnsupportedType() { + // Closes the final "instanceof BooleanAttribute" false outcome of indexAttribute's + // top-level dispatch condition: an attribute that is neither IndexableAttributeInterface, + // Date, Number nor Boolean must be skipped entirely rather than throw. + Content content = newContent(); + AttributeInterface unsupported = Mockito.mock(AttributeInterface.class); + Mockito.when(unsupported.isSimple()).thenReturn(true); + Mockito.when(unsupported.getName()).thenReturn("unsupported"); + Mockito.when(unsupported.getValue()).thenReturn("nonNullValue"); + content.addAttribute(unsupported); + + SolrInputDocument document = newIndexerDAO().createDocument(content); + + Assertions.assertNull(document.getField("en_unsupported")); + } + // ---- composite children: qualified path, gated by the inherited searchable flag ---- @Test diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java index 11e486ec9b..0de4475002 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java @@ -442,6 +442,21 @@ void shouldFilterOnNullValueBooleanFilterAsExistenceQuery() throws Exception { testSearchFacetedContents(filters, categories, allowedGroups, "+(+en_key:*) +(entity_group:free)"); } + @Test + void shouldIgnoreUnsupportedSingleValueType() throws Exception { + // Closes the "else if (value instanceof Boolean)" false branch of createSingleValueQuery: + // a value that is neither String, Date, Number nor Boolean falls through every branch, + // producing a no-op (empty) sub-query for that field instead of throwing. + SolrSearchEngineFilter filter = new SolrSearchEngineFilter<>("key", new Object()); + + SearchEngineFilter[] filters = new SearchEngineFilter[]{filter}; + SearchEngineFilter[] categories = new SearchEngineFilter[]{}; + List allowedGroups = new ArrayList<>(); + + testSearchFacetedContents(filters, categories, allowedGroups, + "+() +(entity_group:free)"); + } + @Test void shouldFilterOnRangeOfStrings() throws Exception { SolrSearchEngineFilter filter = new SolrSearchEngineFilter("key", "A", "E"); diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java index 79010433d1..9e1f30a65b 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java @@ -30,6 +30,7 @@ import org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFields; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; class SolrFieldsCheckerTest { @@ -115,6 +116,7 @@ private ThreeStateAttribute threeStateAttribute(String name, boolean searchable) return attribute; } + private TestComposite composite(String name, AttributeInterface... children) { TestComposite composite = new TestComposite(); composite.setName(name); @@ -164,6 +166,36 @@ void shouldCreateStringFieldForSearchableTopLevelThreeStateAttribute() { Assertions.assertEquals(false, field.get(SolrFields.SOLR_FIELD_MULTIVALUED)); } + @Test + void shouldNotCreateFieldForTopLevelAttributeOfUnsupportedType() { + // Closes the final "instanceof BooleanAttribute" false outcome of + // isIndexableAttributeField: an attribute that is neither IndexableAttributeInterface, + // Date, Number nor Boolean must not produce a field. + AttributeInterface unsupported = Mockito.mock(AttributeInterface.class); + Mockito.when(unsupported.isSimple()).thenReturn(true); + Mockito.when(unsupported.getName()).thenReturn("unsupported"); + + SolrFieldsChecker checker = this.checker(List.of(unsupported), lang("en")); + + List names = fieldNames(checker); + assertFalse(names.stream().anyMatch(n -> n.contains("unsupported")), names.toString()); + } + + @Test + void shouldSkipNonComplexAttributeChildrenEvenWhenNotSimple() { + // Defensive branch of checkComplexAttributeChildren: an attribute that reports + // isSimple()==false but is not an AbstractComplexAttribute (unlike every real + // Composite/List attribute) must be skipped rather than throw a ClassCastException. + AttributeInterface fakeComplexAttribute = Mockito.mock(AttributeInterface.class); + Mockito.when(fakeComplexAttribute.isSimple()).thenReturn(false); + Mockito.when(fakeComplexAttribute.getName()).thenReturn("fake"); + + SolrFieldsChecker checker = this.checker(List.of(fakeComplexAttribute), lang("en")); + + List names = fieldNames(checker); + assertFalse(names.stream().anyMatch(n -> n.contains("fake")), names.toString()); + } + // ---- composite children: qualified path, single-valued, gated by the inherited searchable flag ---- // Single-valued because a Composite occurs at most once per document per lang: Monolist and // Monolist-of-Composite ancestry are excluded below, so the field can never repeat. diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManagerTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManagerTest.java index 6b4d38d9d7..bf60292fcc 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManagerTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManagerTest.java @@ -6,17 +6,28 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import com.agiletec.aps.system.common.entity.model.SmallEntityType; +import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.TextAttribute; import com.agiletec.aps.system.services.category.ICategoryManager; import com.agiletec.aps.system.services.lang.ILangManager; +import com.agiletec.aps.system.services.lang.Lang; import com.agiletec.plugins.jacms.aps.system.services.content.IContentManager; +import com.agiletec.plugins.jacms.aps.system.services.content.model.Content; import java.util.List; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.solr.client.solrj.impl.HttpSolrClient; import org.apache.solr.client.solrj.request.schema.SchemaRequest; import org.apache.solr.common.util.NamedList; +import org.apache.solr.common.util.SimpleOrderedMap; import org.entando.entando.aps.system.services.cache.ICacheInfoManager; import org.entando.entando.aps.system.services.tenants.ITenantManager; import org.entando.entando.ent.exception.EntException; +import org.entando.entando.plugins.jpsolr.aps.system.solr.model.ContentTypeSettings; +import org.entando.entando.plugins.jpsolr.aps.system.solr.model.ContentTypeSettings.AttributeSettings; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -124,4 +135,138 @@ void shouldRefreshCmsFields() throws Exception { Mockito.verify(solrClient, Mockito.times(1)) .request(Mockito.any(SchemaRequest.MultiUpdate.class), eq("entando")); } + + /** Exposes the protected {@code addAttribute} so tests can build composite fixtures. */ + private static class TestComposite extends CompositeAttribute { + + void addChild(AttributeInterface attribute) { + this.addAttribute(attribute); + } + } + + private Lang lang(String code) { + Lang lang = new Lang(); + lang.setCode(code); + lang.setDescr(code); + return lang; + } + + @Test + void shouldBuildContentTypesSettingsWithNestedBooleanAttributes() throws Exception { + // getContentTypesSettings()/collectNestedBooleanAttributes() had no dedicated unit test at + // all: this closes buildCurrentFieldConfig's "existing field found" branch, and + // collectNestedBooleanAttributes' simple/complex child dispatch, boolean/non-boolean leaf + // dispatch, recursion into a nested Composite, and the top-level Monolist exclusion. + Mockito.when(langManager.getLangs()).thenReturn(List.of(lang("en"))); + + SimpleOrderedMap existingField = new SimpleOrderedMap<>(); + existingField.add("name", "en_myComposite_featured"); + existingField.add("type", "boolean"); + existingField.add("multiValued", false); + NamedList solrClientResponse = new NamedList<>(); + solrClientResponse.add("fields", List.of(existingField)); + Mockito.when(solrClient.request(Mockito.any(SchemaRequest.Fields.class), eq("entando"))) + .thenReturn(solrClientResponse); + + Mockito.when(contentManager.getSmallEntityTypes()) + .thenReturn(List.of(new SmallEntityType("TST", "Test type"))); + + BooleanAttribute featured = new BooleanAttribute(); + featured.setName("featured"); + featured.setType("Boolean"); + featured.setSearchable(true); + + TextAttribute note = new TextAttribute(); + note.setName("note"); + note.setType("Text"); + + BooleanAttribute innerFlag = new BooleanAttribute(); + innerFlag.setName("innerFlag"); + innerFlag.setType("Boolean"); + innerFlag.setSearchable(true); + TestComposite inner = new TestComposite(); + inner.setName("inner"); + inner.setType("Composite"); + inner.addChild(innerFlag); + + TestComposite composite = new TestComposite(); + composite.setName("myComposite"); + composite.setType("Composite"); + composite.addChild(featured); + composite.addChild(note); + composite.addChild(inner); + + BooleanAttribute listItemType = new BooleanAttribute(); + listItemType.setName("myListItem"); + listItemType.setType("Boolean"); + listItemType.setSearchable(true); + MonoListAttribute list = new MonoListAttribute(); + list.setName("myList"); + list.setType("Monolist"); + list.setNestedAttributeType(listItemType); + + // A plain top-level simple attribute (isSimple()==true) must skip nested-boolean collection + // entirely rather than attempt it: covers the "false" outcome of "!attribute.isSimple()". + TextAttribute title = new TextAttribute(); + title.setName("title"); + title.setType("Text"); + + Content prototype = new Content(); + prototype.setTypeCode("TST"); + prototype.addAttribute(title); + prototype.addAttribute(composite); + prototype.addAttribute(list); + Mockito.when(contentManager.createContentType("TST")).thenReturn(prototype); + + List settings = solrSearchEngineManager.getContentTypesSettings(); + + Assertions.assertEquals(1, settings.size()); + List attributeSettings = settings.get(0).getAttributeSettings(); + List codes = attributeSettings.stream().map(AttributeSettings::getCode).toList(); + // The nested boolean "featured" (found in the schema fields) and "inner.innerFlag" (recursion, + // missing from the schema fields) are both registered; the plain-text "note" child and the + // boolean nested inside the top-level Monolist are not (List/Monolist ancestry is excluded). + Assertions.assertTrue(codes.contains("featured"), codes.toString()); + Assertions.assertTrue(codes.contains("innerFlag"), codes.toString()); + Assertions.assertFalse(codes.contains("myListItem"), codes.toString()); + Assertions.assertFalse(codes.contains("note"), codes.toString()); + + AttributeSettings featuredSettings = attributeSettings.stream() + .filter(s -> "featured".equals(s.getCode())).findFirst().orElseThrow(); + Assertions.assertTrue(featuredSettings.isValid()); + + AttributeSettings innerFlagSettings = attributeSettings.stream() + .filter(s -> "innerFlag".equals(s.getCode())).findFirst().orElseThrow(); + Assertions.assertFalse(innerFlagSettings.isValid(), "no schema field exists yet for the nested innerFlag"); + } + + @Test + void shouldSkipNonComplexAttributeInterfaceInstanceEvenWhenNotSimple() throws Exception { + // Defensive branch of collectNestedBooleanAttributes: an attribute that reports + // isSimple()==false but is not an AbstractComplexAttribute (unlike every real + // Composite/List attribute) must be skipped rather than throw a ClassCastException. + Mockito.when(langManager.getLangs()).thenReturn(List.of(lang("en"))); + NamedList solrClientResponse = new NamedList<>(); + solrClientResponse.add("fields", List.of()); + Mockito.when(solrClient.request(Mockito.any(SchemaRequest.Fields.class), eq("entando"))) + .thenReturn(solrClientResponse); + + Mockito.when(contentManager.getSmallEntityTypes()) + .thenReturn(List.of(new SmallEntityType("TST", "Test type"))); + + AttributeInterface fakeComplexAttribute = Mockito.mock(AttributeInterface.class); + Mockito.when(fakeComplexAttribute.isSimple()).thenReturn(false); + Mockito.when(fakeComplexAttribute.getName()).thenReturn("fake"); + Mockito.when(fakeComplexAttribute.getType()).thenReturn("Fake"); + + Content prototype = new Content(); + prototype.setTypeCode("TST"); + prototype.addAttribute(fakeComplexAttribute); + Mockito.when(contentManager.createContentType("TST")).thenReturn(prototype); + + List settings = solrSearchEngineManager.getContentTypesSettings(); + + Assertions.assertEquals(1, settings.size()); + Assertions.assertEquals(1, settings.get(0).getAttributeSettings().size()); + } } diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java index 388467ed1f..d5ac2a8a6c 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java @@ -1,5 +1,6 @@ package org.entando.entando.plugins.jpsolr.aps.system.solr.model; +import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; import com.agiletec.aps.system.common.entity.model.attribute.CheckBoxAttribute; import com.agiletec.aps.system.common.entity.model.attribute.TextAttribute; @@ -13,6 +14,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -179,6 +181,23 @@ void shouldExpectStringTypeForSearchableThreeStateAttribute() { Assertions.assertTrue(contentTypeSettings.isValid()); } + @Test + void shouldNotExpectFieldForUnsupportedAttributeType() { + // Closes the final "instanceof BooleanAttribute" false outcome of addAttribute's dispatch + // condition: an attribute that is neither IndexableAttributeInterface, Date, Number nor + // Boolean must not get an expected field configuration (isValid() trivially true). + + ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); + + AttributeInterface unsupported = Mockito.mock(AttributeInterface.class); + Mockito.when(unsupported.getName()).thenReturn("unsupported"); + Mockito.when(unsupported.getType()).thenReturn("Unsupported"); + + contentTypeSettings.addAttribute(unsupported, Map.of(), getLanguages("en")); + + Assertions.assertTrue(contentTypeSettings.isValid()); + } + @Test void shouldExpectSingleValuedBooleanTypeForNestedCheckBoxAttribute() { // Single-valued: a Composite occurs at most once per document per lang (the caller From 1488737dfb4afcdc05b4083e2b9dc4638053288a Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 23 Jul 2026 15:31:58 +0200 Subject: [PATCH 3/4] ESB-1133 Quality gate scan update --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 45b87885ad..5dfc1f4a75 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -85,6 +85,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up JDK 17 uses: actions/setup-java@v4 From 6d46bffe6c75683d1263b87f1673fd4657fc4bcc Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Thu, 23 Jul 2026 16:46:45 +0200 Subject: [PATCH 4/4] ESB-1133 Quality gate --- ...stractBaseEntityAttributeConfigAction.java | 2 +- .../CompositeAttributeConfigActionTest.java | 95 ++++++++-------- .../services/content/ContentManagerTest.java | 17 ++- .../resource/ResourceManagerTest.java | 22 ++-- .../ContentControllerIntegrationTest.java | 6 +- .../common/entity/AbstractEntityDAO.java | 7 +- .../CompositeAttributeXmlConfigTest.java | 104 ++++++++++++++++++ .../content/AdvContentFacetManager.java | 19 ++-- .../jpsolr/aps/system/solr/IndexerDAO.java | 2 +- .../system/solr/SolrComplexAttributes.java | 4 +- .../aps/system/solr/SolrFieldsChecker.java | 4 +- .../aps/system/solr/IndexerDAOTest.java | 17 +-- .../aps/system/solr/SearcherDAOTest.java | 16 +-- .../system/solr/SolrFieldsCheckerTest.java | 17 +-- .../solr/SolrSearchEngineManagerTest.java | 62 ++++++----- .../solr/model/ContentTypeSettingsTest.java | 9 +- 16 files changed, 267 insertions(+), 136 deletions(-) create mode 100644 engine/src/test/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttributeXmlConfigTest.java diff --git a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java index 33f3fcf79f..310c554d72 100644 --- a/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java +++ b/admin-console/src/main/java/com/agiletec/apsadmin/system/entity/type/AbstractBaseEntityAttributeConfigAction.java @@ -280,7 +280,7 @@ public boolean isNestedSearchableOptionSupported(String attributeTypeCode) { try { AttributeInterface attribute = this.getAttributePrototype(attributeTypeCode); return NestedBooleanSearchSupport.isIndexableNestedBoolean(attribute); - } catch (Throwable t) { + } catch (Exception t) { _logger.error("error in isNestedSearchableOptionSupported", t); } return false; diff --git a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java index c9f4e39905..96455f7c5b 100644 --- a/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java +++ b/admin-console/src/test/java/com/agiletec/apsadmin/system/entity/type/CompositeAttributeConfigActionTest.java @@ -3,6 +3,12 @@ import static com.agiletec.apsadmin.system.entity.type.ICompositeAttributeConfigAction.COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM; import static com.agiletec.apsadmin.system.entity.type.IEntityTypeConfigAction.ENTITY_TYPE_ON_EDIT_SESSION_PARAM; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.agiletec.aps.system.common.entity.IEntityManager; import com.agiletec.aps.system.common.entity.model.IApsEntity; @@ -27,7 +33,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.BeanFactory; @@ -49,13 +54,13 @@ class CompositeAttributeConfigActionTest { @BeforeEach void setUp() { - Mockito.when(request.getSession()).thenReturn(session); + when(request.getSession()).thenReturn(session); CompositeAttribute compositeAttribute = new CompositeAttribute(); compositeAttribute.setName(COMPOSITE_ATTRIBUTE_NAME); addTextAttribute(compositeAttribute, "attribute1"); addTextAttribute(compositeAttribute, "attribute2"); - Mockito.lenient().when(session.getAttribute(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM)).thenReturn(compositeAttribute); + lenient().when(session.getAttribute(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM)).thenReturn(compositeAttribute); } private void addTextAttribute(CompositeAttribute compositeAttribute, String attributeName) { @@ -70,16 +75,16 @@ void testMoveAttribute() { action.setMovement(ApsAdminSystemConstants.MOVEMENT_UP_CODE); action.setAttributeIndex(1); action.moveAttributeElement(); - Mockito.verify(session, Mockito.times(1)) - .setAttribute(Mockito.eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); + verify(session, times(1)) + .setAttribute(eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); } @Test void testRemoveAttributeElement() { action.setAttributeIndex(0); action.removeAttributeElement(); - Mockito.verify(session, Mockito.times(1)) - .setAttribute(Mockito.eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); + verify(session, times(1)) + .setAttribute(eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); } @@ -87,32 +92,32 @@ void testRemoveAttributeElement() { void testSaveAttributeElement() { String entityManagerName = "EntityManagerName"; String attributeTypeCode = "typeCode"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); Map attributeTypes = new HashMap<>(); attributeTypes.put(attributeTypeCode, new TextAttribute()); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + IEntityManager entityManager = mock(IEntityManager.class); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); action.setAttributeTypeCode(attributeTypeCode); action.saveAttributeElement(); - Mockito.verify(session, Mockito.times(1)) - .setAttribute(Mockito.eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); + verify(session, times(1)) + .setAttribute(eq(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM), any()); } @Test void testNestedSearchableOptionSupportedForBooleanLikes() { String entityManagerName = "EntityManagerName"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); Map attributeTypes = new HashMap<>(); attributeTypes.put("Boolean", new BooleanAttribute()); attributeTypes.put("CheckBox", new CheckBoxAttribute()); attributeTypes.put("ThreeState", new ThreeStateAttribute()); attributeTypes.put("Text", new TextAttribute()); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + IEntityManager entityManager = mock(IEntityManager.class); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); // every boolean-like composite child may be flagged searchable; other types may not Assertions.assertTrue(action.isNestedSearchableOptionSupported("Boolean")); @@ -124,11 +129,11 @@ void testNestedSearchableOptionSupportedForBooleanLikes() { @Test void testNestedSearchableOptionSupportedHandlesException() { String entityManagerName = "EntityManagerName"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenThrow(new RuntimeException()); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + IEntityManager entityManager = mock(IEntityManager.class); + when(entityManager.getEntityAttributePrototypes()).thenThrow(new RuntimeException()); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); Assertions.assertFalse(action.isNestedSearchableOptionSupported("Boolean")); } @@ -137,18 +142,18 @@ void testNestedSearchableOptionSupportedHandlesException() { void shouldMethodNotAddAttributeElement() { String entityManagerName = "EntityManagerName"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); String attributeTypeCode = "typeCode"; Map attributeTypes = new HashMap<>(); attributeTypes.put(attributeTypeCode, new TextAttribute()); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + IEntityManager entityManager = mock(IEntityManager.class); + when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); action.setAttributeTypeCode("attributeTypeCodeNotExistent"); - Mockito.when(textProvider.getText(any(), (String[]) any())).thenReturn("label"); + when(textProvider.getText(any(), (String[]) any())).thenReturn("label"); String result = action.addAttributeElement(); @@ -160,12 +165,12 @@ void shouldMethodNotAddAttributeElement() { void shouldMethodAddAttributeElementRaiseFailure() { String entityManagerName = "EntityManagerName"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenThrow(new RuntimeException()); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + IEntityManager entityManager = mock(IEntityManager.class); + when(entityManager.getEntityAttributePrototypes()).thenThrow(new RuntimeException()); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); action.setAttributeTypeCode(""); String result = action.addAttributeElement(); @@ -177,15 +182,15 @@ void shouldMethodAddAttributeElementRaiseFailure() { void shouldMethodAddAttributeElement() { String entityManagerName = "EntityManagerName"; - Mockito.when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) + when(session.getAttribute(IEntityTypeConfigAction.ENTITY_TYPE_MANAGER_SESSION_PARAM)) .thenReturn(entityManagerName); String attributeTypeCode = "typeCode"; Map attributeTypes = new HashMap<>(); attributeTypes.put(attributeTypeCode, new TextAttribute()); - IEntityManager entityManager = Mockito.mock(IEntityManager.class); - Mockito.when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); - Mockito.when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); + IEntityManager entityManager = mock(IEntityManager.class); + when(entityManager.getEntityAttributePrototypes()).thenReturn(attributeTypes); + when(beanFactory.getBean(entityManagerName)).thenReturn(entityManager); action.setAttributeTypeCode(attributeTypeCode); String result = action.addAttributeElement(); @@ -198,31 +203,31 @@ void shouldMethodAddAttributeElement() { @Test void shouldMethodSaveCompositeAttributeSaveComposite() { - IApsEntity entity = Mockito.mock(IApsEntity.class); - Mockito.when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(entity); + IApsEntity entity = mock(IApsEntity.class); + when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(entity); CompositeAttribute compositeAttribute = new CompositeAttribute(); compositeAttribute.setName(COMPOSITE_ATTRIBUTE_NAME); - Mockito.when(entity.getAttribute(COMPOSITE_ATTRIBUTE_NAME)).thenReturn(compositeAttribute); + when(entity.getAttribute(COMPOSITE_ATTRIBUTE_NAME)).thenReturn(compositeAttribute); action.saveCompositeAttribute(); - Mockito.verify(session, Mockito.times(1)) - .setAttribute(Mockito.eq(ENTITY_TYPE_ON_EDIT_SESSION_PARAM), any()); + verify(session, times(1)) + .setAttribute(eq(ENTITY_TYPE_ON_EDIT_SESSION_PARAM), any()); - Mockito.verify(session, Mockito.times(1)) + verify(session, times(1)) .removeAttribute(COMPOSITE_ATTRIBUTE_ON_EDIT_SESSION_PARAM); } @Test void shouldMethodSaveCompositeAttributeSaveMonolist() { MonoListAttribute attribute = new MonoListAttribute(); - IApsEntity entity = Mockito.mock(IApsEntity.class); - Mockito.when(entity.getAttribute(COMPOSITE_ATTRIBUTE_NAME)).thenReturn(attribute); - Mockito.when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(entity); + IApsEntity entity = mock(IApsEntity.class); + when(entity.getAttribute(COMPOSITE_ATTRIBUTE_NAME)).thenReturn(attribute); + when(session.getAttribute(ENTITY_TYPE_ON_EDIT_SESSION_PARAM)).thenReturn(entity); action.saveCompositeAttribute(); - Mockito.verify(session, Mockito.times(1)) - .setAttribute(Mockito.eq(ENTITY_TYPE_ON_EDIT_SESSION_PARAM), any()); + verify(session, times(1)) + .setAttribute(eq(ENTITY_TYPE_ON_EDIT_SESSION_PARAM), any()); } } diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java index dc37970dbe..16ffa202d4 100644 --- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java @@ -20,6 +20,12 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.spy; import com.agiletec.aps.system.common.entity.model.IApsEntity; import com.agiletec.aps.system.common.entity.parse.IEntityTypeFactory; @@ -37,7 +43,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.BeanFactory; @@ -71,7 +76,7 @@ class ContentManagerTest { private ContentManager contentManager; @BeforeEach - public void setUp() throws Exception { + void setUp() throws Exception { MockitoAnnotations.initMocks(this); this.contentManager.setEntityClassName(className); this.contentManager.setConfigItemName(JacmsSystemConstants.CONFIG_ITEM_CONTENT_TYPES); @@ -149,8 +154,8 @@ private IApsEntity createFakeEntity(String typeCode, String viewPage, String def @Test void failGetDefaultModelById() throws Exception { Assertions.assertThrows(EntRuntimeException.class, () -> { - Mockito.lenient().when(this.entityTypeFactory.extractEntityType(Mockito.anyString(), Mockito.any(Class.class), - Mockito.anyString(), Mockito.eq(this.entityTypeDom), Mockito.eq(this.beanName), Mockito.eq(this.entityDom))).thenThrow(EntException.class); + lenient().when(this.entityTypeFactory.extractEntityType(anyString(), any(Class.class), + anyString(), eq(this.entityTypeDom), eq(this.beanName), eq(this.entityDom))).thenThrow(EntException.class); String modelId = this.contentManager.getDefaultModel("ART123"); }); } @@ -162,8 +167,8 @@ void getEntityPrototypeShouldReturnNullForNullTypeCode() { @Test void shouldReturnNullFromModelAccessorsWhenTypeNotFound() { - ContentManager spyManager = Mockito.spy(this.contentManager); - Mockito.doReturn(null).when(spyManager).getTypeById(Mockito.anyString()); + ContentManager spyManager = spy(this.contentManager); + doReturn(null).when(spyManager).getTypeById(anyString()); Assertions.assertNull(spyManager.getViewPage("XYZ123")); Assertions.assertNull(spyManager.getDefaultModel("XYZ123")); diff --git a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManagerTest.java b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManagerTest.java index 331cc686ad..71c628ff2a 100644 --- a/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManagerTest.java +++ b/cms-plugin/src/test/java/com/agiletec/plugins/jacms/aps/system/services/resource/ResourceManagerTest.java @@ -27,9 +27,10 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import org.mockito.Mockito; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -50,13 +51,13 @@ class ResourceManagerTest { private ResourceManager resourceManager; @BeforeEach - public void setUp() throws Exception { - AttachResource mockAttachResource = Mockito.mock(AttachResource.class); - Mockito.lenient().when(mockAttachResource.getType()).thenReturn("Attach"); - Mockito.lenient().when(mockAttachResource.getResourcePrototype()).thenReturn(mockAttachResource); - ImageResource mockImageResource = Mockito.mock(ImageResource.class); - Mockito.lenient().when(mockImageResource.getResourcePrototype()).thenReturn(mockImageResource); - Mockito.lenient().when(mockImageResource.getType()).thenReturn("Image"); + void setUp() throws Exception { + AttachResource mockAttachResource = mock(AttachResource.class); + lenient().when(mockAttachResource.getType()).thenReturn("Attach"); + lenient().when(mockAttachResource.getResourcePrototype()).thenReturn(mockAttachResource); + ImageResource mockImageResource = mock(ImageResource.class); + lenient().when(mockImageResource.getResourcePrototype()).thenReturn(mockImageResource); + lenient().when(mockImageResource.getType()).thenReturn("Image"); Map types = new HashMap<>(); types.put("Image", mockImageResource); types.put("Attach", mockAttachResource); @@ -64,13 +65,14 @@ public void setUp() throws Exception { } @Test - public void status_should_be_ready_on_init() { + void status_should_be_ready_on_init() { when(cacheWrapper.getStatus()).thenReturn(IResourceManager.STATUS_READY); int status = this.resourceManager.getStatus(); assertThat(status, is(IResourceManager.STATUS_READY)); } - public void createResourceType() { + @Test + void createResourceType() { ResourceInterface type = this.resourceManager.createResourceType("Image"); Assertions.assertNotNull(type); Assertions.assertEquals("Image", type.getType()); diff --git a/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java b/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java index 749514d367..1cfb6f726f 100644 --- a/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java +++ b/cms-plugin/src/test/java/org/entando/entando/plugins/jacms/web/content/ContentControllerIntegrationTest.java @@ -2333,7 +2333,7 @@ void testUpdateContentsBatch() throws Exception { batchContentStatusRequest.getCodes().add(newContentId3); - batchContentStatusRequest.getCodes().stream().forEach(code -> { + batchContentStatusRequest.getCodes().forEach(code -> { try { Assertions.assertNotNull(this.contentManager.loadContent(code, false)); Assertions.assertNull(this.contentManager.loadContent(code, true)); @@ -2349,7 +2349,7 @@ void testUpdateContentsBatch() throws Exception { .header("Authorization", "Bearer " + accessToken)); result.andExpect(status().isOk()); - batchContentStatusRequest.getCodes().stream().forEach(code -> { + batchContentStatusRequest.getCodes().forEach(code -> { try { Assertions.assertNotNull(this.contentManager.loadContent(code, false)); Assertions.assertNotNull(this.contentManager.loadContent(code, true)); @@ -2367,7 +2367,7 @@ void testUpdateContentsBatch() throws Exception { .header("Authorization", "Bearer " + accessToken)); result.andExpect(status().isOk()); - batchContentStatusRequest.getCodes().stream().forEach(code -> { + batchContentStatusRequest.getCodes().forEach(code -> { try { Assertions.assertNotNull(this.contentManager.loadContent(code, false)); Assertions.assertNull(this.contentManager.loadContent(code, true)); diff --git a/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntityDAO.java b/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntityDAO.java index a8236f1188..1bc4239a5c 100644 --- a/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntityDAO.java +++ b/engine/src/main/java/com/agiletec/aps/system/common/entity/AbstractEntityDAO.java @@ -244,9 +244,10 @@ private void addAttributeSearchRecord(String id, AttributeInterface attribute, S return; } boolean composite = (attribute instanceof CompositeAttribute) && !listAncestor; - String childPath = composite - ? ((null == path) ? attribute.getName() : path + "_" + attribute.getName()) - : null; + String childPath = null; + if (composite) { + childPath = (null == path) ? attribute.getName() : path + "_" + attribute.getName(); + } boolean childListAncestor = listAncestor || !(attribute instanceof CompositeAttribute); for (int i = 0; i < children.size(); i++) { this.addAttributeSearchRecord(id, children.get(i), childPath, childListAncestor, stat); diff --git a/engine/src/test/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttributeXmlConfigTest.java b/engine/src/test/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttributeXmlConfigTest.java new file mode 100644 index 0000000000..08e27cf294 --- /dev/null +++ b/engine/src/test/java/com/agiletec/aps/system/common/entity/model/attribute/CompositeAttributeXmlConfigTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2015-Present Entando Inc. (http://www.entando.com) All rights reserved. + * + * This library is free software; you can redistribute it and/or modify it under + * the terms of the GNU Lesser General Public License as published by the Free + * Software Foundation; either version 2.1 of the License, or (at your option) + * any later version. + * + * This library is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more + * details. + */ +package com.agiletec.aps.system.common.entity.model.attribute; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.agiletec.aps.system.common.entity.parse.attribute.BooleanAttributeHandler; +import com.agiletec.aps.system.common.entity.parse.attribute.TextAttributeHandler; +import com.agiletec.aps.system.services.lang.ILangManager; +import com.agiletec.aps.system.services.lang.Lang; +import java.util.Collections; +import java.util.Map; +import org.jdom2.Element; +import org.junit.jupiter.api.Test; + +/** + * Verifies {@link CompositeAttribute#setComplexAttributeConfig} - in particular the new rule that a + * plain boolean composite child may keep its configured {@code searchable} flag, while every other + * type is forced non-searchable regardless of what the XML config says. + */ +class CompositeAttributeXmlConfigTest { + + @Test + void booleanChildKeepsConfiguredSearchableFlag() throws Exception { + CompositeAttribute composite = parseComposite( + childElement("Boolean", "certified", true), Map.of("Boolean", booleanPrototype())); + + AttributeInterface child = composite.getAttributeMap().get("certified"); + assertTrue(child.isSearchable()); + } + + @Test + void nonBooleanChildIsForcedNonSearchable() throws Exception { + CompositeAttribute composite = parseComposite( + childElement("Text", "note", true), Map.of("Text", textPrototype())); + + AttributeInterface child = composite.getAttributeMap().get("note"); + assertFalse(child.isSearchable()); + } + + // --- fixtures ------------------------------------------------------ + + private CompositeAttribute parseComposite(Element childElement, Map attrTypes) + throws Exception { + Element attributesWrapper = new Element("attributes"); + attributesWrapper.addContent(childElement); + Element compositeElement = new Element("composite"); + compositeElement.addContent(attributesWrapper); + + CompositeAttribute composite = new CompositeAttribute(); + composite.setName("address"); + composite.setComplexAttributeConfig(compositeElement, attrTypes); + return composite; + } + + private Element childElement(String attributeType, String name, boolean searchable) { + Element element = new Element(attributeType.toLowerCase()); + element.setAttribute("attributetype", attributeType); + element.setAttribute("name", name); + element.setAttribute("searchable", String.valueOf(searchable)); + return element; + } + + private BooleanAttribute booleanPrototype() { + BooleanAttribute attribute = new BooleanAttribute(); + attribute.setType("Boolean"); + attribute.setHandler(new BooleanAttributeHandler()); + attribute.setLangManager(langManager()); + return attribute; + } + + private TextAttribute textPrototype() { + TextAttribute attribute = new TextAttribute(); + attribute.setType("Text"); + attribute.setHandler(new TextAttributeHandler()); + attribute.setLangManager(langManager()); + return attribute; + } + + private ILangManager langManager() { + ILangManager langManager = mock(ILangManager.class); + Lang en = new Lang(); + en.setCode("en"); + en.setDescr("English"); + when(langManager.getDefaultLang()).thenReturn(en); + when(langManager.getLangs()).thenReturn(Collections.singletonList(en)); + return langManager; + } + +} diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java index 277146793d..4beef54cce 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/content/AdvContentFacetManager.java @@ -58,6 +58,9 @@ public class AdvContentFacetManager implements IAdvContentFacetManager { private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[a-zA-Z0-9_.:,-]+"); private static final Pattern INJECTION_PATTERN = Pattern.compile("\\$\\{|%24%7B", Pattern.CASE_INSENSITIVE); + private static final String INVALID_PARAMETER_CODE = "INVALID_PARAMETER"; + private static final String INVALID_PARAMETER_MESSAGE_KEY = "parameter.invalid"; + private final ICategoryManager categoryManager; private final ICmsSearchEngineManager searchEngineManager; private final IAuthorizationManager authorizationManager; @@ -218,8 +221,8 @@ private void rejectIfInvalidBooleanFilter(Filter filter, String fieldPrefix, if (FilterOperator.GREATER.getValue().equalsIgnoreCase(operator) || FilterOperator.LOWER.getValue().equalsIgnoreCase(operator)) { logger.warn("Rejected range operator '{}' on boolean filter in field '{}'", operator, fieldPrefix); - bindingResult.rejectValue(null, "INVALID_PARAMETER", - new Object[]{fieldPrefix + ".operator"}, "parameter.invalid"); + bindingResult.rejectValue(null, INVALID_PARAMETER_CODE, + new Object[]{fieldPrefix + ".operator"}, INVALID_PARAMETER_MESSAGE_KEY); } rejectIfNotStrictBoolean(filter.getValue(), fieldPrefix + ".value", bindingResult); if (null != filter.getAllowedValues()) { @@ -236,8 +239,8 @@ private static void rejectIfNotStrictBoolean(String value, String field, } if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) { logger.warn("Rejected non-boolean value in field '{}': '{}'", field, value); - bindingResult.rejectValue(null, "INVALID_PARAMETER", - new Object[]{field}, "parameter.invalid"); + bindingResult.rejectValue(null, INVALID_PARAMETER_CODE, + new Object[]{field}, INVALID_PARAMETER_MESSAGE_KEY); } } @@ -248,8 +251,8 @@ private static void rejectIfUnsafeIdentifier(String value, String field, } if (!SAFE_IDENTIFIER.matcher(value).matches()) { logger.warn("Rejected unsafe identifier in field '{}': '{}'", field, value); - bindingResult.rejectValue(null, "INVALID_PARAMETER", - new Object[]{field}, "parameter.invalid"); + bindingResult.rejectValue(null, INVALID_PARAMETER_CODE, + new Object[]{field}, INVALID_PARAMETER_MESSAGE_KEY); } } @@ -260,8 +263,8 @@ private static void rejectIfInjection(String value, String field, } if (INJECTION_PATTERN.matcher(value).find()) { logger.warn("Rejected injection pattern in field '{}': '{}'", field, value); - bindingResult.rejectValue(null, "INVALID_PARAMETER", - new Object[]{field}, "parameter.invalid"); + bindingResult.rejectValue(null, INVALID_PARAMETER_CODE, + new Object[]{field}, INVALID_PARAMETER_MESSAGE_KEY); } } diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java index 199da0edd5..6e645b671e 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAO.java @@ -155,7 +155,7 @@ protected void indexCategories(IApsEntity entity, SolrInputDocument document) { for (ITreeNode category : categories) { this.extractCategoryCodes(category, codes); } - codes.stream().forEach(c -> document.addField(SolrFields.SOLR_CONTENT_CATEGORY_FIELD_NAME, c)); + codes.forEach(c -> document.addField(SolrFields.SOLR_CONTENT_CATEGORY_FIELD_NAME, c)); } } diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java index c420c53f3d..a5a1ad9f2d 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrComplexAttributes.java @@ -69,8 +69,8 @@ public static String solrType(AttributeInterface attribute) { * {@code getValue()} already coerces {@code null} to {@code false}. */ public static Object solrValue(AttributeInterface attribute) { - if (attribute instanceof ThreeStateAttribute) { - Boolean value = ((ThreeStateAttribute) attribute).getValue(); + if (attribute instanceof ThreeStateAttribute threeStateAttribute) { + Boolean value = threeStateAttribute.getValue(); return (null == value) ? "none" : value.toString(); } return ((BooleanAttribute) attribute).getValue(); diff --git a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java index fb368fa6ae..9ddd9a5677 100644 --- a/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java +++ b/solr-plugin/src/main/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsChecker.java @@ -143,8 +143,8 @@ private String buildFieldName(Lang lang, String name) { // Date/Number/Text children remain full-text-only (unchanged, matching the baseline Lucene // engine). The field name carries the full composite path, e.g. "en_complexAttrName_boolAttrName". private void checkComplexAttributeChildren(AttributeInterface attribute, Lang lang, String namePrefix) { - if (attribute instanceof AbstractComplexAttribute && !(attribute instanceof ListAttributeInterface)) { - for (AttributeInterface child : ((AbstractComplexAttribute) attribute).getAttributes()) { + if (attribute instanceof AbstractComplexAttribute complexAttribute && !(attribute instanceof ListAttributeInterface)) { + for (AttributeInterface child : complexAttribute.getAttributes()) { this.checkNestedAttribute(child, lang, namePrefix); } } diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java index b6b3811f19..fcff8bfee2 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/IndexerDAOTest.java @@ -31,14 +31,15 @@ import org.apache.solr.common.SolrInputDocument; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class IndexerDAOTest { private IndexerDAO newIndexerDAO() { - IndexerDAO indexerDAO = new IndexerDAO(Mockito.mock(SolrClient.class), "core"); - ILangManager langManager = Mockito.mock(ILangManager.class); - Mockito.when(langManager.getLangs()).thenReturn(List.of(lang("en"))); + IndexerDAO indexerDAO = new IndexerDAO(mock(SolrClient.class), "core"); + ILangManager langManager = mock(ILangManager.class); + when(langManager.getLangs()).thenReturn(List.of(lang("en"))); indexerDAO.setLangManager(langManager); return indexerDAO; } @@ -193,10 +194,10 @@ void shouldNotIndexTopLevelAttributeOfUnsupportedType() { // top-level dispatch condition: an attribute that is neither IndexableAttributeInterface, // Date, Number nor Boolean must be skipped entirely rather than throw. Content content = newContent(); - AttributeInterface unsupported = Mockito.mock(AttributeInterface.class); - Mockito.when(unsupported.isSimple()).thenReturn(true); - Mockito.when(unsupported.getName()).thenReturn("unsupported"); - Mockito.when(unsupported.getValue()).thenReturn("nonNullValue"); + AttributeInterface unsupported = mock(AttributeInterface.class); + when(unsupported.isSimple()).thenReturn(true); + when(unsupported.getName()).thenReturn("unsupported"); + when(unsupported.getValue()).thenReturn("nonNullValue"); content.addAttribute(unsupported); SolrInputDocument document = newIndexerDAO().createDocument(content); diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java index 0de4475002..5cd2ba6420 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SearcherDAOTest.java @@ -24,10 +24,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -475,7 +477,7 @@ void shouldHandleArrayOfArraysFilters() throws Exception { ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(SolrQuery.class); QueryResponse queryResponse = mockQueryResponse(); - Mockito.when(solrClient.query(Mockito.any(), queryCaptor.capture())).thenReturn(queryResponse); + when(solrClient.query(any(), queryCaptor.capture())).thenReturn(queryResponse); SolrSearchEngineFilter filterSort = new SolrSearchEngineFilter("created", false); SolrSearchEngineFilter filterValue = new SolrSearchEngineFilter("key", true, "value"); @@ -505,7 +507,7 @@ private void testSearchFacetedContents(SearchEngineFilter[] filters, SearchEngin List allowedGroups, String expectedQuery) throws Exception { ArgumentCaptor queryCaptor = ArgumentCaptor.forClass(SolrQuery.class); QueryResponse queryResponse = mockQueryResponse(); - Mockito.when(solrClient.query(Mockito.any(), queryCaptor.capture())).thenReturn(queryResponse); + when(solrClient.query(any(), queryCaptor.capture())).thenReturn(queryResponse); searcherDAO.searchFacetedContents(filters, categories, allowedGroups); @@ -516,11 +518,11 @@ private void testSearchFacetedContents(SearchEngineFilter[] filters, SearchEngin private void mockDefaultLang() { Lang lang = new Lang(); lang.setCode("en"); - Mockito.when(langManager.getDefaultLang()).thenReturn(lang); + when(langManager.getDefaultLang()).thenReturn(lang); } private void mockCategory(String categoryCode) { - Mockito.when(treeNodeManager.getNode(categoryCode)).thenReturn(Mockito.mock(ITreeNode.class)); + when(treeNodeManager.getNode(categoryCode)).thenReturn(mock(ITreeNode.class)); } private Date getDate(String date) throws Exception { @@ -530,9 +532,9 @@ private Date getDate(String date) throws Exception { } private QueryResponse mockQueryResponse() { - QueryResponse queryResponse = Mockito.mock(QueryResponse.class); + QueryResponse queryResponse = mock(QueryResponse.class); SolrDocumentList documents = new SolrDocumentList(); - Mockito.when(queryResponse.getResults()).thenReturn(documents); + when(queryResponse.getResults()).thenReturn(documents); return queryResponse; } } diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java index 9e1f30a65b..85b01233d4 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrFieldsCheckerTest.java @@ -30,7 +30,8 @@ import org.entando.entando.plugins.jpsolr.aps.system.solr.model.SolrFields; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class SolrFieldsCheckerTest { @@ -171,9 +172,9 @@ void shouldNotCreateFieldForTopLevelAttributeOfUnsupportedType() { // Closes the final "instanceof BooleanAttribute" false outcome of // isIndexableAttributeField: an attribute that is neither IndexableAttributeInterface, // Date, Number nor Boolean must not produce a field. - AttributeInterface unsupported = Mockito.mock(AttributeInterface.class); - Mockito.when(unsupported.isSimple()).thenReturn(true); - Mockito.when(unsupported.getName()).thenReturn("unsupported"); + AttributeInterface unsupported = mock(AttributeInterface.class); + when(unsupported.isSimple()).thenReturn(true); + when(unsupported.getName()).thenReturn("unsupported"); SolrFieldsChecker checker = this.checker(List.of(unsupported), lang("en")); @@ -186,9 +187,9 @@ void shouldSkipNonComplexAttributeChildrenEvenWhenNotSimple() { // Defensive branch of checkComplexAttributeChildren: an attribute that reports // isSimple()==false but is not an AbstractComplexAttribute (unlike every real // Composite/List attribute) must be skipped rather than throw a ClassCastException. - AttributeInterface fakeComplexAttribute = Mockito.mock(AttributeInterface.class); - Mockito.when(fakeComplexAttribute.isSimple()).thenReturn(false); - Mockito.when(fakeComplexAttribute.getName()).thenReturn("fake"); + AttributeInterface fakeComplexAttribute = mock(AttributeInterface.class); + when(fakeComplexAttribute.isSimple()).thenReturn(false); + when(fakeComplexAttribute.getName()).thenReturn("fake"); SolrFieldsChecker checker = this.checker(List.of(fakeComplexAttribute), lang("en")); @@ -282,7 +283,7 @@ void shouldNotCreateFieldForBooleanInsideCompositeNestedInMonolist() { private List fieldNames(SolrFieldsChecker checker) { return checker.checkFields().getFieldsToAdd().stream() .map(field -> (String) field.get(SolrFields.SOLR_FIELD_NAME)) - .collect(Collectors.toList()); + .toList(); } private Map fieldsToAdd(SolrFieldsChecker checker, String fieldName) { diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManagerTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManagerTest.java index bf60292fcc..7c6facee8d 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManagerTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/SolrSearchEngineManagerTest.java @@ -5,6 +5,13 @@ import static com.agiletec.plugins.jacms.aps.system.services.searchengine.ICmsSearchEngineManager.STATUS_RELOADING_INDEXES_IN_PROGRESS; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.agiletec.aps.system.common.entity.model.SmallEntityType; import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; @@ -35,7 +42,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.MockedConstruction; -import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -62,11 +68,11 @@ class SolrSearchEngineManagerTest { @BeforeEach void setUp() throws Exception { - mockedConstructionSolrClientBuilder = Mockito.mockConstruction(HttpSolrClient.Builder.class, + mockedConstructionSolrClientBuilder = mockConstruction(HttpSolrClient.Builder.class, (builder, context) -> { - solrClient = Mockito.mock(HttpSolrClient.class); - Mockito.when(builder.withHttpClient(any())).thenReturn(builder); - Mockito.when(builder.build()).thenReturn(solrClient); + solrClient = mock(HttpSolrClient.class); + when(builder.withHttpClient(any())).thenReturn(builder); + when(builder.build()).thenReturn(solrClient); }); solrProxy = new SolrProxyTenantAware( langManager, categoryManager, tenantManager, solrHttpClientBuilder @@ -87,8 +93,8 @@ void tearDown() { @Test void shouldRollbackIndexStatusIfReloadThreadDoesNotStart() throws Exception { solrProxy.getIndexStatus().setValue(STATUS_NEED_TO_RELOAD_INDEXES); - try (MockedConstruction c = Mockito.mockConstruction(SolrIndexLoaderThread.class, - (thread, context) -> Mockito.doThrow(RuntimeException.class).when(thread).start())) { + try (MockedConstruction c = mockConstruction(SolrIndexLoaderThread.class, + (thread, context) -> doThrow(RuntimeException.class).when(thread).start())) { Assertions.assertThrows(EntException.class, () -> solrSearchEngineManager.startReloadContentsReferences()); } Assertions.assertEquals(STATUS_NEED_TO_RELOAD_INDEXES, solrProxy.getIndexStatus().getValue()); @@ -97,8 +103,8 @@ void shouldRollbackIndexStatusIfReloadThreadDoesNotStart() throws Exception { @Test void shouldSetStatusInProgressIfReloadThreadIsStartedStart() throws Exception { solrProxy.getIndexStatus().setValue(STATUS_READY); - try (MockedConstruction c = Mockito.mockConstruction(SolrIndexLoaderThread.class, - (thread, context) -> Mockito.doNothing().when(thread).start())) { + try (MockedConstruction c = mockConstruction(SolrIndexLoaderThread.class, + (thread, context) -> doNothing().when(thread).start())) { Assertions.assertNotNull(solrSearchEngineManager.startReloadContentsReferences()); } Assertions.assertEquals(STATUS_RELOADING_INDEXES_IN_PROGRESS, solrProxy.getIndexStatus().getValue()); @@ -114,8 +120,8 @@ void shouldSkipReloadThreadIfReloadIsAlreadyInProgress() throws Exception { @Test void shouldReloadByType() throws Exception { solrProxy.getIndexStatus().setValue(STATUS_READY); - try (MockedConstruction c = Mockito.mockConstruction(SolrIndexLoaderThread.class, - (thread, context) -> Mockito.doNothing().when(thread).start())) { + try (MockedConstruction c = mockConstruction(SolrIndexLoaderThread.class, + (thread, context) -> doNothing().when(thread).start())) { solrSearchEngineManager.startReloadContentsReferencesByType("ART"); } Assertions.assertEquals(STATUS_RELOADING_INDEXES_IN_PROGRESS, solrProxy.getIndexStatus().getValue()); @@ -126,14 +132,14 @@ void shouldRefreshCmsFields() throws Exception { solrProxy.getIndexStatus().setValue(STATUS_READY); NamedList solrClientResponse = new NamedList<>(); solrClientResponse.add("fields", List.of()); - Mockito.when(solrClient.request(Mockito.any(SchemaRequest.Fields.class), eq("entando"))) + when(solrClient.request(any(SchemaRequest.Fields.class), eq("entando"))) .thenReturn(solrClientResponse); - try (MockedConstruction c = Mockito.mockConstruction(SolrIndexLoaderThread.class, - (thread, context) -> Mockito.doNothing().when(thread).start())) { + try (MockedConstruction c = mockConstruction(SolrIndexLoaderThread.class, + (thread, context) -> doNothing().when(thread).start())) { solrSearchEngineManager.refreshCmsFields(); } - Mockito.verify(solrClient, Mockito.times(1)) - .request(Mockito.any(SchemaRequest.MultiUpdate.class), eq("entando")); + verify(solrClient, times(1)) + .request(any(SchemaRequest.MultiUpdate.class), eq("entando")); } /** Exposes the protected {@code addAttribute} so tests can build composite fixtures. */ @@ -157,7 +163,7 @@ void shouldBuildContentTypesSettingsWithNestedBooleanAttributes() throws Excepti // all: this closes buildCurrentFieldConfig's "existing field found" branch, and // collectNestedBooleanAttributes' simple/complex child dispatch, boolean/non-boolean leaf // dispatch, recursion into a nested Composite, and the top-level Monolist exclusion. - Mockito.when(langManager.getLangs()).thenReturn(List.of(lang("en"))); + when(langManager.getLangs()).thenReturn(List.of(lang("en"))); SimpleOrderedMap existingField = new SimpleOrderedMap<>(); existingField.add("name", "en_myComposite_featured"); @@ -165,10 +171,10 @@ void shouldBuildContentTypesSettingsWithNestedBooleanAttributes() throws Excepti existingField.add("multiValued", false); NamedList solrClientResponse = new NamedList<>(); solrClientResponse.add("fields", List.of(existingField)); - Mockito.when(solrClient.request(Mockito.any(SchemaRequest.Fields.class), eq("entando"))) + when(solrClient.request(any(SchemaRequest.Fields.class), eq("entando"))) .thenReturn(solrClientResponse); - Mockito.when(contentManager.getSmallEntityTypes()) + when(contentManager.getSmallEntityTypes()) .thenReturn(List.of(new SmallEntityType("TST", "Test type"))); BooleanAttribute featured = new BooleanAttribute(); @@ -216,7 +222,7 @@ void shouldBuildContentTypesSettingsWithNestedBooleanAttributes() throws Excepti prototype.addAttribute(title); prototype.addAttribute(composite); prototype.addAttribute(list); - Mockito.when(contentManager.createContentType("TST")).thenReturn(prototype); + when(contentManager.createContentType("TST")).thenReturn(prototype); List settings = solrSearchEngineManager.getContentTypesSettings(); @@ -245,24 +251,24 @@ void shouldSkipNonComplexAttributeInterfaceInstanceEvenWhenNotSimple() throws Ex // Defensive branch of collectNestedBooleanAttributes: an attribute that reports // isSimple()==false but is not an AbstractComplexAttribute (unlike every real // Composite/List attribute) must be skipped rather than throw a ClassCastException. - Mockito.when(langManager.getLangs()).thenReturn(List.of(lang("en"))); + when(langManager.getLangs()).thenReturn(List.of(lang("en"))); NamedList solrClientResponse = new NamedList<>(); solrClientResponse.add("fields", List.of()); - Mockito.when(solrClient.request(Mockito.any(SchemaRequest.Fields.class), eq("entando"))) + when(solrClient.request(any(SchemaRequest.Fields.class), eq("entando"))) .thenReturn(solrClientResponse); - Mockito.when(contentManager.getSmallEntityTypes()) + when(contentManager.getSmallEntityTypes()) .thenReturn(List.of(new SmallEntityType("TST", "Test type"))); - AttributeInterface fakeComplexAttribute = Mockito.mock(AttributeInterface.class); - Mockito.when(fakeComplexAttribute.isSimple()).thenReturn(false); - Mockito.when(fakeComplexAttribute.getName()).thenReturn("fake"); - Mockito.when(fakeComplexAttribute.getType()).thenReturn("Fake"); + AttributeInterface fakeComplexAttribute = mock(AttributeInterface.class); + when(fakeComplexAttribute.isSimple()).thenReturn(false); + when(fakeComplexAttribute.getName()).thenReturn("fake"); + when(fakeComplexAttribute.getType()).thenReturn("Fake"); Content prototype = new Content(); prototype.setTypeCode("TST"); prototype.addAttribute(fakeComplexAttribute); - Mockito.when(contentManager.createContentType("TST")).thenReturn(prototype); + when(contentManager.createContentType("TST")).thenReturn(prototype); List settings = solrSearchEngineManager.getContentTypesSettings(); diff --git a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java index d5ac2a8a6c..c4f66234a1 100644 --- a/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java +++ b/solr-plugin/src/test/java/org/entando/entando/plugins/jpsolr/aps/system/solr/model/ContentTypeSettingsTest.java @@ -14,7 +14,8 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mockito; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) @@ -189,9 +190,9 @@ void shouldNotExpectFieldForUnsupportedAttributeType() { ContentTypeSettings contentTypeSettings = new ContentTypeSettings("NWS", "News"); - AttributeInterface unsupported = Mockito.mock(AttributeInterface.class); - Mockito.when(unsupported.getName()).thenReturn("unsupported"); - Mockito.when(unsupported.getType()).thenReturn("Unsupported"); + AttributeInterface unsupported = mock(AttributeInterface.class); + when(unsupported.getName()).thenReturn("unsupported"); + when(unsupported.getType()).thenReturn("Unsupported"); contentTypeSettings.addAttribute(unsupported, Map.of(), getLanguages("en"));