This is an automated email from the ASF dual-hosted git repository.
joerghoh pushed a commit to branch master
in repository
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-i18n.git
The following commit(s) were added to refs/heads/master by this push:
new 725d0ab SLING-13394 Avoid full i18n reloads for edits within known
dictionaries (#26)
725d0ab is described below
commit 725d0abf1515a8ed8133afbc799b1fceed317e00
Author: Jörg Hoh <[email protected]>
AuthorDate: Tue Oct 20 19:58:32 2026 +0200
SLING-13394 Avoid full i18n reloads for edits within known dictionaries
(#26)
* SLING-13394 avoid full reloads when individual messages are updated
---
README.md | 13 +
.../sling/i18n/impl/JcrResourceBundleProvider.java | 78 ++-
.../i18n/impl/JcrResourceBundleProviderTest.java | 627 ++++++++++++++++++++-
.../sling/i18n/it/ResourceBundleProviderIT.java | 161 +++++-
4 files changed, 842 insertions(+), 37 deletions(-)
diff --git a/README.md b/README.md
index 64dc559..cb7440f 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,19 @@ resources. Further details in
<https://sling.apache.org/documentation/bundles/in
## Dictionary updates
+Entry additions, edits, and removals below known dictionary roots refresh only
+the bundles using those roots and their dependent locales, including all
+matching basenames. Unrelated cached bundles remain unchanged. Changes to
+dictionary roots (including locale or basename metadata), removal of their
+ancestors, and discovery of new dictionaries still trigger a full refresh.
+Observation respects the configured included and excluded paths. A legitimate
+full refresh still clears the cache before loading replacements; this does not
+eliminate the availability gap during that refresh.
+
+JSON content events below a known file root are targeted. Providers that report
+content updates at the dictionary root, including the JCR provider's `nt:file`
+events, retain full-refresh behavior.
+
When `preload.bundles` is enabled, an individual bundle refresh reloads parents
before their dependent locales so that refreshed bundles use current fallback
translations. With preloading disabled, affected bundles are invalidated and
diff --git
a/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
b/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
index f347869..2fd8d96 100644
--- a/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
+++ b/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
@@ -50,6 +50,7 @@ import org.apache.sling.api.resource.ValueMap;
import
org.apache.sling.api.resource.observation.ExternalResourceChangeListener;
import org.apache.sling.api.resource.observation.ResourceChange;
import org.apache.sling.api.resource.observation.ResourceChangeListener;
+import org.apache.sling.api.resource.path.Path;
import org.apache.sling.commons.scheduler.ScheduleOptions;
import org.apache.sling.commons.scheduler.Scheduler;
import org.apache.sling.i18n.ResourceBundleProvider;
@@ -261,8 +262,15 @@ public class JcrResourceBundleProvider
change.getPath());
status.reloadAll = true;
} else {
+ boolean withinKnownDictionary = false;
for (final String root : languageRootPaths) {
- if (change.getPath().startsWith(root)) {
+ if (change.getType() == ResourceChange.ChangeType.REMOVED &&
new Path(change.getPath()).matches(root)) {
+ // Removing an ancestor also removes its dictionaries.
+ status.reloadAll = true;
+ return;
+ }
+ if (new Path(root).matches(change.getPath())) {
+ withinKnownDictionary = true;
// figure out which JcrResourceBundles from the cached
ones is affected
for (JcrResourceBundle bundle :
resourceBundleRegistry.getResourceBundles()) {
if (bundle.getLanguageRootPaths().contains(root)) {
@@ -277,55 +285,63 @@ public class JcrResourceBundleProvider
}
}
- // may be a completely new dictionary
+ // Removal handling only needs the path-based classification above.
+ if (change.getType() == ResourceChange.ChangeType.REMOVED) {
+ return;
+ }
+
if (status.resourceResolver == null) {
status.resourceResolver = createResourceResolver();
}
- if (isDictionaryResource(status.resourceResolver, change)) {
+ final Resource resource =
status.resourceResolver.getResource(change.getPath());
+ // An added or changed resource may have been removed before this
event is processed.
+ if (resource == null) {
+ log.trace("Could not get resource for '{}' for event {}",
change.getPath(), change.getType());
+ return;
+ }
+
+ // A new language root can introduce another locale or basename,
even inside a known dictionary.
+ if (isDictionaryRoot(resource)) {
+ log.debug("Found dictionary root change at '{}', requiring a
full reload", change.getPath());
+ status.reloadAll = true;
+ return;
+ }
+
+ // An entry outside known roots may reveal another dictionary.
Entries inside known dictionaries
+ // are already covered by the targeted refreshes collected above.
+ if (!withinKnownDictionary && isMessageEntry(resource)) {
status.reloadAll = true;
}
}
}
- private boolean isDictionaryResource(final ResourceResolver resolver,
final ResourceChange change) {
- // language node changes happen quite frequently
(https://issues.apache.org/jira/browse/SLING-2881)
- // therefore only consider changes either for sling:MessageEntry's
- // or for JSON dictionaries
- // get valuemap
- final Resource resource = resolver.getResource(change.getPath());
- if (resource == null) {
- log.trace("Could not get resource for '{}' for event {}",
change.getPath(), change.getType());
- return false;
- }
- if (resource.getResourceType() == null) {
- return false;
- }
+ /**
+ * Identifies language-root metadata independently of whether the
dictionary is already known.
+ */
+ private boolean isDictionaryRoot(final Resource resource) {
+ final ValueMap valueMap = resource.getValueMap();
+ return hasMixin(valueMap, JcrResourceBundle.MIXIN_LANGUAGE) &&
valueMap.containsKey(PROP_LANGUAGE);
+ }
+
+ /**
+ * Recognizes message entries by their resource type or message mixin,
without inferring dictionary ownership.
+ */
+ private boolean isMessageEntry(final Resource resource) {
if (resource.isResourceType(JcrResourceBundle.RT_MESSAGE_ENTRY)) {
log.debug(
"Found new dictionary entry: New {} resource in '{}'
detected",
JcrResourceBundle.RT_MESSAGE_ENTRY,
- change.getPath());
+ resource.getPath());
return true;
}
- final ValueMap valueMap = resource.getValueMap();
- // FIXME: derivatives from mix:Message are not detected
- if (hasMixin(valueMap, JcrResourceBundle.MIXIN_MESSAGE)) {
+ // FIXME: derivatives of sling:Message are not detected
+ if (hasMixin(resource.getValueMap(), JcrResourceBundle.MIXIN_MESSAGE))
{
log.debug(
"Found new dictionary entry: New {} resource in '{}'
detected",
JcrResourceBundle.MIXIN_MESSAGE,
- change.getPath());
+ resource.getPath());
return true;
}
- if (change.getPath().endsWith(".json")) {
- // check for mixin
- if (hasMixin(valueMap, JcrResourceBundle.MIXIN_LANGUAGE)) {
- log.debug(
- "Found new dictionary: New {} resource in '{}'
detected",
- JcrResourceBundle.MIXIN_LANGUAGE,
- change.getPath());
- return true;
- }
- }
return false;
}
diff --git
a/src/test/java/org/apache/sling/i18n/impl/JcrResourceBundleProviderTest.java
b/src/test/java/org/apache/sling/i18n/impl/JcrResourceBundleProviderTest.java
index 3c335fc..6ba0a8a 100644
---
a/src/test/java/org/apache/sling/i18n/impl/JcrResourceBundleProviderTest.java
+++
b/src/test/java/org/apache/sling/i18n/impl/JcrResourceBundleProviderTest.java
@@ -18,18 +18,641 @@
*/
package org.apache.sling.i18n.impl;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Locale;
-
+import java.util.Map;
+import java.util.ResourceBundle;
+import java.util.stream.Collectors;
+
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.ModifiableValueMap;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.api.resource.observation.ResourceChange;
+import org.apache.sling.api.resource.observation.ResourceChange.ChangeType;
+import org.apache.sling.commons.scheduler.ScheduleOptions;
+import org.apache.sling.commons.scheduler.Scheduler;
+import org.apache.sling.serviceusermapping.ServiceUserMapped;
import org.apache.sling.testing.mock.sling.junit.SlingContext;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
-
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.mockito.Mockito;
+import org.osgi.framework.Constants;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+
+/**
+ * Covers locale handling and observation-driven refresh with eager and lazy
loading.
+ * Observation tests explicitly run queued work, then check translations,
dependent
+ * bundle references, and preservation of unrelated cached bundles.
+ */
+@RunWith(Parameterized.class)
public class JcrResourceBundleProviderTest {
+ private static final String KEY = "checkout-label";
+ private static final String[] BASENAMES = {null, "checkout", "store",
"other"};
+ private static final String RELOAD_ALL = "ResourceBundleProvider: reload
all resource bundles";
+
@Rule
public final SlingContext context = new SlingContext();
+ @Parameterized.Parameters(name = "preload_bundles={0}")
+ public static Iterable<Boolean> preloadBundles() {
+ return Arrays.asList(Boolean.FALSE, Boolean.TRUE);
+ }
+
+ @Parameterized.Parameter
+ public boolean preload;
+
+ private JcrResourceBundleProvider provider;
+ private ResourceResolverFactory factory;
+ private final List<String> dictionaryPaths = new ArrayList<>();
+ private final Map<String, Runnable> scheduledJobs = new LinkedHashMap<>();
+ private String scheduledJobName;
+
+ private void setupObservation() throws Exception {
+ Resource english = createDictionary("/apps/i18n/en", "en", "checkout",
"store", "other");
+ createEntry(english.getPath() + "/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "English");
+ createDictionary("/apps/i18n/de", "de");
+ createDictionary("/apps/i18n/fr", "fr");
+
+ ResourceResolver resolver = Mockito.spy(context.resourceResolver());
+ Mockito.doNothing().when(resolver).close();
+ Mockito.doAnswer(query -> dictionaries().iterator())
+ .when(resolver)
+ .findResources(JcrResourceBundle.QUERY_LANGUAGE_ROOTS,
"xpath");
+ Mockito.doAnswer(query -> dictionaries().stream()
+ .map(root -> {
+ Map<String, Object> row = new
HashMap<>(root.getValueMap());
+ row.put("jcr:path", root.getPath());
+ return row;
+ })
+ .iterator())
+ .when(resolver)
+ .queryResources(JcrResourceBundle.QUERY_LANGUAGE_ROOTS,
"xpath");
+ factory = Mockito.mock(ResourceResolverFactory.class);
+
Mockito.when(factory.getServiceResourceResolver(null)).thenReturn(resolver);
+ context.registerService(ResourceResolverFactory.class, factory,
Constants.SERVICE_RANKING, 1000);
+ context.registerService(ServiceUserMapped.class,
Mockito.mock(ServiceUserMapped.class));
+
+ Scheduler scheduler = context.registerService(Scheduler.class,
Mockito.mock(Scheduler.class));
+ ScheduleOptions options = Mockito.mock(ScheduleOptions.class);
+ Mockito.when(scheduler.NOW()).thenReturn(options);
+ Mockito.when(scheduler.AT(any(Date.class))).thenReturn(options);
+ Mockito.doAnswer(invocation -> {
+ scheduledJobName = invocation.getArgument(0);
+ return options;
+ })
+ .when(options)
+ .name(any(String.class));
+ Mockito.doAnswer(invocation -> {
+ scheduledJobs.put(scheduledJobName,
invocation.getArgument(0));
+ return true;
+ })
+ .when(scheduler)
+ .schedule(any(Runnable.class), any(ScheduleOptions.class));
+ Mockito.doAnswer(invocation ->
scheduledJobs.remove(invocation.getArgument(0)) != null)
+ .when(scheduler)
+ .unschedule(any(String.class));
+
+ Map<String, Object> config = new HashMap<>();
+ config.put("preload.bundles", preload);
+ config.put("locale.default", "en");
+ config.put("included.paths", new String[] {"/apps", "/libs"});
+ config.put("excluded.paths", new String[] {"/apps/excluded",
"/apps/i18n/de/excluded"});
+ provider =
context.registerInjectActivateService(JcrResourceBundleProvider.class, config);
+ runScheduledWork();
+ cacheBundles();
+ }
+
+ private Resource createDictionary(String path, String language, String...
basenames) {
+ Resource root = context.create()
+ .resource(
+ path,
+ "jcr:primaryType",
+ "nt:unstructured",
+ "jcr:mixinTypes",
+ new String[] {JcrResourceBundle.MIXIN_LANGUAGE},
+ "jcr:language",
+ language,
+ "sling:basename",
+ basenames);
+ dictionaryPaths.add(path);
+ return root;
+ }
+
+ private List<Resource> dictionaries() {
+ return dictionaryPaths.stream()
+ .map(path -> context.resourceResolver().getResource(path))
+ .filter(root -> root != null
+ && root.getValueMap().containsKey("jcr:language")
+ &&
Arrays.asList(root.getValueMap().get("jcr:mixinTypes", new String[0]))
+ .contains(JcrResourceBundle.MIXIN_LANGUAGE))
+ .collect(Collectors.toList());
+ }
+
+ private Resource createEntry(String path, String type, String message) {
+ return context.create().resource(path, "jcr:primaryType", type,
"sling:key", KEY, "sling:message", message);
+ }
+
+ private Map<String, List<ResourceBundle>> cacheBundles() {
+ Map<String, List<ResourceBundle>> bundles = new LinkedHashMap<>();
+ for (String basename : BASENAMES) {
+ bundles.put(
+ basename,
+ Arrays.asList(
+ provider.getResourceBundle(basename,
Locale.ENGLISH),
+ provider.getResourceBundle(basename,
Locale.GERMAN),
+ provider.getResourceBundle(basename,
Locale.GERMANY),
+ provider.getResourceBundle(basename,
Locale.FRENCH)));
+ }
+ return bundles;
+ }
+
+ private void runScheduledWork() {
+ List<Runnable> jobs = new ArrayList<>(scheduledJobs.values());
+ scheduledJobs.clear();
+ jobs.forEach(Runnable::run);
+ }
+
+ private void assertTargetedChange(String path, ChangeType type, String
expected, String... affectedBasenames) {
+ Map<String, List<ResourceBundle>> before = cacheBundles();
+ provider.onChange(Collections.singletonList(new ResourceChange(type,
path, false)));
+ assertEquals(affectedBasenames.length, scheduledJobs.size());
+ assertFalse("Entry edits must not schedule rediscovery",
scheduledJobs.containsKey(RELOAD_ALL));
+ Map<String, List<ResourceBundle>> pending = cacheBundles();
+ before.forEach((basename, bundles) -> {
+ for (int i = 0; i < bundles.size(); i++) {
+ assertSame(
+ "Changes must wait for scheduled work",
+ bundles.get(i),
+ pending.get(basename).get(i));
+ }
+ });
+ runScheduledWork();
+ Map<String, List<ResourceBundle>> after = cacheBundles();
+ before.forEach((basename, bundles) -> {
+ boolean affected =
Arrays.asList(affectedBasenames).contains(basename);
+ List<ResourceBundle> refreshed = after.get(basename);
+ assertSame("English fallback is unchanged", bundles.get(0),
refreshed.get(0));
+ assertSame("French is unrelated", bundles.get(3),
refreshed.get(3));
+ if (affected) {
+ assertNotSame(bundles.get(1), refreshed.get(1));
+ assertNotSame("Dependent country bundle must refresh",
bundles.get(2), refreshed.get(2));
+ assertSame(refreshed.get(1), ((JcrResourceBundle)
refreshed.get(2)).getParent());
+ assertEquals(expected, refreshed.get(1).getString(KEY));
+ assertEquals(expected, refreshed.get(2).getString(KEY));
+ } else {
+ assertSame("Unrelated basename is unchanged", bundles.get(1),
refreshed.get(1));
+ assertSame(bundles.get(2), refreshed.get(2));
+ }
+ });
+ }
+
+ private void assertFullReload(ResourceChange... changes) {
+ Map<String, List<ResourceBundle>> before = cacheBundles();
+ provider.onChange(Arrays.asList(changes));
+ assertEquals(Collections.singleton(RELOAD_ALL),
scheduledJobs.keySet());
+ runScheduledWork();
+ Map<String, List<ResourceBundle>> after = cacheBundles();
+ before.forEach((basename, bundles) -> {
+ for (int i = 0; i < bundles.size(); i++) {
+ assertNotSame(
+ "Discovery changes must invalidate every bundle",
+ bundles.get(i),
+ after.get(basename).get(i));
+ }
+ });
+ }
+
+ private void assertIgnoredChange(ResourceChange... changes) {
+ Map<String, List<ResourceBundle>> before = cacheBundles();
+ provider.onChange(Arrays.asList(changes));
+ assertTrue(scheduledJobs.isEmpty());
+ runScheduledWork();
+ Map<String, List<ResourceBundle>> after = cacheBundles();
+ before.forEach((basename, bundles) -> {
+ for (int i = 0; i < bundles.size(); i++) {
+ assertSame(bundles.get(i), after.get(basename).get(i));
+ }
+ });
+ }
+
+ /**
+ * Adds, updates, and removes a typed entry in a known German dictionary.
+ * Only its bundles and dependent country bundles should refresh; removal
restores the English fallback.
+ */
+ @Test
+ public void knownMessageEntryChangesAreTargeted() throws Exception {
+ setupObservation();
+ Resource entry = createEntry("/apps/i18n/de/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "Added");
+ assertTargetedChange(entry.getPath(), ChangeType.ADDED, "Added",
(String) null);
+ entry.adaptTo(ModifiableValueMap.class).put("sling:message",
"Changed");
+ assertTargetedChange(entry.getPath(), ChangeType.CHANGED, "Changed",
(String) null);
+ context.resourceResolver().delete(entry);
+ assertTargetedChange(entry.getPath(), ChangeType.REMOVED, "English",
(String) null);
+ }
+
+ @Test
+ public void entryRemovalSchedulesRefreshWithoutResolverLogin() throws
Exception {
+ setupObservation();
+ Resource entry = createEntry("/apps/i18n/de/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "German");
+ assertTargetedChange(entry.getPath(), ChangeType.ADDED, "German",
(String) null);
+ context.resourceResolver().delete(entry);
+ ResourceResolver resolver = factory.getServiceResourceResolver(null);
+ Mockito.when(factory.getServiceResourceResolver(null)).thenThrow(new
LoginException("Temporarily unavailable"));
+ Mockito.clearInvocations(factory);
+
+ provider.onChange(Collections.singletonList(new
ResourceChange(ChangeType.REMOVED, entry.getPath(), false)));
+
+ Mockito.verifyNoInteractions(factory);
+ assertEquals(1, scheduledJobs.size());
+ assertFalse(scheduledJobs.containsKey(RELOAD_ALL));
+
Mockito.doReturn(resolver).when(factory).getServiceResourceResolver(null);
+ runScheduledWork();
+ assertEquals("English",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ assertEquals("English",
provider.getResourceBundle(Locale.GERMANY).getString(KEY));
+ }
+
+ @Test
+ public void removalDoesNotUseResolverOpenedForEarlierEvent() throws
Exception {
+ setupObservation();
+ Resource entry = createEntry("/apps/i18n/de/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "German");
+ assertTargetedChange(entry.getPath(), ChangeType.ADDED, "German",
(String) null);
+ ResourceResolver resolver = factory.getServiceResourceResolver(null);
+ String removedPath = "/apps/i18n/de/removed";
+ entry.adaptTo(ModifiableValueMap.class).put("sling:message",
"Changed");
+ Mockito.clearInvocations(resolver);
+
+ provider.onChange(Arrays.asList(
+ new ResourceChange(ChangeType.CHANGED, entry.getPath(), false),
+ new ResourceChange(ChangeType.REMOVED, removedPath, false)));
+
+ Mockito.verify(resolver).getResource(entry.getPath());
+ Mockito.verify(resolver, Mockito.never()).getResource(removedPath);
+ assertEquals(1, scheduledJobs.size());
+ assertFalse(scheduledJobs.containsKey(RELOAD_ALL));
+ runScheduledWork();
+ assertEquals("Changed",
provider.getResourceBundle(Locale.GERMANY).getString(KEY));
+ }
+
+ /**
+ * Verifies that entries using the message mixin receive the same targeted
refresh
+ * treatment as entries using the message primary type.
+ */
+ @Test
+ public void knownMessageMixinChangesAreTargeted() throws Exception {
+ setupObservation();
+ Resource entry = createEntry("/apps/i18n/de/" + KEY,
"nt:unstructured", "Added");
+ entry.adaptTo(ModifiableValueMap.class).put("jcr:mixinTypes", new
String[] {JcrResourceBundle.MIXIN_MESSAGE});
+ assertTargetedChange(entry.getPath(), ChangeType.ADDED, "Added",
(String) null);
+ entry.adaptTo(ModifiableValueMap.class).put("sling:message",
"Changed");
+ assertTargetedChange(entry.getPath(), ChangeType.CHANGED, "Changed",
(String) null);
+ context.resourceResolver().delete(entry);
+ assertTargetedChange(entry.getPath(), ChangeType.REMOVED, "English",
(String) null);
+ }
+
+ /**
+ * Preserves observation support for entries loaded by their message
properties alone,
+ * without requiring a message primary type or mixin.
+ */
+ @Test
+ public void knownUntypedEntryChangesRemainTargeted() throws Exception {
+ setupObservation();
+ Resource entry = createEntry("/apps/i18n/de/" + KEY,
"nt:unstructured", "Added");
+ assertTargetedChange(entry.getPath(), ChangeType.ADDED, "Added",
(String) null);
+ entry.adaptTo(ModifiableValueMap.class).put("sling:message",
"Changed");
+ assertTargetedChange(entry.getPath(), ChangeType.CHANGED, "Changed",
(String) null);
+ context.resourceResolver().delete(entry);
+ assertTargetedChange(entry.getPath(), ChangeType.REMOVED, "English",
(String) null);
+ }
+
+ /**
+ * A language mixin without a locale does not define a new dictionary root.
+ * Such an entry must still use the surrounding dictionary's targeted
refresh.
+ */
+ @Test
+ public void languageMixinWithoutLocaleDoesNotIntroduceDictionary() throws
Exception {
+ setupObservation();
+ Resource entry = createEntry("/apps/i18n/de/" + KEY,
"nt:unstructured", "Added");
+ entry.adaptTo(ModifiableValueMap.class).put("jcr:mixinTypes", new
String[] {JcrResourceBundle.MIXIN_LANGUAGE});
+ assertTargetedChange(entry.getPath(), ChangeType.ADDED, "Added",
(String) null);
+ }
+
+ /**
+ * A shared root contributes to unnamed bundles and multiple named bundles.
+ * Refresh every matching basename, but preserve bundles using only the
other root.
+ */
+ @Test
+ public void sharedRootRefreshesEveryAffectedBasename() throws Exception {
+ setupObservation();
+ context.resourceResolver()
+ .getResource("/apps/i18n/de")
+ .adaptTo(ModifiableValueMap.class)
+ .put("sling:basename", new String[] {"checkout", "store"});
+ Resource other = createDictionary("/apps/i18n/de-other", "de",
"store", "other");
+ context.create().resource(other.getPath() + "/secondary",
"sling:message", "Other dictionary");
+ provider.clearCache();
+ cacheBundles();
+ Resource entry = createEntry("/apps/i18n/de/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "Added");
+ assertTargetedChange(entry.getPath(), ChangeType.ADDED, "Added", null,
"checkout", "store");
+ entry.adaptTo(ModifiableValueMap.class).put("sling:message",
"Changed");
+ assertTargetedChange(entry.getPath(), ChangeType.CHANGED, "Changed",
null, "checkout", "store");
+ context.resourceResolver().delete(entry);
+ assertTargetedChange(entry.getPath(), ChangeType.REMOVED, "English",
null, "checkout", "store");
+ }
+
+ /**
+ * A typed entry outside known roots must trigger discovery and expose its
translation.
+ * The sibling path de-extra must not be mistaken for a descendant of the
known de root.
+ */
+ @Test
+ public void unknownMessageEntryTriggersDiscovery() throws Exception {
+ setupObservation();
+ createDictionary("/apps/i18n/de-extra", "de");
+ Resource entry = createEntry("/apps/i18n/de-extra/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "Discovered");
+ assertFullReload(new ResourceChange(ChangeType.ADDED, entry.getPath(),
false));
+ assertEquals("Discovered",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ }
+
+ /**
+ * A changed entry carrying the message mixin can reveal an unknown
dictionary,
+ * even when the event is not an addition.
+ */
+ @Test
+ public void unknownMessageMixinTriggersDiscovery() throws Exception {
+ setupObservation();
+ createDictionary("/apps/i18n/de-extra", "de");
+ Resource entry = createEntry("/apps/i18n/de-extra/" + KEY,
"nt:unstructured", "Discovered");
+ entry.adaptTo(ModifiableValueMap.class).put("jcr:mixinTypes", new
String[] {JcrResourceBundle.MIXIN_MESSAGE});
+ assertFullReload(new ResourceChange(ChangeType.CHANGED,
entry.getPath(), false));
+ assertEquals("Discovered",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ }
+
+ /**
+ * Adding a language root must trigger discovery even when its entries
have no message type.
+ */
+ @Test
+ public void unknownLanguageRootTriggersDiscovery() throws Exception {
+ setupObservation();
+ Resource root = createDictionary("/apps/i18n/de-extra", "de");
+ createEntry(root.getPath() + "/" + KEY, "nt:unstructured",
"Discovered");
+ assertFullReload(new ResourceChange(ChangeType.ADDED, root.getPath(),
false));
+ assertEquals("Discovered",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ }
+
+ /**
+ * A new root nested below a known dictionary can introduce a different
locale and basename.
+ * Rediscovery must replace the previously cached empty Italian bundle.
+ */
+ @Test
+ public void nestedLanguageRootTriggersDiscovery() throws Exception {
+ setupObservation();
+ ResourceBundle italian = provider.getResourceBundle("checkout",
Locale.ITALIAN);
+ Resource root = createDictionary("/apps/i18n/de/nested", "it",
"checkout");
+ createEntry(root.getPath() + "/" + KEY, "nt:unstructured", "Italian");
+ assertFullReload(new ResourceChange(ChangeType.ADDED, root.getPath(),
false));
+ assertNotSame(italian, provider.getResourceBundle("checkout",
Locale.ITALIAN));
+ assertEquals(
+ "Italian",
+ provider.getResourceBundle("checkout",
Locale.ITALIAN).getString(KEY));
+ }
+
+ /**
+ * An unknown dictionary in an event batch must promote the batch to a
full reload,
+ * even if an earlier event only affected a known dictionary.
+ */
+ @Test
+ public void discoveryInBatchIsNotHiddenByKnownEntry() throws Exception {
+ setupObservation();
+ Resource known = createEntry("/apps/i18n/de/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "German");
+ createDictionary("/apps/i18n/fr-extra", "fr");
+ Resource unknown = createEntry("/apps/i18n/fr-extra/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "French");
+ assertFullReload(
+ new ResourceChange(ChangeType.CHANGED, known.getPath(), false),
+ new ResourceChange(ChangeType.ADDED, unknown.getPath(),
false));
+ assertEquals("German",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ assertEquals("French",
provider.getResourceBundle(Locale.FRENCH).getString(KEY));
+ }
+
+ /**
+ * Changing a root's locale must rediscover ownership: its translation
moves to Italian,
+ * while German returns to its English fallback.
+ */
+ @Test
+ public void rootLocaleChangeTriggersDiscovery() throws Exception {
+ setupObservation();
+ cacheGermanEntry();
+ Resource root =
context.resourceResolver().getResource("/apps/i18n/de");
+ root.adaptTo(ModifiableValueMap.class).put("jcr:language", "it");
+ assertFullReload(new ResourceChange(ChangeType.CHANGED,
root.getPath(), false));
+ assertEquals("English",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ assertEquals("German",
provider.getResourceBundle(Locale.ITALIAN).getString(KEY));
+ }
+
+ /**
+ * Changing root basename metadata must invalidate cached named bundles so
that
+ * newly matching basenames receive the dictionary and nonmatching ones
retain their fallback.
+ */
+ @Test
+ public void rootBasenameChangeTriggersDiscovery() throws Exception {
+ setupObservation();
+ cacheGermanEntry();
+ Resource root =
context.resourceResolver().getResource("/apps/i18n/de");
+ root.adaptTo(ModifiableValueMap.class).put("sling:basename", new
String[] {"checkout"});
+ assertFullReload(new ResourceChange(ChangeType.CHANGED,
root.getPath(), false));
+ assertEquals(
+ "German", provider.getResourceBundle("checkout",
Locale.GERMAN).getString(KEY));
+ assertEquals(
+ "English", provider.getResourceBundle("store",
Locale.GERMAN).getString(KEY));
+ }
+
+ /**
+ * Removing the language mixin makes a known root ineligible for discovery.
+ * A full refresh must discard its previously cached translations.
+ */
+ @Test
+ public void rootLanguageMixinRemovalTriggersDiscovery() throws Exception {
+ setupObservation();
+ cacheGermanEntry();
+ Resource root =
context.resourceResolver().getResource("/apps/i18n/de");
+ root.adaptTo(ModifiableValueMap.class).remove("jcr:mixinTypes");
+ assertFullReload(new ResourceChange(ChangeType.CHANGED,
root.getPath(), false));
+ assertEquals("English",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ }
+
+ /**
+ * Removing a known root requires a full refresh even though the resource
can no longer
+ * be inspected; its former translations must fall back to English.
+ */
+ @Test
+ public void rootRemovalTriggersDiscovery() throws Exception {
+ setupObservation();
+ cacheGermanEntry();
+
context.resourceResolver().delete(context.resourceResolver().getResource("/apps/i18n/de"));
+ assertFullReload(new ResourceChange(ChangeType.REMOVED,
"/apps/i18n/de", false));
+ assertEquals("English",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ }
+
+ private void cacheGermanEntry() {
+ createEntry("/apps/i18n/de/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "German");
+ provider.clearCache();
+ cacheBundles();
+ assertEquals("German",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ }
+
+ /**
+ * Removing the locale property invalidates a known dictionary just like
removing its
+ * language mixin, so cached translations must not survive rediscovery.
+ */
+ @Test
+ public void rootLanguagePropertyRemovalTriggersDiscovery() throws
Exception {
+ setupObservation();
+ cacheGermanEntry();
+ Resource root =
context.resourceResolver().getResource("/apps/i18n/de");
+ root.adaptTo(ModifiableValueMap.class).remove("jcr:language");
+ assertFullReload(new ResourceChange(ChangeType.CHANGED,
root.getPath(), false));
+ assertEquals("English",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ }
+
+ /**
+ * A removal event on a dictionary container must invalidate all roots
below it,
+ * without relying on separate removal events for each descendant.
+ */
+ @Test
+ public void dictionaryAncestorRemovalTriggersDiscovery() throws Exception {
+ setupObservation();
+
context.resourceResolver().delete(context.resourceResolver().getResource("/apps/i18n"));
+ assertFullReload(new ResourceChange(ChangeType.REMOVED, "/apps/i18n",
false));
+ assertEquals(KEY,
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ }
+
+ /**
+ * Shared string prefixes do not imply dictionary ancestry.
+ * Changes and removals under decorations must not invalidate the
dictionary at de.
+ */
+ @Test
+ public void rootPrefixDoesNotMatchSiblingPaths() throws Exception {
+ setupObservation();
+ Resource entry = createEntry("/apps/i18n/decorations/" + KEY,
"nt:unstructured", "Not a dictionary");
+ assertIgnoredChange(new ResourceChange(ChangeType.CHANGED,
entry.getPath(), false));
+ context.resourceResolver().delete(entry.getParent());
+ assertIgnoredChange(new ResourceChange(ChangeType.REMOVED,
"/apps/i18n/decorations", false));
+ }
+
+ /**
+ * Path filters apply before both targeted refresh and discovery,
including excluded
+ * descendants of known roots, excluded roots, and paths outside the
include set.
+ */
+ @Test
+ public void excludedAndNonIncludedChangesAreIgnored() throws Exception {
+ setupObservation();
+ Resource known = createEntry("/apps/i18n/de/excluded/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "Excluded");
+ Resource excludedRoot = createDictionary("/apps/excluded/de", "de");
+ Resource excluded =
+ createEntry(excludedRoot.getPath() + "/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "Excluded");
+ Resource outsideRoot = createDictionary("/content/i18n/de", "de");
+ Resource outside =
+ createEntry(outsideRoot.getPath() + "/" + KEY,
JcrResourceBundle.RT_MESSAGE_ENTRY, "Outside");
+ assertIgnoredChange(
+ new ResourceChange(ChangeType.ADDED, known.getPath(), false),
+ new ResourceChange(ChangeType.CHANGED, excluded.getPath(),
false),
+ new ResourceChange(ChangeType.CHANGED, excludedRoot.getPath(),
false),
+ new ResourceChange(ChangeType.REMOVED, known.getPath(), false),
+ new ResourceChange(ChangeType.ADDED, outside.getPath(),
false));
+ }
+
+ private Resource createJsonDictionary(String path, String message) {
+ Resource root = createDictionary(path, "de");
+ root.adaptTo(ModifiableValueMap.class).put("jcr:primaryType",
"nt:file");
+ context.create()
+ .resource(
+ path + "/jcr:content",
+ "jcr:primaryType",
+ "nt:resource",
+ "jcr:mimeType",
+ "application/json",
+ "jcr:data",
+ jsonData(message));
+ return root;
+ }
+
+ private byte[] jsonData(String message) {
+ return ("{\"" + KEY + "\":\"" + message +
"\"}").getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Direct events on a known JSON dictionary's content child allow targeted
refresh.
+ * Replacing, removing, and restoring the data must update translations
without replacing unrelated bundles.
+ */
+ @Test
+ public void knownJsonContentChangesAreTargeted() throws Exception {
+ setupObservation();
+ Resource root = createJsonDictionary("/apps/i18n/de.json", "Original");
+ provider.clearCache();
+ cacheBundles();
+ Resource content = root.getChild("jcr:content");
+ content.adaptTo(ModifiableValueMap.class).put("jcr:data",
jsonData("Changed"));
+ assertTargetedChange(content.getPath(), ChangeType.CHANGED, "Changed",
(String) null);
+ context.resourceResolver().delete(content);
+ assertTargetedChange(root.getPath() + "/jcr:content",
ChangeType.REMOVED, "English", (String) null);
+ context.create()
+ .resource(
+ root.getPath() + "/jcr:content",
+ "jcr:primaryType",
+ "nt:resource",
+ "jcr:mimeType",
+ "application/json",
+ "jcr:data",
+ jsonData("Added"));
+ assertTargetedChange(root.getPath() + "/jcr:content",
ChangeType.ADDED, "Added", (String) null);
+ }
+
+ /**
+ * Adding an unknown JSON root must rediscover dictionaries and expose its
translations.
+ */
+ @Test
+ public void unknownJsonDictionaryTriggersDiscovery() throws Exception {
+ setupObservation();
+ Resource root = createJsonDictionary("/apps/i18n/de.json",
"Discovered");
+ assertFullReload(new ResourceChange(ChangeType.ADDED, root.getPath(),
false));
+ assertEquals("Discovered",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ }
+
+ /**
+ * Events reported at the JSON root retain full-refresh behavior, unlike
content-child events.
+ * Both a changed root and its subsequent removal must update the returned
translations.
+ */
+ @Test
+ public void knownJsonRootChangesTriggerDiscovery() throws Exception {
+ setupObservation();
+ Resource root = createJsonDictionary("/apps/i18n/de.json", "Original");
+ provider.clearCache();
+ cacheBundles();
+
root.getChild("jcr:content").adaptTo(ModifiableValueMap.class).put("jcr:data",
jsonData("Changed"));
+ assertFullReload(new ResourceChange(ChangeType.CHANGED,
root.getPath(), false));
+ assertEquals("Changed",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ context.resourceResolver().delete(root);
+ assertFullReload(new ResourceChange(ChangeType.REMOVED,
root.getPath(), false));
+ assertEquals("English",
provider.getResourceBundle(Locale.GERMAN).getString(KEY));
+ }
+
@Test
public void testToLocale() {
// empty string must return default locale
diff --git
a/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java
b/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java
index b6e7255..2524109 100644
--- a/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java
+++ b/src/test/java/org/apache/sling/i18n/it/ResourceBundleProviderIT.java
@@ -19,13 +19,22 @@
package org.apache.sling.i18n.it;
import javax.inject.Inject;
+import javax.jcr.Binary;
import javax.jcr.Node;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Dictionary;
+import java.util.Hashtable;
+import java.util.List;
import java.util.Locale;
import java.util.ResourceBundle;
+import java.util.concurrent.CopyOnWriteArrayList;
+import org.apache.sling.api.resource.observation.ResourceChange;
+import org.apache.sling.api.resource.observation.ResourceChangeListener;
import org.apache.sling.i18n.ResourceBundleProvider;
import org.apache.sling.jcr.api.SlingRepository;
import org.junit.After;
@@ -35,9 +44,15 @@ import org.junit.runner.RunWith;
import org.ops4j.pax.exam.junit.PaxExam;
import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
import org.ops4j.pax.exam.spi.reactors.PerClass;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.util.tracker.ServiceTracker;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
@RunWith(PaxExam.class)
@@ -55,7 +70,9 @@ public class ResourceBundleProviderIT extends I18nTestSupport
{
private SlingRepository repository;
@Inject
- private ResourceBundleProvider resourceBundleProvider;
+ private BundleContext bundleContext;
+
+ private ServiceTracker<ResourceBundleProvider, ResourceBundleProvider>
providerTracker;
private Session session;
private Node i18nRoot;
@@ -89,6 +106,9 @@ public class ResourceBundleProviderIT extends
I18nTestSupport {
@Before
public void setup() throws RepositoryException {
+ // Startup configuration can replace the service after Pax Exam
injects test fields.
+ providerTracker = new ServiceTracker<>(bundleContext,
ResourceBundleProvider.class, null);
+ providerTracker.open();
session = repository.loginAdministrative(null);
final Node root = session.getRootNode();
final Node libs;
@@ -111,6 +131,13 @@ public class ResourceBundleProviderIT extends
I18nTestSupport {
i18nRoot.remove();
session.save();
session.logout();
+ providerTracker.close();
+ }
+
+ private ResourceBundleProvider getProvider() {
+ ResourceBundleProvider provider = providerTracker.getService();
+ assertNotNull("No active ResourceBundleProvider service", provider);
+ return provider;
}
private Node addLanguageNode(Node parent, String language) throws
RepositoryException {
@@ -135,8 +162,8 @@ public class ResourceBundleProviderIT extends
I18nTestSupport {
@Override
protected void exec() {
{
- final ResourceBundle resourceBundle =
resourceBundleProvider.getResourceBundle(
- basename, locale); // this is the resource bundle
for de_DE
+ final ResourceBundle resourceBundle =
+ getProvider().getResourceBundle(basename, locale);
// this is the resource bundle for de_DE
assertNotNull(resourceBundle);
assertEquals(value, resourceBundle.getString(key));
}
@@ -179,7 +206,7 @@ public class ResourceBundleProviderIT extends
I18nTestSupport {
* Verifies that repository observation propagates an English fallback
update
* to German, German-country, and French bundles after they have been
cached.
* The nt:unstructured entry is supported by message loading but avoids the
- * sling:MessageEntry detection that promotes edits to full rediscovery.
+ * sling:MessageEntry detection, covering untyped entries as well.
* This exercises targeted refresh, where stale parent references would
* otherwise leave dependent locales returning the old English value.
*/
@@ -196,6 +223,11 @@ public class ResourceBundleProviderIT extends
I18nTestSupport {
assertMessages(MSG_KEY2, "new English fallback", "new English
fallback", "new English fallback");
}
+ /**
+ * Exercises repository observation for locale-specific entries, shared
English fallbacks,
+ * and named dictionaries. Isolated German entry additions, edits,
removals, and message-mixin
+ * additions must update German and country bundles without replacing
English, French, or unrelated named bundles.
+ */
@Test
public void testChangesDetection() throws RepositoryException {
// set a key which is only available in the en dictionary
@@ -237,5 +269,126 @@ public class ResourceBundleProviderIT extends
I18nTestSupport {
session.save();
assertMessage(MSG_KEY3, Locale.ENGLISH, null, "EN_basename_changed");
assertMessage(MSG_KEY3, Locale.ENGLISH, BASENAME,
"EN_basename_changed");
+
+ // Wait for the preceding fallback refresh to reach cached children
before checking isolation.
+ assertMessages(MSG_KEY3, "EN_basename_changed", "EN_basename_changed",
"EN_basename_changed");
+ ResourceBundle english =
getProvider().getResourceBundle(Locale.ENGLISH);
+ ResourceBundle french = getProvider().getResourceBundle(Locale.FRENCH);
+ ResourceBundle named = getProvider().getResourceBundle(BASENAME,
Locale.GERMAN);
+ ResourceBundle german = getProvider().getResourceBundle(Locale.GERMAN);
+ ResourceBundle country =
getProvider().getResourceBundle(Locale.GERMANY);
+
+ final String isolatedKey = "isolated";
+ setMessage(deRoot, isolatedKey, "DE_added");
+ session.save();
+ assertMessages(isolatedKey, "DE_added", "DE_added", isolatedKey);
+ assertNotSame(german, getProvider().getResourceBundle(Locale.GERMAN));
+ assertNotSame(country,
getProvider().getResourceBundle(Locale.GERMANY));
+ assertSame(english, getProvider().getResourceBundle(Locale.ENGLISH));
+ assertSame(french, getProvider().getResourceBundle(Locale.FRENCH));
+ assertSame(named, getProvider().getResourceBundle(BASENAME,
Locale.GERMAN));
+
+ // A property change on an existing sling:MessageEntry must not
rediscover every dictionary.
+ setMessage(deRoot, isolatedKey, "DE_changed");
+ session.save();
+ assertMessages(isolatedKey, "DE_changed", "DE_changed", isolatedKey);
+ assertSame(english, getProvider().getResourceBundle(Locale.ENGLISH));
+ assertSame(french, getProvider().getResourceBundle(Locale.FRENCH));
+ assertSame(named, getProvider().getResourceBundle(BASENAME,
Locale.GERMAN));
+
+ deRoot.getNode("node_" + isolatedKey).remove();
+ session.save();
+ assertMessages(isolatedKey, isolatedKey, isolatedKey, isolatedKey);
+ assertSame(english, getProvider().getResourceBundle(Locale.ENGLISH));
+ assertSame(french, getProvider().getResourceBundle(Locale.FRENCH));
+ assertSame(named, getProvider().getResourceBundle(BASENAME,
Locale.GERMAN));
+
+ // Re-add the entry using the supported message mixin instead of its
primary type.
+ Node mixedMessage = deRoot.addNode("node_" + isolatedKey,
"nt:unstructured");
+ mixedMessage.addMixin("sling:Message");
+ mixedMessage.setProperty("sling:key", isolatedKey);
+ mixedMessage.setProperty("sling:message", "DE_mixin");
+ session.save();
+ assertMessages(isolatedKey, "DE_mixin", "DE_mixin", isolatedKey);
+ assertSame(english, getProvider().getResourceBundle(Locale.ENGLISH));
+ assertSame(french, getProvider().getResourceBundle(Locale.FRENCH));
+ assertSame(named, getProvider().getResourceBundle(BASENAME,
Locale.GERMAN));
+ }
+
+ /**
+ * Verifies JSON dictionary discovery, content updates, locale
reassignment, and removal
+ * against real repository events. JCR reports nt:file content updates at
the dictionary
+ * root, so these events deliberately retain full refreshes rather than
targeted content-child refreshes.
+ */
+ @Test
+ public void testJsonChangesDetection() throws RepositoryException {
+ final String key = "json-label";
+ assertMessages(key, key, key, key);
+ Node dictionary = i18nRoot.addNode("de.json", "nt:file");
+ dictionary.addMixin("mix:language");
+ dictionary.setProperty("jcr:language", "de");
+ Node content = dictionary.addNode("jcr:content", "nt:resource");
+ content.setProperty("jcr:mimeType", "application/json");
+ setJsonMessage(content, key, "Original");
+ session.save();
+ assertMessages(key, "Original", "Original", key);
+
+ ResourceBundle english =
getProvider().getResourceBundle(Locale.ENGLISH);
+ ResourceBundle french = getProvider().getResourceBundle(Locale.FRENCH);
+ ResourceBundle named = getProvider().getResourceBundle(BASENAME,
Locale.GERMAN);
+ List<ResourceChange> observed = new CopyOnWriteArrayList<>();
+ Dictionary<String, Object> properties = new Hashtable<>();
+ final String dictionaryPath = dictionary.getPath();
+ properties.put(ResourceChangeListener.PATHS, dictionaryPath);
+ ServiceRegistration<ResourceChangeListener> listener =
+ bundleContext.registerService(ResourceChangeListener.class,
observed::addAll, properties);
+ try {
+ setJsonMessage(content, key, "Changed");
+ session.save();
+ assertMessages(key, "Changed", "Changed", key);
+ // The JCR provider rolls nt:file content updates up to the root,
retaining full refreshes.
+ new Retry(RETRY_TIMEOUT_MSEC) {
+ @Override
+ protected void exec() {
+ assertTrue(observed.stream()
+ .anyMatch(change -> change.getType() ==
ResourceChange.ChangeType.CHANGED
+ &&
change.getPath().equals(dictionaryPath)));
+ }
+ };
+ assertNotSame(english,
getProvider().getResourceBundle(Locale.ENGLISH));
+ assertNotSame(french,
getProvider().getResourceBundle(Locale.FRENCH));
+ assertNotSame(named, getProvider().getResourceBundle(BASENAME,
Locale.GERMAN));
+ } finally {
+ listener.unregister();
+ }
+
+ english = getProvider().getResourceBundle(Locale.ENGLISH);
+ french = getProvider().getResourceBundle(Locale.FRENCH);
+ assertMessage(key, Locale.ITALIAN, null, key);
+ dictionary.setProperty("jcr:language", "it");
+ session.save();
+ assertMessages(key, key, key, key);
+ assertMessage(key, Locale.ITALIAN, null, "Changed");
+ assertNotSame(english,
getProvider().getResourceBundle(Locale.ENGLISH));
+ assertNotSame(french, getProvider().getResourceBundle(Locale.FRENCH));
+
+ english = getProvider().getResourceBundle(Locale.ENGLISH);
+ french = getProvider().getResourceBundle(Locale.FRENCH);
+ dictionary.remove();
+ session.save();
+ assertMessages(key, key, key, key);
+ assertMessage(key, Locale.ITALIAN, null, key);
+ assertNotSame(english,
getProvider().getResourceBundle(Locale.ENGLISH));
+ assertNotSame(french, getProvider().getResourceBundle(Locale.FRENCH));
+ }
+
+ private void setJsonMessage(Node content, String key, String message)
throws RepositoryException {
+ byte[] json = ("{\"" + key + "\":\"" + message +
"\"}").getBytes(StandardCharsets.UTF_8);
+ Binary binary = session.getValueFactory().createBinary(new
ByteArrayInputStream(json));
+ try {
+ content.setProperty("jcr:data", binary);
+ } finally {
+ binary.dispose();
+ }
}
}