From 46fa776ccb05e861279e1d99b1f08eafdb9ebd8a Mon Sep 17 00:00:00 2001 From: Fabian Pichler Date: Sun, 2 Aug 2026 06:16:31 +0200 Subject: [PATCH 1/5] CELDEV-1332 add messages component and web overlay --- celements-messages/component/pom.xml | 64 +++++ .../messages/api/MessagesController.java | 135 ++++++++++ .../messages/api/MessagesControllerTest.java | 235 ++++++++++++++++++ celements-messages/web-module/pom.xml | 28 +++ .../src/main/webapp/WEB-INF/web.xml | 3 + .../main/webapp/templates/celAjax/Messages.vm | 5 + .../templates/celAjax/ValidationMessages.vm | 5 + .../celMessages/general/celements.vm | 215 ++++++++++++++++ .../celMessages/validation/celements.vm | 19 ++ 9 files changed, 709 insertions(+) create mode 100644 celements-messages/component/pom.xml create mode 100644 celements-messages/component/src/main/java/com/celements/messages/api/MessagesController.java create mode 100644 celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java create mode 100644 celements-messages/web-module/pom.xml create mode 100644 celements-messages/web-module/src/main/webapp/WEB-INF/web.xml create mode 100644 celements-messages/web-module/src/main/webapp/templates/celAjax/Messages.vm create mode 100644 celements-messages/web-module/src/main/webapp/templates/celAjax/ValidationMessages.vm create mode 100644 celements-messages/web-module/src/main/webapp/templates/celMessages/general/celements.vm create mode 100644 celements-messages/web-module/src/main/webapp/templates/celMessages/validation/celements.vm diff --git a/celements-messages/component/pom.xml b/celements-messages/component/pom.xml new file mode 100644 index 00000000..12ad5582 --- /dev/null +++ b/celements-messages/component/pom.xml @@ -0,0 +1,64 @@ + + + + com.celements + celements-parent + 7.1-SNAPSHOT + + 4.0.0 + celements-messages + 7.1-SNAPSHOT + Celements Messages + + + com.celements + celements-core + provided + + + com.celements + celements-model + provided + + + com.celements + celements-spring-security + provided + + + com.celements + celements-xwiki-velocity + provided + + + com.celements + celements-xwiki-core + provided + + + org.springframework + spring-web + provided + + + javax.inject + javax.inject + + + javax.servlet + javax.servlet-api + provided + + + com.celements + celements-shared-tests + test + + + + scm:git:git@github.com:celements/celements-features.git + scm:git:git@github.com:celements/celements-features.git + https://github.com/celements/celements-features + HEAD + + diff --git a/celements-messages/component/src/main/java/com/celements/messages/api/MessagesController.java b/celements-messages/component/src/main/java/com/celements/messages/api/MessagesController.java new file mode 100644 index 00000000..be8ebc0c --- /dev/null +++ b/celements-messages/component/src/main/java/com/celements/messages/api/MessagesController.java @@ -0,0 +1,135 @@ +package com.celements.messages.api; + +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; +import javax.servlet.ServletContext; + +import org.apache.velocity.VelocityContext; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.xwiki.velocity.VelocityEngine; +import org.xwiki.velocity.VelocityManager; +import org.xwiki.velocity.XWikiVelocityException; + +import com.celements.sajson.JsonBuilder; +import com.celements.spring.security.AuthenticatedBaseController; +import com.celements.web.service.IPrepareVelocityContext; + +@RestController +@RequestMapping("/v1/messages") +@PreAuthorize("permitAll()") +public class MessagesController extends AuthenticatedBaseController { + + static final String GENERAL_FRAGMENT_DIRECTORY = "/templates/celMessages/general/"; + static final String VALIDATION_FRAGMENT_DIRECTORY = "/templates/celMessages/validation/"; + private static final String REQUIRED_FRAGMENT_NAME = "celements.vm"; + private static final String JSON_BUILDER_CONTEXT_KEY = "jsonBuilder"; + private static final String JSON_MEDIA_TYPE = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8"; + + private final ServletContext servletContext; + private final VelocityManager velocityManager; + private final IPrepareVelocityContext prepareVelocityContext; + + @Inject + public MessagesController(ServletContext servletContext, VelocityManager velocityManager, + IPrepareVelocityContext prepareVelocityContext) { + this.servletContext = servletContext; + this.velocityManager = velocityManager; + this.prepareVelocityContext = prepareVelocityContext; + } + + @GetMapping(produces = JSON_MEDIA_TYPE) + public String getMessages() throws IOException, XWikiVelocityException { + return renderFragments(GENERAL_FRAGMENT_DIRECTORY); + } + + @GetMapping(value = "/validation", produces = JSON_MEDIA_TYPE) + public String getValidationMessages() throws IOException, XWikiVelocityException { + return renderFragments(VALIDATION_FRAGMENT_DIRECTORY); + } + + private String renderFragments(String directory) throws IOException, XWikiVelocityException { + checkAuth(); + VelocityContext velocityContext = velocityManager.getVelocityContext(); + prepareVelocityContext.prepareVelocityContext(velocityContext); + var jsonBuilder = new JsonBuilder(); + jsonBuilder.openDictionary(); + List fragments = discoverFragments(directory); + requireBaseFragment(directory, fragments); + boolean hadJsonBuilder = velocityContext.containsKey(JSON_BUILDER_CONTEXT_KEY); + Object previousJsonBuilder = velocityContext.get(JSON_BUILDER_CONTEXT_KEY); + velocityContext.put(JSON_BUILDER_CONTEXT_KEY, jsonBuilder); + try { + VelocityEngine velocityEngine = velocityManager.getVelocityEngine(); + for (String fragment : fragments) { + evaluateFragment(velocityEngine, velocityContext, jsonBuilder, fragment); + } + jsonBuilder.closeDictionary(); + if (!jsonBuilder.isComplete()) { + throw new IllegalStateException("Messages JSON builder is incomplete"); + } + return jsonBuilder.getJSON(); + } finally { + if (hadJsonBuilder) { + velocityContext.put(JSON_BUILDER_CONTEXT_KEY, previousJsonBuilder); + } else { + velocityContext.remove(JSON_BUILDER_CONTEXT_KEY); + } + } + } + + private List discoverFragments(String directory) { + return Optional.ofNullable(servletContext.getResourcePaths(directory)) + .orElseGet(Collections::emptySet).stream().filter(path -> isDirectFragment(directory, path)) + .sorted().toList(); + } + + private boolean isDirectFragment(String directory, String path) { + return path.startsWith(directory) && path.endsWith(".vm") + && !path.substring(directory.length()).contains("/"); + } + + private void requireBaseFragment(String directory, List fragments) throws IOException { + String requiredFragment = directory + REQUIRED_FRAGMENT_NAME; + if (!fragments.contains(requiredFragment)) { + throw new IOException("Required message fragment not found: " + requiredFragment); + } + } + + private void evaluateFragment(VelocityEngine velocityEngine, VelocityContext velocityContext, + JsonBuilder jsonBuilder, String fragment) throws IOException, XWikiVelocityException { + int initialDepth = jsonBuilder.getOpenCommandCount(); + var writer = new StringWriter(); + boolean evaluated = velocityEngine.evaluate(velocityContext, writer, fragment, + getFragmentContent(fragment)); + if (!evaluated) { + throw new XWikiVelocityException("Failed to evaluate message fragment: " + fragment); + } + if (!writer.toString().trim().isEmpty()) { + throw new IOException("Message fragment emitted response content: " + fragment); + } + if (jsonBuilder.getOpenCommandCount() != initialDepth) { + throw new IllegalStateException( + "Message fragment left an unbalanced JSON builder: " + fragment); + } + } + + private String getFragmentContent(String fragment) throws IOException { + try (InputStream stream = servletContext.getResourceAsStream(fragment)) { + if (stream == null) { + throw new IOException("Message fragment not found: " + fragment); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java b/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java new file mode 100644 index 00000000..ba854eec --- /dev/null +++ b/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java @@ -0,0 +1,235 @@ +package com.celements.messages.api; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import javax.servlet.ServletContext; + +import org.apache.velocity.VelocityContext; +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.xwiki.velocity.VelocityEngine; +import org.xwiki.velocity.VelocityManager; +import org.xwiki.velocity.XWikiVelocityException; + +import com.celements.auth.user.User; +import com.celements.sajson.JsonBuilder; +import com.celements.web.service.IPrepareVelocityContext; + +public class MessagesControllerTest { + + private ServletContext servletContext; + private VelocityManager velocityManager; + private VelocityEngine velocityEngine; + private IPrepareVelocityContext prepareVelocityContext; + private VelocityContext velocityContext; + private TestMessagesController controller; + private List evaluatedFragments; + + @Before + public void prepareTest() { + servletContext = createMock(ServletContext.class); + velocityManager = createMock(VelocityManager.class); + velocityEngine = createMock(VelocityEngine.class); + prepareVelocityContext = createMock(IPrepareVelocityContext.class); + velocityContext = new VelocityContext(); + controller = new TestMessagesController(servletContext, velocityManager, + prepareVelocityContext); + evaluatedFragments = new ArrayList<>(); + } + + @Test + public void testEndpointMappings() throws Exception { + assertNotNull(MessagesController.class.getAnnotation(RestController.class)); + assertArrayEquals(new String[] { "/v1/messages" }, + MessagesController.class.getAnnotation(RequestMapping.class).value()); + assertEquals("permitAll()", MessagesController.class.getAnnotation(PreAuthorize.class).value()); + GetMapping general = MessagesController.class.getMethod("getMessages") + .getAnnotation(GetMapping.class); + assertArrayEquals(new String[0], general.value()); + assertArrayEquals(new String[] { "application/json;charset=UTF-8" }, general.produces()); + GetMapping validation = MessagesController.class.getMethod("getValidationMessages") + .getAnnotation(GetMapping.class); + assertArrayEquals(new String[] { "/validation" }, validation.value()); + assertArrayEquals(new String[] { "application/json;charset=UTF-8" }, validation.produces()); + } + + @Test + public void testGetMessagesUsesPreparedExistingContextAndLexicalDirectFragments() + throws Exception { + String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + String zeta = directory + "zeta.vm"; + expectContextAndDiscovery(directory, Set.of(zeta, directory + "nested/ignored.vm", celements)); + expectFragment(celements, " \n", true, builder -> { + builder.addProperty("string", "value"); + builder.addProperty("boolean", true); + builder.addProperty("number", 7); + }); + expectFragment(zeta, "\n", true, builder -> builder.addProperty("zeta", "last")); + replayAll(); + + String json = controller.getMessages(); + + assertEquals( + "{\"string\" : \"value\", \"boolean\" : true, \"number\" : 7, " + "\"zeta\" : \"last\"}", + json); + assertTrue(controller.isCheckAuthCalled()); + assertEquals(List.of(celements, zeta), evaluatedFragments); + assertFalse(velocityContext.containsKey("jsonBuilder")); + verifyAll(); + } + + @Test + public void testGetValidationMessagesUsesOnlyValidationDirectory() throws Exception { + String directory = MessagesController.VALIDATION_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + expectContextAndDiscovery(directory, Set.of(celements)); + expectFragment(celements, "", true, builder -> builder.addProperty("required", "Required")); + replayAll(); + + assertEquals("{\"required\" : \"Required\"}", controller.getValidationMessages()); + + assertEquals(List.of(celements), evaluatedFragments); + verifyAll(); + } + + @Test + public void testMissingCelementsFragmentFailsCompleteRequest() { + String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; + expectContextAndDiscoveryWithoutEngine(directory, Set.of(directory + "product.vm")); + replayAll(); + + assertThrows(IOException.class, controller::getMessages); + + assertFalse(velocityContext.containsKey("jsonBuilder")); + verifyAll(); + } + + @Test + public void testNonWhitespaceFragmentOutputFailsCompleteRequest() throws Exception { + String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + expectContextAndDiscovery(directory, Set.of(celements)); + expectFragment(celements, "unexpected", true, + builder -> builder.addProperty("message", "value")); + replayAll(); + + assertThrows(IOException.class, controller::getMessages); + + assertFalse(velocityContext.containsKey("jsonBuilder")); + verifyAll(); + } + + @Test + public void testFailedFragmentEvaluationFailsCompleteRequest() throws Exception { + String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + expectContextAndDiscovery(directory, Set.of(celements)); + expectFragment(celements, "", false, builder -> assertNotNull(builder)); + replayAll(); + + assertThrows(XWikiVelocityException.class, controller::getMessages); + + assertFalse(velocityContext.containsKey("jsonBuilder")); + verifyAll(); + } + + @Test + public void testUnbalancedBuilderDepthFailsCompleteRequest() throws Exception { + String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + expectContextAndDiscovery(directory, Set.of(celements)); + expectFragment(celements, "", true, builder -> builder.openDictionary("nested")); + replayAll(); + + assertThrows(IllegalStateException.class, controller::getMessages); + + assertFalse(velocityContext.containsKey("jsonBuilder")); + verifyAll(); + } + + @Test + public void testExistingJsonBuilderContextValueIsRestored() throws Exception { + String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + var previousBuilder = new JsonBuilder(); + velocityContext.put("jsonBuilder", previousBuilder); + expectContextAndDiscovery(directory, Set.of(celements)); + expectFragment(celements, "", true, builder -> builder.addProperty("message", "value")); + replayAll(); + + controller.getMessages(); + + assertSame(previousBuilder, velocityContext.get("jsonBuilder")); + verifyAll(); + } + + private void expectContextAndDiscovery(String directory, Set resources) + throws XWikiVelocityException { + expectContextAndDiscoveryWithoutEngine(directory, resources); + expect(velocityManager.getVelocityEngine()).andReturn(velocityEngine); + } + + private void expectContextAndDiscoveryWithoutEngine(String directory, Set resources) { + expect(velocityManager.getVelocityContext()).andReturn(velocityContext); + prepareVelocityContext.prepareVelocityContext(same(velocityContext)); + expect(servletContext.getResourcePaths(directory)).andReturn(resources); + } + + private void expectFragment(String path, String output, boolean result, + Consumer builderAction) throws Exception { + String source = "fragment:" + path; + expect(servletContext.getResourceAsStream(path)) + .andReturn(new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8))); + expect(velocityEngine.evaluate(same(velocityContext), isA(StringWriter.class), eq(path), + eq(source))).andAnswer(() -> { + evaluatedFragments.add(path); + builderAction.accept((JsonBuilder) velocityContext.get("jsonBuilder")); + ((StringWriter) getCurrentArguments()[1]).write(output); + return result; + }); + } + + private void replayAll() { + replay(servletContext, velocityManager, velocityEngine, prepareVelocityContext); + } + + private void verifyAll() { + verify(servletContext, velocityManager, velocityEngine, prepareVelocityContext); + } + + private static final class TestMessagesController extends MessagesController { + + private boolean checkAuthCalled; + + TestMessagesController(ServletContext servletContext, VelocityManager velocityManager, + IPrepareVelocityContext prepareVelocityContext) { + super(servletContext, velocityManager, prepareVelocityContext); + } + + @Override + protected Optional checkAuth() { + checkAuthCalled = true; + return Optional.empty(); + } + + boolean isCheckAuthCalled() { + return checkAuthCalled; + } + } +} diff --git a/celements-messages/web-module/pom.xml b/celements-messages/web-module/pom.xml new file mode 100644 index 00000000..e254a6eb --- /dev/null +++ b/celements-messages/web-module/pom.xml @@ -0,0 +1,28 @@ + + + + com.celements + celements-parent + 7.1-SNAPSHOT + + 4.0.0 + celements-messages-web + 7.1-SNAPSHOT + war + Celements Messages Web + + + com.celements + celements-messages + + + + celements-messages-web + + + scm:git:git@github.com:celements/celements-features.git + scm:git:git@github.com:celements/celements-features.git + https://github.com/celements/celements-features + HEAD + + diff --git a/celements-messages/web-module/src/main/webapp/WEB-INF/web.xml b/celements-messages/web-module/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000..efb8ef04 --- /dev/null +++ b/celements-messages/web-module/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,3 @@ + + + diff --git a/celements-messages/web-module/src/main/webapp/templates/celAjax/Messages.vm b/celements-messages/web-module/src/main/webapp/templates/celAjax/Messages.vm new file mode 100644 index 00000000..11ac604d --- /dev/null +++ b/celements-messages/web-module/src/main/webapp/templates/celAjax/Messages.vm @@ -0,0 +1,5 @@ +#set($jsonBuilder = $services.celementsweb.getNewJSONBuilder()) +$jsonBuilder.openDictionary() +#parse('celMessages/general/celements.vm') +$jsonBuilder.closeDictionary() +$jsonBuilder.getJSON() diff --git a/celements-messages/web-module/src/main/webapp/templates/celAjax/ValidationMessages.vm b/celements-messages/web-module/src/main/webapp/templates/celAjax/ValidationMessages.vm new file mode 100644 index 00000000..02dab642 --- /dev/null +++ b/celements-messages/web-module/src/main/webapp/templates/celAjax/ValidationMessages.vm @@ -0,0 +1,5 @@ +#set($jsonBuilder = $services.celementsweb.getNewJSONBuilder()) +$jsonBuilder.openDictionary() +#parse('celMessages/validation/celements.vm') +$jsonBuilder.closeDictionary() +$jsonBuilder.getJSON() diff --git a/celements-messages/web-module/src/main/webapp/templates/celMessages/general/celements.vm b/celements-messages/web-module/src/main/webapp/templates/celMessages/general/celements.vm new file mode 100644 index 00000000..63dedd8f --- /dev/null +++ b/celements-messages/web-module/src/main/webapp/templates/celMessages/general/celements.vm @@ -0,0 +1,215 @@ +$jsonBuilder.openProperty('celmeta') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('language', "$language") +$jsonBuilder.addProperty('admin-language', "$admin_language") +$jsonBuilder.addProperty('contextPath', "${request.contextPath}") +$jsonBuilder.addProperty('database', "${context.database}") +$jsonBuilder.openProperty('isAdmin') +$jsonBuilder.addValue($isAdmin) +$jsonBuilder.openProperty('isSuperAdmin') +$jsonBuilder.addValue($isSuperAdmin) +$jsonBuilder.addProperty('startupTimeStamp', "${services.celementsweb.getLastStartupTimeStamp()}") +$jsonBuilder.closeDictionary() +## Reconnector +$jsonBuilder.openProperty('Reconnector') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('retryNotice', "$!adminMsg.get('cel_reconnector_retryNotice')") +$jsonBuilder.addProperty('retryDelayNotice', "$!adminMsg.get('cel_reconnector_retryDelayNotice')") +$jsonBuilder.closeDictionary() +## jquery.formater +$jsonBuilder.openProperty('jqueryFormater') +$jsonBuilder.openDictionary() +$jsonBuilder.openProperty('monthsFull') +$jsonBuilder.openArray() +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_january')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_february')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_march')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_april')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_may')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_june')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_july')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_august')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_september')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_october')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_november')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsFull_december')") +$jsonBuilder.closeArray() +$jsonBuilder.openProperty('monthsShort') +$jsonBuilder.openArray() +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_january')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_february')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_march')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_april')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_may')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_june')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_july')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_august')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_september')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_october')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_november')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_monthsShort_december')") +$jsonBuilder.closeArray() +$jsonBuilder.openProperty('daysFull') +$jsonBuilder.openArray() +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysFull_sunday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysFull_monday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysFull_tuesday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysFull_wednesday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysFull_thursday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysFull_friday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysFull_saturday')") +$jsonBuilder.closeArray() +$jsonBuilder.openProperty('daysShort') +$jsonBuilder.openArray() +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysShort_sunday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysShort_monday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysShort_tuesday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysShort_wednesday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysShort_thursday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysShort_friday')") +$jsonBuilder.addValue("$!adminMsg.get('cel_date_formater_daysShort_saturday')") +$jsonBuilder.closeArray() +$jsonBuilder.closeDictionary() +## celements photo slideShow +$jsonBuilder.openProperty('celslideshow') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('cmPause', "$!adminMsg.get('cel_slideshow_cm_pause')") +$jsonBuilder.addProperty('cmContinue', "$!adminMsg.get('cel_slideshow_cm_continue')") +$jsonBuilder.addProperty('cmOpenLink', "$!adminMsg.get('cel_slideshow_cm_openlink')") +$jsonBuilder.closeDictionary() +## celements admin importer (import.js) +$jsonBuilder.openProperty('celAdminImport') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('availableDocuments', "$!adminMsg.get('core.importer.availableDocuments')") +$jsonBuilder.addProperty('importHistoryLabel', "$!adminMsg.get('core.importer.importHistory')") +$jsonBuilder.addProperty('selectionEmpty', "$!adminMsg.get('core.importer.selectionEmptyWarning')") +$jsonBuilder.addProperty('import', "$!adminMsg.get('core.importer.import')") +$jsonBuilder.addProperty('package', "$!adminMsg.get('core.importer.package')") +$jsonBuilder.addProperty('description', "$!adminMsg.get('core.importer.package.description')") +$jsonBuilder.addProperty('version', "$!adminMsg.get('core.importer.package.version')") +$jsonBuilder.addProperty('licence', "$!adminMsg.get('core.importer.package.licence')") +$jsonBuilder.addProperty('author', "$!adminMsg.get('core.importer.package.author')") +$jsonBuilder.addProperty('documentSelected', "$!adminMsg.get('core.importer.documentSelected')") +$jsonBuilder.addProperty('whenDocumentAlreadyExists', "$!adminMsg.get('core.importer.whenDocumentAlreadyExists')") +$jsonBuilder.addProperty('addNewVersion', "$!adminMsg.get('core.importer.addNewVersion')") +$jsonBuilder.addProperty('replaceDocumentHistory', "$!adminMsg.get('core.importer.replaceDocumentHistory')") +$jsonBuilder.addProperty('resetHistory', "$!adminMsg.get('core.importer.resetHistory')") +$jsonBuilder.addProperty('importAsBackup', "$!adminMsg.get('core.importer.importAsBackup')") +$jsonBuilder.addProperty('select', "$!adminMsg.get('core.importer.select')") +$jsonBuilder.addProperty('all', "$!adminMsg.get('core.importer.selectAll')") +$jsonBuilder.addProperty('none', "$!adminMsg.get('core.importer.selectNone')") +$jsonBuilder.closeDictionary() +## celements menu +$jsonBuilder.openProperty('celmenu') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('supportLinkURL', "$!services.celementsweb.getSupportLinkURL()") +$jsonBuilder.closeDictionary() +## xwiki/actionButtons.js +$jsonBuilder.openProperty('actionButtons') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('cancel', "$!adminMsg.get('core.shortcuts.edit.cancel')") +$jsonBuilder.addProperty('preview', "$!adminMsg.get('core.shortcuts.edit.preview')") +$jsonBuilder.addProperty('backtoedit', "$!adminMsg.get('core.shortcuts.edit.backtoedit')") +$jsonBuilder.addProperty('saveandview', "$!adminMsg.get('core.shortcuts.edit.saveandview')") +$jsonBuilder.addProperty('saveandcontinue', "$!adminMsg.get('core.shortcuts.edit.saveandcontinue')") +$jsonBuilder.addProperty('mandatoryField', "$!adminMsg.get('core.editors.validation.mandatoryField')") +$jsonBuilder.addProperty('commentPrompt', "$!adminMsg.get('core.comment.prompt')") +$jsonBuilder.closeDictionary() +## xwiki/attachments.js +$jsonBuilder.openProperty('attachments') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('deleteConfirm', "$!adminMsg.get('core.viewers.attachments.delete.confirm')") +$jsonBuilder.addProperty('deleteInProgress', "$!adminMsg.get('core.viewers.attachments.delete.inProgress')") +$jsonBuilder.addProperty('deleteDone', "$!adminMsg.get('core.viewers.attachments.delete.done')") +$jsonBuilder.addProperty('deleteFailed', "$!adminMsg.get('core.viewers.attachments.delete.failed')") +$jsonBuilder.addProperty('addFileInput', "$!adminMsg.get('core.viewers.attachments.upload.addFileInput')") +$jsonBuilder.addProperty('removeFileInput', "$!adminMsg.get('core.viewers.attachments.upload.removeFileInput')") +$jsonBuilder.addProperty('removeFileInputTitle', "$!adminMsg.get('core.viewers.attachments.upload.removeFileInput.title')") +$jsonBuilder.addProperty('extranb', "$!adminMsg.get('docextra.extranb', ['__number__'])") +$jsonBuilder.closeDictionary() +## xwiki/code.js +$jsonBuilder.openProperty('code') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('showLineNumbers', "$!adminMsg.get('core.viewers.code.showLineNumbers')") +$jsonBuilder.addProperty('hideLineNumbers', "$!adminMsg.get('core.viewers.code.hideLineNumbers')") +$jsonBuilder.closeDictionary() +## xwiki/comments.js +$jsonBuilder.openProperty('comments') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('commentDeleted', "$!adminMsg.get('core.viewers.comments.commentDeleted')") +$jsonBuilder.addProperty('deleteConfirm', "$!adminMsg.get('core.viewers.comments.delete.confirm')") +$jsonBuilder.addProperty('deleteInProgress', "$!adminMsg.get('core.viewers.comments.delete.inProgress')") +$jsonBuilder.addProperty('deleteDone', "$!adminMsg.get('core.viewers.comments.delete.done')") +$jsonBuilder.addProperty('deleteFailed', "$!adminMsg.get('core.viewers.comments.delete.failed')") +$jsonBuilder.addProperty('editFormFetchInProgress', "$!adminMsg.get('core.viewers.comments.editForm.fetch.inProgress')") +$jsonBuilder.addProperty('editFormFetchFailed', "$!adminMsg.get('core.viewers.comments.editForm.fetch.failed')") +$jsonBuilder.addProperty('addInProgress', "$!adminMsg.get('core.viewers.comments.add.inProgress')") +$jsonBuilder.addProperty('addDone', "$!adminMsg.get('core.viewers.comments.add.done')") +$jsonBuilder.addProperty('addFailed', "$!adminMsg.get('core.viewers.comments.add.failed')") +$jsonBuilder.addProperty('previewButtonPreview', "$!adminMsg.get('core.viewers.comments.preview.button.preview')") +$jsonBuilder.addProperty('previewInProgress', "$!adminMsg.get('core.viewers.comments.preview.inProgress')") +$jsonBuilder.addProperty('previewFailed', "$!adminMsg.get('core.viewers.comments.preview.failed')") +$jsonBuilder.addProperty('previewButtonBack', "$!adminMsg.get('core.viewers.comments.preview.button.back')") +$jsonBuilder.addProperty('extranb', "$!adminMsg.get('docextra.extranb', ['__number__'])") +$jsonBuilder.closeDictionary() +## xwiki/confirmationBox.js +$jsonBuilder.openProperty('confirmationBox') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('defaultQuestion', "$!adminMsg.get('core.widgets.confirmationBox.defaultQuestion')") +$jsonBuilder.addProperty('yes', "$!adminMsg.get('core.widgets.confirmationBox.button.yes')") +$jsonBuilder.addProperty('no', "$!adminMsg.get('core.widgets.confirmationBox.button.no')") +$jsonBuilder.closeDictionary() +## xwiki/confirmedAjaxRequest.js +$jsonBuilder.openProperty('confirmedAjaxRequest') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('notificationInProgress', "$!adminMsg.get('core.widgets.confirmationBox.notification.inProgress')") +$jsonBuilder.addProperty('notificationDone', "$!adminMsg.get('core.widgets.confirmationBox.notification.done')") +$jsonBuilder.addProperty('notificationFailed', "$!adminMsg.get('core.widgets.confirmationBox.notification.failed')") +$jsonBuilder.closeDictionary() +## xwiki/dataeditors.js +$jsonBuilder.openProperty('dataeditors') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('confirmationText', "$!adminMsg.get('core.editors.object.delete.confirm')") +$jsonBuilder.addProperty('progressMessageText', "$!adminMsg.get('core.editors.object.delete.inProgress')") +$jsonBuilder.addProperty('successMessageText', "$!adminMsg.get('core.editors.object.delete.done')") +$jsonBuilder.addProperty('failureMessageText', "$!adminMsg.get('core.editors.object.delete.failed')") +$jsonBuilder.closeDictionary() +## xwiki/livetable.js +$jsonBuilder.openProperty('livetable') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('resultsOf', "$!adminMsg.get('xe.pagination.results.of')") +$jsonBuilder.addProperty('results', "$!adminMsg.get('xe.pagination.results')") +$jsonBuilder.closeDictionary() +## xwiki/usersandgroups.js +$jsonBuilder.openProperty('usersandgroups') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('denyrightforcurrentuser', "$!adminMsg.get('rightsmanager.denyrightforcurrentuser')") +$jsonBuilder.addProperty('clearrightforcurrentuserinstead', "$!adminMsg.get('rightsmanager.clearrightforcurrentuserinstead')") +$jsonBuilder.addProperty('clearrightforcurrentuser', "$!adminMsg.get('rightsmanager.clearrightforcurrentuser')") +$jsonBuilder.addProperty('denyrightforgroup', "$!adminMsg.get('rightsmanager.denyrightforgroup')") +$jsonBuilder.addProperty('clearrightforgroupinstead', "$!adminMsg.get('rightsmanager.clearrightforgroupinstead')") +$jsonBuilder.addProperty('clearrightforgroup', "$!adminMsg.get('rightsmanager.clearrightforgroup')") +$jsonBuilder.addProperty('denyrightforuorg', "$!adminMsg.get('rightsmanager.denyrightforuorg')") +$jsonBuilder.addProperty('clearrightforuorg', "$!adminMsg.get('rightsmanager.clearrightforuorg')") +$jsonBuilder.addProperty('saveFailure', "$!adminMsg.get('platform.core.rightsManagement.saveFailure')") +$jsonBuilder.addProperty('ajaxFailure', "$!adminMsg.get('platform.core.rightsManagement.ajaxFailure')") +$jsonBuilder.addProperty('edit', "$!adminMsg.get('edit')") +$jsonBuilder.addProperty('delete', "$!adminMsg.get('delete')") +$jsonBuilder.addProperty('confirmdeleteuser', "$!adminMsg.get('rightsmanager.confirmdeleteuser')") +$jsonBuilder.addProperty('confirmdeletegroup', "$!adminMsg.get('rightsmanager.confirmdeletegroup')") +$jsonBuilder.addProperty('confirmdeletemember', "$!adminMsg.get('rightsmanager.confirmdeletemember')") +$jsonBuilder.closeDictionary() +## xwiki.js +$jsonBuilder.openProperty('xwiki') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('readTechnicalInformation', "$!adminMsg.get('platform.core.rendering.error.readTechnicalInformation')") +$jsonBuilder.addProperty('edit', "$!adminMsg.get('edit')") +$jsonBuilder.addProperty('createAjaxError', "$!adminMsg.get('core.create.ajax.error')") +$jsonBuilder.closeDictionary() +## xwikiexplorer.js +$jsonBuilder.openProperty('xwikiexplorer') +$jsonBuilder.openDictionary() +$jsonBuilder.addProperty('attachmentsTitle', "$!adminMsg.get('xwikiexplorer.attachments.title')") +$jsonBuilder.addProperty('addPageTitle', "$!adminMsg.get('xwikiexplorer.addpage.title')") +$jsonBuilder.addProperty('addAttachmentTitle', "$!adminMsg.get('xwikiexplorer.addattachment.title')") +$jsonBuilder.closeDictionary() diff --git a/celements-messages/web-module/src/main/webapp/templates/celMessages/validation/celements.vm b/celements-messages/web-module/src/main/webapp/templates/celMessages/validation/celements.vm new file mode 100644 index 00000000..36d571ea --- /dev/null +++ b/celements-messages/web-module/src/main/webapp/templates/celMessages/validation/celements.vm @@ -0,0 +1,19 @@ +$jsonBuilder.addProperty('language', "$language") +$jsonBuilder.addProperty('admin-language', "$admin_language") +$jsonBuilder.addProperty('required', "$adminMsg.get('cel_form_valid_required')") +$jsonBuilder.addProperty('validate-dateRange', "$!adminMsg.get('cel_valid_dateRange', ['', ''])") +$jsonBuilder.addProperty('validate-warning-header', "$!adminMsg.get('cel_tm_saving_dialog_warning_header')") +$jsonBuilder.addProperty('validate-blog-dateRange', "$!adminMsg.get('cel_valid_blog_dateRange', ['', ''])") +$jsonBuilder.addProperty('validate-number', "Please enter a valid number in this field.") +$jsonBuilder.addProperty('validate-digits', "Please use numbers only in this field. please avoid spaces or other characters such as dots or commas.") +$jsonBuilder.addProperty('validate-alpha', "Please use letters only (a-z) in this field.") +$jsonBuilder.addProperty('validate-alphanum', "Please use only letters (a-z) or numbers (0-9) only in this field. No spaces or other characters are allowed.") +$jsonBuilder.addProperty('validate-date', "Please enter a valid date.") +$jsonBuilder.addProperty('validate-email', "Please enter a valid email address. For example fred@domain.com .") +$jsonBuilder.addProperty('validate-url', "Please enter a valid URL.") +$jsonBuilder.addProperty('validate-date-au', "Please use this date format: dd/mm/yyyy. For example 17/03/2006 for the 17th of March, 2006.") +$jsonBuilder.addProperty('validate-currency-dollar', 'Please enter a valid $ amount. For example $100.00 .') +$jsonBuilder.addProperty('validate-selection', 'Please make a selection') +$jsonBuilder.addProperty('validate-one-required', 'Please select one of the above options.') +$jsonBuilder.addProperty('validate-email-equal', "$!adminMsg.get('cel_form_valid_email-equal')") +$jsonBuilder.addProperty('validate-docname', "$adminMsg.get('cel_tm_validate_warning')") From 2b610a2d46832fb3858092de871d0d3bf56a1632 Mon Sep 17 00:00:00 2001 From: Fabian Pichler Date: Sun, 2 Aug 2026 07:31:44 +0200 Subject: [PATCH 2/5] CELDEV-1332 Share message aggregation with legacy adapters --- celements-messages/component/pom.xml | 5 + .../messages/api/MessagesController.java | 98 +--------- .../script/MessagesScriptService.java | 35 ++++ .../messages/service/MessageService.java | 121 ++++++++++++ .../messages/api/MessagesControllerTest.java | 159 +++------------ .../script/MessagesScriptServiceTest.java | 80 ++++++++ .../messages/service/MessageServiceTest.java | 184 ++++++++++++++++++ .../main/webapp/templates/celAjax/Messages.vm | 6 +- .../templates/celAjax/ValidationMessages.vm | 6 +- 9 files changed, 460 insertions(+), 234 deletions(-) create mode 100644 celements-messages/component/src/main/java/com/celements/messages/script/MessagesScriptService.java create mode 100644 celements-messages/component/src/main/java/com/celements/messages/service/MessageService.java create mode 100644 celements-messages/component/src/test/java/com/celements/messages/script/MessagesScriptServiceTest.java create mode 100644 celements-messages/component/src/test/java/com/celements/messages/service/MessageServiceTest.java diff --git a/celements-messages/component/pom.xml b/celements-messages/component/pom.xml index 12ad5582..6d5e21ab 100644 --- a/celements-messages/component/pom.xml +++ b/celements-messages/component/pom.xml @@ -40,6 +40,11 @@ spring-web provided + + org.xwiki.platform + xwiki-core-script + provided + javax.inject javax.inject diff --git a/celements-messages/component/src/main/java/com/celements/messages/api/MessagesController.java b/celements-messages/component/src/main/java/com/celements/messages/api/MessagesController.java index be8ebc0c..3e1ea041 100644 --- a/celements-messages/component/src/main/java/com/celements/messages/api/MessagesController.java +++ b/celements-messages/component/src/main/java/com/celements/messages/api/MessagesController.java @@ -1,15 +1,8 @@ package com.celements.messages.api; import java.io.IOException; -import java.io.InputStream; -import java.io.StringWriter; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.List; -import java.util.Optional; import javax.inject.Inject; -import javax.servlet.ServletContext; import org.apache.velocity.VelocityContext; import org.springframework.http.MediaType; @@ -17,11 +10,10 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.xwiki.velocity.VelocityEngine; import org.xwiki.velocity.VelocityManager; import org.xwiki.velocity.XWikiVelocityException; -import com.celements.sajson.JsonBuilder; +import com.celements.messages.service.MessageService; import com.celements.spring.security.AuthenticatedBaseController; import com.celements.web.service.IPrepareVelocityContext; @@ -30,106 +22,34 @@ @PreAuthorize("permitAll()") public class MessagesController extends AuthenticatedBaseController { - static final String GENERAL_FRAGMENT_DIRECTORY = "/templates/celMessages/general/"; - static final String VALIDATION_FRAGMENT_DIRECTORY = "/templates/celMessages/validation/"; - private static final String REQUIRED_FRAGMENT_NAME = "celements.vm"; - private static final String JSON_BUILDER_CONTEXT_KEY = "jsonBuilder"; private static final String JSON_MEDIA_TYPE = MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8"; - private final ServletContext servletContext; private final VelocityManager velocityManager; private final IPrepareVelocityContext prepareVelocityContext; + private final MessageService messageService; @Inject - public MessagesController(ServletContext servletContext, VelocityManager velocityManager, - IPrepareVelocityContext prepareVelocityContext) { - this.servletContext = servletContext; + public MessagesController(VelocityManager velocityManager, + IPrepareVelocityContext prepareVelocityContext, MessageService messageService) { this.velocityManager = velocityManager; this.prepareVelocityContext = prepareVelocityContext; + this.messageService = messageService; } @GetMapping(produces = JSON_MEDIA_TYPE) public String getMessages() throws IOException, XWikiVelocityException { - return renderFragments(GENERAL_FRAGMENT_DIRECTORY); + return messageService.getMessages(getPreparedVelocityContext()); } @GetMapping(value = "/validation", produces = JSON_MEDIA_TYPE) public String getValidationMessages() throws IOException, XWikiVelocityException { - return renderFragments(VALIDATION_FRAGMENT_DIRECTORY); + return messageService.getValidationMessages(getPreparedVelocityContext()); } - private String renderFragments(String directory) throws IOException, XWikiVelocityException { + private VelocityContext getPreparedVelocityContext() { checkAuth(); VelocityContext velocityContext = velocityManager.getVelocityContext(); prepareVelocityContext.prepareVelocityContext(velocityContext); - var jsonBuilder = new JsonBuilder(); - jsonBuilder.openDictionary(); - List fragments = discoverFragments(directory); - requireBaseFragment(directory, fragments); - boolean hadJsonBuilder = velocityContext.containsKey(JSON_BUILDER_CONTEXT_KEY); - Object previousJsonBuilder = velocityContext.get(JSON_BUILDER_CONTEXT_KEY); - velocityContext.put(JSON_BUILDER_CONTEXT_KEY, jsonBuilder); - try { - VelocityEngine velocityEngine = velocityManager.getVelocityEngine(); - for (String fragment : fragments) { - evaluateFragment(velocityEngine, velocityContext, jsonBuilder, fragment); - } - jsonBuilder.closeDictionary(); - if (!jsonBuilder.isComplete()) { - throw new IllegalStateException("Messages JSON builder is incomplete"); - } - return jsonBuilder.getJSON(); - } finally { - if (hadJsonBuilder) { - velocityContext.put(JSON_BUILDER_CONTEXT_KEY, previousJsonBuilder); - } else { - velocityContext.remove(JSON_BUILDER_CONTEXT_KEY); - } - } - } - - private List discoverFragments(String directory) { - return Optional.ofNullable(servletContext.getResourcePaths(directory)) - .orElseGet(Collections::emptySet).stream().filter(path -> isDirectFragment(directory, path)) - .sorted().toList(); - } - - private boolean isDirectFragment(String directory, String path) { - return path.startsWith(directory) && path.endsWith(".vm") - && !path.substring(directory.length()).contains("/"); - } - - private void requireBaseFragment(String directory, List fragments) throws IOException { - String requiredFragment = directory + REQUIRED_FRAGMENT_NAME; - if (!fragments.contains(requiredFragment)) { - throw new IOException("Required message fragment not found: " + requiredFragment); - } - } - - private void evaluateFragment(VelocityEngine velocityEngine, VelocityContext velocityContext, - JsonBuilder jsonBuilder, String fragment) throws IOException, XWikiVelocityException { - int initialDepth = jsonBuilder.getOpenCommandCount(); - var writer = new StringWriter(); - boolean evaluated = velocityEngine.evaluate(velocityContext, writer, fragment, - getFragmentContent(fragment)); - if (!evaluated) { - throw new XWikiVelocityException("Failed to evaluate message fragment: " + fragment); - } - if (!writer.toString().trim().isEmpty()) { - throw new IOException("Message fragment emitted response content: " + fragment); - } - if (jsonBuilder.getOpenCommandCount() != initialDepth) { - throw new IllegalStateException( - "Message fragment left an unbalanced JSON builder: " + fragment); - } - } - - private String getFragmentContent(String fragment) throws IOException { - try (InputStream stream = servletContext.getResourceAsStream(fragment)) { - if (stream == null) { - throw new IOException("Message fragment not found: " + fragment); - } - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } + return velocityContext; } } diff --git a/celements-messages/component/src/main/java/com/celements/messages/script/MessagesScriptService.java b/celements-messages/component/src/main/java/com/celements/messages/script/MessagesScriptService.java new file mode 100644 index 00000000..6fcdeacd --- /dev/null +++ b/celements-messages/component/src/main/java/com/celements/messages/script/MessagesScriptService.java @@ -0,0 +1,35 @@ +package com.celements.messages.script; + +import java.io.IOException; + +import javax.inject.Inject; + +import org.springframework.stereotype.Component; +import org.xwiki.script.service.ScriptService; +import org.xwiki.velocity.VelocityManager; +import org.xwiki.velocity.XWikiVelocityException; + +import com.celements.messages.service.MessageService; + +@Component(MessagesScriptService.NAME) +public class MessagesScriptService implements ScriptService { + + public static final String NAME = "celementsMessages"; + + private final VelocityManager velocityManager; + private final MessageService messageService; + + @Inject + public MessagesScriptService(VelocityManager velocityManager, MessageService messageService) { + this.velocityManager = velocityManager; + this.messageService = messageService; + } + + public String getMessages() throws IOException, XWikiVelocityException { + return messageService.getMessages(velocityManager.getVelocityContext()); + } + + public String getValidationMessages() throws IOException, XWikiVelocityException { + return messageService.getValidationMessages(velocityManager.getVelocityContext()); + } +} diff --git a/celements-messages/component/src/main/java/com/celements/messages/service/MessageService.java b/celements-messages/component/src/main/java/com/celements/messages/service/MessageService.java new file mode 100644 index 00000000..3a7790db --- /dev/null +++ b/celements-messages/component/src/main/java/com/celements/messages/service/MessageService.java @@ -0,0 +1,121 @@ +package com.celements.messages.service; + +import java.io.IOException; +import java.io.InputStream; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; +import javax.servlet.ServletContext; + +import org.apache.velocity.VelocityContext; +import org.springframework.stereotype.Component; +import org.xwiki.velocity.VelocityEngine; +import org.xwiki.velocity.VelocityManager; +import org.xwiki.velocity.XWikiVelocityException; + +import com.celements.sajson.JsonBuilder; + +@Component +public class MessageService { + + static final String GENERAL_FRAGMENT_DIRECTORY = "/templates/celMessages/general/"; + static final String VALIDATION_FRAGMENT_DIRECTORY = "/templates/celMessages/validation/"; + private static final String REQUIRED_FRAGMENT_NAME = "celements.vm"; + private static final String JSON_BUILDER_CONTEXT_KEY = "jsonBuilder"; + + private final ServletContext servletContext; + private final VelocityManager velocityManager; + + @Inject + public MessageService(ServletContext servletContext, VelocityManager velocityManager) { + this.servletContext = servletContext; + this.velocityManager = velocityManager; + } + + public String getMessages(VelocityContext velocityContext) + throws IOException, XWikiVelocityException { + return renderFragments(GENERAL_FRAGMENT_DIRECTORY, velocityContext); + } + + public String getValidationMessages(VelocityContext velocityContext) + throws IOException, XWikiVelocityException { + return renderFragments(VALIDATION_FRAGMENT_DIRECTORY, velocityContext); + } + + private String renderFragments(String directory, VelocityContext velocityContext) + throws IOException, XWikiVelocityException { + var jsonBuilder = new JsonBuilder(); + jsonBuilder.openDictionary(); + List fragments = discoverFragments(directory); + requireBaseFragment(directory, fragments); + boolean hadJsonBuilder = velocityContext.containsKey(JSON_BUILDER_CONTEXT_KEY); + Object previousJsonBuilder = velocityContext.get(JSON_BUILDER_CONTEXT_KEY); + velocityContext.put(JSON_BUILDER_CONTEXT_KEY, jsonBuilder); + try { + VelocityEngine velocityEngine = velocityManager.getVelocityEngine(); + for (String fragment : fragments) { + evaluateFragment(velocityEngine, velocityContext, jsonBuilder, fragment); + } + jsonBuilder.closeDictionary(); + if (!jsonBuilder.isComplete()) { + throw new IllegalStateException("Messages JSON builder is incomplete"); + } + return jsonBuilder.getJSON(); + } finally { + if (hadJsonBuilder) { + velocityContext.put(JSON_BUILDER_CONTEXT_KEY, previousJsonBuilder); + } else { + velocityContext.remove(JSON_BUILDER_CONTEXT_KEY); + } + } + } + + private List discoverFragments(String directory) { + return Optional.ofNullable(servletContext.getResourcePaths(directory)) + .orElseGet(Collections::emptySet).stream().filter(path -> isDirectFragment(directory, path)) + .sorted().toList(); + } + + private boolean isDirectFragment(String directory, String path) { + return path.startsWith(directory) && path.endsWith(".vm") + && !path.substring(directory.length()).contains("/"); + } + + private void requireBaseFragment(String directory, List fragments) throws IOException { + String requiredFragment = directory + REQUIRED_FRAGMENT_NAME; + if (!fragments.contains(requiredFragment)) { + throw new IOException("Required message fragment not found: " + requiredFragment); + } + } + + private void evaluateFragment(VelocityEngine velocityEngine, VelocityContext velocityContext, + JsonBuilder jsonBuilder, String fragment) throws IOException, XWikiVelocityException { + int initialDepth = jsonBuilder.getOpenCommandCount(); + var writer = new StringWriter(); + boolean evaluated = velocityEngine.evaluate(velocityContext, writer, fragment, + getFragmentContent(fragment)); + if (!evaluated) { + throw new XWikiVelocityException("Failed to evaluate message fragment: " + fragment); + } + if (!writer.toString().trim().isEmpty()) { + throw new IOException("Message fragment emitted response content: " + fragment); + } + if (jsonBuilder.getOpenCommandCount() != initialDepth) { + throw new IllegalStateException( + "Message fragment left an unbalanced JSON builder: " + fragment); + } + } + + private String getFragmentContent(String fragment) throws IOException { + try (InputStream stream = servletContext.getResourceAsStream(fragment)) { + if (stream == null) { + throw new IOException("Message fragment not found: " + fragment); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java b/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java index ba854eec..cc009ce6 100644 --- a/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java +++ b/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java @@ -3,17 +3,8 @@ import static org.easymock.EasyMock.*; import static org.junit.Assert.*; -import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.StringWriter; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; import java.util.Optional; -import java.util.Set; -import java.util.function.Consumer; - -import javax.servlet.ServletContext; import org.apache.velocity.VelocityContext; import org.junit.Before; @@ -22,34 +13,28 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.xwiki.velocity.VelocityEngine; import org.xwiki.velocity.VelocityManager; -import org.xwiki.velocity.XWikiVelocityException; import com.celements.auth.user.User; -import com.celements.sajson.JsonBuilder; +import com.celements.messages.service.MessageService; import com.celements.web.service.IPrepareVelocityContext; public class MessagesControllerTest { - private ServletContext servletContext; private VelocityManager velocityManager; - private VelocityEngine velocityEngine; private IPrepareVelocityContext prepareVelocityContext; + private MessageService messageService; private VelocityContext velocityContext; private TestMessagesController controller; - private List evaluatedFragments; @Before public void prepareTest() { - servletContext = createMock(ServletContext.class); velocityManager = createMock(VelocityManager.class); - velocityEngine = createMock(VelocityEngine.class); prepareVelocityContext = createMock(IPrepareVelocityContext.class); + messageService = createMock(MessageService.class); velocityContext = new VelocityContext(); - controller = new TestMessagesController(servletContext, velocityManager, - prepareVelocityContext); - evaluatedFragments = new ArrayList<>(); + controller = new TestMessagesController(velocityManager, prepareVelocityContext, + messageService); } @Test @@ -69,157 +54,61 @@ public void testEndpointMappings() throws Exception { } @Test - public void testGetMessagesUsesPreparedExistingContextAndLexicalDirectFragments() - throws Exception { - String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; - String celements = directory + "celements.vm"; - String zeta = directory + "zeta.vm"; - expectContextAndDiscovery(directory, Set.of(zeta, directory + "nested/ignored.vm", celements)); - expectFragment(celements, " \n", true, builder -> { - builder.addProperty("string", "value"); - builder.addProperty("boolean", true); - builder.addProperty("number", 7); - }); - expectFragment(zeta, "\n", true, builder -> builder.addProperty("zeta", "last")); + public void testGetMessagesUsesPreparedExistingContext() throws Exception { + expectPreparedContext(); + expect(messageService.getMessages(same(velocityContext))).andReturn("{\"message\":\"value\"}"); replayAll(); - String json = controller.getMessages(); + assertEquals("{\"message\":\"value\"}", controller.getMessages()); - assertEquals( - "{\"string\" : \"value\", \"boolean\" : true, \"number\" : 7, " + "\"zeta\" : \"last\"}", - json); assertTrue(controller.isCheckAuthCalled()); - assertEquals(List.of(celements, zeta), evaluatedFragments); - assertFalse(velocityContext.containsKey("jsonBuilder")); verifyAll(); } @Test - public void testGetValidationMessagesUsesOnlyValidationDirectory() throws Exception { - String directory = MessagesController.VALIDATION_FRAGMENT_DIRECTORY; - String celements = directory + "celements.vm"; - expectContextAndDiscovery(directory, Set.of(celements)); - expectFragment(celements, "", true, builder -> builder.addProperty("required", "Required")); + public void testGetValidationMessagesUsesPreparedExistingContext() throws Exception { + expectPreparedContext(); + expect(messageService.getValidationMessages(same(velocityContext))) + .andReturn("{\"required\":\"Required\"}"); replayAll(); - assertEquals("{\"required\" : \"Required\"}", controller.getValidationMessages()); - - assertEquals(List.of(celements), evaluatedFragments); - verifyAll(); - } - - @Test - public void testMissingCelementsFragmentFailsCompleteRequest() { - String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; - expectContextAndDiscoveryWithoutEngine(directory, Set.of(directory + "product.vm")); - replayAll(); - - assertThrows(IOException.class, controller::getMessages); + assertEquals("{\"required\":\"Required\"}", controller.getValidationMessages()); - assertFalse(velocityContext.containsKey("jsonBuilder")); + assertTrue(controller.isCheckAuthCalled()); verifyAll(); } @Test - public void testNonWhitespaceFragmentOutputFailsCompleteRequest() throws Exception { - String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; - String celements = directory + "celements.vm"; - expectContextAndDiscovery(directory, Set.of(celements)); - expectFragment(celements, "unexpected", true, - builder -> builder.addProperty("message", "value")); + public void testServiceFailurePropagates() throws Exception { + expectPreparedContext(); + expect(messageService.getMessages(same(velocityContext))).andThrow(new IOException("failed")); replayAll(); assertThrows(IOException.class, controller::getMessages); - assertFalse(velocityContext.containsKey("jsonBuilder")); verifyAll(); } - @Test - public void testFailedFragmentEvaluationFailsCompleteRequest() throws Exception { - String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; - String celements = directory + "celements.vm"; - expectContextAndDiscovery(directory, Set.of(celements)); - expectFragment(celements, "", false, builder -> assertNotNull(builder)); - replayAll(); - - assertThrows(XWikiVelocityException.class, controller::getMessages); - - assertFalse(velocityContext.containsKey("jsonBuilder")); - verifyAll(); - } - - @Test - public void testUnbalancedBuilderDepthFailsCompleteRequest() throws Exception { - String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; - String celements = directory + "celements.vm"; - expectContextAndDiscovery(directory, Set.of(celements)); - expectFragment(celements, "", true, builder -> builder.openDictionary("nested")); - replayAll(); - - assertThrows(IllegalStateException.class, controller::getMessages); - - assertFalse(velocityContext.containsKey("jsonBuilder")); - verifyAll(); - } - - @Test - public void testExistingJsonBuilderContextValueIsRestored() throws Exception { - String directory = MessagesController.GENERAL_FRAGMENT_DIRECTORY; - String celements = directory + "celements.vm"; - var previousBuilder = new JsonBuilder(); - velocityContext.put("jsonBuilder", previousBuilder); - expectContextAndDiscovery(directory, Set.of(celements)); - expectFragment(celements, "", true, builder -> builder.addProperty("message", "value")); - replayAll(); - - controller.getMessages(); - - assertSame(previousBuilder, velocityContext.get("jsonBuilder")); - verifyAll(); - } - - private void expectContextAndDiscovery(String directory, Set resources) - throws XWikiVelocityException { - expectContextAndDiscoveryWithoutEngine(directory, resources); - expect(velocityManager.getVelocityEngine()).andReturn(velocityEngine); - } - - private void expectContextAndDiscoveryWithoutEngine(String directory, Set resources) { + private void expectPreparedContext() { expect(velocityManager.getVelocityContext()).andReturn(velocityContext); prepareVelocityContext.prepareVelocityContext(same(velocityContext)); - expect(servletContext.getResourcePaths(directory)).andReturn(resources); - } - - private void expectFragment(String path, String output, boolean result, - Consumer builderAction) throws Exception { - String source = "fragment:" + path; - expect(servletContext.getResourceAsStream(path)) - .andReturn(new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8))); - expect(velocityEngine.evaluate(same(velocityContext), isA(StringWriter.class), eq(path), - eq(source))).andAnswer(() -> { - evaluatedFragments.add(path); - builderAction.accept((JsonBuilder) velocityContext.get("jsonBuilder")); - ((StringWriter) getCurrentArguments()[1]).write(output); - return result; - }); } private void replayAll() { - replay(servletContext, velocityManager, velocityEngine, prepareVelocityContext); + replay(velocityManager, prepareVelocityContext, messageService); } private void verifyAll() { - verify(servletContext, velocityManager, velocityEngine, prepareVelocityContext); + verify(velocityManager, prepareVelocityContext, messageService); } private static final class TestMessagesController extends MessagesController { private boolean checkAuthCalled; - TestMessagesController(ServletContext servletContext, VelocityManager velocityManager, - IPrepareVelocityContext prepareVelocityContext) { - super(servletContext, velocityManager, prepareVelocityContext); + TestMessagesController(VelocityManager velocityManager, + IPrepareVelocityContext prepareVelocityContext, MessageService messageService) { + super(velocityManager, prepareVelocityContext, messageService); } @Override diff --git a/celements-messages/component/src/test/java/com/celements/messages/script/MessagesScriptServiceTest.java b/celements-messages/component/src/test/java/com/celements/messages/script/MessagesScriptServiceTest.java new file mode 100644 index 00000000..5830dc72 --- /dev/null +++ b/celements-messages/component/src/test/java/com/celements/messages/script/MessagesScriptServiceTest.java @@ -0,0 +1,80 @@ +package com.celements.messages.script; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +import java.io.IOException; + +import org.apache.velocity.VelocityContext; +import org.junit.Before; +import org.junit.Test; +import org.springframework.stereotype.Component; +import org.xwiki.script.service.ScriptService; +import org.xwiki.velocity.VelocityManager; + +import com.celements.messages.service.MessageService; + +public class MessagesScriptServiceTest { + + private VelocityManager velocityManager; + private MessageService messageService; + private VelocityContext velocityContext; + private MessagesScriptService scriptService; + + @Before + public void prepareTest() { + velocityManager = createMock(VelocityManager.class); + messageService = createMock(MessageService.class); + velocityContext = new VelocityContext(); + scriptService = new MessagesScriptService(velocityManager, messageService); + } + + @Test + public void testComponentRegistration() { + assertTrue(scriptService instanceof ScriptService); + assertEquals(MessagesScriptService.NAME, + MessagesScriptService.class.getAnnotation(Component.class).value()); + } + + @Test + public void testGetMessagesUsesActiveExistingContext() throws Exception { + expect(velocityManager.getVelocityContext()).andReturn(velocityContext); + expect(messageService.getMessages(same(velocityContext))).andReturn("{\"message\":\"value\"}"); + replayAll(); + + assertEquals("{\"message\":\"value\"}", scriptService.getMessages()); + + verifyAll(); + } + + @Test + public void testGetValidationMessagesUsesActiveExistingContext() throws Exception { + expect(velocityManager.getVelocityContext()).andReturn(velocityContext); + expect(messageService.getValidationMessages(same(velocityContext))) + .andReturn("{\"required\":\"Required\"}"); + replayAll(); + + assertEquals("{\"required\":\"Required\"}", scriptService.getValidationMessages()); + + verifyAll(); + } + + @Test + public void testServiceFailurePropagates() throws Exception { + expect(velocityManager.getVelocityContext()).andReturn(velocityContext); + expect(messageService.getMessages(same(velocityContext))).andThrow(new IOException("failed")); + replayAll(); + + assertThrows(IOException.class, scriptService::getMessages); + + verifyAll(); + } + + private void replayAll() { + replay(velocityManager, messageService); + } + + private void verifyAll() { + verify(velocityManager, messageService); + } +} diff --git a/celements-messages/component/src/test/java/com/celements/messages/service/MessageServiceTest.java b/celements-messages/component/src/test/java/com/celements/messages/service/MessageServiceTest.java new file mode 100644 index 00000000..b26a0dee --- /dev/null +++ b/celements-messages/component/src/test/java/com/celements/messages/service/MessageServiceTest.java @@ -0,0 +1,184 @@ +package com.celements.messages.service; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +import javax.servlet.ServletContext; + +import org.apache.velocity.VelocityContext; +import org.junit.Before; +import org.junit.Test; +import org.xwiki.velocity.VelocityEngine; +import org.xwiki.velocity.VelocityManager; +import org.xwiki.velocity.XWikiVelocityException; + +import com.celements.sajson.JsonBuilder; + +public class MessageServiceTest { + + private ServletContext servletContext; + private VelocityManager velocityManager; + private VelocityEngine velocityEngine; + private VelocityContext velocityContext; + private MessageService messageService; + private List evaluatedFragments; + + @Before + public void prepareTest() { + servletContext = createMock(ServletContext.class); + velocityManager = createMock(VelocityManager.class); + velocityEngine = createMock(VelocityEngine.class); + velocityContext = new VelocityContext(); + messageService = new MessageService(servletContext, velocityManager); + evaluatedFragments = new ArrayList<>(); + } + + @Test + public void testGetMessagesUsesLexicalDirectFragments() throws Exception { + String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + String zeta = directory + "zeta.vm"; + expectDiscovery(directory, Set.of(zeta, directory + "nested/ignored.vm", celements)); + expectFragment(celements, " \n", true, builder -> { + builder.addProperty("string", "value"); + builder.addProperty("boolean", true); + builder.addProperty("number", 7); + }); + expectFragment(zeta, "\n", true, builder -> builder.addProperty("zeta", "last")); + replayAll(); + + String json = messageService.getMessages(velocityContext); + + assertEquals( + "{\"string\" : \"value\", \"boolean\" : true, \"number\" : 7, " + + "\"zeta\" : \"last\"}", + json); + assertEquals(List.of(celements, zeta), evaluatedFragments); + assertFalse(velocityContext.containsKey("jsonBuilder")); + verifyAll(); + } + + @Test + public void testGetValidationMessagesUsesOnlyValidationDirectory() throws Exception { + String directory = MessageService.VALIDATION_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + expectDiscovery(directory, Set.of(celements)); + expectFragment(celements, "", true, builder -> builder.addProperty("required", "Required")); + replayAll(); + + assertEquals("{\"required\" : \"Required\"}", + messageService.getValidationMessages(velocityContext)); + + assertEquals(List.of(celements), evaluatedFragments); + verifyAll(); + } + + @Test + public void testMissingCelementsFragmentFailsCompleteRequest() { + String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; + expect(servletContext.getResourcePaths(directory)).andReturn(Set.of(directory + "product.vm")); + replayAll(); + + assertThrows(IOException.class, () -> messageService.getMessages(velocityContext)); + + assertFalse(velocityContext.containsKey("jsonBuilder")); + verifyAll(); + } + + @Test + public void testNonWhitespaceFragmentOutputFailsCompleteRequest() throws Exception { + String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + expectDiscovery(directory, Set.of(celements)); + expectFragment(celements, "unexpected", true, + builder -> builder.addProperty("message", "value")); + replayAll(); + + assertThrows(IOException.class, () -> messageService.getMessages(velocityContext)); + + assertFalse(velocityContext.containsKey("jsonBuilder")); + verifyAll(); + } + + @Test + public void testFailedFragmentEvaluationFailsCompleteRequest() throws Exception { + String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + expectDiscovery(directory, Set.of(celements)); + expectFragment(celements, "", false, builder -> assertNotNull(builder)); + replayAll(); + + assertThrows(XWikiVelocityException.class, () -> messageService.getMessages(velocityContext)); + + assertFalse(velocityContext.containsKey("jsonBuilder")); + verifyAll(); + } + + @Test + public void testUnbalancedBuilderDepthFailsCompleteRequest() throws Exception { + String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + expectDiscovery(directory, Set.of(celements)); + expectFragment(celements, "", true, builder -> builder.openDictionary("nested")); + replayAll(); + + assertThrows(IllegalStateException.class, + () -> messageService.getMessages(velocityContext)); + + assertFalse(velocityContext.containsKey("jsonBuilder")); + verifyAll(); + } + + @Test + public void testExistingJsonBuilderContextValueIsRestored() throws Exception { + String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; + String celements = directory + "celements.vm"; + var previousBuilder = new JsonBuilder(); + velocityContext.put("jsonBuilder", previousBuilder); + expectDiscovery(directory, Set.of(celements)); + expectFragment(celements, "", true, builder -> builder.addProperty("message", "value")); + replayAll(); + + messageService.getMessages(velocityContext); + + assertSame(previousBuilder, velocityContext.get("jsonBuilder")); + verifyAll(); + } + + private void expectDiscovery(String directory, Set resources) + throws XWikiVelocityException { + expect(servletContext.getResourcePaths(directory)).andReturn(resources); + expect(velocityManager.getVelocityEngine()).andReturn(velocityEngine); + } + + private void expectFragment(String path, String output, boolean result, + Consumer builderAction) throws Exception { + String source = "fragment:" + path; + expect(servletContext.getResourceAsStream(path)) + .andReturn(new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8))); + expect(velocityEngine.evaluate(same(velocityContext), isA(StringWriter.class), eq(path), + eq(source))).andAnswer(() -> { + evaluatedFragments.add(path); + builderAction.accept((JsonBuilder) velocityContext.get("jsonBuilder")); + ((StringWriter) getCurrentArguments()[1]).write(output); + return result; + }); + } + + private void replayAll() { + replay(servletContext, velocityManager, velocityEngine); + } + + private void verifyAll() { + verify(servletContext, velocityManager, velocityEngine); + } +} diff --git a/celements-messages/web-module/src/main/webapp/templates/celAjax/Messages.vm b/celements-messages/web-module/src/main/webapp/templates/celAjax/Messages.vm index 11ac604d..e6a863b2 100644 --- a/celements-messages/web-module/src/main/webapp/templates/celAjax/Messages.vm +++ b/celements-messages/web-module/src/main/webapp/templates/celAjax/Messages.vm @@ -1,5 +1 @@ -#set($jsonBuilder = $services.celementsweb.getNewJSONBuilder()) -$jsonBuilder.openDictionary() -#parse('celMessages/general/celements.vm') -$jsonBuilder.closeDictionary() -$jsonBuilder.getJSON() +$services.celementsMessages.getMessages() diff --git a/celements-messages/web-module/src/main/webapp/templates/celAjax/ValidationMessages.vm b/celements-messages/web-module/src/main/webapp/templates/celAjax/ValidationMessages.vm index 02dab642..256eaa1e 100644 --- a/celements-messages/web-module/src/main/webapp/templates/celAjax/ValidationMessages.vm +++ b/celements-messages/web-module/src/main/webapp/templates/celAjax/ValidationMessages.vm @@ -1,5 +1 @@ -#set($jsonBuilder = $services.celementsweb.getNewJSONBuilder()) -$jsonBuilder.openDictionary() -#parse('celMessages/validation/celements.vm') -$jsonBuilder.closeDictionary() -$jsonBuilder.getJSON() +$services.celementsMessages.getValidationMessages() From 8f4fb2778f3b414f550720ee2baf3d83c1b3f0cc Mon Sep 17 00:00:00 2001 From: Fabian Pichler Date: Tue, 4 Aug 2026 06:29:11 +0200 Subject: [PATCH 3/5] Update celements-messages/component/pom.xml Co-authored-by: Marc Sladek --- celements-messages/component/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/celements-messages/component/pom.xml b/celements-messages/component/pom.xml index 6d5e21ab..4722999b 100644 --- a/celements-messages/component/pom.xml +++ b/celements-messages/component/pom.xml @@ -7,7 +7,7 @@ 4.0.0 celements-messages - 7.1-SNAPSHOT + 7.2-SNAPSHOT Celements Messages From 9b7d45c238131b428eb80f43a845bde7c5f1cd4d Mon Sep 17 00:00:00 2001 From: Fabian Pichler Date: Tue, 4 Aug 2026 06:29:23 +0200 Subject: [PATCH 4/5] Update celements-messages/web-module/pom.xml Co-authored-by: Marc Sladek --- celements-messages/web-module/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/celements-messages/web-module/pom.xml b/celements-messages/web-module/pom.xml index e254a6eb..76acdbdc 100644 --- a/celements-messages/web-module/pom.xml +++ b/celements-messages/web-module/pom.xml @@ -7,7 +7,7 @@ 4.0.0 celements-messages-web - 7.1-SNAPSHOT + 7.2-SNAPSHOT war Celements Messages Web From 4c416e61e90656d59befed1a8b10d0ca6b279ee9 Mon Sep 17 00:00:00 2001 From: Fabian Pichler Date: Tue, 4 Aug 2026 07:05:07 +0200 Subject: [PATCH 5/5] CELDEV-1332 Align messages tests with component conventions --- celements-messages/component/pom.xml | 5 + .../messages/service/MessageService.java | 7 +- .../messages/api/MessagesControllerTest.java | 85 ++++++-------- .../script/MessagesScriptServiceTest.java | 46 +++----- .../messages/service/MessageServiceTest.java | 110 ++++++++++-------- 5 files changed, 126 insertions(+), 127 deletions(-) diff --git a/celements-messages/component/pom.xml b/celements-messages/component/pom.xml index 4722999b..64fbdd6f 100644 --- a/celements-messages/component/pom.xml +++ b/celements-messages/component/pom.xml @@ -59,6 +59,11 @@ celements-shared-tests test + + com.celements + celements-subsystem-migration-manager + test + scm:git:git@github.com:celements/celements-features.git diff --git a/celements-messages/component/src/main/java/com/celements/messages/service/MessageService.java b/celements-messages/component/src/main/java/com/celements/messages/service/MessageService.java index 3a7790db..721b891d 100644 --- a/celements-messages/component/src/main/java/com/celements/messages/service/MessageService.java +++ b/celements-messages/component/src/main/java/com/celements/messages/service/MessageService.java @@ -76,8 +76,11 @@ private String renderFragments(String directory, VelocityContext velocityContext private List discoverFragments(String directory) { return Optional.ofNullable(servletContext.getResourcePaths(directory)) - .orElseGet(Collections::emptySet).stream().filter(path -> isDirectFragment(directory, path)) - .sorted().toList(); + .orElseGet(Collections::emptySet) + .stream() + .filter(path -> isDirectFragment(directory, path)) + .sorted() + .toList(); } private boolean isDirectFragment(String directory, String path) { diff --git a/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java b/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java index cc009ce6..103ea62a 100644 --- a/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java +++ b/celements-messages/component/src/test/java/com/celements/messages/api/MessagesControllerTest.java @@ -4,37 +4,35 @@ import static org.junit.Assert.*; import java.io.IOException; -import java.util.Optional; import org.apache.velocity.VelocityContext; import org.junit.Before; import org.junit.Test; +import org.springframework.aop.framework.Advised; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.xwiki.velocity.VelocityManager; -import com.celements.auth.user.User; +import com.celements.auth.user.UserService; +import com.celements.common.test.AbstractComponentTest; import com.celements.messages.service.MessageService; +import com.celements.rights.access.IRightsAccessFacadeRole; import com.celements.web.service.IPrepareVelocityContext; +import com.xpn.xwiki.XWiki; -public class MessagesControllerTest { +public class MessagesControllerTest extends AbstractComponentTest { - private VelocityManager velocityManager; - private IPrepareVelocityContext prepareVelocityContext; - private MessageService messageService; private VelocityContext velocityContext; - private TestMessagesController controller; + private MessagesController controller; @Before - public void prepareTest() { - velocityManager = createMock(VelocityManager.class); - prepareVelocityContext = createMock(IPrepareVelocityContext.class); - messageService = createMock(MessageService.class); + public void prepareTest() throws Exception { + registerComponentMocks(VelocityManager.class, IPrepareVelocityContext.class, + MessageService.class, UserService.class, IRightsAccessFacadeRole.class); velocityContext = new VelocityContext(); - controller = new TestMessagesController(velocityManager, prepareVelocityContext, - messageService); + controller = getBeanTarget(MessagesController.class); } @Test @@ -55,70 +53,57 @@ public void testEndpointMappings() throws Exception { @Test public void testGetMessagesUsesPreparedExistingContext() throws Exception { + expectAnonymousAuth(); expectPreparedContext(); - expect(messageService.getMessages(same(velocityContext))).andReturn("{\"message\":\"value\"}"); - replayAll(); + expect(getMock(MessageService.class).getMessages(same(velocityContext))) + .andReturn("{\"message\":\"value\"}"); + replayDefault(); assertEquals("{\"message\":\"value\"}", controller.getMessages()); - assertTrue(controller.isCheckAuthCalled()); - verifyAll(); + verifyDefault(); } @Test public void testGetValidationMessagesUsesPreparedExistingContext() throws Exception { + expectAnonymousAuth(); expectPreparedContext(); - expect(messageService.getValidationMessages(same(velocityContext))) + expect(getMock(MessageService.class).getValidationMessages(same(velocityContext))) .andReturn("{\"required\":\"Required\"}"); - replayAll(); + replayDefault(); assertEquals("{\"required\":\"Required\"}", controller.getValidationMessages()); - assertTrue(controller.isCheckAuthCalled()); - verifyAll(); + verifyDefault(); } @Test public void testServiceFailurePropagates() throws Exception { + expectAnonymousAuth(); expectPreparedContext(); - expect(messageService.getMessages(same(velocityContext))).andThrow(new IOException("failed")); - replayAll(); + expect(getMock(MessageService.class).getMessages(same(velocityContext))) + .andThrow(new IOException("failed")); + replayDefault(); assertThrows(IOException.class, controller::getMessages); - verifyAll(); + verifyDefault(); } private void expectPreparedContext() { - expect(velocityManager.getVelocityContext()).andReturn(velocityContext); - prepareVelocityContext.prepareVelocityContext(same(velocityContext)); + expect(getMock(VelocityManager.class).getVelocityContext()).andReturn(velocityContext); + getMock(IPrepareVelocityContext.class).prepareVelocityContext(same(velocityContext)); } - private void replayAll() { - replay(velocityManager, prepareVelocityContext, messageService); + private void expectAnonymousAuth() throws Exception { + expect(getMock(XWiki.class).checkAuth(getXContext())).andReturn(null); } - private void verifyAll() { - verify(velocityManager, prepareVelocityContext, messageService); - } - - private static final class TestMessagesController extends MessagesController { - - private boolean checkAuthCalled; - - TestMessagesController(VelocityManager velocityManager, - IPrepareVelocityContext prepareVelocityContext, MessageService messageService) { - super(velocityManager, prepareVelocityContext, messageService); - } - - @Override - protected Optional checkAuth() { - checkAuthCalled = true; - return Optional.empty(); - } - - boolean isCheckAuthCalled() { - return checkAuthCalled; - } + @SuppressWarnings("unchecked") + private T getBeanTarget(Class beanClass) throws Exception { + T bean = getBeanFactory().getBean(beanClass); + return bean instanceof Advised advised + ? (T) advised.getTargetSource().getTarget() + : bean; } } diff --git a/celements-messages/component/src/test/java/com/celements/messages/script/MessagesScriptServiceTest.java b/celements-messages/component/src/test/java/com/celements/messages/script/MessagesScriptServiceTest.java index 5830dc72..a42abae5 100644 --- a/celements-messages/component/src/test/java/com/celements/messages/script/MessagesScriptServiceTest.java +++ b/celements-messages/component/src/test/java/com/celements/messages/script/MessagesScriptServiceTest.java @@ -12,21 +12,19 @@ import org.xwiki.script.service.ScriptService; import org.xwiki.velocity.VelocityManager; +import com.celements.common.test.AbstractComponentTest; import com.celements.messages.service.MessageService; -public class MessagesScriptServiceTest { +public class MessagesScriptServiceTest extends AbstractComponentTest { - private VelocityManager velocityManager; - private MessageService messageService; private VelocityContext velocityContext; private MessagesScriptService scriptService; @Before - public void prepareTest() { - velocityManager = createMock(VelocityManager.class); - messageService = createMock(MessageService.class); + public void prepareTest() throws Exception { + registerComponentMocks(VelocityManager.class, MessageService.class); velocityContext = new VelocityContext(); - scriptService = new MessagesScriptService(velocityManager, messageService); + scriptService = getBeanFactory().getBean(MessagesScriptService.class); } @Test @@ -38,43 +36,37 @@ public void testComponentRegistration() { @Test public void testGetMessagesUsesActiveExistingContext() throws Exception { - expect(velocityManager.getVelocityContext()).andReturn(velocityContext); - expect(messageService.getMessages(same(velocityContext))).andReturn("{\"message\":\"value\"}"); - replayAll(); + expect(getMock(VelocityManager.class).getVelocityContext()).andReturn(velocityContext); + expect(getMock(MessageService.class).getMessages(same(velocityContext))) + .andReturn("{\"message\":\"value\"}"); + replayDefault(); assertEquals("{\"message\":\"value\"}", scriptService.getMessages()); - verifyAll(); + verifyDefault(); } @Test public void testGetValidationMessagesUsesActiveExistingContext() throws Exception { - expect(velocityManager.getVelocityContext()).andReturn(velocityContext); - expect(messageService.getValidationMessages(same(velocityContext))) + expect(getMock(VelocityManager.class).getVelocityContext()).andReturn(velocityContext); + expect(getMock(MessageService.class).getValidationMessages(same(velocityContext))) .andReturn("{\"required\":\"Required\"}"); - replayAll(); + replayDefault(); assertEquals("{\"required\":\"Required\"}", scriptService.getValidationMessages()); - verifyAll(); + verifyDefault(); } @Test public void testServiceFailurePropagates() throws Exception { - expect(velocityManager.getVelocityContext()).andReturn(velocityContext); - expect(messageService.getMessages(same(velocityContext))).andThrow(new IOException("failed")); - replayAll(); + expect(getMock(VelocityManager.class).getVelocityContext()).andReturn(velocityContext); + expect(getMock(MessageService.class).getMessages(same(velocityContext))) + .andThrow(new IOException("failed")); + replayDefault(); assertThrows(IOException.class, scriptService::getMessages); - verifyAll(); - } - - private void replayAll() { - replay(velocityManager, messageService); - } - - private void verifyAll() { - verify(velocityManager, messageService); + verifyDefault(); } } diff --git a/celements-messages/component/src/test/java/com/celements/messages/service/MessageServiceTest.java b/celements-messages/component/src/test/java/com/celements/messages/service/MessageServiceTest.java index b26a0dee..1813eaf2 100644 --- a/celements-messages/component/src/test/java/com/celements/messages/service/MessageServiceTest.java +++ b/celements-messages/component/src/test/java/com/celements/messages/service/MessageServiceTest.java @@ -3,42 +3,54 @@ import static org.easymock.EasyMock.*; import static org.junit.Assert.*; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.StringWriter; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.ArrayList; import java.util.List; -import java.util.Set; import java.util.function.Consumer; -import javax.servlet.ServletContext; - import org.apache.velocity.VelocityContext; import org.junit.Before; +import org.junit.ClassRule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.mock.web.MockServletContext; +import org.springframework.util.FileSystemUtils; +import org.springframework.web.context.ConfigurableWebApplicationContext; import org.xwiki.velocity.VelocityEngine; import org.xwiki.velocity.VelocityManager; import org.xwiki.velocity.XWikiVelocityException; +import com.celements.common.test.AbstractComponentTest; import com.celements.sajson.JsonBuilder; -public class MessageServiceTest { +public class MessageServiceTest extends AbstractComponentTest { + + @ClassRule + public static final TemporaryFolder temporaryFolder = new TemporaryFolder(); - private ServletContext servletContext; - private VelocityManager velocityManager; private VelocityEngine velocityEngine; private VelocityContext velocityContext; private MessageService messageService; private List evaluatedFragments; + @Override + protected void beforeSpringContextRefresh(ConfigurableApplicationContext context) { + super.beforeSpringContextRefresh(context); + ((ConfigurableWebApplicationContext) context).setServletContext(new MockServletContext( + "file:" + temporaryFolder.getRoot().getAbsolutePath())); + } + @Before - public void prepareTest() { - servletContext = createMock(ServletContext.class); - velocityManager = createMock(VelocityManager.class); - velocityEngine = createMock(VelocityEngine.class); + public void prepareTest() throws Exception { + FileSystemUtils.deleteRecursively(temporaryFolder.getRoot().toPath().resolve("templates")); + registerComponentMocks(VelocityManager.class); + velocityEngine = createDefaultMock(VelocityEngine.class); velocityContext = new VelocityContext(); - messageService = new MessageService(servletContext, velocityManager); + messageService = getBeanFactory().getBean(MessageService.class); evaluatedFragments = new ArrayList<>(); } @@ -47,14 +59,17 @@ public void testGetMessagesUsesLexicalDirectFragments() throws Exception { String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; String celements = directory + "celements.vm"; String zeta = directory + "zeta.vm"; - expectDiscovery(directory, Set.of(zeta, directory + "nested/ignored.vm", celements)); + createFragment(celements); + createFragment(zeta); + createFragment(directory + "nested/ignored.vm"); + expectVelocityEngine(); expectFragment(celements, " \n", true, builder -> { builder.addProperty("string", "value"); builder.addProperty("boolean", true); builder.addProperty("number", 7); }); expectFragment(zeta, "\n", true, builder -> builder.addProperty("zeta", "last")); - replayAll(); + replayDefault(); String json = messageService.getMessages(velocityContext); @@ -64,78 +79,82 @@ public void testGetMessagesUsesLexicalDirectFragments() throws Exception { json); assertEquals(List.of(celements, zeta), evaluatedFragments); assertFalse(velocityContext.containsKey("jsonBuilder")); - verifyAll(); + verifyDefault(); } @Test public void testGetValidationMessagesUsesOnlyValidationDirectory() throws Exception { String directory = MessageService.VALIDATION_FRAGMENT_DIRECTORY; String celements = directory + "celements.vm"; - expectDiscovery(directory, Set.of(celements)); + createFragment(celements); + expectVelocityEngine(); expectFragment(celements, "", true, builder -> builder.addProperty("required", "Required")); - replayAll(); + replayDefault(); assertEquals("{\"required\" : \"Required\"}", messageService.getValidationMessages(velocityContext)); assertEquals(List.of(celements), evaluatedFragments); - verifyAll(); + verifyDefault(); } @Test - public void testMissingCelementsFragmentFailsCompleteRequest() { + public void testMissingCelementsFragmentFailsCompleteRequest() throws Exception { String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; - expect(servletContext.getResourcePaths(directory)).andReturn(Set.of(directory + "product.vm")); - replayAll(); + createFragment(directory + "product.vm"); + replayDefault(); assertThrows(IOException.class, () -> messageService.getMessages(velocityContext)); assertFalse(velocityContext.containsKey("jsonBuilder")); - verifyAll(); + verifyDefault(); } @Test public void testNonWhitespaceFragmentOutputFailsCompleteRequest() throws Exception { String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; String celements = directory + "celements.vm"; - expectDiscovery(directory, Set.of(celements)); + createFragment(celements); + expectVelocityEngine(); expectFragment(celements, "unexpected", true, builder -> builder.addProperty("message", "value")); - replayAll(); + replayDefault(); assertThrows(IOException.class, () -> messageService.getMessages(velocityContext)); assertFalse(velocityContext.containsKey("jsonBuilder")); - verifyAll(); + verifyDefault(); } @Test public void testFailedFragmentEvaluationFailsCompleteRequest() throws Exception { String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; String celements = directory + "celements.vm"; - expectDiscovery(directory, Set.of(celements)); + createFragment(celements); + expectVelocityEngine(); expectFragment(celements, "", false, builder -> assertNotNull(builder)); - replayAll(); + replayDefault(); assertThrows(XWikiVelocityException.class, () -> messageService.getMessages(velocityContext)); assertFalse(velocityContext.containsKey("jsonBuilder")); - verifyAll(); + verifyDefault(); } @Test public void testUnbalancedBuilderDepthFailsCompleteRequest() throws Exception { String directory = MessageService.GENERAL_FRAGMENT_DIRECTORY; String celements = directory + "celements.vm"; - expectDiscovery(directory, Set.of(celements)); + createFragment(celements); + expectVelocityEngine(); expectFragment(celements, "", true, builder -> builder.openDictionary("nested")); - replayAll(); + replayDefault(); assertThrows(IllegalStateException.class, () -> messageService.getMessages(velocityContext)); assertFalse(velocityContext.containsKey("jsonBuilder")); - verifyAll(); + verifyDefault(); } @Test @@ -144,27 +163,30 @@ public void testExistingJsonBuilderContextValueIsRestored() throws Exception { String celements = directory + "celements.vm"; var previousBuilder = new JsonBuilder(); velocityContext.put("jsonBuilder", previousBuilder); - expectDiscovery(directory, Set.of(celements)); + createFragment(celements); + expectVelocityEngine(); expectFragment(celements, "", true, builder -> builder.addProperty("message", "value")); - replayAll(); + replayDefault(); messageService.getMessages(velocityContext); assertSame(previousBuilder, velocityContext.get("jsonBuilder")); - verifyAll(); + verifyDefault(); + } + + private void createFragment(String path) throws IOException { + var fragment = temporaryFolder.getRoot().toPath().resolve(path.substring(1)); + Files.createDirectories(fragment.getParent()); + Files.writeString(fragment, "fragment:" + path, StandardCharsets.UTF_8); } - private void expectDiscovery(String directory, Set resources) - throws XWikiVelocityException { - expect(servletContext.getResourcePaths(directory)).andReturn(resources); - expect(velocityManager.getVelocityEngine()).andReturn(velocityEngine); + private void expectVelocityEngine() throws XWikiVelocityException { + expect(getMock(VelocityManager.class).getVelocityEngine()).andReturn(velocityEngine); } private void expectFragment(String path, String output, boolean result, Consumer builderAction) throws Exception { String source = "fragment:" + path; - expect(servletContext.getResourceAsStream(path)) - .andReturn(new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8))); expect(velocityEngine.evaluate(same(velocityContext), isA(StringWriter.class), eq(path), eq(source))).andAnswer(() -> { evaluatedFragments.add(path); @@ -173,12 +195,4 @@ private void expectFragment(String path, String output, boolean result, return result; }); } - - private void replayAll() { - replay(servletContext, velocityManager, velocityEngine); - } - - private void verifyAll() { - verify(servletContext, velocityManager, velocityEngine); - } }