From 08a9f1e7a1cbac97f3edfa4b6db7e5996e8c1d77 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 30 Jan 2026 10:50:01 +0100 Subject: [PATCH 1/6] ESB-827: added local cache to the Content Manager --- pom.xml | 40 +- .../services/content/ContentManager.java | 57 ++- .../services/content/IContentManager.java | 8 + .../services/content/IFContentLocalCache.java | 192 +++++++++ .../jacms/apsadmin/content/ContentAction.java | 6 + .../action/list/ListAttributeAction.java | 4 +- .../jacms/apsadmin/content/content.xml | 5 + .../content/helper/ContentActionHelper.java | 4 +- .../aps/system/config/LocalCacheConfig.java | 34 ++ .../jacms/aps/managers/cmsManagersConfig.xml | 3 + .../content/IFContentLocalCacheTest.java | 394 ++++++++++++++++++ 11 files changed, 737 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCache.java create mode 100644 src/main/java/org/entando/entando/plugins/jacms/aps/system/config/LocalCacheConfig.java create mode 100644 src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java diff --git a/pom.xml b/pom.xml index a3e0fc8f..25f4ab5f 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.entando.entando.plugins entando-plugin-jacms war - 6.5.3 + 6.5.4 Entando Plugin: CMS Allows registered users to manage dynamic contents and digital assets http://www.entando.com/ @@ -86,6 +86,28 @@ org.apache.maven.plugins maven-surefire-plugin + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + test-jar + package + + test-jar + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 8 + 8 + + @@ -180,6 +202,11 @@ + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + org.apache.commons commons-collections4 @@ -267,6 +294,17 @@ org.mockito mockito-junit-jupiter + test + + + org.mockito + mockito-core + + + + + org.mockito + mockito-inline org.apache.struts diff --git a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java index 6ffa10ef..e41382f2 100644 --- a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java +++ b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java @@ -14,6 +14,7 @@ package com.agiletec.plugins.jacms.aps.system.services.content; import com.agiletec.aps.system.ApsSystemUtils; +import com.agiletec.aps.system.ApsSystemUtils.ApsDeepDebug; import com.agiletec.aps.system.SystemConstants; import com.agiletec.aps.system.common.entity.ApsEntityManager; import com.agiletec.aps.system.common.entity.IEntityDAO; @@ -33,6 +34,7 @@ import com.agiletec.plugins.jacms.aps.system.services.content.model.ContentRecordVO; import com.agiletec.plugins.jacms.aps.system.services.content.model.SmallContentType; import com.agiletec.plugins.jacms.aps.system.services.resource.ResourceUtilizer; +import com.github.benmanes.caffeine.cache.Cache; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -41,6 +43,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import org.apache.commons.lang3.StringUtils; import org.entando.entando.aps.system.services.cache.ICacheInfoManager; import org.entando.entando.ent.exception.EntException; import org.entando.entando.ent.exception.EntRuntimeException; @@ -54,7 +57,7 @@ * the contents. */ public class ContentManager extends ApsEntityManager - implements IContentManager, GroupUtilizer, PageUtilizer, ContentUtilizer, ResourceUtilizer, CategoryUtilizer { + implements IFContentLocalCache, IContentManager, GroupUtilizer, PageUtilizer, ContentUtilizer, ResourceUtilizer, CategoryUtilizer { private static final EntLogger logger = EntLogFactory.getSanitizedLogger(ContentManager.class); @@ -72,6 +75,8 @@ public class ContentManager extends ApsEntityManager private ICacheInfoManager cacheInfoManager; + private com.github.benmanes.caffeine.cache.Cache localCache; + @Override protected String getConfigItemName() { return JacmsSystemConstants.CONFIG_ITEM_CONTENT_TYPES; @@ -91,8 +96,8 @@ public Content createContentType(String typeCode) { } /** - * Return a list of the of the content types in a 'small form'. 'Small form' - * mans that the contents returned are purged from all unnecessary + * Return a list of the content types in a 'small form'. 'Small form' + * means that the contents returned are purged from all unnecessary * information (eg. attributes). * * @return The list of the types in a (small form). @@ -187,6 +192,17 @@ public Content loadContent(String id, boolean onLine) throws EntException { } } + @Override + public Content loadAndCacheContent(String id, boolean onLine) throws EntException { + try { + ContentRecordVO contentVo = this.loadAndCacheContentVO(id); + return this.createContent(contentVo, onLine); + } catch (EntException e) { + logger.error("Error while loading content : id {}", id, e); + throw new EntException("Error while loading content : id " + id, e); + } + } + protected Content createContent(ContentRecordVO contentVo, boolean onLine) throws EntException { Content content = null; try { @@ -238,7 +254,7 @@ protected Content createContent(ContentRecordVO contentVo, boolean onLine) throw /** * Return a {@link ContentRecordVO} (shortly: VO) containing the all content - * informations stored in the DB. + * information stored in the DB. * * @param id The id of the requested content. * @return The VO object corresponding to the wanted content. @@ -247,6 +263,7 @@ protected Content createContent(ContentRecordVO contentVo, boolean onLine) throw @Override public ContentRecordVO loadContentVO(String id) throws EntException { try { + ApsDeepDebug.print("cms-local-cache", "cache IGNORE " + id); return (ContentRecordVO) this.getContentDAO().loadEntityRecord(id); } catch (Throwable t) { logger.error("Error while loading content vo : id {}", id, t); @@ -254,6 +271,28 @@ public ContentRecordVO loadContentVO(String id) throws EntException { } } + @Override + public ContentRecordVO loadAndCacheContentVO(String id) throws EntException { + + try { + return IFContentLocalCache.loadAndCacheContentVO(id, localCache, + () -> (ContentRecordVO) this.getContentDAO().loadEntityRecord(id)); + } catch (Throwable t) { + logger.error("Error while loading content vo : id {}", id, t); + throw new EntException("Error while loading content vo : id " + id, t); + } + } + + @Override + public void evict(String key) { + IFContentLocalCache.evict(key, localCache); + } + + @Override + public void evict(List keys) { + IFContentLocalCache.evict(keys, localCache); + } + /** * Save a content in the DB. * @@ -271,7 +310,7 @@ public void saveContentAndContinue(Content content) throws EntException { } /** - * Save a content in the DB. Hopefully this method has no annotation + * Save a content in the DB. Hopefully, this method has no annotation * attached */ @Override @@ -280,6 +319,7 @@ public void addContent(Content content) throws EntException { } private void addUpdateContent(Content content, boolean updateDate) throws EntException { + IFContentLocalCache.evict(content, localCache); try { content.setLastModified(new Date()); if (updateDate) { @@ -751,4 +791,11 @@ public void setCacheInfoManager(ICacheInfoManager cacheInfoManager) { this.cacheInfoManager = cacheInfoManager; } + public Cache getLocalCache() { + return localCache; + } + + public void setLocalCache(Cache localCache) { + this.localCache = localCache; + } } diff --git a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IContentManager.java b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IContentManager.java index 36ea6646..d45e82f2 100644 --- a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IContentManager.java +++ b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IContentManager.java @@ -101,6 +101,8 @@ public interface IContentManager extends IEntityManager { */ public Content loadContent(String id, boolean onLine) throws EntException; + Content loadAndCacheContent(String id, boolean onLine) throws EntException; + /** * Restituisce un VO contenente le informazioni del record su db * corrispondente al contenuto di cui all'id inserito. @@ -111,6 +113,12 @@ public interface IContentManager extends IEntityManager { */ public ContentRecordVO loadContentVO(String id) throws EntException; + ContentRecordVO loadAndCacheContentVO(String id) throws EntException; + + void evict(String key); + + void evict(List keys); + /** * Salva un contenuto sul DB. Il metodo viene utilizzato sia nel caso di * salvataggio di un nuovo contenuto (in tal caso l'id del contenuto nuovo diff --git a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCache.java b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCache.java new file mode 100644 index 00000000..b3b570f7 --- /dev/null +++ b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCache.java @@ -0,0 +1,192 @@ +package com.agiletec.plugins.jacms.aps.system.services.content; + +import com.agiletec.aps.system.ApsSystemUtils.ApsDeepDebug; +import com.agiletec.aps.system.common.entity.model.attribute.AbstractListAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ListAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; +import com.agiletec.plugins.jacms.aps.system.services.content.model.Content; +import com.agiletec.plugins.jacms.aps.system.services.content.model.ContentRecordVO; +import com.agiletec.plugins.jacms.aps.system.services.content.model.SymbolicLink; +import com.agiletec.plugins.jacms.aps.system.services.content.model.attribute.LinkAttribute; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import org.apache.commons.lang.StringUtils; +import org.entando.entando.aps.system.services.IFeatureFlag; + +public interface IFContentLocalCache extends IFeatureFlag { + boolean LOCAL_CMS_CACHE_ENABLED = checkEnabled(); // useful for test + + default boolean isEnabled() { + return LOCAL_CMS_CACHE_ENABLED; + } + + static boolean checkEnabled() { + return IFeatureFlag.readEnablementStatus("LOCAL_CMS_CACHE"); + } + + /** + * + * @param id the content id used as a key in the cache + * @param localCache the current Cache object + * @param action the action to perform in case of cache miss + * @return the contentRecordVO found in cache or loaded from the database + */ + static ContentRecordVO loadAndCacheContentVO(final String id, + final Cache localCache, + final Supplier action) { + + if (action == null) + return null; + if (checkEnabled() + && localCache != null) { + if (ApsDeepDebug.isTagEnabled("cms-local-cache")) { + if (localCache.asMap().containsKey(id)) { + ApsDeepDebug.print("cms-local-cache","cache HIT " + id); + } else { + ApsDeepDebug.print("cms-local-cache","cache miss " + id); + } + } + return (ContentRecordVO) localCache.get(id, key -> action.get()); + } else { + return action.get(); + } + } + + /** + * Evict a single key from the cache given the content object + * @param content the content object associated with the key to remove + * @param localCache the cache instance + */ + static void evict(final Content content, final Cache localCache) { + if (content != null) { + evict(content.getId(), localCache); + } + } + + /** + * Evict a single key from the cache + * @param key the key associated with the object to remove + * @param localCache the cache instance + */ + static void evict(final String key, final Cache localCache) { + if (checkEnabled() + && localCache != null + && StringUtils.isNotBlank(key)) { + ApsDeepDebug.print("cms-local-cache", "Evicting key from cache: " + key); + localCache.invalidate(key); + } + } + + /** + * Evict multiple keys from the cache + * @param keys the list of keys to remove + * @param localCache the cache instance + */ + static void evict(final List keys, final Cache localCache) { + if (checkEnabled() + && localCache != null + && keys != null) { + ApsDeepDebug.print("cms-local-cache", "Evicting keys from cache: " + keys); + localCache.invalidateAll(keys); + } + } + + /** + * Flush the cache references for a given content. This gets called typically from content actions + * @param content the content in session + * @param cm the content manager instance + */ + static void flushReferences(final Content content,IContentManager cm) { + try { + if (checkEnabled() + && cm != null) { + List refs = getContentReferences(content); + cm.evict(refs); + } + } catch (Exception e) { + ApsDeepDebug.print("cms-local-cache", "Error cleaning cache when flushing references from action"); + } + } + + /** + * Get the contents referenced by analyzing the attributes list + * @param content the content in session + * @return the list of the references + */ + static List getContentReferences(final Content content) { + final List references = new ArrayList<>(); + + if (content != null) { + content.getAttributeList() + .forEach(a -> { + if (a instanceof LinkAttribute) { + processLinkAttribute((LinkAttribute) a, references); + } + if (a instanceof CompositeAttribute) { + processCompositeAttribute((CompositeAttribute) a, references); + } + if (a instanceof ListAttribute) { + processListAttribute((ListAttribute) a, references); + } + if (a instanceof MonoListAttribute) { + processListAttribute((MonoListAttribute) a, references); + } + }); + } + return references; + } + + static void processListAttribute(final AbstractListAttribute attr, final List references) { + if (attr != null) { + attr.getAttributes().forEach(ca -> { + if (ca instanceof LinkAttribute) { + processLinkAttribute((LinkAttribute) ca, references); + } + if (ca instanceof CompositeAttribute) { + processCompositeAttribute((CompositeAttribute) ca, references); + } + }); + } + } + + static void processCompositeAttribute(final CompositeAttribute attr, final List references) { + if (attr != null) { + attr.getAttributes().forEach(ca -> { + if (ca instanceof LinkAttribute) { + processLinkAttribute((LinkAttribute) ca, references); + } + }); + } + } + + static void processLinkAttribute(final LinkAttribute attr, final List reference) { + if (attr != null && attr.getValue() instanceof SymbolicLink) { + final SymbolicLink l = (SymbolicLink) attr.getValue(); + + if (l.getDestType() == SymbolicLink.CONTENT_TYPE) { + reference.add(l.getContentDest()); + } + } + } + + static Cache instantiateLocalCache(long maxSize, long expireMinutes, boolean stats) { + if (checkEnabled()) { + Caffeine builder = + com.github.benmanes.caffeine.cache.Caffeine.newBuilder() + .maximumSize(maxSize) + .expireAfterAccess(expireMinutes, TimeUnit.MINUTES); + if (stats) { + builder.recordStats(); + } + return builder.build(); + } + // effectively a no-op! + return Caffeine.newBuilder() + .maximumSize(0).build(); + } +} diff --git a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java index 130577fd..c2f2c390 100644 --- a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java +++ b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java @@ -13,6 +13,7 @@ */ package com.agiletec.plugins.jacms.apsadmin.content; +import com.agiletec.aps.system.ApsSystemUtils.ApsDeepDebug; import org.entando.entando.ent.exception.EntException; import com.agiletec.aps.system.services.baseconfig.ConfigInterface; import com.agiletec.aps.system.services.group.Group; @@ -306,6 +307,11 @@ public String suspend() { return SUCCESS; } + public String leave() { + ApsDeepDebug.print("cms-local-cache", "leaving content edit"); + return SUCCESS; + } + public int[] getLinkDestinations() { return SymbolicLink.getDestinationTypes(); } diff --git a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/action/list/ListAttributeAction.java b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/action/list/ListAttributeAction.java index 79deb58a..46a8267c 100644 --- a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/action/list/ListAttributeAction.java +++ b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/attribute/action/list/ListAttributeAction.java @@ -29,7 +29,7 @@ /** * Classi action base delegata - * alla gestione delle operazione sugli attributi di contenuto tipo lista. + * alla gestione delle operazioni sugli attributi di contenuto tipo lista. * @author E.Santoboni */ public class ListAttributeAction extends com.agiletec.apsadmin.system.entity.attribute.action.list.ListAttributeAction { @@ -124,4 +124,4 @@ public void setContentActionHelper(IContentActionHelper contentActionHelper) { private IContentActionHelper _contentActionHelper; -} \ No newline at end of file +} diff --git a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/content.xml b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/content.xml index 81c11cbc..8f53eccc 100644 --- a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/content.xml +++ b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/content.xml @@ -286,6 +286,11 @@ validateContents + + results + editContents + + diff --git a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/helper/ContentActionHelper.java b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/helper/ContentActionHelper.java index 2710fc23..7c38adce 100644 --- a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/helper/ContentActionHelper.java +++ b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/helper/ContentActionHelper.java @@ -203,10 +203,10 @@ public Map getReferencingObjects(Content content, HttpServletRequest request) th * ripubblicazione di contenuti non del gruppo ad accesso libero. * L'operazione si rende necessaria per ovviare a casi nel cui il contenuto, * di un particolare gruppo, sia stato pubblicato precedentemente in una - * pagina o referenziato in un'altro contenuto grazie alla associazione di + * pagina o referenziato in un altro contenuto grazie alla associazione di * questo con altri gruppi abilitati alla visualizzazione. Il controllo * evidenzia quali devono essere i gruppi al quale il contenuto deve essere - * necessariamente associato (ed il perchè) per salvaguardare le precedenti + * necessariamente associato (e il perché) per salvaguardare le precedenti * relazioni. * * @param content Il contenuto da analizzare. diff --git a/src/main/java/org/entando/entando/plugins/jacms/aps/system/config/LocalCacheConfig.java b/src/main/java/org/entando/entando/plugins/jacms/aps/system/config/LocalCacheConfig.java new file mode 100644 index 00000000..e02b2067 --- /dev/null +++ b/src/main/java/org/entando/entando/plugins/jacms/aps/system/config/LocalCacheConfig.java @@ -0,0 +1,34 @@ +package org.entando.entando.plugins.jacms.aps.system.config; + +import com.agiletec.plugins.jacms.aps.system.services.content.IFContentLocalCache; +import com.github.benmanes.caffeine.cache.Cache; +import org.entando.entando.ent.util.EntLogging.EntLogFactory; +import org.entando.entando.ent.util.EntLogging.EntLogger; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; + +@Configuration +public class LocalCacheConfig implements IFContentLocalCache { + + private final Environment env; + private final EntLogger logger = EntLogFactory.getSanitizedLogger(getClass()); + + public LocalCacheConfig(Environment env) { + this.env = env; + } + + @Bean(name = "localEditContentCache") + public Cache localCache() { + + long maxSize = env.getProperty("CMS_LOCAL_CACHE_MAX_SIZE", Long.class, 50L); + long expireMinutes = env.getProperty("CMS_LOCAL_CACHE_EXPIRE_MINUTES", Long.class, 5L); + boolean stats = env.getProperty("CMS_LOCAL_CACHE_STATS", Boolean.class, false); + + logger.info("Configuring Local Cache: maxSize={}, expireMinutes={}, stats={}", + maxSize, expireMinutes, stats); + + return IFContentLocalCache.instantiateLocalCache(maxSize, expireMinutes, stats); + } + +} diff --git a/src/main/resources/spring/plugins/jacms/aps/managers/cmsManagersConfig.xml b/src/main/resources/spring/plugins/jacms/aps/managers/cmsManagersConfig.xml index e006987c..80fdf0ee 100644 --- a/src/main/resources/spring/plugins/jacms/aps/managers/cmsManagersConfig.xml +++ b/src/main/resources/spring/plugins/jacms/aps/managers/cmsManagersConfig.xml @@ -11,6 +11,8 @@ http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd" > + + @@ -70,6 +72,7 @@ + diff --git a/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java b/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java new file mode 100644 index 00000000..1734743f --- /dev/null +++ b/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java @@ -0,0 +1,394 @@ +package com.agiletec.plugins.jacms.aps.system.services.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.agiletec.aps.system.services.keygenerator.KeyGeneratorManager; +import com.agiletec.plugins.jacms.aps.system.services.content.model.Content; +import com.agiletec.plugins.jacms.aps.system.services.content.model.ContentRecordVO; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import org.entando.entando.ent.exception.EntException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.BeanFactory; + +@ExtendWith(MockitoExtension.class) +class IFContentLocalCacheTest { + + private Cache localCache; + + @Mock + private Supplier actionMock; + + @Mock + private ContentRecordVO contentVOMock; + + @BeforeEach + void setUp() { + localCache = Caffeine.newBuilder() + .maximumSize(2).build(); + } + + @Test + void allocateCache_enabled() { + String contentId = "2677"; + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(true); + mockedStatic.when(() -> IFContentLocalCache.instantiateLocalCache( + anyLong(), + anyLong(), + any(Boolean.class) + )).thenCallRealMethod(); + + Cache cache = IFContentLocalCache.instantiateLocalCache(100, 100, true); + assertNotNull(cache); + cache.put(contentId, contentVOMock); + assertTrue(cache.asMap().containsKey(contentId)); + } + } + + @Test + void allocateCache_disabled() { + String contentId = "2677"; + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(false); + mockedStatic.when(() -> IFContentLocalCache.instantiateLocalCache( + anyLong(), + anyLong(), + any(Boolean.class) + )).thenCallRealMethod(); + + Cache cache = IFContentLocalCache.instantiateLocalCache(100, 100, true); + assertNotNull(cache); + cache.put(contentId, contentVOMock); + // NOTE: we are not explicitly flushing the cache, we are only cleaning up expired entries + // since the cache size SHOULD be zero, the cache is emptied as a SIDE EFFECT!!! + cache.cleanUp(); + assertFalse(cache.asMap().containsKey(contentId)); + } + } + + @Test + void loadAndCacheContentVO_disabled() { + String contentId = "2677"; + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(false); + mockedStatic.when(() -> IFContentLocalCache.loadAndCacheContentVO( + anyString(), + any(Cache.class), + any(Supplier.class) + )).thenCallRealMethod(); + + when(actionMock.get()).thenReturn(contentVOMock); + // put the object in the cache; it won't be accessed though + localCache.put(contentId, contentVOMock); + ContentRecordVO result = IFContentLocalCache.loadAndCacheContentVO(contentId, localCache, actionMock); + + assertEquals(contentVOMock, result); + // verify that the supplier has been called to collect the data even if it's been cached + verify(actionMock, times(1)).get(); + } + } + + @Test + void loadAndCacheContentVO_enabled_cacheMiss() { + String contentId = "2677"; + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(true); + mockedStatic.when(() -> IFContentLocalCache.loadAndCacheContentVO( + anyString(), + any(Cache.class), + any(Supplier.class) + )).thenCallRealMethod(); + + when(actionMock.get()).thenReturn(contentVOMock); + ContentRecordVO result = IFContentLocalCache.loadAndCacheContentVO(contentId, localCache, actionMock); + + assertEquals(contentVOMock, result); + // verify that the supplier has been called to collect the data + verify(actionMock, times(1)).get(); + assertTrue(localCache.asMap().containsKey(contentId)); + } + } + + @Test + void loadAndCacheContentVO_enabled_cacheHit() { + String contentId = "2677"; + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(true); + mockedStatic.when(() -> IFContentLocalCache.loadAndCacheContentVO( + anyString(), + any(Cache.class), + any(Supplier.class) + )).thenCallRealMethod(); + // put in cache + localCache.put(contentId, contentVOMock); + + lenient().when(actionMock.get()).thenReturn(contentVOMock); + ContentRecordVO result = IFContentLocalCache.loadAndCacheContentVO(contentId, localCache, actionMock); + + assertEquals(contentVOMock, result); + // verify that the supplier was never called to collect the data + verify(actionMock, never()).get(); + } + } + + @Test + void evict_enabled() { + String contentIdA = "2677"; + String contentIdB = "2381"; + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(true); + mockedStatic.when(() -> IFContentLocalCache.evict( + anyString(), + any(Cache.class) + )).thenCallRealMethod(); + mockedStatic.when(() -> IFContentLocalCache.evict( + any(List.class), + any(Cache.class) + )).thenCallRealMethod(); + // put in cache + localCache.put(contentIdA, contentVOMock); + + IFContentLocalCache.evict(contentIdA, localCache); + // verify that the supplier was never called to collect the data + assertFalse(localCache.asMap().containsKey(contentIdA)); + + // group evict + localCache.put(contentIdA, contentVOMock); + localCache.put(contentIdB, contentVOMock); + + List candidates = new ArrayList<>(); + candidates.add(contentIdA); + candidates.add(contentIdB); + IFContentLocalCache.evict(candidates, localCache); + + assertFalse(localCache.asMap().containsKey(contentIdA)); + assertFalse(localCache.asMap().containsKey(contentIdB)); + } + } + + @Test + void evict_disabled() { + String contentIdA = "2677"; + String contentIdB = "2381"; + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(false); + mockedStatic.when(() -> IFContentLocalCache.evict( + anyString(), + any(Cache.class) + )).thenCallRealMethod(); + mockedStatic.when(() -> IFContentLocalCache.evict( + any(List.class), + any(Cache.class) + )).thenCallRealMethod(); + // put in cache + localCache.put(contentIdA, contentVOMock); + localCache.put(contentIdB, contentVOMock); + + // do nothing, the cache is left unchanged + IFContentLocalCache.evict(contentIdA, localCache); + + // verify that the supplier was never called to collect the data + // NOTE: the real cache would have size 0, therefore, it would be empty + assertTrue(localCache.asMap().containsKey(contentIdA)); + + List candidates = new ArrayList<>(); + candidates.add(contentIdA); + candidates.add(contentIdB); + IFContentLocalCache.evict(candidates, localCache); + + assertTrue(localCache.asMap().containsKey(contentIdA)); + assertTrue(localCache.asMap().containsKey(contentIdB)); + } + } + + @Mock + private KeyGeneratorManager keyGeneratorManagerMock; + + @Mock + private ContentDAO contentDAOMock; + + @Mock + private BeanFactory beanFactoryMock; + + @Mock + private Cache localCacheMock; + + @InjectMocks + private ContentManager contentManager; + + @Test + void content_evict_enabled_existing_content() { + Content cnt = new Content(); + + cnt.setId("CNT2381"); + contentManager.setLocalCache(localCacheMock); + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(true); + mockedStatic.when(() -> IFContentLocalCache.evict( + any(Content.class), + any(Cache.class) + )).thenCallRealMethod(); + mockedStatic.when(() -> IFContentLocalCache.evict( + any(String.class), + any(Cache.class) + )).thenCallRealMethod(); + + when(contentManager.loadContentVO(anyString())).thenReturn(contentVOMock); + + contentManager.addContent(cnt); + + // let's verify that the eviction has been called. it must ALWAYS be invoked even when + // the local cache is disabled by the feature flag + mockedStatic.verify( + () -> IFContentLocalCache.evict(any(Content.class), eq(localCacheMock)), + times(1) + ); + verify(localCacheMock).invalidate(anyString()); + + } catch (EntException e) { + throw new RuntimeException(e); + } + } + + @Test + void content_evict_enabled_new_content() { + Content cnt = new Content(); + + contentManager.setLocalCache(localCacheMock); + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(true); + mockedStatic.when(() -> IFContentLocalCache.evict( + any(Content.class), + any(Cache.class) + )).thenCallRealMethod(); + mockedStatic.when(() -> IFContentLocalCache.evict( + any(String.class), + any(Cache.class) + )).thenCallRealMethod(); + + when(keyGeneratorManagerMock.getUniqueKeyCurrentValue()).thenReturn(2677); + when(contentManager.loadContentVO(anyString())).thenReturn(contentVOMock); + when(beanFactoryMock.getBean(anyString())).thenReturn(keyGeneratorManagerMock); + + + contentManager.addContent(cnt); + + // let's verify that the eviction has been called. it must ALWAYS be invoked even when + // the local cache is disabled by the feature flag + mockedStatic.verify( + () -> IFContentLocalCache.evict(any(Content.class), eq(localCacheMock)), + times(1) + ); + // the content is new, nothing to evict + verify(localCacheMock, never()).invalidate(anyString()); + + } catch (EntException e) { + throw new RuntimeException(e); + } + } + + @Test + void content_evict_disabled_new_content() { + Content cnt = new Content(); + + contentManager.setLocalCache(localCacheMock); + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(false); + mockedStatic.when(() -> IFContentLocalCache.evict( + any(Content.class), + any(Cache.class) + )).thenCallRealMethod(); + mockedStatic.when(() -> IFContentLocalCache.evict( + any(String.class), + any(Cache.class) + )).thenCallRealMethod(); + + when(keyGeneratorManagerMock.getUniqueKeyCurrentValue()).thenReturn(2677); + when(contentManager.loadContentVO(anyString())).thenReturn(contentVOMock); + when(beanFactoryMock.getBean(anyString())).thenReturn(keyGeneratorManagerMock); + + contentManager.addContent(cnt); + + // let's verify that the eviction has been called. it must ALWAYS be invoked even when + // the local cache is disabled by the feature flag + mockedStatic.verify( + () -> IFContentLocalCache.evict(any(Content.class), eq(localCacheMock)), + times(1) + ); + // the content is new, and the cache disabled, the cache implementation does not get called + verify(localCacheMock, never()).invalidate(anyString()); + + } catch (EntException e) { + throw new RuntimeException(e); + } + } + + @Test + void content_evict_disabled_existing_content() { + Content cnt = new Content(); + + cnt.setId("CNT2381"); + contentManager.setLocalCache(localCacheMock); + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(false); + mockedStatic.when(() -> IFContentLocalCache.evict( + any(Content.class), + any(Cache.class) + )).thenCallRealMethod(); + mockedStatic.when(() -> IFContentLocalCache.evict( + any(String.class), + any(Cache.class) + )).thenCallRealMethod(); + + when(contentManager.loadContentVO(anyString())).thenReturn(contentVOMock); + + contentManager.addContent(cnt); + + // let's verify that the eviction has been called. it must ALWAYS be invoked even when + // the local cache is disabled by the feature flag + mockedStatic.verify( + () -> IFContentLocalCache.evict(any(Content.class), eq(localCacheMock)), + times(1) + ); + // the caching is disabled, no eviction in the cache implementation gets called + verify(localCacheMock, never()).invalidate(anyString()); + + } catch (EntException e) { + throw new RuntimeException(e); + } + } +} From 4def495ff22c00da2605244a50c5263a43ab516b Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 30 Jan 2026 11:50:07 +0100 Subject: [PATCH 2/6] ESB-827: Evict content references from cache when leaving content edit --- .../plugins/jacms/apsadmin/content/ContentAction.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java index c2f2c390..964a712c 100644 --- a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java +++ b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java @@ -14,6 +14,7 @@ package com.agiletec.plugins.jacms.apsadmin.content; import com.agiletec.aps.system.ApsSystemUtils.ApsDeepDebug; +import com.agiletec.plugins.jacms.aps.system.services.content.IFContentLocalCache; import org.entando.entando.ent.exception.EntException; import com.agiletec.aps.system.services.baseconfig.ConfigInterface; import com.agiletec.aps.system.services.group.Group; @@ -270,6 +271,8 @@ protected String saveContent(boolean approve) { } catch (Throwable t) { _logger.error("error in saveContent", t); return FAILURE; + } finally { + IFContentLocalCache.flushReferences(this.getContent(), this.getContentManager()); } return SUCCESS; } @@ -303,12 +306,14 @@ public String suspend() { } catch (Throwable t) { _logger.error("error in suspend", t); return FAILURE; + } finally { + IFContentLocalCache.flushReferences(this.getContent(), this.getContentManager()); } return SUCCESS; } public String leave() { - ApsDeepDebug.print("cms-local-cache", "leaving content edit"); + IFContentLocalCache.flushReferences(this.getContent(), this.getContentManager()); return SUCCESS; } From 34607ee13845473cbdfaf7cdcc9df1ff4ce1775f Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Fri, 30 Jan 2026 11:51:03 +0100 Subject: [PATCH 3/6] ESB-827: code cleaning --- .../jacms/apsadmin/content/ContentAction.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java index 964a712c..c2c38682 100644 --- a/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java +++ b/src/main/java/com/agiletec/plugins/jacms/apsadmin/content/ContentAction.java @@ -13,9 +13,6 @@ */ package com.agiletec.plugins.jacms.apsadmin.content; -import com.agiletec.aps.system.ApsSystemUtils.ApsDeepDebug; -import com.agiletec.plugins.jacms.aps.system.services.content.IFContentLocalCache; -import org.entando.entando.ent.exception.EntException; import com.agiletec.aps.system.services.baseconfig.ConfigInterface; import com.agiletec.aps.system.services.group.Group; import com.agiletec.aps.system.services.page.IPage; @@ -25,19 +22,20 @@ import com.agiletec.apsadmin.system.ApsAdminSystemConstants; import com.agiletec.plugins.jacms.aps.system.JacmsSystemConstants; import com.agiletec.plugins.jacms.aps.system.services.content.ContentUtilizer; +import com.agiletec.plugins.jacms.aps.system.services.content.IFContentLocalCache; import com.agiletec.plugins.jacms.aps.system.services.content.model.Content; import com.agiletec.plugins.jacms.aps.system.services.content.model.SymbolicLink; import com.agiletec.plugins.jacms.aps.system.services.resource.IResourceManager; import com.agiletec.plugins.jacms.apsadmin.util.ResourceIconUtil; -import org.apache.commons.lang.StringUtils; -import org.entando.entando.plugins.jacms.aps.util.CmsPageUtil; -import org.entando.entando.ent.util.EntLogging.EntLogger; -import org.entando.entando.ent.util.EntLogging.EntLogFactory; - import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import org.apache.commons.lang.StringUtils; +import org.entando.entando.ent.exception.EntException; +import org.entando.entando.ent.util.EntLogging.EntLogFactory; +import org.entando.entando.ent.util.EntLogging.EntLogger; +import org.entando.entando.plugins.jacms.aps.util.CmsPageUtil; /** * Action principale per la redazione contenuti. From 7b9a8e1199aedca475dd4b0acfa0a7f9dce6ea9f Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Wed, 4 Feb 2026 13:20:11 +0100 Subject: [PATCH 4/6] ESB-827: quality gate --- .../services/content/ContentManager.java | 2 - .../services/content/ContentManagerTest.java | 190 ++++++++++++++++-- 2 files changed, 175 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java index e41382f2..06f113a2 100644 --- a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java +++ b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java @@ -43,7 +43,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import org.apache.commons.lang3.StringUtils; import org.entando.entando.aps.system.services.cache.ICacheInfoManager; import org.entando.entando.ent.exception.EntException; import org.entando.entando.ent.exception.EntRuntimeException; @@ -273,7 +272,6 @@ public ContentRecordVO loadContentVO(String id) throws EntException { @Override public ContentRecordVO loadAndCacheContentVO(String id) throws EntException { - try { return IFContentLocalCache.loadAndCacheContentVO(id, localCache, () -> (ContentRecordVO) this.getContentDAO().loadEntityRecord(id)); diff --git a/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java b/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java index 530ac1e6..9787609c 100644 --- a/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java +++ b/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManagerTest.java @@ -13,32 +13,46 @@ */ package com.agiletec.plugins.jacms.aps.system.services.content; -import com.agiletec.aps.system.common.entity.model.IApsEntity; -import com.agiletec.aps.system.common.entity.parse.IEntityTypeFactory; -import com.agiletec.aps.system.common.notify.INotifyManager; -import org.entando.entando.ent.exception.EntException; -import com.agiletec.aps.system.services.category.Category; -import com.agiletec.plugins.jacms.aps.system.JacmsSystemConstants; -import com.agiletec.plugins.jacms.aps.system.services.content.model.Content; -import com.agiletec.plugins.jacms.aps.system.services.content.parse.ContentDOM; -import com.agiletec.plugins.jacms.aps.system.services.content.parse.ContentTypeDOM; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.beans.factory.BeanFactory; - import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.agiletec.aps.system.common.entity.model.IApsEntity; +import com.agiletec.aps.system.common.entity.parse.IEntityTypeFactory; +import com.agiletec.aps.system.common.notify.INotifyManager; +import com.agiletec.aps.system.services.category.Category; +import com.agiletec.plugins.jacms.aps.system.JacmsSystemConstants; +import com.agiletec.plugins.jacms.aps.system.services.content.model.Content; +import com.agiletec.plugins.jacms.aps.system.services.content.model.ContentRecordVO; +import com.agiletec.plugins.jacms.aps.system.services.content.parse.ContentDOM; +import com.agiletec.plugins.jacms.aps.system.services.content.parse.ContentTypeDOM; +import com.github.benmanes.caffeine.cache.Cache; +import java.util.function.Supplier; +import org.entando.entando.ent.exception.EntException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.BeanFactory; @ExtendWith(MockitoExtension.class) class ContentManagerTest { @@ -61,6 +75,9 @@ class ContentManagerTest { @Mock private INotifyManager notifyManager; + @Mock + private Cache localCache; + private String beanName = "jacmsContentManager"; private String className = "com.agiletec.plugins.jacms.aps.system.services.content.model.Content"; @@ -136,6 +153,150 @@ void testGetXML() throws Throwable { assertTrue(xml.indexOf("") != -1); } + @Test + void testLoadAndCacheContentVO() throws EntException { + String contentId = "ART1"; + ContentRecordVO vo = new ContentRecordVO(); + vo.setId(contentId); + when(contentDAO.loadEntityRecord(contentId)).thenReturn(vo); + + ContentRecordVO result = contentManager.loadAndCacheContentVO(contentId); + + assertNotNull(result); + assertThat(result.getId(), is(contentId)); + } + + @Test + void testLoadAndCacheContentVO_Error() { + String contentId = "ART1"; + when(contentDAO.loadEntityRecord(contentId)).thenThrow(new RuntimeException("DB Error")); + + assertThrows(EntException.class, () -> contentManager.loadAndCacheContentVO(contentId)); + } + + @Test + void testLoadAndCacheContent_OnLine() throws Exception { + String contentId = "ART1"; + String typeCode = "ART"; + String xmlOnLine = "online"; + + ContentRecordVO vo = new ContentRecordVO(); + vo.setId(contentId); + vo.setTypeCode(typeCode); + vo.setXmlOnLine(xmlOnLine); + vo.setOnLine(true); + + // Mocking createEntityFromXml indirectly by mocking its behavior in createContent + ContentManager spyContentManager = spy(contentManager); + Content content = new Content(); + content.setId(contentId); + content.setTypeCode(typeCode); + + when(contentDAO.loadEntityRecord(contentId)).thenReturn(vo); + org.mockito.Mockito.doReturn(content).when(spyContentManager).createContent(eq(vo), eq(true)); + + Content result = spyContentManager.loadAndCacheContent(contentId, true); + + assertNotNull(result); + assertThat(result.getId(), is(contentId)); + } + + @Test + void testLoadAndCacheContent_Work() throws Exception { + String contentId = "ART1"; + String typeCode = "ART"; + String xmlWork = "work"; + + ContentRecordVO vo = new ContentRecordVO(); + vo.setId(contentId); + vo.setTypeCode(typeCode); + vo.setXmlWork(xmlWork); + vo.setOnLine(false); + + ContentManager spyContentManager = spy(contentManager); + Content content = new Content(); + content.setId(contentId); + content.setTypeCode(typeCode); + + when(contentDAO.loadEntityRecord(contentId)).thenReturn(vo); + org.mockito.Mockito.doReturn(content).when(spyContentManager).createContent(eq(vo), eq(false)); + + Content result = spyContentManager.loadAndCacheContent(contentId, false); + + assertNotNull(result); + assertThat(result.getId(), is(contentId)); + + // again! + result = spyContentManager.loadAndCacheContent(contentId, false); + assertNotNull(result); + assertThat(result.getId(), is(contentId)); + } + + @Test + void testLoadAndCacheContent_NullVO() throws EntException { + String contentId = "ART1"; + when(contentDAO.loadEntityRecord(contentId)).thenReturn(null); + + Content result = contentManager.loadAndCacheContent(contentId, true); + lenient().when(localCache.get(eq(contentId), any())).thenReturn(null); + + assertNull(result); + } + + @Test + void testLoadAndCacheContentVO_CacheInteraction() throws EntException { + String contentId = "ART1"; + ContentRecordVO vo = new ContentRecordVO(); + vo.setId(contentId); + + try (MockedStatic mockedCache = mockStatic(IFContentLocalCache.class)) { + mockedCache.when(IFContentLocalCache::checkEnabled).thenReturn(true); + mockedCache.when(() -> IFContentLocalCache.instantiateLocalCache( + anyLong(), + anyLong(), + any(Boolean.class) + )).thenCallRealMethod(); + mockedCache.when(() -> IFContentLocalCache.loadAndCacheContentVO( + anyString(), + any(Cache.class), + any(Supplier.class) + )).thenCallRealMethod(); + + // Mock the DAO to return the VO when loadEntityRecord is called + when(contentDAO.loadEntityRecord(contentId)).thenReturn(vo); + + // Mock localCache.get to execute the mapping function (which calls the supplier/DAO) + when(localCache.get(eq(contentId), any())).thenAnswer(invocation -> { + java.util.function.Function mappingFunction = invocation.getArgument(1); + return mappingFunction.apply(invocation.getArgument(0)); + }); + + ContentRecordVO result = contentManager.loadAndCacheContentVO(contentId); + + assertNotNull(result); + assertThat(result.getId(), is(contentId)); + // Verify that the cache was used + verify(localCache, atLeastOnce()).get(eq(contentId), any()); + } + } + + @Test + void testEvict_CacheInteraction() { + String contentId = "ART1"; + + try (MockedStatic mockedCache = mockStatic(IFContentLocalCache.class)) { + mockedCache.when(IFContentLocalCache::checkEnabled).thenReturn(true); + mockedCache.when(() -> IFContentLocalCache.evict(eq(contentId), any(Cache.class))) + .thenCallRealMethod(); + + contentManager.evict(contentId); + + // Verifichiamo che localCache.invalidate sia stato chiamato + verify(localCache).invalidate(contentId); + } + } + + private IApsEntity createFakeEntity(String typeCode, String viewPage, String defaultModel) { Content content = new Content(); content.setTypeCode(typeCode); @@ -144,4 +305,3 @@ private IApsEntity createFakeEntity(String typeCode, String viewPage, String def return content; } } - From 6c57e296e98713e8bc548bc39e65152270b565c8 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Wed, 4 Feb 2026 14:15:56 +0100 Subject: [PATCH 5/6] ESB-827: quality gate --- .../content/IFContentLocalCacheTest.java | 100 ++++++++++++++++++ .../apsadmin/content/TestContentAction.java | 74 ++++++++++++- 2 files changed, 171 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java b/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java index 1734743f..2d87ff98 100644 --- a/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java +++ b/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java @@ -9,18 +9,26 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; 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.attribute.AttributeInterface; +import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.ListAttribute; +import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; import com.agiletec.aps.system.services.keygenerator.KeyGeneratorManager; import com.agiletec.plugins.jacms.aps.system.services.content.model.Content; import com.agiletec.plugins.jacms.aps.system.services.content.model.ContentRecordVO; +import com.agiletec.plugins.jacms.aps.system.services.content.model.SymbolicLink; +import com.agiletec.plugins.jacms.aps.system.services.content.model.attribute.LinkAttribute; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.function.Supplier; import org.entando.entando.ent.exception.EntException; @@ -111,6 +119,31 @@ void loadAndCacheContentVO_disabled() { assertEquals(contentVOMock, result); // verify that the supplier has been called to collect the data even if it's been cached verify(actionMock, times(1)).get(); + // verify that the cache was NOT used to get the value + // (we can check if the internal logic was bypassed by ensuring cache hit logic didn't trigger, + // but the fact that supplier was called is already a good indicator) + } + } + + @Test + void loadAndCacheContentVO_featureFlagDisabled() { + String contentId = "ART2381"; + + try (MockedStatic mockedStatic = mockStatic(IFContentLocalCache.class)) { + // Specifically testing when checkEnabled() returns false + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(false); + mockedStatic.when(() -> IFContentLocalCache.loadAndCacheContentVO(anyString(), any(Cache.class), any(Supplier.class))) + .thenCallRealMethod(); + + when(actionMock.get()).thenReturn(contentVOMock); + + ContentRecordVO result = IFContentLocalCache.loadAndCacheContentVO(contentId, localCache, actionMock); + + assertEquals(contentVOMock, result); + verify(actionMock, times(1)).get(); + // Verify that the cache was not even checked (if we were using a mock cache) + // Since we use a real cache in setUp, we rely on the fact that actionMock.get() is called + // even if we were to put something in localCache. } } @@ -391,4 +424,71 @@ void content_evict_disabled_existing_content() { throw new RuntimeException(e); } } + @Test + void testGetContentReferences() { + // Scenario 1: content null + List refs = IFContentLocalCache.getContentReferences(null); + assertNotNull(refs); + assertTrue(refs.isEmpty()); + + // Scenario 2: content without attributes + Content content = new Content(); + refs = IFContentLocalCache.getContentReferences(content); + assertNotNull(refs); + assertTrue(refs.isEmpty()); + + // Scenario 3: content with LinkAttribute + String refId1 = "REF1"; + LinkAttribute linkAttr = createLinkAttribute("link1", refId1); + content.addAttribute(linkAttr); + + refs = IFContentLocalCache.getContentReferences(content); + assertEquals(1, refs.size()); + assertTrue(refs.contains(refId1)); + + // Scenario 4: content with CompositeAttribute containing LinkAttribute + String refId2 = "REF2"; + CompositeAttribute compositeAttr = mock(CompositeAttribute.class); + LinkAttribute subLink1 = createLinkAttribute("subLink1", refId2); + when(compositeAttr.getAttributes()).thenReturn(Arrays.asList(subLink1)); + content.addAttribute(compositeAttr); + + refs = IFContentLocalCache.getContentReferences(content); + assertEquals(2, refs.size()); + assertTrue(refs.contains(refId1)); + assertTrue(refs.contains(refId2)); + + // Scenario 5: content with ListAttribute containing LinkAttribute + String refId3 = "REF3"; + ListAttribute listAttr = mock(ListAttribute.class); + LinkAttribute listLink1 = createLinkAttribute("listLink1", refId3); + when(listAttr.getAttributes()).thenReturn(Arrays.asList(listLink1)); + content.addAttribute(listAttr); + + refs = IFContentLocalCache.getContentReferences(content); + assertEquals(3, refs.size()); + assertTrue(refs.contains(refId3)); + + // Scenario 6: content with MonoListAttribute containing CompositeAttribute + String refId4 = "REF4"; + MonoListAttribute monoListAttr = mock(MonoListAttribute.class); + CompositeAttribute subComposite = mock(CompositeAttribute.class); + LinkAttribute subLink2 = createLinkAttribute("subLink2", refId4); + when(subComposite.getAttributes()).thenReturn(Arrays.asList(subLink2)); + when(monoListAttr.getAttributes()).thenReturn(Arrays.asList(subComposite)); + content.addAttribute(monoListAttr); + + refs = IFContentLocalCache.getContentReferences(content); + assertEquals(4, refs.size()); + assertTrue(refs.contains(refId4)); + } + + private LinkAttribute createLinkAttribute(String name, String contentId) { + LinkAttribute linkAttr = new LinkAttribute(); + linkAttr.setName(name); + SymbolicLink symbolicLink = new SymbolicLink(); + symbolicLink.setDestinationToContent(contentId); + linkAttr.setSymbolicLink(symbolicLink); + return linkAttr; + } } diff --git a/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentAction.java b/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentAction.java index 6a07699d..91000cb4 100644 --- a/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentAction.java +++ b/src/test/java/com/agiletec/plugins/jacms/apsadmin/content/TestContentAction.java @@ -19,9 +19,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.List; -import java.util.Map; - import com.agiletec.aps.system.common.entity.model.EntitySearchFilter; import com.agiletec.aps.system.common.entity.model.attribute.BooleanAttribute; import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute; @@ -30,15 +27,20 @@ import com.agiletec.apsadmin.system.ApsAdminSystemConstants; import com.agiletec.apsadmin.system.BaseAction; import com.agiletec.plugins.jacms.aps.system.services.content.IContentManager; +import com.agiletec.plugins.jacms.aps.system.services.content.IFContentLocalCache; import com.agiletec.plugins.jacms.aps.system.services.content.model.Content; import com.agiletec.plugins.jacms.aps.system.services.content.model.SymbolicLink; import com.agiletec.plugins.jacms.aps.system.services.content.model.attribute.LinkAttribute; import com.agiletec.plugins.jacms.apsadmin.content.util.AbstractBaseTestContentAction; import com.opensymphony.xwork2.Action; import com.opensymphony.xwork2.ActionSupport; +import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import java.util.stream.IntStream; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; /** * @author E.Santoboni @@ -605,6 +607,72 @@ void testSaveContentWithPageReference() throws Throwable { throw t; } } + + @Test + void testConfigureMainGroup() throws Throwable { + String contentId = "ART1"; + String contentOnSessionMarker = this.extractSessionMarker(contentId, ApsAdminSystemConstants.EDIT); + + // mainGroup is correctly assigned when the content does not have one + this.executeEdit(contentId, "admin"); + Content contentOnSession = this.getContentOnEdit(contentOnSessionMarker); + assertNotNull(contentOnSession); + String originalMainGroup = contentOnSession.getMainGroup(); + assertNotNull(originalMainGroup); + + // Force mainGroup to null to test loading from the parameter + contentOnSession.setMainGroup(null); + + String newMainGroup = "customers"; + this.initContentAction("/do/jacms/Content", "configureMainGroup", contentOnSessionMarker); + this.addParameter("mainGroup", newMainGroup); + String result = this.executeAction(); + assertEquals(Action.SUCCESS, result); + contentOnSession = this.getContentOnEdit(contentOnSessionMarker); + assertEquals(newMainGroup, contentOnSession.getMainGroup()); + + // the mainGroup is not overwritten if it is already present in the content + this.initContentAction("/do/jacms/Content", "configureMainGroup", contentOnSessionMarker); + this.addParameter("mainGroup", "coach"); + result = this.executeAction(); + assertEquals(Action.SUCCESS, result); + contentOnSession = this.getContentOnEdit(contentOnSessionMarker); + assertEquals(newMainGroup, contentOnSession.getMainGroup()); // resta customers + + // mainGroup is not set if the group does not exist + contentOnSession.setMainGroup(null); + this.initContentAction("/do/jacms/Content", "configureMainGroup", contentOnSessionMarker); + this.addParameter("mainGroup", "nonExistentGroup"); + result = this.executeAction(); + assertEquals(Action.SUCCESS, result); + contentOnSession = this.getContentOnEdit(contentOnSessionMarker); + assertNull(contentOnSession.getMainGroup()); + } + + @Test + void testLeave() throws Throwable { + String contentId = "ART1"; + String contentOnSessionMarker = this.extractSessionMarker(contentId, ApsAdminSystemConstants.EDIT); + + this.executeEdit(contentId, "admin"); + + try (MockedStatic mockedStatic = Mockito.mockStatic(IFContentLocalCache.class)) { + mockedStatic.when(IFContentLocalCache::checkEnabled).thenReturn(true); + mockedStatic.when(() -> IFContentLocalCache.flushReferences(Mockito.any(), Mockito.any())).thenCallRealMethod(); + mockedStatic.when(() -> IFContentLocalCache.getContentReferences(Mockito.any())).thenCallRealMethod(); + + this.initContentAction("/do/jacms/Content", "leave", contentOnSessionMarker); + + ContentAction action = (ContentAction) this.getAction(); + IContentManager contentManagerMock = Mockito.mock(IContentManager.class); + action.setContentManager(contentManagerMock); + + String result = this.executeAction(); + assertEquals(Action.SUCCESS, result); + + Mockito.verify(contentManagerMock, Mockito.atLeastOnce()).evict(Mockito.anyList()); + } + } private void removeTestContent(String descr) throws Throwable { EntitySearchFilter filter1 = new EntitySearchFilter(IContentManager.CONTENT_MODIFY_DATE_FILTER_KEY, false); From 56cd1dfab30687e85ae9d528af3ce1ed8d45a573 Mon Sep 17 00:00:00 2001 From: "Matteo E. Minnai" Date: Wed, 4 Feb 2026 16:13:00 +0100 Subject: [PATCH 6/6] ESB-827: quality gate --- .../system/services/content/ContentManager.java | 4 ++-- .../services/content/IFContentLocalCache.java | 14 ++++++++------ .../services/content/IFContentLocalCacheTest.java | 1 - 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java index 06f113a2..8bd155d2 100644 --- a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java +++ b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/ContentManager.java @@ -74,7 +74,7 @@ public class ContentManager extends ApsEntityManager private ICacheInfoManager cacheInfoManager; - private com.github.benmanes.caffeine.cache.Cache localCache; + private transient com.github.benmanes.caffeine.cache.Cache localCache; @Override protected String getConfigItemName() { @@ -275,7 +275,7 @@ public ContentRecordVO loadAndCacheContentVO(String id) throws EntException { try { return IFContentLocalCache.loadAndCacheContentVO(id, localCache, () -> (ContentRecordVO) this.getContentDAO().loadEntityRecord(id)); - } catch (Throwable t) { + } catch (Exception t) { logger.error("Error while loading content vo : id {}", id, t); throw new EntException("Error while loading content vo : id " + id, t); } diff --git a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCache.java b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCache.java index b3b570f7..b4bc7ff9 100644 --- a/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCache.java +++ b/src/main/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCache.java @@ -21,6 +21,8 @@ public interface IFContentLocalCache extends IFeatureFlag { boolean LOCAL_CMS_CACHE_ENABLED = checkEnabled(); // useful for test + String CMS_LOCAL_CACHE = "cms-local-cache"; + default boolean isEnabled() { return LOCAL_CMS_CACHE_ENABLED; } @@ -44,11 +46,11 @@ static ContentRecordVO loadAndCacheContentVO(final String id, return null; if (checkEnabled() && localCache != null) { - if (ApsDeepDebug.isTagEnabled("cms-local-cache")) { + if (ApsDeepDebug.isTagEnabled(CMS_LOCAL_CACHE)) { if (localCache.asMap().containsKey(id)) { - ApsDeepDebug.print("cms-local-cache","cache HIT " + id); + ApsDeepDebug.print(CMS_LOCAL_CACHE,"cache HIT " + id); } else { - ApsDeepDebug.print("cms-local-cache","cache miss " + id); + ApsDeepDebug.print(CMS_LOCAL_CACHE,"cache miss " + id); } } return (ContentRecordVO) localCache.get(id, key -> action.get()); @@ -77,7 +79,7 @@ static void evict(final String key, final Cache localCache) { if (checkEnabled() && localCache != null && StringUtils.isNotBlank(key)) { - ApsDeepDebug.print("cms-local-cache", "Evicting key from cache: " + key); + ApsDeepDebug.print(CMS_LOCAL_CACHE, "Evicting key from cache: " + key); localCache.invalidate(key); } } @@ -91,7 +93,7 @@ static void evict(final List keys, final Cache localCach if (checkEnabled() && localCache != null && keys != null) { - ApsDeepDebug.print("cms-local-cache", "Evicting keys from cache: " + keys); + ApsDeepDebug.print(CMS_LOCAL_CACHE, "Evicting keys from cache: " + keys); localCache.invalidateAll(keys); } } @@ -109,7 +111,7 @@ static void flushReferences(final Content content,IContentManager cm) { cm.evict(refs); } } catch (Exception e) { - ApsDeepDebug.print("cms-local-cache", "Error cleaning cache when flushing references from action"); + ApsDeepDebug.print(CMS_LOCAL_CACHE, "Error cleaning cache when flushing references from action"); } } diff --git a/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java b/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java index 2d87ff98..421e398e 100644 --- a/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java +++ b/src/test/java/com/agiletec/plugins/jacms/aps/system/services/content/IFContentLocalCacheTest.java @@ -16,7 +16,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.agiletec.aps.system.common.entity.model.attribute.AttributeInterface; import com.agiletec.aps.system.common.entity.model.attribute.CompositeAttribute; import com.agiletec.aps.system.common.entity.model.attribute.ListAttribute; import com.agiletec.aps.system.common.entity.model.attribute.MonoListAttribute;