list) {
});
}
- /* --------------------
- Sling instance logic
- -------------------- */
-
- /**
- * Creates a new {@link ResourceResolver} instance for accessing repository resources
- * @return New instance of {@code ResourceResolver}
- * @throws LoginException If the resolver cannot be created
- */
- private ResourceResolver newResolver() throws LoginException {
- return resourceResolverFactory.getServiceResourceResolver(
- Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, "eak-service")
- );
- }
-
/* -------------------
Configuration logic
------------------- */
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSource.java b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSource.java
index d771d1a10..abd331be9 100644
--- a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSource.java
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSource.java
@@ -14,13 +14,13 @@
package com.exadel.aem.toolkit.core.configurator.servlets.form;
+import javax.annotation.Nonnull;
import javax.servlet.Servlet;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.servlets.HttpConstants;
import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
-import org.jetbrains.annotations.NotNull;
import org.osgi.service.component.annotations.Component;
import com.exadel.aem.toolkit.core.configurator.models.internal.ConfigDefinition;
@@ -45,7 +45,7 @@ public class ConfigDataSource extends SlingSafeMethodsServlet {
* @param response The HTTP response
*/
@Override
- protected void doGet(@NotNull SlingHttpServletRequest request, @NotNull SlingHttpServletResponse response) {
+ protected void doGet(@Nonnull SlingHttpServletRequest request, @Nonnull SlingHttpServletResponse response) {
ConfigDefinition config = ConfigDefinition.from(request);
if (config == null || !config.isValid()) {
return;
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/replication/ReplicationServlet.java b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/replication/ReplicationServlet.java
index 5443a185b..dfe011c16 100644
--- a/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/replication/ReplicationServlet.java
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/configurator/servlets/replication/ReplicationServlet.java
@@ -15,6 +15,7 @@
import java.io.IOException;
import java.util.Objects;
+import javax.annotation.Nonnull;
import javax.jcr.Session;
import javax.servlet.Servlet;
@@ -25,7 +26,6 @@
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.servlets.HttpConstants;
import org.apache.sling.api.servlets.SlingAllMethodsServlet;
-import org.jetbrains.annotations.NotNull;
import org.osgi.framework.BundleContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
@@ -100,8 +100,8 @@ private void deactivate() {
*/
@Override
protected void doPost(
- @NotNull SlingHttpServletRequest request,
- @NotNull SlingHttpServletResponse response) throws IOException {
+ @Nonnull SlingHttpServletRequest request,
+ @Nonnull SlingHttpServletResponse response) throws IOException {
if (!configChangeListener.isEnabled()) {
sendError(response, SlingHttpServletResponse.SC_SERVICE_UNAVAILABLE, ConfigAccess.DISABLED.getError());
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/ChangeSample.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/ChangeSample.java
new file mode 100644
index 000000000..f442c6518
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/ChangeSample.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.models;
+
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Represents a change announcement entry defining a JCR path or XPath to report as changed when the relay is enabled or
+ * disabled.
+ * Note: This class is not a part of the public API and is subject to change. Do not use it in your own
+ * code
+ */
+public class ChangeSample {
+ private final String path;
+ private final int limit;
+ private final String user;
+
+ /**
+ * Creates a new {@code ChangeSample} with the provided path, limit and user values
+ * @param path JCR path or XPath expression to report as changed
+ * @param limit Optional limit of resources to report as changed under the provided path
+ * @param user Optional user identifier to use for reporting changes
+ */
+ @JsonCreator
+ ChangeSample(
+ @JsonProperty("path") String path,
+ @JsonProperty("limit") int limit,
+ @JsonProperty("user") String user) {
+ this.path = path;
+ this.limit = limit;
+ this.user = user;
+ }
+
+ /**
+ * Gets the JCR path or XPath expression defined in this announcement
+ * @return String value
+ */
+ public String getPath() {
+ return path;
+ }
+
+ /**
+ * Gets the optional limit of resources to report as changed under the provided path
+ * @return Integer limit value
+ */
+ public int getLimit() {
+ return limit;
+ }
+
+ /**
+ * Gets the optional user identifier to use for reporting changes
+ * @return A nullable user identifier string
+ */
+ public String getUser() {
+ return user;
+ }
+
+ /**
+ * {@inheritDoc}
+ * The equality check is based solely on the {@code path} property since we do not want to report the
+ * same path as changed multiple times
+ */
+ @Override
+ public final boolean equals(Object other) {
+ if (!(other instanceof ChangeSample)) {
+ return false;
+ }
+ ChangeSample announcement = (ChangeSample) other;
+ return Objects.equals(path, announcement.path);
+ }
+
+ /**
+ * {@inheritDoc}
+ * The hash code is based solely on the {@code path} property since we do not want to report the same path as
+ * changed multiple times
+ */
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(path);
+ }
+}
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayInfo.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayInfo.java
new file mode 100644
index 000000000..08f1d824a
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayInfo.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.models;
+
+import java.util.Collection;
+import java.util.Collections;
+import javax.annotation.Nonnull;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * Represents the set of data required to configure and operate a {@code RelayProvider}. Every entry must at least have
+ * a valid path mapping for path translation. Mapped paths should not be the same or parent/child of each other to avoid
+ * traversal ambiguity.
+ * Note: This class is not a part of the public API and is subject to change. Do not use it in your own
+ * code
+ */
+public class RelayInfo {
+
+ private final RelayMapping pathMapping;
+
+ private final Collection userMappings;
+
+ private final Collection changeSamples;
+
+ /**
+ * Creates a new {@code RelayInfo} with the provided path mapping, user mappings and change samples
+ * @param pathMapping A non-null source-to-target mapping entry for path translation
+ * @param userMappings A collection of source-to-target mapping entries for user identity translation
+ * @param changeSamples A collection of change announcement entries defining JCR paths or XPaths to report as
+ * changed when the relay is enabled or disabled
+ */
+ public RelayInfo(
+ @Nonnull RelayMapping pathMapping,
+ Collection userMappings,
+ Collection changeSamples) {
+ this.pathMapping = pathMapping;
+ this.userMappings = userMappings;
+ this.changeSamples = changeSamples;
+ }
+
+ /**
+ * Gets the source JCR path used for path translation in this relay
+ * @return A non-null string value
+ */
+ @Nonnull
+ public String getSource() {
+ return StringUtils.defaultString(pathMapping.getFrom());
+ }
+
+ /**
+ * Gets the target JCR path used for path translation in this relay
+ * @return A non-null string value
+ */
+ @Nonnull
+ public String getTarget() {
+ return StringUtils.defaultString(pathMapping.getTo());
+ }
+
+ /**
+ * Retrieves the mapped user ID per the collection of user mappings defined in this relay
+ * @param source The source user ID to translate
+ * @return A nullable target user ID
+ */
+ public String getUserMapping(String source) {
+ if (CollectionUtils.isEmpty(userMappings)) {
+ return null;
+ }
+ return userMappings
+ .stream()
+ .filter(mapping -> StringUtils.equals(source, mapping.getFrom()))
+ .map(RelayMapping::getTo)
+ .findFirst()
+ .orElse(null);
+ }
+
+ /**
+ * Gets the collection of change announcement entries defining JCR paths or XPaths to report as changed when the
+ * relay is enabled or disabled
+ * @return A collection of {@link ChangeSample} instances
+ */
+ public Collection getChangeSamples() {
+ return Collections.unmodifiableCollection(changeSamples);
+ }
+}
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayMapping.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayMapping.java
new file mode 100644
index 000000000..9e4225dac
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayMapping.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.models;
+
+import java.util.Objects;
+
+import org.apache.commons.lang3.StringUtils;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper;
+
+/**
+ * Represents a source-to-target mapping entry used for path or user identity translation. Mappings are designed so that
+ * a set of mappings can only contain entries with different sources ({@code from}-s) to avoid ambiguity as to which
+ * mapping to apply for a given source value.
+ * Note: This class is not a part of the public API and is subject to change. Do not use it in your own
+ * code
+ */
+public class RelayMapping {
+
+ private final String from;
+ private final String to;
+
+ /**
+ * Creates a new {@code Mapping} with the provided source and target values
+ * @param from Source value
+ * @param to Target value
+ */
+ @JsonCreator
+ RelayMapping(@JsonProperty("from") String from, @JsonProperty("to") String to) {
+ this.from = from;
+ this.to = to;
+ }
+
+ /**
+ * Gets the source value of this mapping
+ * @return A nullable source string
+ */
+ public String getFrom() {
+ return from;
+ }
+
+ /**
+ * Gets the target value of this mapping
+ * @return A nullable target string
+ */
+ public String getTo() {
+ return to;
+ }
+
+ /**
+ * Gets whether the mapping has non-blank source and target values
+ * @return True or false
+ */
+ public boolean isValid() {
+ return StringUtils.isNoneBlank(getFrom(), getTo())
+ && !RelayPathHelper.isSamePathOrSubpath(getFrom(), getTo())
+ && !RelayPathHelper.isSamePathOrSubpath(getTo(), getFrom());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public final boolean equals(Object other) {
+ if (!(other instanceof RelayMapping)) {
+ return false;
+ }
+ RelayMapping mapping = (RelayMapping) other;
+ return Objects.equals(from, mapping.from);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(from);
+ }
+}
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayResource.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayResource.java
new file mode 100644
index 000000000..885058d41
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/models/RelayResource.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.models;
+
+import java.util.Iterator;
+import javax.annotation.Nonnull;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceMetadata;
+import org.apache.sling.api.resource.ResourceWrapper;
+
+import com.exadel.aem.toolkit.core.CoreConstants;
+import com.exadel.aem.toolkit.core.relay.utils.RelayResourceHelper;
+
+/**
+ * An implementation of {@link Resource} used by the relay provider to expose resources at a mapped source path.
+ * Note: This class is not a part of the public API and is subject to change. Do not use it in your own
+ * code
+ */
+public class RelayResource extends ResourceWrapper {
+
+ private final String path;
+ private final ResourceMetadata resourceMetadata;
+
+ /**
+ * Creates a new {@code RelayResource} wrapping the provided resource and overriding its path
+ * @param original A non-null original {@link Resource} to wrap
+ * @param path A non-null JCR path to expose for this resource
+ */
+ public RelayResource(@Nonnull Resource original, @Nonnull String path) {
+ super(original);
+ this.path = StringUtils.defaultIfEmpty(
+ StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH),
+ CoreConstants.SEPARATOR_SLASH);
+ // We must create a copy of the resource metadata to avoid the "{@code JcrNodeResourceMetadata is locked}" exception
+ // because the {@code original} resource is already locked by Sling
+ this.resourceMetadata = new ResourceMetadata();
+ this.resourceMetadata.putAll((ResourceMetadata) original.getResourceMetadata().clone());
+ this.resourceMetadata.put(ResourceMetadata.RESOLUTION_PATH, this.path);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Resource getChild(@Nonnull String relativePath) {
+ String fullPath = path + CoreConstants.SEPARATOR_SLASH + StringUtils.strip(relativePath, CoreConstants.SEPARATOR_SLASH);
+ return getResourceResolver().getResource(fullPath);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @Nonnull
+ public String getName() {
+ return path.contains(CoreConstants.SEPARATOR_SLASH)
+ ? StringUtils.substringAfterLast(path, CoreConstants.SEPARATOR_SLASH)
+ : path;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Resource getParent() {
+ if (!StringUtils.contains(path, CoreConstants.SEPARATOR_SLASH)) {
+ return null;
+ }
+ String parentPath = StringUtils.substringBeforeLast(path, CoreConstants.SEPARATOR_SLASH);
+ if (parentPath.isEmpty()) {
+ parentPath = CoreConstants.SEPARATOR_SLASH;
+ }
+ return getResourceResolver().getResource(parentPath);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @Nonnull
+ public String getPath() {
+ return path;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @Nonnull
+ public ResourceMetadata getResourceMetadata() {
+ return resourceMetadata;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public @Nonnull Iterator listChildren() {
+ return RelayResourceHelper.listChildren(getResource(), path);
+ }
+}
+
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/PathSampler.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/PathSampler.java
new file mode 100644
index 000000000..2fbf6c1c2
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/PathSampler.java
@@ -0,0 +1,267 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.services;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.jcr.RepositoryException;
+import javax.jcr.Session;
+import javax.jcr.query.Query;
+import javax.jcr.query.QueryResult;
+import javax.jcr.query.Row;
+import javax.jcr.query.RowIterator;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.api.resource.observation.ResourceChange;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.exadel.aem.toolkit.core.CoreConstants;
+import com.exadel.aem.toolkit.core.relay.models.ChangeSample;
+import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper;
+import com.exadel.aem.toolkit.core.utils.ResolverUtil;
+
+/**
+ * Resolves a list of JCR paths or XPath expressions to concrete JCR paths and produces
+ * {@link ResourceChange} notifications for use during relay provider start and stop
+ */
+class PathSampler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PathSampler.class);
+
+ private static final String PROPERTY_USER_ID = "userId";
+
+ private ResourceResolverFactory resolverFactory;
+ private Collection samples;
+ private String source;
+ private String target;
+
+ /**
+ * Default (instantiation-restricting) constructor
+ */
+ private PathSampler() {
+ }
+
+ /**
+ * Resolves the configured path samples and produces a collection of {@link ResourceChange} notifications
+ * @return A non-null, possibly empty collection of {@code ResourceChange} instances
+ */
+ Collection createChanges() {
+ Set paths = new HashSet<>();
+
+ ResourceResolver resolver = null;
+ try {
+ for (ChangeSample sample : CollectionUtils.emptyIfNull(samples)) {
+ if (isXpath(sample.getPath())) {
+ resolver = rotateResolver(resolver, sample.getUser());
+ if (resolver == null) {
+ // Exception is already logged
+ continue;
+ }
+ Session session = resolver.adaptTo(Session.class);
+ if (session == null) {
+ LOG.error("Failed to adapt a session from the resolver for user {}", resolver.getUserID());
+ continue;
+ }
+ paths.addAll(resolveXpath(sample, session));
+ } else if (isJcrPath(sample.getPath())) {
+ paths.add(sample.getPath());
+ }
+ }
+ } finally {
+ if (resolver != null) {
+ resolver.close();
+ }
+ }
+ // All resolved paths must point to source instead of target
+ return paths
+ .stream()
+ .map(p -> RelayPathHelper.isSamePathOrSubpath(p, target) ? RelayPathHelper.replace(p, target, source) : p)
+ .map(path -> new ResourceChange(ResourceChange.ChangeType.CHANGED, path, false))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Gets whether the provided value represents a plain JCR path
+ * @param value Arbitrary string to check
+ * @return True or false
+ */
+ private static boolean isJcrPath(String value) {
+ return StringUtils.startsWith(value, CoreConstants.SEPARATOR_SLASH);
+ }
+
+ /**
+ * Gets whether the provided value represents an XPath expression
+ * @param value Arbitrary string to check
+ * @return True or false
+ */
+ private static boolean isXpath(String value) {
+ if (StringUtils.isBlank(value)) {
+ return false;
+ }
+ return StringUtils.startsWithAny(value.trim(), "/jcr:root", "//")
+ || StringUtils.containsAny(value, CoreConstants.ARRAY_OPENING, CoreConstants.SEPARATOR_AT, "(*");
+ }
+
+ /**
+ * Executes the provided XPath expression against the given session and collects the resulting JCR paths
+ * @param sample {@link ChangeSample} instance containing the XPath expression to execute and the optional limit of
+ * results
+ * @param session JCR {@link Session} used for query execution
+ * @return A non-null, possibly empty list of JCR path strings matching the expression
+ */
+ private static List resolveXpath(ChangeSample sample, Session session) {
+ try {
+ @SuppressWarnings("deprecation")
+ Query query = session.getWorkspace().getQueryManager().createQuery(sample.getPath(), Query.XPATH);
+ if (sample.getLimit() > 0) {
+ query.setLimit(sample.getLimit());
+ }
+ QueryResult queryResult = query.execute();
+ List result = new ArrayList<>();
+ RowIterator rowIterator = queryResult.getRows();
+ while (rowIterator.hasNext()) {
+ Row next = rowIterator.nextRow();
+ result.add(next.getPath());
+ }
+ return result;
+ } catch (RepositoryException e) {
+ LOG.error("Failed to execute the XPath expression {}", sample.getPath(), e);
+ return Collections.emptyList();
+ }
+ }
+
+ /**
+ * Called by {@link #createChanges()} to obtain a {@link ResourceResolver} for the provided user ID, closing the
+ * preexisting resolver if the associated user ID differs
+ * @param existing Existing resolver, or {@code null}
+ * @param userId User ID to obtain a resolver for; might be {@code null} or empty for the default resolver
+ * @return A {@code ResourceResolver} instance for the provided user ID, a pre-existent resolver if still valid for
+ * the current user, or {@code null} if the resolver cannot be created
+ */
+ private ResourceResolver rotateResolver(ResourceResolver existing, String userId) {
+ if (existing != null) {
+ String existingUserId = (String) existing.getPropertyMap().get(PROPERTY_USER_ID);
+ boolean isMatch = StringUtils.equals(userId, existingUserId)
+ || (StringUtils.isEmpty(userId) && ResolverUtil.SERVICE_USER_ID.equals(existingUserId));
+ if (isMatch) {
+ return existing;
+ }
+ }
+ try {
+ ResourceResolver newResolver = ResolverUtil.newResolver(resolverFactory, userId);
+ newResolver.getPropertyMap().put(PROPERTY_USER_ID, userId);
+ return newResolver;
+ } catch (LoginException e) {
+ LOG.error("Failed to create a resource resolver for {}", userId, e);
+ return null;
+ } finally {
+ if (existing != null) {
+ existing.close();
+ }
+ }
+ }
+
+ /* -------
+ Builder
+ ------- */
+
+ /**
+ * Creates a new {@link Builder} for instantiating a {@link PathSampler}
+ * @return A new {@code Builder} instance
+ */
+ static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Constructs {@link PathSampler} instances with the required configuration
+ */
+ @SuppressWarnings({"UnusedReturnValue"})
+ static class Builder {
+
+ private ResourceResolverFactory resolverFactory;
+ private String source;
+ private String target;
+ private Collection samples;
+
+ /**
+ * Default (instantiation-restricting) constructor
+ */
+ private Builder() {
+ }
+
+ /**
+ * Sets the {@link ResourceResolverFactory} used to create user-mapped resolvers
+ * @param value {@code ResourceResolverFactory} instance
+ * @return This builder
+ */
+ Builder resolverFactory(ResourceResolverFactory value) {
+ this.resolverFactory = value;
+ return this;
+ }
+
+ /**
+ * Sets the collection of path samples to resolve and report as changed when the relay is enabled or disabled
+ * @param value Collection of {@link ChangeSample} instances
+ * @return This builder
+ */
+ Builder samples(Collection value) {
+ samples = value;
+ return this;
+ }
+
+ /**
+ * Sets the JCR source path prefix to be replaced in the resolved paths
+ * @param value Source JCR path prefix
+ * @return This builder
+ */
+ Builder source(String value) {
+ this.source = value;
+ return this;
+ }
+
+ /**
+ * Sets the JCR target path prefix to replace the source prefix with in the resolved paths
+ * @param value Target JCR path prefix
+ * @return This builder
+ */
+ Builder target(String value) {
+ this.target = value;
+ return this;
+ }
+
+ /**
+ * Creates a configured {@link PathSampler} from the current builder state
+ * @return A new {@link PathSampler} instance
+ */
+ PathSampler build() {
+ PathSampler result = new PathSampler();
+ result.resolverFactory = resolverFactory;
+ result.source = source;
+ result.target = target;
+ result.samples = samples;
+ return result;
+ }
+ }
+}
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayConfig.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayConfig.java
new file mode 100644
index 000000000..5c2d344f7
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayConfig.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.services;
+
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.AttributeType;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+
+/**
+ * OSGi Metatype configuration for the relay provider host. Defines path and user mappings and optional
+ * change-reporting settings
+ */
+@ObjectClassDefinition(name = "EToolbox Authoring Kit - Relay")
+public @interface RelayConfig {
+
+ /**
+ * Gets whether this relay configuration is active
+ * @return True or false
+ */
+ @AttributeDefinition(
+ name = "Enable",
+ description = "Is the relay enabled?",
+ type = AttributeType.BOOLEAN
+ )
+ boolean enabled();
+
+ /**
+ * Gets the list of JCR path mapping rules
+ * @return A non-null array of path mapping rule strings; might be empty
+ */
+ @AttributeDefinition(
+ name = "Path mappings",
+ description = "List of path mapping rules."
+ )
+ String[] pathMappings();
+
+ /**
+ * Gets the optional list of user mapping rules
+ * @return A non-null array of user mapping rule strings; might be empty
+ */
+ @AttributeDefinition(
+ name = "User mappings",
+ description = "List of user mapping rules (optional). "
+ + "For mapping targets, we support registered subservice names with optional \"@\" suffix."
+ )
+ String[] userMappings();
+
+ /**
+ * Gets the optional list of JCR paths or XPath expressions whose matching resources are reported as changed
+ * @return A non-null array of path or XPath expression strings; might be empty
+ */
+ @AttributeDefinition(
+ name = "Announce changes",
+ description = "Optional list of JCR paths or XPath expressions. "
+ + "Target paths matching the list will be announced as changed as the relay is enabled or disabled."
+ )
+ String[] announcedPaths();
+}
+
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProvider.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProvider.java
new file mode 100644
index 000000000..4d6c0019f
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProvider.java
@@ -0,0 +1,225 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.services;
+
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+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.spi.resource.provider.ProviderContext;
+import org.apache.sling.spi.resource.provider.ResolveContext;
+import org.apache.sling.spi.resource.provider.ResourceContext;
+import org.apache.sling.spi.resource.provider.ResourceProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.exadel.aem.toolkit.core.relay.models.RelayInfo;
+import com.exadel.aem.toolkit.core.relay.models.RelayResource;
+import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper;
+import com.exadel.aem.toolkit.core.relay.utils.RelayResourceHelper;
+
+/**
+ * A Sling {@link ResourceProvider} and {@link ResourceChangeListener} that transparently relays resource
+ * resolution and change notifications from a source JCR path to a configurable target path. Optionally maps
+ * the resource resolver user identity to a different user or service account
+ */
+class RelayProvider extends ResourceProvider implements ResourceChangeListener, ExternalResourceChangeListener {
+
+ private static final Logger LOG = LoggerFactory.getLogger(RelayProvider.class);
+
+ private final ResourceResolverFactory resolverFactory;
+ private volatile RelayInfo relay;
+ private volatile PathSampler sampler;
+
+ /**
+ * Creates a new {@code RelayProvider} instance for the given relay model
+ * @param resolverFactory The {@link ResourceResolverFactory} instance used to create mapped resource resolvers for
+ * change sampling and user identity mapping
+ * @param relay The {@link RelayInfo} model containing the configuration for this provider
+ */
+ RelayProvider(ResourceResolverFactory resolverFactory, RelayInfo relay) {
+ this.resolverFactory = resolverFactory;
+ update(relay);
+ }
+
+ /* -----------------
+ Fields assignment
+ ----------------- */
+
+ /**
+ * Updates the configuration of this provider based on the given relay model. This method is called when the OSGi
+ * component is activated or its configuration is updated to apply the new configuration to this provider instance
+ * @param model The {@link RelayInfo} model containing the new configuration for this provider
+ */
+ synchronized void update(RelayInfo model) {
+ relay = model;
+ sampler = PathSampler
+ .builder()
+ .resolverFactory(resolverFactory)
+ .source(model.getSource())
+ .target(model.getTarget())
+ .samples(model.getChangeSamples())
+ .build();
+ }
+
+ /* ------------------------
+ ResourceProvider members
+ ------------------------ */
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public Resource getResource(
+ @Nonnull ResolveContext context,
+ @Nonnull String path,
+ @Nonnull ResourceContext resourceContext,
+ @Nullable Resource parent) {
+ RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method
+ if (!RelayPathHelper.isSamePathOrSubpath(path, localRelay.getSource())) {
+ return null;
+ }
+ String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget());
+ Resource resolved = RelayResourceHelper.getResource(
+ context.getResourceResolver(),
+ resolverFactory,
+ localRelay.getUserMapping(context.getResourceResolver().getUserID()),
+ targetPath);
+ if (resolved != null) {
+ return new RelayResource(resolved, path);
+ }
+ return RelayResourceHelper.getResource(context, path, resourceContext, parent);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @Nullable
+ public Iterator listChildren(@Nonnull ResolveContext context, @Nonnull Resource parent) {
+ RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method
+ String path = parent.getPath();
+ String targetPath = RelayPathHelper.replace(path, localRelay.getSource(), localRelay.getTarget());
+ Resource resolved = RelayResourceHelper.getResource(
+ context.getResourceResolver(),
+ resolverFactory,
+ localRelay.getUserMapping(context.getResourceResolver().getUserID()),
+ targetPath);
+ if (resolved != null) {
+ resolved = new RelayResource(resolved, path);
+ } else {
+ resolved = RelayResourceHelper.getResource(context, path, ResourceContext.EMPTY_CONTEXT, parent);
+ }
+ if (resolved == null) {
+ return null;
+ } else if (!(resolved instanceof RelayResource)) {
+ // We have fallen back to an "original" resource, so we should iterate through it without any mapping
+ return RelayResourceHelper.listChildren(context, resolved);
+ }
+ // This method call exerts the path mapping logic for the children of the target resource
+ return RelayResourceHelper.listChildren(resolved, parent.getPath());
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void start(@Nonnull ProviderContext providerContext) {
+ RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method
+ LOG.info("Relay provider for {} -> {} is starting", localRelay.getSource(), localRelay.getTarget());
+ super.start(providerContext);
+ sampler = PathSampler
+ .builder()
+ .resolverFactory(resolverFactory)
+ .source(localRelay.getSource())
+ .target(localRelay.getTarget())
+ .samples(localRelay.getChangeSamples())
+ .build();
+ announce(sampler, providerContext);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void stop() {
+ RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method
+ LOG.info("Relay provider for {} -> {} is stopping", localRelay.getSource(), localRelay.getTarget());
+ if (sampler != null && getProviderContext() != null) {
+ announce(sampler, getProviderContext());
+ }
+ sampler = null;
+ super.stop();
+ }
+
+ /* ------------------------------
+ ResourceChangeListener members
+ ------------------------------ */
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void onChange(@Nonnull List changes) {
+ if (getProviderContext() == null) {
+ return;
+ }
+ RelayInfo localRelay = relay; // Use a local copy to avoid potential race conditions with the update() method
+ List mappedChanges = changes.stream()
+ .map(change -> new ResourceChange(
+ change.getType(),
+ RelayPathHelper.replace(change.getPath(), localRelay.getTarget(), localRelay.getSource()),
+ change.isExternal()))
+ .collect(Collectors.toList());
+ getProviderContext().getObservationReporter().reportChanges(mappedChanges, false);
+ }
+
+ /* -------------
+ Announcements
+ ------------- */
+
+ /**
+ * Announces the configured change samples as resource changes, generally to indicate that the relay is changing
+ * state and trigger any necessary updates in the system
+ */
+ void announce() {
+ PathSampler localSampler = sampler;
+ if (localSampler != null && getProviderContext() != null) {
+ announce(localSampler, getProviderContext());
+ }
+ }
+
+ /**
+ * Generates resource changes based on the configured change samples and reports them through the observation
+ * reporter
+ * @param sampler The {@link PathSampler} instance used to generate the resource changes to report
+ * @param providerContext The {@link ProviderContext} instance used to access the observation reporter for reporting
+ * the generated changes
+ */
+ private static void announce(PathSampler sampler, ProviderContext providerContext) {
+ Collection changes = sampler.createChanges();
+ if (!changes.isEmpty()) {
+ providerContext.getObservationReporter().reportChanges(changes, false);
+ }
+ }
+}
+
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHost.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHost.java
new file mode 100644
index 000000000..9c3d94877
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHost.java
@@ -0,0 +1,265 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.services;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Hashtable;
+import java.util.Iterator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.api.resource.observation.ExternalResourceChangeListener;
+import org.apache.sling.api.resource.observation.ResourceChangeListener;
+import org.apache.sling.spi.resource.provider.ResourceProvider;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.InvalidSyntaxException;
+import org.osgi.framework.ServiceReference;
+import org.osgi.framework.ServiceRegistration;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.metatype.annotations.Designate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.exadel.aem.toolkit.core.CoreConstants;
+import com.exadel.aem.toolkit.core.relay.models.ChangeSample;
+import com.exadel.aem.toolkit.core.relay.models.RelayInfo;
+import com.exadel.aem.toolkit.core.relay.models.RelayMapping;
+import com.exadel.aem.toolkit.core.relay.utils.RelayPathHelper;
+import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil;
+
+/**
+ * Handles {@link RelayConfig} factory configurations to create and register {@link RelayProvider} instances at the
+ * configured source paths.
+ * Note: This class is not a part of the public API and is subject to change. Do not use it in your own
+ * code
+ */
+@Component(immediate = true, service = RelayProviderHost.class)
+@Designate(ocd = RelayConfig.class, factory = true)
+public class RelayProviderHost {
+
+ private static final Logger LOG = LoggerFactory.getLogger(RelayProviderHost.class);
+
+ @Reference
+ private transient ResourceResolverFactory resolverFactory;
+
+ private final Map, RelayProvider> registrations = new HashMap<>();
+
+ private final ReentrantLock lock = new ReentrantLock();
+
+ /**
+ * Called by the OSGi container when this component is activated or its configuration is updated to register
+ * {@link RelayProvider} instances for each valid path mapping
+ * @param context OSGi {@link BundleContext} used to register provider services
+ * @param config {@link RelayConfig} instance containing the current configuration
+ */
+ @Activate
+ private void activate(BundleContext context, RelayConfig config) {
+ List relays = parseConfig(config);
+ lock.lock();
+ try {
+ Iterator> iterator = registrations.keySet().iterator();
+ while (iterator.hasNext()) {
+ ServiceRegistration> registration = iterator.next();
+ String existingRoot = (String) registration
+ .getReference()
+ .getProperty(ResourceProvider.PROPERTY_ROOT);
+ RelayInfo matchingRelay = relays.stream()
+ .filter(relay -> StringUtils.equals(relay.getSource(), existingRoot))
+ .findFirst()
+ .orElse(null);
+ boolean shouldUnregister = false;
+ if (matchingRelay != null) {
+ String[] existingObservedPaths = (String[]) registration.getReference().getProperty(ResourceChangeListener.PATHS);
+ String existingTarget = ArrayUtils.isNotEmpty(existingObservedPaths) ? existingObservedPaths[0] : null;
+ if (StringUtils.equals(existingTarget, matchingRelay.getTarget())) {
+ RelayProvider provider = registrations.get(registration);
+ provider.announce();
+ provider.update(matchingRelay);
+ provider.announce();
+ relays.remove(matchingRelay);
+ } else {
+ shouldUnregister = true;
+ }
+ } else {
+ shouldUnregister = true;
+ }
+ if (shouldUnregister) {
+ try {
+ registration.unregister();
+ iterator.remove();
+ } catch (IllegalStateException e) {
+ LOG.warn("Could not unregister relay provider for path {}", existingRoot, e);
+ }
+ }
+ }
+ if (relays.isEmpty()) {
+ return;
+ }
+
+ // Register providers for the remaining new relays that didn't match any existing ones
+ Map providedPaths = getAlreadyProvidedPaths(context);
+ for (RelayInfo relay : relays) {
+ String shadowedEntries = providedPaths.entrySet().stream()
+ .filter(e -> RelayPathHelper.isSamePathOrSubpath(e.getKey(), relay.getSource()))
+ .map(e -> e.getKey() + " by " + e.getValue())
+ .collect(Collectors.joining(CoreConstants.SEPARATOR_COMMA + StringUtils.SPACE));
+ if (!shadowedEntries.isEmpty()) {
+ LOG.warn(
+ "Skipping relay registration for path {} to avoid shadowing {}",
+ relay.getSource(),
+ shadowedEntries);
+ continue;
+ }
+ RelayProvider provider = new RelayProvider(resolverFactory, relay);
+ Dictionary properties = new Hashtable<>();
+ properties.put(ResourceProvider.PROPERTY_ROOT, relay.getSource());
+ properties.put(ResourceChangeListener.PATHS, new String[]{relay.getTarget()});
+ ServiceRegistration> registration = context.registerService(
+ new String[]{
+ ResourceProvider.class.getName(),
+ ResourceChangeListener.class.getName(),
+ ExternalResourceChangeListener.class.getName()},
+ provider,
+ properties);
+ registrations.put(registration, provider);
+ // Add newly registered path to providedPaths to prevent overlap with remaining relays
+ providedPaths.put(StringUtils.stripEnd(relay.getSource(), CoreConstants.SEPARATOR_SLASH), "this config");
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Discards all previously registered {@link RelayProvider} service instances
+ */
+ @Deactivate
+ private void deactivate() {
+ lock.lock();
+ try {
+ Iterator> iterator = registrations.keySet().iterator();
+ while (iterator.hasNext()) {
+ ServiceRegistration> registration = iterator.next();
+ try {
+ registration.unregister();
+ iterator.remove();
+ } catch (IllegalStateException e) {
+ LOG.warn("Could not unregister relay provider", e);
+ }
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Parses the given {@link RelayConfig} instance to create a list of {@link RelayInfo} models for valid path mappings
+ * @param config A {@code RelayConfig} instance; expected to be non-null
+ * @return A list of {@code Relay} models; might be empty but never null
+ */
+ private static List parseConfig(RelayConfig config) {
+ if (!config.enabled()) {
+ return Collections.emptyList();
+ }
+
+ Set pathMappings = new LinkedHashSet<>();
+ for (String mappingSource : ArrayUtils.nullToEmpty(config.pathMappings())) {
+ RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class);
+ if (mapping != null
+ && mapping.isValid()
+ && StringUtils.startsWith(mapping.getFrom(), CoreConstants.SEPARATOR_SLASH)
+ && StringUtils.startsWith(mapping.getTo(), CoreConstants.SEPARATOR_SLASH)) {
+ if (pathMappings.contains(mapping)) {
+ LOG.warn("Skipping duplicate path mapping with source {} and target {}", mapping.getFrom(), mapping.getTo());
+ continue;
+ }
+ pathMappings.add(mapping);
+ }
+ }
+ if (pathMappings.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ Set userMappings = new HashSet<>();
+ for (String mappingSource : ArrayUtils.nullToEmpty(config.userMappings())) {
+ RelayMapping mapping = ObjectConversionUtil.toObject(mappingSource, RelayMapping.class);
+ if (mapping != null && mapping.isValid()) {
+ if (userMappings.contains(mapping)) {
+ LOG.warn("Skipping duplicate user mapping with source {} and target {}", mapping.getFrom(), mapping.getTo());
+ continue;
+ }
+ userMappings.add(mapping);
+ }
+ }
+
+ Set samples = new HashSet<>();
+ for (String sampleSource : ArrayUtils.nullToEmpty(config.announcedPaths())) {
+ ChangeSample changeSample = ObjectConversionUtil.toObject(sampleSource, ChangeSample.class);
+ if (changeSample != null && StringUtils.isNotBlank(changeSample.getPath())) {
+ samples.add(changeSample);
+ }
+ }
+
+ return pathMappings.stream()
+ .sorted(Comparator.comparingInt(m -> m.getFrom().length()))
+ .map(m -> new RelayInfo(m, userMappings, samples))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Collects the JCR paths already provided by already registered {@link ResourceProvider} services to avoid
+ * "shadowing" external providers
+ * @param context OSGi {@link BundleContext} used to query registered provider services
+ * @return A map of JCR path-to-provider name entries; might be empty but never null
+ */
+ private static Map getAlreadyProvidedPaths(BundleContext context) {
+ Map result = new HashMap<>();
+ ServiceReference>[] serviceReferences = null;
+ try {
+ serviceReferences = context.getServiceReferences(ResourceProvider.class.getName(), null);
+ } catch (InvalidSyntaxException e) {
+ LOG.warn("Could not collect info on predefined resource providers", e);
+ }
+ if (serviceReferences == null) {
+ return result;
+ }
+ for (ServiceReference> ref : serviceReferences) {
+ Object providedPath = ref.getProperty(ResourceProvider.PROPERTY_ROOT);
+ Object providerId = ref.getProperty(ResourceProvider.PROPERTY_NAME);
+ if (providerId == null || StringUtils.isEmpty(providerId.toString())) {
+ providerId = ref.getBundle().getSymbolicName();
+ }
+ String providedPathString = providedPath instanceof String ? (String) providedPath : null;
+ if (StringUtils.isNotEmpty(providedPathString)) {
+ result.put(StringUtils.stripEnd(providedPathString, CoreConstants.SEPARATOR_SLASH), providerId.toString());
+ }
+ }
+ return result;
+ }
+}
+
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelper.java
new file mode 100644
index 000000000..1f5444e97
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelper.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.utils;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.exadel.aem.toolkit.core.CoreConstants;
+
+/**
+ * Provides utility methods for JCR path manipulation within the relay infrastructure
+ * Note: This class is not a part of the public API and is subject to change. Do not use it in your own
+ * code
+ */
+public class RelayPathHelper {
+
+ /**
+ * Default (instantiation-blocking) constructor
+ */
+ private RelayPathHelper() {
+ }
+
+ /**
+ * Gets whether the provided path is a descendant of the provided root path
+ * @param path JCR path to check
+ * @param root Root JCR path to check against
+ * @return True or false
+ */
+ public static boolean isSamePathOrSubpath(String path, String root) {
+ if (StringUtils.isAllEmpty(path, root)) {
+ return true;
+ }
+ String normalizedPath = normalize(path);
+ String normalizedRoot = normalize(root);
+ return StringUtils.equals(normalizedPath, normalizedRoot)
+ || StringUtils.startsWith(normalizedPath, normalizedRoot + CoreConstants.SEPARATOR_SLASH);
+ }
+
+ /**
+ * Replaces the {@code source} prefix in the provided path with the {@code target} prefix. Returns the original
+ * path unchanged if it does not start with {@code source}
+ * @param path JCR path to transform
+ * @param source Prefix to replace
+ * @param target Replacement prefix
+ * @return A transformed path string
+ */
+ public static String replace(String path, String source, String target) {
+ String normalizedPath = normalize(path);
+ String normalizedSource = normalize(source);
+ String normalizedTarget = normalize(target);
+ if (StringUtils.equals(normalizedPath, normalizedSource)) {
+ return normalizedTarget;
+ }
+ if (StringUtils.startsWith(normalizedPath, normalizedSource + CoreConstants.SEPARATOR_SLASH)) {
+ return normalizedTarget + normalizedPath.substring(normalizedSource.length());
+ }
+ return normalizedPath;
+ }
+
+ /**
+ * Normalizes the provided path by stripping trailing slashes and replacing an empty result with a single slash
+ * @param path JCR path to normalize
+ * @return A normalized path string
+ */
+ private static String normalize(String path) {
+ if (StringUtils.isBlank(path)) {
+ return path;
+ }
+ return StringUtils.defaultIfEmpty(
+ StringUtils.stripEnd(path, CoreConstants.SEPARATOR_SLASH),
+ CoreConstants.SEPARATOR_SLASH);
+ }
+}
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelper.java b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelper.java
new file mode 100644
index 000000000..ee2aa9916
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelper.java
@@ -0,0 +1,187 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.utils;
+
+import java.util.Iterator;
+import java.util.Spliterators;
+import java.util.stream.StreamSupport;
+import javax.annotation.Nonnull;
+
+import org.apache.sling.api.resource.LoginException;
+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.spi.resource.provider.ResolveContext;
+import org.apache.sling.spi.resource.provider.ResourceContext;
+import org.apache.sling.spi.resource.provider.ResourceProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.exadel.aem.toolkit.core.CoreConstants;
+import com.exadel.aem.toolkit.core.relay.models.RelayResource;
+import com.exadel.aem.toolkit.core.utils.ResolverUtil;
+
+/**
+ * Provides utility methods for resolving and listing Sling resources within the relay infrastructure
+ * Note: This class is not a part of the public API and is subject to change. Do not use it in your own
+ * code
+ */
+public class RelayResourceHelper {
+
+ private static final Logger LOG = LoggerFactory.getLogger(RelayResourceHelper.class);
+
+ /**
+ * Default (instantiation-blocking) constructor
+ */
+ private RelayResourceHelper() {}
+
+ /**
+ * Resolves a resource at the provided path using a potentially modified {@link ResourceResolver}. Returns the
+ * resolved resource or {@code null} when the path cannot be resolved
+ * @param resolver The base {@code ResourceResolver} instance used for resolution
+ * @param resolverFactory The {@link ResourceResolverFactory} instance used to create subsidiary resolvers if
+ * needed
+ * @param userId A nullable user ID to impersonate when creating a subsidiary resolver
+ * @param path A non-null JCR path of the resource to resolve
+ * @return A nullable {@link Resource} instance
+ */
+ public static Resource getResource(
+ @Nonnull ResourceResolver resolver,
+ @Nonnull ResourceResolverFactory resolverFactory,
+ String userId,
+ @Nonnull String path) {
+
+ ResourceResolver effectiveResolver = resolver;
+ if (userId != null && !userId.equals(resolver.getUserID())) {
+ String key = ResourceResolver.class.getName() + CoreConstants.SEPARATOR_AT + userId;
+ // We are going to create another ResourceResolver for a mapped user ID. We need it to live as long
+ // as the resource(-s) we have resolved with it live. To achieve that, we put it into the property map
+ // of the basic ResourceResolver so that it will be automatically closed by Sling.
+ // See https://sling.apache.org/apidocs/sling12/org/apache/sling/api/resource/ResourceResolver.html#getPropertyMap
+ Object subsidiaryResolver = resolver
+ .getPropertyMap()
+ .computeIfAbsent(key, k -> {
+ try {
+ LOG.debug("Creating subsidiary resolver for user {} to resolve {}", userId, path);
+ return ResolverUtil.newResolver(resolverFactory, userId);
+ } catch (LoginException e) {
+ LOG.warn("Failed to create subsidiary resolver for user {} to resolve {}", userId, path);
+ return new Object(); // A sentinel value to avoid repeated attempts to create a resolver for the same user ID
+ }
+ });
+ if ((subsidiaryResolver instanceof ResourceResolver)) {
+ effectiveResolver = (ResourceResolver) subsidiaryResolver;
+ } else {
+ LOG.debug("Could not resolve {} with user {} due to a user mapping/login failure", path, userId);
+ return null;
+ }
+ }
+ Resource result = effectiveResolver.getResource(path);
+ if (result == null) {
+ LOG.debug("Could not retrieve {} with user {}", path, effectiveResolver.getUserID());
+ } else {
+ LOG.debug("Retrieved {} -> {} with user {}", path, result.getPath(), effectiveResolver.getUserID());
+ }
+ return result;
+ }
+
+ /**
+ * Delegates resource resolution for the provided path to a parent {@link ResourceProvider} obtained from the given
+ * {@link ResolveContext}. This is generally used as a fallback method for
+ * {@link #getResource(ResourceResolver, ResourceResolverFactory, String, String)}
+ * @param resolveContext {@link ResolveContext} from which the parent provider and parent context are extracted
+ * @param path JCR path of the resource to resolve
+ * @param resourceContext {@link ResourceContext} for the resolution request
+ * @param parent Nullable parent {@link Resource}
+ * @return A nullable {@link Resource} resolved by the parent provider, or {@code null} if the context or parent
+ * provider is missing
+ */
+ @SuppressWarnings("unchecked")
+ public static Resource getResource(
+ ResolveContext> resolveContext,
+ String path,
+ ResourceContext resourceContext,
+ Resource parent) {
+ reportFallingBack(path);
+ if (resolveContext == null) {
+ reportMissingContext(path);
+ return null;
+ }
+ ResourceProvider> parentResourceProvider = resolveContext.getParentResourceProvider();
+ ResolveContext> parentContext = resolveContext.getParentResolveContext();
+ if (parentResourceProvider == null || parentContext == null) {
+ reportMissingContext(path);
+ return null;
+ }
+ return ((ResourceProvider) parentResourceProvider).getResource((ResolveContext) parentContext, path, resourceContext, parent);
+ }
+
+ /**
+ * Lists children of the provided target resource, wrapping each in a {@link RelayResource} with a path
+ * relative to the given path prefix
+ * @param target {@link Resource} whose children to list
+ * @param path JCR path under which the children should be exposed
+ * @return A non-null {@code Iterator} of {@link Resource} instances
+ */
+ public static Iterator listChildren(Resource target, String path) {
+ Resource effectiveTarget = target instanceof RelayResource ? ((RelayResource) target).getResource() : target;
+ return StreamSupport.stream(Spliterators.spliteratorUnknownSize(effectiveTarget.listChildren(), 0), false)
+ .map(child -> new RelayResource(child, path + CoreConstants.SEPARATOR_SLASH + child.getName()))
+ .map(Resource.class::cast)
+ .iterator();
+ }
+
+ /**
+ * Delegates child listing for the provided parent resource to a parent {@link ResourceProvider} obtained from the
+ * given {@link ResolveContext}. This is generally used as a fallback method for listing children of a resource that
+ * has not been relayed (is "original")
+ * @param resolveContext {@link ResolveContext} from which the parent provider and parent context are extracted
+ * @param parent Parent {@link Resource} whose children to list
+ * @return A nullable {@code Iterator} of child {@link Resource} instances, or {@code null} if the context or parent
+ * provider is missing
+ */
+ @SuppressWarnings("unchecked")
+ public static Iterator listChildren(
+ ResolveContext> resolveContext,
+ Resource parent) {
+ reportFallingBack(parent.getPath());
+ if (resolveContext == null) {
+ reportMissingContext(parent.getPath());
+ return null;
+ }
+ ResourceProvider> parentResourceProvider = resolveContext.getParentResourceProvider();
+ ResolveContext> parentContext = resolveContext.getParentResolveContext();
+ if (parentResourceProvider == null || parentContext == null) {
+ reportMissingContext(parent.getPath());
+ return null;
+ }
+ return ((ResourceProvider) parentResourceProvider).listChildren((ResolveContext) parentContext, parent);
+ }
+
+ /**
+ * Logs a warning when the resource provider or resolve context is missing for the given path
+ * @param path JCR path that could not be resolved
+ */
+ private static void reportMissingContext(String path) {
+ LOG.warn("Missing resolution context for {}", path);
+ }
+
+ /**
+ * Logs a debug message when falling back to the parent resource provider for the given path
+ * @param path JCR path that is being resolved
+ */
+ private static void reportFallingBack(String path) {
+ LOG.debug("Falling back to parent resource provider for {}", path);
+ }
+}
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtil.java b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtil.java
index 390a7c458..8ec352b6b 100644
--- a/core/src/main/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtil.java
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtil.java
@@ -76,6 +76,25 @@ public static boolean isJson(String value) {
}
}
+ /**
+ * Parses the provided string value that expectedly represents a JSON into an object of given {@code type}
+ * @param value String value; a non-null string is expected
+ * @param type Class of the target object
+ * @param Type of the target object
+ * @return An instance of the target type, or null if conversion fails
+ */
+ public static T toObject(String value, Class type) {
+ if (StringUtils.isBlank(value)) {
+ return null;
+ }
+ try {
+ return OBJECT_MAPPER.readValue(value, type);
+ } catch (IOException e) {
+ LOG.error(EXCEPTION_COULD_NOT_READ, e);
+ return null;
+ }
+ }
+
/**
* Analyzes the structure of the given Java entity and converts its available methods and accessors into the
* property map. The method reveals the same fields and methods that the {@code Jackson} serializer would find
@@ -87,8 +106,8 @@ public static Map toPropertyMap(Object value) {
return OBJECT_MAPPER.convertValue(value, PROPERTY_MAP_REFERENCE);
} catch (IllegalArgumentException e) {
LOG.error(EXCEPTION_COULD_NOT_READ, e);
+ return Collections.emptyMap();
}
- return Collections.emptyMap();
}
/**
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java
new file mode 100644
index 000000000..d23f77a2a
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ResolverUtil.java
@@ -0,0 +1,168 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.utils;
+
+import java.io.Closeable;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.annotation.Nonnull;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.api.SlingHttpServletRequest;
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.api.scripting.SlingBindings;
+import org.apache.sling.api.scripting.SlingScriptHelper;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.FrameworkUtil;
+
+import com.exadel.aem.toolkit.core.CoreConstants;
+
+/**
+ * Provides utility methods for creating {@link ResourceResolver} instances
+ * Note: This class is not a part of the public API and is subject to change. Do not use it in your own code
+ */
+public class ResolverUtil {
+
+ public static final String SERVICE_USER_ID = "eak-service";
+
+ /**
+ * Default (instantiation-restricting) constructor
+ */
+ private ResolverUtil() {
+ }
+
+ /**
+ * Creates a new {@link ResourceResolver} instance using the provided resource resolver factory
+ * @param factory The {@code ResourceResolverFactory} instance
+ * @return New instance of {@code ResourceResolver}
+ * @throws LoginException If the resolver cannot be created
+ */
+ @Nonnull
+ public static ResourceResolver newResolver(@Nonnull ResourceResolverFactory factory) throws LoginException {
+ return factory.getServiceResourceResolver(
+ Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER_ID)
+ );
+ }
+
+ /**
+ * Creates a new {@link ResourceResolver} instance for the given user identifier using the provided resource
+ * resolver factory
+ * @param factory The {@code ResourceResolverFactory} instance
+ * @param user The user identifier string
+ * @return New instance of {@code ResourceResolver}
+ * @throws LoginException If the resolver cannot be created
+ */
+ @Nonnull
+ public static ResourceResolver newResolver(
+ @Nonnull ResourceResolverFactory factory,
+ String user) throws LoginException {
+
+ Bundle bundle = FrameworkUtil.getBundle(ResolverUtil.class);
+ BundleContext context = bundle != null ? bundle.getBundleContext() : null;
+ return newResolver(factory, user, context);
+ }
+
+ /**
+ * Creates a new {@link ResourceResolver} instance for the given user identifier using the provided resource
+ * resolver factory and bundle context
+ * @param factory The {@code ResourceResolverFactory} instance
+ * @param user The user identifier string
+ * @param context The {@code BundleContext} instance to use for locating the target bundle
+ * @return New instance of {@code ResourceResolver}
+ * @throws LoginException If the resolver cannot be created
+ */
+ static ResourceResolver newResolver(
+ @Nonnull ResourceResolverFactory factory,
+ String user,
+ BundleContext context) throws LoginException {
+ if (StringUtils.isBlank(user)) {
+ return newResolver(factory); // Use the default eak-service resolver
+ }
+
+ if (!StringUtils.contains(user, CoreConstants.SEPARATOR_AT)) {
+ return factory.getServiceResourceResolver(Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, user));
+ }
+
+ String localizedUserId = StringUtils.substringBefore(user, CoreConstants.SEPARATOR_AT);
+ String bundleId = StringUtils.substringAfter(user, CoreConstants.SEPARATOR_AT);
+ if (context == null) {
+ throw new LoginException("Not running in an OSGi container");
+ }
+ Bundle targetBundle = Arrays
+ .stream(context.getBundles())
+ .filter(b -> StringUtils.equals(b.getSymbolicName(), bundleId))
+ .findFirst()
+ .orElse(null);
+ if (targetBundle == null) {
+ throw new LoginException("Could not locate required bundle: " + bundleId);
+ } else if (targetBundle.getBundleContext() == null) {
+ throw new LoginException("Bundle " + bundleId + " is not active");
+ }
+
+ AtomicReference nestedException = new AtomicReference<>();
+ ResourceResolver resolverByTargetBundle = ServiceUtil.withService(
+ ResourceResolverFactory.class,
+ targetBundle.getBundleContext(),
+ (f, callback) -> {
+ try {
+ ResourceResolver resolver = f.getServiceResourceResolver(
+ Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, localizedUserId)
+ );
+ // By adding the callback to the resolver's property map, we ensure that the obtained service
+ // will be unget no sooner than the resolver is closed
+ // See https://sling.apache.org/apidocs/sling12/org/apache/sling/api/resource/ResourceResolver.html#getPropertyMap
+ resolver.getPropertyMap().put("unget", (Closeable) callback::run);
+ return resolver;
+ } catch (LoginException e) {
+ nestedException.set(e);
+ callback.run();
+ return null;
+ }
+ },
+ null);
+ if (nestedException.get() != null) {
+ throw nestedException.get();
+ } else if (resolverByTargetBundle == null) {
+ throw new LoginException("No resource resolver created. See above log for details");
+ }
+ return resolverByTargetBundle;
+ }
+
+ /**
+ * Creates a new {@link ResourceResolver} instance for the given username using the resource resolver factory
+ * obtained from the provided request's Sling bindings
+ * @param request A {@link SlingHttpServletRequest} instance
+ * @return New instance of {@code ResourceResolver}
+ * @throws LoginException If the resolver cannot be created
+ */
+ @Nonnull
+ public static ResourceResolver newResolver(@Nonnull SlingHttpServletRequest request) throws LoginException {
+
+ SlingBindings bindings = (SlingBindings) request.getAttribute(SlingBindings.class.getName());
+ SlingScriptHelper scriptHelper = bindings != null ? (SlingScriptHelper) bindings.get(SlingBindings.SLING) : null;
+ ResourceResolverFactory factory = scriptHelper != null
+ ? scriptHelper.getService(ResourceResolverFactory.class)
+ : null;
+ if (factory == null) {
+ throw new LoginException("Could not obtain ResourceResolverFactory");
+ }
+ return factory.getServiceResourceResolver(
+ Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER_ID)
+ );
+ }
+}
diff --git a/core/src/main/java/com/exadel/aem/toolkit/core/utils/ServiceUtil.java b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ServiceUtil.java
new file mode 100644
index 000000000..7217cfe7a
--- /dev/null
+++ b/core/src/main/java/com/exadel/aem/toolkit/core/utils/ServiceUtil.java
@@ -0,0 +1,290 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.utils;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BiConsumer;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import javax.annotation.Nonnull;
+
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.FrameworkUtil;
+import org.osgi.framework.ServiceReference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Provides utility methods for working with OSGi services outside the usual component injection context
+ * Note: This class is not a part of the public API and is subject to change. Do not use it in your own
+ * code
+ */
+@SuppressWarnings("LoggingSimilarMessage")
+public class ServiceUtil {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ServiceUtil.class);
+
+ private static final String ERROR_CONTEXT = "Could not obtain OSGi bundle context for {}";
+ private static final String ERROR_HANDLING = "Error handling a service reference for {}";
+ private static final String ERROR_RETRIEVAL = "Could not retrieve instance of {}";
+ private static final String ERROR_RUNNING = "Error running a method against {}";
+
+ /**
+ * Default (instantiation-blocking) constructor
+ */
+ private ServiceUtil() {
+ }
+
+ /* --------------------------------------
+ Consumer-style methods (non-returning)
+ -------------------------------------- */
+
+ /**
+ * Executes the provided routine with an instance of the specified service class, if available. The service is
+ * automatically obtained and released after the consumer is executed
+ * @param serviceClass The class of the service to be used
+ * @param consumer The routine to be executed with the service instance
+ * @param The type of the service
+ */
+ public static void withService(
+ @Nonnull Class serviceClass,
+ @Nonnull Consumer consumer) {
+ Bundle bundle = FrameworkUtil.getBundle(ServiceUtil.class);
+ BundleContext context = bundle != null ? bundle.getBundleContext() : null;
+ if (context == null) {
+ // Absence of bundle is generally unlikely
+ LOG.debug(ERROR_CONTEXT, serviceClass.getName());
+ return;
+ }
+ withService(serviceClass, context, consumer);
+ }
+
+ /**
+ * Executes the provided routine with an instance of the specified service class, if available. The service is
+ * automatically obtained and released after the consumer is executed
+ * @param serviceClass The class of the service to be used
+ * @param context The OSGi bundle context to be used for service retrieval
+ * @param consumer The routine to be executed with the service instance
+ * @param The type of the service
+ */
+ public static void withService(
+ @Nonnull Class serviceClass,
+ @Nonnull BundleContext context,
+ @Nonnull Consumer consumer) {
+
+ ServiceReference reference = null;
+ try {
+ reference = context.getServiceReference(serviceClass);
+ T service = reference != null ? context.getService(reference) : null;
+ if (service == null) {
+ // This is an anticipated case because a service might not be registered due to a particular config,
+ // e.g., a ConfigChangeListener
+ LOG.debug(ERROR_RETRIEVAL, serviceClass.getName());
+ return;
+ }
+ try {
+ consumer.accept(service);
+ } catch (RuntimeException e) {
+ LOG.error(ERROR_RUNNING, serviceClass.getName(), e);
+ }
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ LOG.error(ERROR_HANDLING, serviceClass.getName(), e);
+ } finally {
+ ungetService(context, reference);
+ }
+ }
+
+ /**
+ * Executes the provided routine with an instance of the specified service class, if available. The service is
+ * automatically obtained. It is released when the client code triggers the provided callback
+ * @param serviceClass The class of the service to be used
+ * @param context The OSGi bundle context to be used for service retrieval
+ * @param consumer The routine to be executed with the service instance. The second argument is a callback to
+ * trigger service release
+ * @param The type of the service
+ */
+ public static void withService(
+ @Nonnull Class serviceClass,
+ @Nonnull BundleContext context,
+ @Nonnull BiConsumer consumer) {
+
+ try {
+ ServiceReference reference = context.getServiceReference(serviceClass);
+ T service = reference != null ? context.getService(reference) : null;
+ if (service == null) {
+ // This is an anticipated case because a service might not be registered due to a particular config,
+ // e.g., a ConfigChangeListener
+ LOG.debug(ERROR_RETRIEVAL, serviceClass.getName());
+ return;
+ }
+ AtomicBoolean alreadyReleased = new AtomicBoolean(false);
+ try {
+ consumer.accept(
+ service,
+ () -> {
+ alreadyReleased.set(true);
+ ungetService(context, reference);
+ });
+ } catch (RuntimeException e) {
+ LOG.error(ERROR_RUNNING, serviceClass.getName(), e);
+ if (!alreadyReleased.get()) {
+ ungetService(context, reference);
+ }
+ }
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ LOG.error(ERROR_HANDLING, serviceClass.getName(), e);
+ }
+ }
+
+ /* ----------------------
+ Function-style methods
+ ---------------------- */
+
+ /**
+ * Executes the provided routine with an instance of the specified service class to get a result, if available. The
+ * service is automatically obtained and released after the processor is executed
+ * @param serviceClass The class of the service to be used
+ * @param processor The routine to compute the result with the service instance
+ * @param defaultValue The value to be returned if the service is not available or an error occurs
+ * @param The type of the service
+ * @param The type of the result
+ * @return The result of the processor execution, or the default value
+ */
+ public static U withService(
+ @Nonnull Class serviceClass,
+ @Nonnull Function processor,
+ U defaultValue) {
+ Bundle bundle = FrameworkUtil.getBundle(ServiceUtil.class);
+ BundleContext context = bundle != null ? bundle.getBundleContext() : null;
+ if (context == null) {
+ // Absence of bundle is generally unlikely
+ LOG.debug(ERROR_CONTEXT, serviceClass.getName());
+ return defaultValue;
+ }
+ return withService(serviceClass, context, processor, defaultValue);
+ }
+
+ /**
+ * Executes the provided routine with an instance of the specified service class to get a result, if available. The
+ * service is automatically obtained and released after the processor is executed
+ * @param serviceClass The class of the service to be used
+ * @param context The OSGi bundle context to be used for service retrieval
+ * @param processor The routine to compute the result with the service instance
+ * @param defaultValue The value to be returned if the service is not available or an error occurs
+ * @param The type of the service
+ * @param The type of the result
+ * @return The result of the processor execution, or the default value
+ */
+ public static U withService(
+ @Nonnull Class serviceClass,
+ @Nonnull BundleContext context,
+ @Nonnull Function processor,
+ U defaultValue) {
+
+ U result = defaultValue;
+ ServiceReference reference = null;
+ try {
+ reference = context.getServiceReference(serviceClass);
+ T service = reference != null ? context.getService(reference) : null;
+ if (service == null) {
+ // This is an anticipated case because a service might not be registered due to a particular config,
+ // e.g., a ConfigChangeListener
+ LOG.debug(ERROR_RETRIEVAL, serviceClass.getName());
+ return defaultValue;
+ }
+ try {
+ result = processor.apply(service);
+ } catch (RuntimeException e) {
+ LOG.error(ERROR_RUNNING, serviceClass.getName(), e);
+ return defaultValue;
+ }
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ LOG.error(ERROR_HANDLING, serviceClass.getName(), e);
+ } finally {
+ ungetService(context, reference);
+ }
+ return result;
+ }
+
+ /**
+ * Executes the provided routine with an instance of the specified service class to get a result, if available. The
+ * service is automatically obtained. It is released when the client code triggers the provided callback
+ * @param serviceClass The class of the service to be used
+ * @param context The OSGi bundle context to be used for service retrieval
+ * @param processor The routine to compute the result with the service instance. The second argument is a
+ * callback to trigger service release
+ * @param defaultValue The value to be returned if the service is not available or an error occurs
+ * @param The type of the service
+ * @param The type of the result
+ * @return The result of the processor execution, or the default value
+ */
+ public static U withService(
+ @Nonnull Class serviceClass,
+ @Nonnull BundleContext context,
+ @Nonnull BiFunction processor,
+ U defaultValue) {
+
+ U result = defaultValue;
+ try {
+ ServiceReference reference = context.getServiceReference(serviceClass);
+ T service = reference != null ? context.getService(reference) : null;
+ if (service == null) {
+ // This is an anticipated case because a service might not be registered due to a particular config,
+ // e.g., a ConfigChangeListener
+ LOG.debug(ERROR_RETRIEVAL, serviceClass.getName());
+ return defaultValue;
+ }
+ AtomicBoolean alreadyReleased = new AtomicBoolean(false);
+ try {
+ result = processor.apply(
+ service,
+ () -> {
+ alreadyReleased.set(true);
+ ungetService(context, reference);
+ });
+ } catch (RuntimeException e) {
+ LOG.error(ERROR_RUNNING, serviceClass.getName(), e);
+ if (!alreadyReleased.get()) {
+ ungetService(context, reference);
+ }
+ return defaultValue;
+ }
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ LOG.error(ERROR_HANDLING, serviceClass.getName(), e);
+ }
+ return result;
+ }
+
+ /* ---------------
+ Utility methods
+ --------------- */
+
+ /**
+ * Safely ungets the provided service reference from the context, suppressing any exceptions that might occur
+ * @param context The OSGi bundle context to be used for service ungetting
+ * @param reference The service reference to be ungotten
+ */
+ private static void ungetService(BundleContext context, ServiceReference> reference) {
+ if (reference == null) {
+ return;
+ }
+ try {
+ context.ungetService(reference);
+ } catch (IllegalArgumentException | IllegalStateException e) {
+ LOG.warn(ERROR_HANDLING, reference, e);
+ }
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/AllTests.java b/core/src/test/java/com/exadel/aem/toolkit/AllTests.java
deleted file mode 100644
index 865b75bde..000000000
--- a/core/src/test/java/com/exadel/aem/toolkit/AllTests.java
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Licensed under the Apache License, Version 2.0 (the "License").
- * You may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.exadel.aem.toolkit;
-
-import org.junit.runner.RunWith;
-import org.junit.runners.Suite;
-import org.junit.runners.Suite.SuiteClasses;
-
-import com.exadel.aem.toolkit.api.annotations.meta.StringTransformationTest;
-import com.exadel.aem.toolkit.core.configurator.models.internal.ConfigDefinitionTest;
-import com.exadel.aem.toolkit.core.configurator.models.internal.RenderConditionTest;
-import com.exadel.aem.toolkit.core.configurator.services.ConfigChangeListenerTest;
-import com.exadel.aem.toolkit.core.configurator.services.ConfigDataUtilTest;
-import com.exadel.aem.toolkit.core.configurator.servlets.form.ConfigDataSourceTest;
-import com.exadel.aem.toolkit.core.configurator.servlets.form.FieldUtilTest;
-import com.exadel.aem.toolkit.core.configurator.servlets.form.ValueUtilTest;
-import com.exadel.aem.toolkit.core.configurator.servlets.replication.ReplicationServletTest;
-import com.exadel.aem.toolkit.core.configurator.utils.PermissionUtilTest;
-import com.exadel.aem.toolkit.core.injectors.ChildInjectorTest;
-import com.exadel.aem.toolkit.core.injectors.ChildrenInjectorTest;
-import com.exadel.aem.toolkit.core.injectors.EToolboxListInjectorTest;
-import com.exadel.aem.toolkit.core.injectors.EnumValueInjectorTest;
-import com.exadel.aem.toolkit.core.injectors.I18nInjectorTest;
-import com.exadel.aem.toolkit.core.injectors.RequestAttributeInjectorTest;
-import com.exadel.aem.toolkit.core.injectors.RequestParamInjectorTest;
-import com.exadel.aem.toolkit.core.injectors.RequestSelectorsInjectorTest;
-import com.exadel.aem.toolkit.core.injectors.RequestSuffixInjectorTest;
-import com.exadel.aem.toolkit.core.injectors.utils.FilteredResourceDecoratorTest;
-import com.exadel.aem.toolkit.core.lists.models.ListItemTest;
-import com.exadel.aem.toolkit.core.lists.servlets.ItemComponentsServletTest;
-import com.exadel.aem.toolkit.core.lists.servlets.ListsServletTest;
-import com.exadel.aem.toolkit.core.lists.utils.ListHelperTest;
-import com.exadel.aem.toolkit.core.lists.utils.ListPageUtilTest;
-import com.exadel.aem.toolkit.core.lists.utils.ListResourceUtilTest;
-import com.exadel.aem.toolkit.core.optionprovider.services.impl.resolvers.OptionProviderConstantsTest;
-import com.exadel.aem.toolkit.core.optionprovider.services.impl.resolvers.OptionProviderEnumsTest;
-import com.exadel.aem.toolkit.core.optionprovider.services.impl.resolvers.OptionProviderHttpTest;
-import com.exadel.aem.toolkit.core.optionprovider.services.impl.resolvers.OptionProviderInlineOptionsTest;
-import com.exadel.aem.toolkit.core.optionprovider.services.impl.resolvers.OptionProviderTest;
-import com.exadel.aem.toolkit.core.policymanagement.filters.TopLevelPolicyFilterTest;
-
-/**
- * Shortcut class for running all available test cases in a batch
- */
-@RunWith(Suite.class)
-@SuiteClasses({
- StringTransformationTest.class,
-
- ListHelperTest.class,
- ListPageUtilTest.class,
- ListResourceUtilTest.class,
-
- ListsServletTest.class,
- ListItemTest.class,
- ItemComponentsServletTest.class,
-
- RequestAttributeInjectorTest.class,
- RequestParamInjectorTest.class,
- RequestSelectorsInjectorTest.class,
- RequestSuffixInjectorTest.class,
- I18nInjectorTest.class,
- EToolboxListInjectorTest.class,
- ChildInjectorTest.class,
- ChildrenInjectorTest.class,
- EnumValueInjectorTest.class,
- FilteredResourceDecoratorTest.class,
-
- OptionProviderTest.class,
- OptionProviderHttpTest.class,
- OptionProviderEnumsTest.class,
- OptionProviderConstantsTest.class,
- OptionProviderInlineOptionsTest.class,
-
- ConfigChangeListenerTest.class,
- ConfigDataSourceTest.class,
- ConfigDataUtilTest.class,
- ConfigDefinitionTest.class,
- ReplicationServletTest.class,
- RenderConditionTest.class,
- FieldUtilTest.class,
- PermissionUtilTest.class,
- ValueUtilTest.class,
-
- TopLevelPolicyFilterTest.class
-})
-public class AllTests {
-}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/models/internal/RenderConditionTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/configurator/models/internal/RenderConditionTest.java
index ea9f8d5f3..9a90237f8 100644
--- a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/models/internal/RenderConditionTest.java
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/configurator/models/internal/RenderConditionTest.java
@@ -324,7 +324,7 @@ private void createAndInvokeRenderCondition(String feature) {
"etoolbox-authoring-kit/configurator/components/rendercondition",
valueMap);
context.request().setResource(resource);
- RenderCondition model = context.request().adaptTo(RenderCondition.class);
+ context.request().adaptTo(RenderCondition.class);
}
private void assertRenderCondition(boolean expected) {
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSourceTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSourceTest.java
index f06c7fe72..076e9f7aa 100644
--- a/core/src/test/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSourceTest.java
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/configurator/servlets/form/ConfigDataSourceTest.java
@@ -103,19 +103,12 @@ private void setUpBundleContext(String configId) throws IOException {
ConfigurationAdmin mockConfigAdmin = Mockito.mock(ConfigurationAdmin.class);
Mockito.when(mockConfigAdmin.getConfiguration(Mockito.eq(configId), Mockito.isNull())).thenReturn(null);
- MetaTypeService mockMetaTypeService = Mockito.mock(MetaTypeService.class);
-
BundleContext mockBundleContext = Mockito.mock(BundleContext.class);
@SuppressWarnings("unchecked")
ServiceReference mockConfigAdminRef = Mockito.mock(ServiceReference.class);
Mockito.when(mockBundleContext.getServiceReference(Mockito.eq(ConfigurationAdmin.class))).thenReturn(mockConfigAdminRef);
Mockito.when(mockBundleContext.getService(Mockito.eq(mockConfigAdminRef))).thenReturn(mockConfigAdmin);
- @SuppressWarnings("unchecked")
- ServiceReference mockMetaTypeServiceRef = Mockito.mock(ServiceReference.class);
- Mockito.when(mockBundleContext.getServiceReference(Mockito.eq(MetaTypeService.class))).thenReturn(mockMetaTypeServiceRef);
- Mockito.when(mockBundleContext.getService(Mockito.eq(mockMetaTypeServiceRef))).thenReturn(mockMetaTypeService);
-
context.request().setAttribute(BundleContext.class.getName(), mockBundleContext);
}
}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/ChangeSampleTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/ChangeSampleTest.java
new file mode 100644
index 000000000..70bab3467
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/ChangeSampleTest.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.models;
+
+import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil;
+
+public class ChangeSampleTest {
+
+ private static final String PATH_SAMPLE = "/content/sample";
+
+ @Test
+ public void shouldReturnProperties() {
+ ChangeSample withUser = newChangeSample(PATH_SAMPLE, 10, "author");
+
+ assertEquals(PATH_SAMPLE, withUser.getPath());
+ assertEquals(10, withUser.getLimit());
+ assertEquals("author", withUser.getUser());
+
+ // User can be null
+ ChangeSample withoutUser = newChangeSample(PATH_SAMPLE, 0, null);
+ assertNull(withoutUser.getUser());
+ }
+
+ @Test
+ public void shouldBeEqualByPathOnly() {
+ // Same path, different limit and user → equal
+ ChangeSample a = newChangeSample(PATH_SAMPLE, 5, "user1");
+ ChangeSample b = newChangeSample(PATH_SAMPLE, 99, "user2");
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+
+ // Different path → not equal
+ assertNotEquals(a, newChangeSample("/content/other", 5, "user1"));
+
+ // Null check
+ assertNotNull(a);
+ }
+
+ private static ChangeSample newChangeSample(String path, int limit, String user) {
+ String userJson = user != null ? "\"" + user + "\"" : "null";
+ return ObjectConversionUtil.toObject(
+ "{\"path\":\"" + path + "\",\"limit\":" + limit + ",\"user\":" + userJson + "}",
+ ChangeSample.class);
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayInfoTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayInfoTest.java
new file mode 100644
index 000000000..8beda16e8
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayInfoTest.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.models;
+
+import java.util.Collection;
+import java.util.Collections;
+
+import org.apache.commons.lang3.StringUtils;
+import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil;
+
+public class RelayInfoTest {
+
+ private static final String PATH_SOURCE = "/content/source";
+ private static final String PATH_TARGET = "/content/target";
+ private static final String PATH_SAMPLE = "/content/sample";
+ private static final String USER_AUTHOR = "author";
+ private static final String USER_ADMIN = "admin";
+
+ @Test
+ public void shouldReturnSourceAndTarget() {
+ RelayInfo relay = newRelayInfo(PATH_SOURCE, PATH_TARGET);
+
+ assertEquals(PATH_SOURCE, relay.getSource());
+ assertEquals(PATH_TARGET, relay.getTarget());
+
+ // Null from/to in path mapping defaults to empty string
+ RelayInfo withNulls = new RelayInfo(
+ newMapping(null, null),
+ Collections.emptyList(),
+ Collections.emptyList());
+ assertEquals(StringUtils.EMPTY, withNulls.getSource());
+ assertEquals(StringUtils.EMPTY, withNulls.getTarget());
+ }
+
+ @Test
+ public void shouldLookUpUserMapping() {
+ RelayInfo relay = new RelayInfo(
+ newMapping(PATH_SOURCE, PATH_TARGET),
+ Collections.singletonList(newMapping(USER_AUTHOR, USER_ADMIN)),
+ Collections.emptyList());
+
+ assertEquals(USER_ADMIN, relay.getUserMapping(USER_AUTHOR));
+ assertNull(relay.getUserMapping("unknown"));
+
+ // Empty or null user mappings → always null
+ RelayInfo noMappings = newRelayInfo(PATH_SOURCE, PATH_TARGET);
+ assertNull(noMappings.getUserMapping(USER_AUTHOR));
+
+ RelayInfo nullMappings = new RelayInfo(
+ newMapping(PATH_SOURCE, PATH_TARGET),
+ null,
+ Collections.emptyList());
+ assertNull(nullMappings.getUserMapping(USER_AUTHOR));
+ }
+
+ @Test
+ public void shouldReturnChangeSamples() {
+ ChangeSample sample = newChangeSample(PATH_SAMPLE);
+ RelayInfo relay = new RelayInfo(
+ newMapping(PATH_SOURCE, PATH_TARGET),
+ Collections.emptyList(),
+ Collections.singletonList(sample));
+
+ Collection samples = relay.getChangeSamples();
+ assertEquals(1, samples.size());
+ assertEquals(PATH_SAMPLE, samples.iterator().next().getPath());
+
+ // The returned collection is unmodifiable
+ boolean threw = false;
+ try {
+ samples.add(newChangeSample(PATH_SAMPLE));
+ } catch (UnsupportedOperationException e) {
+ threw = true;
+ }
+ assertTrue(threw);
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private static RelayInfo newRelayInfo(String source, String target) {
+ return new RelayInfo(
+ newMapping(source, target),
+ Collections.emptyList(),
+ Collections.emptyList());
+ }
+
+ private static RelayMapping newMapping(String from, String to) {
+ String fromJson = from != null ? "\"" + from + "\"" : "null";
+ String toJson = to != null ? "\"" + to + "\"" : "null";
+ return ObjectConversionUtil.toObject(
+ "{\"from\":" + fromJson + ",\"to\":" + toJson + "}",
+ RelayMapping.class);
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private static ChangeSample newChangeSample(String path) {
+ return ObjectConversionUtil.toObject(
+ "{\"path\":\"" + path + "\"}",
+ ChangeSample.class);
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayMappingTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayMappingTest.java
new file mode 100644
index 000000000..e39c2519f
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayMappingTest.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.models;
+
+import org.apache.commons.lang3.StringUtils;
+import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil;
+
+public class RelayMappingTest {
+
+ private static final String PATH_SOURCE = "/content/source";
+ private static final String PATH_TARGET = "/content/target";
+ private static final String PATH_OTHER = "/content/other";
+
+ @Test
+ public void shouldReturnPropertiesAndValidity() {
+ RelayMapping mapping = newMapping(PATH_SOURCE, PATH_TARGET);
+
+ assertEquals(PATH_SOURCE, mapping.getFrom());
+ assertEquals(PATH_TARGET, mapping.getTo());
+ assertTrue(mapping.isValid());
+
+ assertFalse(newMapping(StringUtils.EMPTY, PATH_TARGET).isValid());
+ assertFalse(newMapping(PATH_SOURCE, StringUtils.EMPTY).isValid());
+ assertFalse(newMapping(null, PATH_TARGET).isValid());
+ assertFalse(newMapping(PATH_SOURCE, PATH_SOURCE).isValid());
+ assertFalse(newMapping(PATH_SOURCE, PATH_SOURCE + "/child").isValid());
+ assertFalse(newMapping(PATH_SOURCE + "/child", PATH_SOURCE).isValid());
+ }
+
+ @Test
+ public void shouldCheckEquality() {
+ RelayMapping a = newMapping(PATH_SOURCE, PATH_TARGET);
+ RelayMapping b = newMapping(PATH_SOURCE, PATH_TARGET);
+
+ assertEquals(a, b);
+ assertEquals(a.hashCode(), b.hashCode());
+ assertEquals(a, newMapping(PATH_SOURCE, PATH_OTHER));
+ assertNotEquals(a, newMapping(PATH_OTHER, PATH_TARGET));
+ }
+
+ private static RelayMapping newMapping(String from, String to) {
+ String fromJson = from != null ? "\"" + from + "\"" : "null";
+ String toJson = to != null ? "\"" + to + "\"" : "null";
+ return ObjectConversionUtil.toObject(
+ "{\"from\":" + fromJson + ",\"to\":" + toJson + "}",
+ RelayMapping.class);
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayResourceTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayResourceTest.java
new file mode 100644
index 000000000..b5d577e9d
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/models/RelayResourceTest.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.models;
+
+import java.util.Iterator;
+
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceMetadata;
+import org.junit.Rule;
+import org.junit.Test;
+import io.wcm.testing.mock.aem.junit.AemContext;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import com.exadel.aem.toolkit.core.AemContextFactory;
+
+public class RelayResourceTest {
+
+ private static final String PATH_TARGET = "/content/target";
+ private static final String PATH_SOURCE = "/content/source";
+ private static final String PATH_CHILD = "/child";
+ private static final String PATH_NO_SLASH = "noSlash";
+
+ @Rule
+ public final AemContext context = AemContextFactory.newInstance();
+
+ @Test
+ public void shouldCopyAndOverrideProperties() {
+ Resource plain = context.create().resource(PATH_TARGET);
+ RelayResource relay = new RelayResource(plain, PATH_SOURCE);
+
+ assertEquals(PATH_SOURCE, relay.getPath());
+ assertSame(plain, relay.getResource());
+ assertNotSame(plain.getResourceMetadata(), relay.getResourceMetadata());
+ assertEquals(PATH_SOURCE, relay.getResourceMetadata().get(ResourceMetadata.RESOLUTION_PATH));
+ }
+
+ @Test
+ public void shouldGetName() {
+ Resource plain = context.create().resource(PATH_TARGET);
+
+ assertEquals("source", new RelayResource(plain, PATH_SOURCE).getName());
+ // Path without any slash: returned as-is
+ assertEquals(PATH_NO_SLASH, new RelayResource(plain, PATH_NO_SLASH).getName());
+ }
+
+ @Test
+ public void shouldGetParent() {
+ final String pathRelayParent = "/relay";
+ Resource original = context.create().resource(PATH_TARGET);
+ context.create().resource(pathRelayParent);
+
+ RelayResource relay = new RelayResource(original, pathRelayParent + "/item");
+ Resource parent = relay.getParent();
+ assertNotNull(parent);
+ assertEquals(pathRelayParent, parent.getPath());
+
+ assertNotNull(new RelayResource(original, "/top").getParent());
+ assertNull(new RelayResource(original, PATH_NO_SLASH).getParent());
+ assertNull(new RelayResource(original, "/missing/item").getParent());
+ }
+
+ @Test
+ public void shouldGetChild() {
+ Resource original = context.create().resource(PATH_TARGET);
+ context.create().resource(PATH_SOURCE + PATH_CHILD);
+ RelayResource relay = new RelayResource(original, PATH_SOURCE);
+
+ Resource child = relay.getChild("child");
+ assertNotNull(child);
+ assertEquals(PATH_SOURCE + PATH_CHILD, child.getPath());
+
+ // Relative path with leading/trailing slashes is stripped
+ Resource childFromSlashed = relay.getChild("/child/");
+ assertNotNull(childFromSlashed);
+ assertEquals(PATH_SOURCE + PATH_CHILD, childFromSlashed.getPath());
+
+ assertNull(relay.getChild("nonexistent"));
+ }
+
+ @Test
+ public void shouldListChildrenWithOverriddenPath() {
+ Resource parent = context.create().resource(PATH_TARGET);
+ context.create().resource(PATH_TARGET + PATH_CHILD);
+ RelayResource relay = new RelayResource(parent, PATH_SOURCE);
+
+ Iterator children = relay.listChildren();
+
+ assertNotNull(children);
+ assertTrue(children.hasNext());
+ Resource child = children.next();
+ assertEquals(PATH_SOURCE + PATH_CHILD, child.getPath());
+ assertTrue(child instanceof RelayResource);
+ assertFalse(children.hasNext());
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/PathSamplerTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/PathSamplerTest.java
new file mode 100644
index 000000000..cd7a2da8c
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/PathSamplerTest.java
@@ -0,0 +1,305 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.services;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.jcr.Session;
+
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.PersistenceException;
+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.testing.mock.sling.ResourceResolverType;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mockito;
+import io.wcm.testing.mock.aem.junit.AemContext;
+import io.wcm.testing.mock.aem.junit.AemContextBuilder;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import com.exadel.aem.toolkit.core.relay.models.ChangeSample;
+import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil;
+
+public class PathSamplerTest {
+
+ private static final String PATH_SOURCE = "/content/source";
+ private static final String PATH_TARGET = "/content/target";
+ private static final String PATH_CHILD = "/page1";
+
+ private static final String XPATH_CHILDREN = "/jcr:root/content/target/*";
+ private static final String XPATH_INVALID = "//[@invalid syntax";
+
+ @Rule
+ public final AemContext context = new AemContextBuilder()
+ .resourceResolverType(ResourceResolverType.JCR_OAK)
+ .build();
+
+ /* ----------------
+ JCR path samples
+ ---------------- */
+
+ @Test
+ public void shouldReturnEmptyForNoSamples() {
+ PathSampler samplerWithNull = PathSampler
+ .builder()
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .build();
+ assertTrue(samplerWithNull.createChanges().isEmpty());
+
+ PathSampler samplerWithEmpty = PathSampler
+ .builder()
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Collections.emptyList())
+ .build();
+ assertTrue(samplerWithEmpty.createChanges().isEmpty());
+ }
+
+ @Test
+ public void shouldCreateChangesFromJcrPaths() {
+ // Single path: emits one CHANGED event
+ PathSampler singleSampler = PathSampler
+ .builder()
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Collections.singletonList(newChangeSample(PATH_TARGET + PATH_CHILD)))
+ .build();
+
+ Collection singleResult = singleSampler.createChanges();
+ assertEquals(1, singleResult.size());
+ assertEquals(ResourceChange.ChangeType.CHANGED, singleResult.iterator().next().getType());
+
+ // Multiple distinct paths: each emits a separate event
+ PathSampler multiSampler = PathSampler
+ .builder()
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Arrays.asList(
+ newChangeSample(PATH_TARGET + "/page1"),
+ newChangeSample(PATH_TARGET + "/page2")))
+ .build();
+ assertEquals(2, multiSampler.createChanges().size());
+
+ // Duplicate path in samples: deduplicated by the internal path set
+ PathSampler dupSampler = PathSampler.builder()
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Arrays.asList(
+ newChangeSample(PATH_TARGET + PATH_CHILD),
+ newChangeSample(PATH_TARGET + PATH_CHILD)))
+ .build();
+ assertEquals(1, dupSampler.createChanges().size());
+ }
+
+ @Test
+ public void shouldRewriteTargetPaths() {
+ PathSampler sampler = PathSampler
+ .builder()
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Arrays.asList(
+ newChangeSample(PATH_TARGET + PATH_CHILD),
+ newChangeSample("/content/unrelated/page")))
+ .build();
+
+ Collection changes = sampler.createChanges();
+ List paths = changes.stream()
+ .map(ResourceChange::getPath)
+ .collect(Collectors.toList());
+
+ assertEquals(2, paths.size());
+ // Path under target is rewritten to source prefix
+ assertTrue(paths.contains(PATH_SOURCE + PATH_CHILD));
+ // Path outside target is returned unchanged
+ assertTrue(paths.contains("/content/unrelated/page"));
+ }
+
+ /* ----------------
+ XPath resolution
+ ---------------- */
+
+ @Test
+ public void shouldResolveXpathExpression() throws LoginException, PersistenceException {
+ context.create().resource(PATH_TARGET + PATH_CHILD);
+ context.resourceResolver().commit();
+
+ PathSampler sampler = PathSampler
+ .builder()
+ .resolverFactory(newMockFactory())
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Collections.singletonList(newChangeSample(XPATH_CHILDREN)))
+ .build();
+
+ Collection changes = sampler.createChanges();
+
+ assertEquals(1, changes.size());
+ assertEquals(PATH_SOURCE + PATH_CHILD, changes.iterator().next().getPath());
+ }
+
+ @Test
+ public void shouldApplyQueryLimit() throws LoginException, PersistenceException {
+ for (int i = 0; i < 10; i++) {
+ context.create().resource(PATH_TARGET + "/node" + i);
+ }
+ context.resourceResolver().commit();
+
+ PathSampler sampler = PathSampler
+ .builder()
+ .resolverFactory(newMockFactory())
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Collections.singletonList(newChangeSampleWithLimit(XPATH_CHILDREN, 5)))
+ .build();
+
+ Collection changes = sampler.createChanges();
+
+ assertEquals(5, changes.size());
+ }
+
+ @Test
+ public void shouldSkipXpathOnError() throws LoginException {
+ // Invalid XPath syntax causes Oak to throw InvalidQueryException (extends RepositoryException)
+ PathSampler repositoryErrorSampler = PathSampler
+ .builder()
+ .resolverFactory(newMockFactory())
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Collections.singletonList(newChangeSample(XPATH_INVALID)))
+ .build();
+
+ assertTrue(repositoryErrorSampler.createChanges().isEmpty());
+
+ // LoginException when obtaining a resolver: sample is skipped
+ ResourceResolverFactory failingFactory = Mockito.mock(ResourceResolverFactory.class);
+ Mockito
+ .when(failingFactory.getServiceResourceResolver(Mockito.any()))
+ .thenThrow(new LoginException("Service user not found"));
+
+ PathSampler loginErrorSampler = PathSampler
+ .builder()
+ .resolverFactory(failingFactory)
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Collections.singletonList(newChangeSample(XPATH_CHILDREN)))
+ .build();
+
+ assertTrue(loginErrorSampler.createChanges().isEmpty());
+ }
+
+ /* -----------------
+ Resolver rotation
+ ----------------- */
+
+ @Test
+ public void shouldReuseResolverForSameUser() throws LoginException {
+ ResourceResolver sessionResolver = newMockResolver();
+ ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class);
+ Mockito.when(factory.getServiceResourceResolver(Mockito.any())).thenReturn(sessionResolver);
+
+ PathSampler sampler = PathSampler.builder()
+ .resolverFactory(factory)
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Arrays.asList(
+ newChangeSampleWithUser(XPATH_CHILDREN, "author"),
+ newChangeSampleWithUser("/jcr:root/content//element(*)", "author")))
+ .build();
+
+ sampler.createChanges();
+
+ // Factory is called once; the same resolver is reused for the second sample
+ Mockito.verify(factory, Mockito.times(1)).getServiceResourceResolver(Mockito.any());
+ }
+
+ @Test
+ public void shouldRotateResolverOnUserChange() throws LoginException {
+ ResourceResolver resolverA = newMockResolver();
+ ResourceResolver resolverB = newMockResolver();
+
+ ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class);
+ Mockito.when(factory.getServiceResourceResolver(Mockito.any()))
+ .thenReturn(resolverA)
+ .thenReturn(resolverB);
+
+ PathSampler sampler = PathSampler.builder()
+ .resolverFactory(factory)
+ .source(PATH_SOURCE)
+ .target(PATH_TARGET)
+ .samples(Arrays.asList(
+ newChangeSampleWithUser(XPATH_CHILDREN, "user1"),
+ newChangeSampleWithUser("/jcr:root/content//element(*)", "user2")))
+ .build();
+
+ sampler.createChanges();
+
+ // Factory is called once per distinct user
+ Mockito.verify(factory, Mockito.times(2)).getServiceResourceResolver(Mockito.any());
+ // The first resolver is closed when the user changes
+ Mockito.verify(resolverA).close();
+ }
+
+ /* ---------------
+ Utility methods
+ --------------- */
+
+ private static ChangeSample newChangeSample(String path) {
+ return ObjectConversionUtil.toObject(
+ "{\"path\":\"" + path + "\"}",
+ ChangeSample.class);
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private static ChangeSample newChangeSampleWithLimit(String path, int limit) {
+ return ObjectConversionUtil.toObject(
+ "{\"path\":\"" + path + "\",\"limit\":" + limit + "}",
+ ChangeSample.class);
+ }
+
+ private static ChangeSample newChangeSampleWithUser(String path, String user) {
+ return ObjectConversionUtil.toObject(
+ "{\"path\":\"" + path + "\",\"user\":\"" + user + "\"}",
+ ChangeSample.class);
+ }
+
+ private ResourceResolverFactory newMockFactory() throws LoginException {
+ // We are not using the built-in resource resolver factory because of lack of support for
+ // ResourceResolver#getPropertyMap() in the built-in mock resource resolver (dependency version issue).
+ // Can be revised to use the real factory once the dependency is updated
+ ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class);
+ ResourceResolver sessionResolver = newMockResolver();
+ Mockito
+ .when(factory.getServiceResourceResolver(Mockito.any()))
+ .thenReturn(sessionResolver);
+ return factory;
+ }
+
+ private ResourceResolver newMockResolver() {
+ Session session = context.resourceResolver().adaptTo(Session.class);
+ assertNotNull(session);
+ ResourceResolver resolver = Mockito.mock(ResourceResolver.class);
+ Mockito.when(resolver.adaptTo(Session.class)).thenReturn(session);
+ Mockito.when(resolver.getPropertyMap()).thenReturn(new HashMap<>());
+ return resolver;
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHostTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHostTest.java
new file mode 100644
index 000000000..9a323618d
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderHostTest.java
@@ -0,0 +1,309 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.services;
+
+import java.util.Dictionary;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.Iterator;
+import java.util.Map;
+import javax.annotation.Nonnull;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.observation.ResourceChangeListener;
+import org.apache.sling.spi.resource.provider.ResolveContext;
+import org.apache.sling.spi.resource.provider.ResourceContext;
+import org.apache.sling.spi.resource.provider.ResourceProvider;
+import org.apache.sling.testing.mock.osgi.MockOsgi;
+import org.junit.Rule;
+import org.junit.Test;
+import org.osgi.framework.InvalidSyntaxException;
+import org.osgi.framework.ServiceReference;
+import io.wcm.testing.mock.aem.junit.AemContext;
+import static org.junit.Assert.assertEquals;
+
+import com.exadel.aem.toolkit.core.AemContextFactory;
+
+public class RelayProviderHostTest {
+
+ private static final String PATH_SOURCE = "/content/source";
+
+ private static final String PATH_MAPPING_A = "{\"from\":\"/content/source\",\"to\":\"/content/target\"}";
+ private static final String PATH_MAPPING_A_NEW_TARGET = "{\"from\":\"/content/source\",\"to\":\"/content/target-updated\"}";
+ private static final String PATH_MAPPING_A_NESTED_SOURCE = "{\"from\":\"/content/source\",\"to\":\"/content\"}";
+ private static final String PATH_MAPPING_A_NESTED_TARGET = "{\"from\":\"/content/source\",\"to\":\"/content/source/child\"}";
+ private static final String PATH_MAPPING_A_DUP_SOURCE = "{\"from\":\"/content/source\",\"to\":\"/content/other\"}";
+
+ private static final String PATH_MAPPING_B = "{\"from\":\"/content/source2\",\"to\":\"/content/target2\"}";
+ private static final String PATH_MAPPING_MISSING_TO = "{\"from\":\"/content/source\"}";
+
+ private static final String USER_MAPPING_A = "{\"from\":\"admin\",\"to\":\"service-user\"}";
+ private static final String USER_MAPPING_A_DUP = "{\"from\":\"admin\",\"to\":\"other-service\"}";
+ private static final String USER_MAPPING_INVALID = "{\"from\":\"admin\"}";
+
+ private static final String CHANGE_SAMPLE_A = "{\"path\":\"/content/dam\"}";
+ private static final String CHANGE_SAMPLE_BLANK_PATH = "{\"path\":\"\"}";
+
+ @Rule
+ public final AemContext context = AemContextFactory.newInstance();
+
+ /* ------------------
+ Service activation
+ ------------------ */
+
+ @Test
+ public void shouldRegisterForEachValidMapping() throws InvalidSyntaxException {
+ context.registerInjectActivateService(
+ new RelayProviderHost(),
+ newProps(true, PATH_MAPPING_A, PATH_MAPPING_B));
+
+ assertEquals(2, countRegisteredProviders());
+ }
+
+ @Test
+ public void shouldParseUserMappingsAndSamples() throws InvalidSyntaxException {
+ context.registerInjectActivateService(
+ new RelayProviderHost(),
+ newExtendedProps(
+ true,
+ new String[]{PATH_MAPPING_A},
+ new String[]{StringUtils.EMPTY, USER_MAPPING_INVALID, USER_MAPPING_A, USER_MAPPING_A_DUP},
+ new String[]{StringUtils.EMPTY, CHANGE_SAMPLE_BLANK_PATH, CHANGE_SAMPLE_A}));
+ assertEquals(1, countRegisteredProviders());
+ }
+
+ @Test
+ public void shouldNotRegisterForInvalidConfig() throws InvalidSyntaxException {
+ // Disabled with a valid mapping
+ context.registerInjectActivateService(new RelayProviderHost(), newProps(false, PATH_MAPPING_A));
+ assertEquals(0, countRegisteredProviders());
+
+ // No path mappings
+ context.registerInjectActivateService(new RelayProviderHost(), newProps(true));
+ assertEquals(0, countRegisteredProviders());
+
+ // Invalid mapping (missing "to" value)
+ context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_MISSING_TO));
+ assertEquals(0, countRegisteredProviders());
+ }
+
+ @Test
+ public void shouldSkipDuplicatePathMappings() throws InvalidSyntaxException {
+ context.registerInjectActivateService(
+ new RelayProviderHost(),
+ newProps(
+ true,
+ StringUtils.EMPTY,
+ PATH_MAPPING_A,
+ PATH_MAPPING_A_NESTED_SOURCE,
+ PATH_MAPPING_A_NESTED_TARGET,
+ PATH_MAPPING_A_DUP_SOURCE));
+ assertEquals(1, countRegisteredProviders());
+ }
+
+ @Test
+ public void shouldSkipShadowedPath() throws InvalidSyntaxException {
+ context.bundleContext().registerService(
+ ResourceProvider.class.getName(),
+ new StubResourceProvider(),
+ newExtProviderProps("external-provider", PATH_SOURCE));
+
+ context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_A));
+
+ assertEquals(1, countRegisteredProviders());
+ }
+
+ @Test
+ public void shouldSkipShadowingByUnnamedProvider() throws InvalidSyntaxException {
+ // Null PROPERTY_NAME → fallback to bundle symbolic name → relay is still shadowed
+ context.bundleContext().registerService(
+ ResourceProvider.class.getName(),
+ new StubResourceProvider(),
+ newExtProviderProps(null, PATH_SOURCE));
+ context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_A));
+ assertEquals(1, countRegisteredProviders());
+
+ // Empty PROPERTY_NAME → same fallback behavior
+ context.bundleContext().registerService(
+ ResourceProvider.class.getName(),
+ new StubResourceProvider(),
+ newExtProviderProps("", "/content/source2"));
+ context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_B));
+ assertEquals(2, countRegisteredProviders());
+ }
+
+ @Test
+ public void shouldRegisterWithRootlessExternalProvider() throws InvalidSyntaxException {
+ // Provider with no PROPERTY_ROOT → not in the provided-paths map → relay is not shadowed
+ context.bundleContext().registerService(
+ ResourceProvider.class.getName(),
+ new StubResourceProvider(),
+ newExtProviderProps("rootless-provider", null));
+ context.registerInjectActivateService(new RelayProviderHost(), newProps(true, PATH_MAPPING_A));
+ assertEquals(2, countRegisteredProviders());
+ }
+
+ /* ---------------------
+ Service re-activation
+ --------------------- */
+
+ @Test
+ public void shouldUpdateProviderOnReactivate() throws InvalidSyntaxException {
+ RelayProviderHost host = context.registerInjectActivateService(
+ new RelayProviderHost(),
+ newProps(true, PATH_MAPPING_A));
+
+ assertEquals(1, countRegisteredProviders());
+
+ MockOsgi.activate(host, context.bundleContext(), newProps(true, PATH_MAPPING_A));
+
+ assertEquals(1, countRegisteredProviders());
+ }
+
+ @Test
+ public void shouldReregisterWhenTargetChanges() throws InvalidSyntaxException {
+ RelayProviderHost host = context.registerInjectActivateService(
+ new RelayProviderHost(),
+ newProps(true, PATH_MAPPING_A));
+
+ assertEquals(1, countRegisteredProviders());
+ assertEquals("/content/target", getRegisteredTarget(PATH_SOURCE));
+
+ MockOsgi.activate(host, context.bundleContext(), newProps(true, PATH_MAPPING_A_NEW_TARGET));
+
+ assertEquals(1, countRegisteredProviders());
+ assertEquals("/content/target-updated", getRegisteredTarget(PATH_SOURCE));
+ }
+
+ @Test
+ public void shouldUnregisterRemovedMappingOnReactivate() throws InvalidSyntaxException {
+ RelayProviderHost host = context.registerInjectActivateService(
+ new RelayProviderHost(),
+ newProps(true, PATH_MAPPING_A, PATH_MAPPING_B));
+
+ assertEquals(2, countRegisteredProviders());
+
+ MockOsgi.activate(host, context.bundleContext(), newProps(true, PATH_MAPPING_A));
+
+ assertEquals(1, countRegisteredProviders());
+ }
+
+ @Test
+ public void shouldRegisterAddedMappingOnReactivate() throws InvalidSyntaxException {
+ RelayProviderHost host = context.registerInjectActivateService(
+ new RelayProviderHost(),
+ newProps(true, PATH_MAPPING_A));
+
+ assertEquals(1, countRegisteredProviders());
+
+ MockOsgi.activate(host, context.bundleContext(), newProps(true, PATH_MAPPING_A, PATH_MAPPING_B));
+
+ assertEquals(2, countRegisteredProviders());
+ }
+
+ /* ------------
+ Deactivation
+ ------------ */
+
+ @Test
+ public void shouldUnregisterOnDeactivate() throws InvalidSyntaxException {
+ RelayProviderHost host = context.registerInjectActivateService(
+ new RelayProviderHost(),
+ newProps(true, PATH_MAPPING_A));
+
+ assertEquals(1, countRegisteredProviders());
+
+ MockOsgi.deactivate(host, context.bundleContext());
+
+ assertEquals(0, countRegisteredProviders());
+ }
+
+ /* ---------------
+ Utility methods
+ --------------- */
+
+ private int countRegisteredProviders() throws InvalidSyntaxException {
+ ServiceReference>[] refs = context.bundleContext()
+ .getAllServiceReferences(ResourceProvider.class.getName(), null);
+ return refs != null ? refs.length : 0;
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private String getRegisteredTarget(String sourcePath) throws InvalidSyntaxException {
+ ServiceReference>[] refs = context
+ .bundleContext()
+ .getAllServiceReferences(ResourceProvider.class.getName(), null);
+ if (refs == null) {
+ return null;
+ }
+ for (ServiceReference> ref : refs) {
+ Object root = ref.getProperty(ResourceProvider.PROPERTY_ROOT);
+ if (sourcePath.equals(root)) {
+ String[] paths = (String[]) ref.getProperty(ResourceChangeListener.PATHS);
+ return ArrayUtils.isNotEmpty(paths) ? paths[0] : null;
+ }
+ }
+ return null;
+ }
+
+ private static Dictionary newExtProviderProps(String name, String root) {
+ Dictionary props = new Hashtable<>();
+ if (root != null) {
+ props.put(ResourceProvider.PROPERTY_ROOT, root);
+ }
+ if (name != null) {
+ props.put(ResourceProvider.PROPERTY_NAME, name);
+ }
+ return props;
+ }
+
+ private static Map newProps(boolean enabled, String... pathMappings) {
+ Map props = new HashMap<>();
+ props.put("enabled", enabled);
+ props.put("pathMappings", pathMappings);
+ return props;
+ }
+
+ private static Map newExtendedProps(
+ boolean enabled,
+ String[] pathMappings,
+ String[] userMappings,
+ String[] announcedPaths) {
+ Map props = new HashMap<>();
+ props.put("enabled", enabled);
+ props.put("pathMappings", pathMappings);
+ props.put("userMappings", userMappings);
+ props.put("announcedPaths", announcedPaths);
+ return props;
+ }
+
+ private static class StubResourceProvider extends ResourceProvider {
+
+ @Override
+ public Resource getResource(
+ @Nonnull ResolveContext ctx,
+ @Nonnull String path,
+ @Nonnull ResourceContext resourceContext,
+ Resource parent) {
+ return null;
+ }
+
+ @Override
+ public Iterator listChildren(@Nonnull ResolveContext ctx, @Nonnull Resource parent) {
+ return null;
+ }
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderTest.java
new file mode 100644
index 000000000..2af8333c9
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/services/RelayProviderTest.java
@@ -0,0 +1,661 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.services;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceMetadata;
+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.spi.resource.provider.ObservationReporter;
+import org.apache.sling.spi.resource.provider.ProviderContext;
+import org.apache.sling.spi.resource.provider.ResolveContext;
+import org.apache.sling.spi.resource.provider.ResourceContext;
+import org.apache.sling.spi.resource.provider.ResourceProvider;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import io.wcm.testing.mock.aem.junit.AemContext;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.exadel.aem.toolkit.core.AemContextFactory;
+import com.exadel.aem.toolkit.core.relay.models.ChangeSample;
+import com.exadel.aem.toolkit.core.relay.models.RelayInfo;
+import com.exadel.aem.toolkit.core.relay.models.RelayMapping;
+import com.exadel.aem.toolkit.core.relay.models.RelayResource;
+import com.exadel.aem.toolkit.core.utils.ObjectConversionUtil;
+
+public class RelayProviderTest {
+
+ private static final String PATH_SOURCE = "/content/source";
+ private static final String PATH_TARGET = "/content/target";
+ private static final String PATH_CHILD_A = "/page1";
+ private static final String PATH_CHILD_B = "/page2";
+ private static final String USER_AUTHOR = "author";
+
+ @Rule
+ public final AemContext context = AemContextFactory.newInstance();
+
+ /* ------------------------------
+ getResource(): path resolution
+ ------------------------------ */
+
+ @Test
+ public void shouldReturnRelayResource() {
+ // Path under source prefix
+ context.create().resource(PATH_TARGET);
+ context.create().resource(PATH_TARGET + PATH_CHILD_A);
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ ResolveContext resolveContext = newResolveContext();
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null);
+
+ assertNotNull(result);
+ assertTrue(result instanceof RelayResource);
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath());
+
+ // Exact source path
+ provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ resolveContext = newResolveContext();
+ resourceContext = Mockito.mock(ResourceContext.class);
+
+ result = provider.getResource(resolveContext, PATH_SOURCE, resourceContext, null);
+
+ assertNotNull(result);
+ assertTrue(result instanceof RelayResource);
+ assertEquals(PATH_SOURCE, result.getPath());
+ }
+
+ @Test
+ public void shouldReturnNullWhenPathNotUnderSource() {
+ String otherPath = "/content/other/page";
+ context.create().resource(otherPath);
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ ResolveContext resolveContext = newResolveContext();
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ Resource result = provider.getResource(resolveContext, otherPath, resourceContext, null);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void shouldFallBackToParentProvider() {
+ ResourceProvider mockProvider = newMockProvider();
+ ResolveContext parentCtx = newMockResolveContext();
+ Resource fallbackResource = context.create().resource(PATH_SOURCE + PATH_CHILD_A);
+ Mockito
+ .when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_SOURCE + PATH_CHILD_A), Mockito.any(), Mockito.any()))
+ .thenReturn(fallbackResource);
+
+ ResolveContext resolveContext = newResolveContext();
+ Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider();
+ Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext();
+
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null);
+
+ assertNotNull(result);
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath());
+ assertFalse(result instanceof RelayResource);
+ }
+
+ @Test
+ public void shouldReturnNullWhenTargetAndParentMiss() {
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ ResolveContext resolveContext = newResolveContext();
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null);
+
+ assertNull(result);
+ }
+
+ /* --------------
+ listChildren()
+ -------------- */
+
+ @Test
+ public void shouldListRelayChildren() {
+ context.create().resource(PATH_TARGET);
+ context.create().resource(PATH_TARGET + PATH_CHILD_A);
+
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ ResolveContext resolveContext = newResolveContext();
+ Resource parent = context.create().resource(PATH_SOURCE);
+
+ // Single child
+ Iterator result = provider.listChildren(resolveContext, parent);
+
+ assertNotNull(result);
+ assertTrue(result.hasNext());
+ Resource child = result.next();
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, child.getPath());
+ assertTrue(child instanceof RelayResource);
+ assertFalse(result.hasNext());
+
+ // Multiple children
+ context.create().resource(PATH_TARGET + PATH_CHILD_B);
+ result = provider.listChildren(resolveContext, parent);
+
+ assertNotNull(result);
+ Map children = new HashMap<>();
+ while (result.hasNext()) {
+ Resource c = result.next();
+ children.put(c.getPath(), c);
+ }
+ assertEquals(2, children.size());
+ assertTrue(children.containsKey(PATH_SOURCE + PATH_CHILD_A));
+ assertTrue(children.containsKey(PATH_SOURCE + PATH_CHILD_B));
+ assertTrue(children.get(PATH_SOURCE + PATH_CHILD_A) instanceof RelayResource);
+ assertTrue(children.get(PATH_SOURCE + PATH_CHILD_B) instanceof RelayResource);
+ }
+
+ @Test
+ public void shouldReturnNullWhenTargetNotFound() {
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ ResolveContext resolveContext = newResolveContext();
+
+ Resource parent = context.create().resource(PATH_SOURCE);
+
+ Iterator result = provider.listChildren(resolveContext, parent);
+
+ assertNull(result);
+ }
+
+ @Test
+ public void shouldDelegateToParentProvider() {
+ ResourceProvider mockProvider = newMockProvider();
+ ResolveContext parentCtx = newMockResolveContext();
+ Resource fallbackResource = context.create().resource("/content/fallback");
+ Mockito
+ .when(mockProvider.getResource(Mockito.any(), Mockito.eq(PATH_SOURCE), Mockito.any(), Mockito.any()))
+ .thenReturn(fallbackResource);
+
+ Resource parent = context.create().resource(PATH_SOURCE);
+ Resource expectedChild = context.create().resource(parent, "page1");
+ Mockito
+ .when(mockProvider.listChildren(Mockito.any(), Mockito.any()))
+ .thenReturn(Collections.singletonList(expectedChild).iterator());
+
+ ResolveContext resolveContext = newResolveContext();
+ Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider();
+ Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext();
+
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+
+ Iterator result = provider.listChildren(resolveContext, parent);
+
+ assertNotNull(result);
+ assertTrue(result.hasNext());
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, result.next().getPath());
+ }
+
+ /* ----------------------
+ Start / stop lifecycle
+ ---------------------- */
+
+ @Test
+ public void shouldStartWithEmptyChangeSamples() {
+ ObservationReporter reporter = Mockito.mock(ObservationReporter.class);
+ ProviderContext providerContext = newMockProviderContext(reporter);
+
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ provider.start(providerContext);
+
+ Mockito.verify(reporter, Mockito.never()).reportChanges(Mockito.any(), Mockito.anyBoolean());
+ }
+
+ @Test
+ public void shouldStartAndReportChangeSamples() {
+ ChangeSample sample = newChangeSample(PATH_TARGET + PATH_CHILD_A);
+ RelayInfo relay = newRelayInfo(
+ PATH_SOURCE, PATH_TARGET,
+ Collections.emptyList(),
+ Collections.singletonList(sample));
+
+ ObservationReporter reporter = Mockito.mock(ObservationReporter.class);
+ ProviderContext providerContext = newMockProviderContext(reporter);
+
+ RelayProvider provider = newProvider(relay);
+ provider.start(providerContext);
+
+ ArgumentCaptor> captor = changeCaptor();
+ Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false));
+
+ Collection changes = captor.getValue();
+ assertEquals(1, changes.size());
+ ResourceChange change = changes.iterator().next();
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, change.getPath());
+ assertEquals(ResourceChange.ChangeType.CHANGED, change.getType());
+ }
+
+ @Test
+ public void shouldSetProviderContext() {
+ ObservationReporter reporter = Mockito.mock(ObservationReporter.class);
+ ProviderContext providerContext = newMockProviderContext(reporter);
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+
+ // Before start: onChange is a no-op because providerContext is null
+ provider.onChange(Collections.singletonList(
+ new ResourceChange(ResourceChange.ChangeType.CHANGED, PATH_TARGET + PATH_CHILD_A, false)));
+ Mockito.verify(reporter, Mockito.never()).reportChanges(Mockito.any(), Mockito.anyBoolean());
+
+ // After start: onChange calls reportChanges because providerContext is set
+ provider.start(providerContext);
+ provider.onChange(Collections.singletonList(
+ new ResourceChange(ResourceChange.ChangeType.CHANGED, PATH_TARGET + PATH_CHILD_A, false)));
+ Mockito.verify(reporter, Mockito.times(1)).reportChanges(Mockito.any(), Mockito.eq(false));
+ }
+
+ @Test
+ public void shouldStopGracefullyBeforeStart() {
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+
+ // Calling stop() without a prior start() must not throw (providerContext and sampler are null)
+ provider.stop();
+ }
+
+ @Test
+ public void shouldStopWithEmptyChangeSamples() {
+ ObservationReporter reporter = Mockito.mock(ObservationReporter.class);
+ ProviderContext providerContext = newMockProviderContext(reporter);
+
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ provider.start(providerContext);
+ Mockito.reset(reporter);
+
+ provider.stop();
+
+ Mockito.verify(reporter, Mockito.never()).reportChanges(Mockito.any(), Mockito.anyBoolean());
+ }
+
+ @Test
+ public void shouldStopAndReportChanges() {
+ ChangeSample sample = newChangeSample(PATH_TARGET + PATH_CHILD_A);
+ RelayInfo relay = newRelayInfo(
+ PATH_SOURCE, PATH_TARGET,
+ Collections.emptyList(),
+ Collections.singletonList(sample));
+
+ ObservationReporter reporter = Mockito.mock(ObservationReporter.class);
+ ProviderContext providerContext = newMockProviderContext(reporter);
+
+ RelayProvider provider = newProvider(relay);
+ provider.start(providerContext);
+
+ Mockito.reset(reporter);
+
+ provider.stop();
+
+ ArgumentCaptor> captor = changeCaptor();
+ Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false));
+
+ Collection changes = captor.getValue();
+ assertEquals(1, changes.size());
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, changes.iterator().next().getPath());
+ }
+
+ /* --------------------
+ Change event mapping
+ -------------------- */
+
+ @Test
+ public void shouldNoOpWhenProviderContextIsNull() {
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+
+ // Not started: providerContext is null, so onChange returns immediately without throwing
+ provider.onChange(Collections.singletonList(
+ new ResourceChange(ResourceChange.ChangeType.CHANGED, PATH_TARGET + PATH_CHILD_A, false)));
+ }
+
+ @Test
+ public void shouldMapSingleChange() {
+ ObservationReporter reporter = Mockito.mock(ObservationReporter.class);
+ ProviderContext providerContext = newMockProviderContext(reporter);
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ provider.start(providerContext);
+
+ provider.onChange(Collections.singletonList(
+ new ResourceChange(ResourceChange.ChangeType.REMOVED, PATH_TARGET + PATH_CHILD_A, false)));
+
+ ArgumentCaptor> captor = listChangeCaptor();
+ Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false));
+
+ List mapped = captor.getValue();
+ assertEquals(1, mapped.size());
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, mapped.get(0).getPath());
+ assertEquals(ResourceChange.ChangeType.REMOVED, mapped.get(0).getType());
+ }
+
+ @Test
+ public void shouldMapChangePathsFromTargetToSource() {
+ ObservationReporter reporter = Mockito.mock(ObservationReporter.class);
+ ProviderContext providerContext = newMockProviderContext(reporter);
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ provider.start(providerContext);
+
+ // Single change
+ provider.onChange(Collections.singletonList(
+ new ResourceChange(ResourceChange.ChangeType.REMOVED, PATH_TARGET + PATH_CHILD_A, false)));
+
+ ArgumentCaptor> captor = listChangeCaptor();
+ Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false));
+
+ List mapped = captor.getValue();
+ assertEquals(1, mapped.size());
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, mapped.get(0).getPath());
+ assertEquals(ResourceChange.ChangeType.REMOVED, mapped.get(0).getType());
+
+ Mockito.reset(reporter);
+
+ // Multiple changes
+ List incoming = Arrays.asList(
+ new ResourceChange(ResourceChange.ChangeType.CHANGED, PATH_TARGET + PATH_CHILD_A, false),
+ new ResourceChange(ResourceChange.ChangeType.ADDED, PATH_TARGET + PATH_CHILD_B, false));
+ provider.onChange(incoming);
+
+ captor = listChangeCaptor();
+ Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false));
+
+ mapped = captor.getValue();
+ assertEquals(2, mapped.size());
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, mapped.get(0).getPath());
+ assertEquals(ResourceChange.ChangeType.CHANGED, mapped.get(0).getType());
+ assertEquals(PATH_SOURCE + PATH_CHILD_B, mapped.get(1).getPath());
+ assertEquals(ResourceChange.ChangeType.ADDED, mapped.get(1).getType());
+ }
+
+ @Test
+ public void shouldHandleEmptyChangeList() {
+ ObservationReporter reporter = Mockito.mock(ObservationReporter.class);
+ ProviderContext providerContext = newMockProviderContext(reporter);
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ provider.start(providerContext);
+
+ provider.onChange(Collections.emptyList());
+
+ ArgumentCaptor> captor = changeCaptor();
+ Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false));
+ assertTrue(captor.getValue().isEmpty());
+ }
+
+ @Test
+ public void shouldPreserveChangeExternalFlag() {
+ ObservationReporter reporter = Mockito.mock(ObservationReporter.class);
+ ProviderContext providerContext = newMockProviderContext(reporter);
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ provider.start(providerContext);
+
+ provider.onChange(Collections.singletonList(
+ new ResourceChange(ResourceChange.ChangeType.CHANGED, PATH_TARGET + PATH_CHILD_A, true)));
+
+ ArgumentCaptor> captor = listChangeCaptor();
+ Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false));
+
+ List mapped = captor.getValue();
+ assertEquals(1, mapped.size());
+ assertTrue(mapped.get(0).isExternal());
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, mapped.get(0).getPath());
+ }
+
+ @Test
+ public void shouldNotMapChangesOutsideTarget() {
+ ObservationReporter reporter = Mockito.mock(ObservationReporter.class);
+ ProviderContext providerContext = newMockProviderContext(reporter);
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ provider.start(providerContext);
+
+ String unrelatedPath = "/content/unrelated/page";
+ provider.onChange(Collections.singletonList(
+ new ResourceChange(ResourceChange.ChangeType.CHANGED, unrelatedPath, false)));
+
+ ArgumentCaptor> captor = listChangeCaptor();
+ Mockito.verify(reporter).reportChanges(captor.capture(), Mockito.eq(false));
+
+ List mapped = captor.getValue();
+ assertEquals(1, mapped.size());
+ // Path not under target prefix → returned unchanged
+ assertEquals(unrelatedPath, mapped.get(0).getPath());
+ }
+
+ /* -------------
+ Config update
+ ------------- */
+
+ @Test
+ public void shouldUpdateRelayConfig() {
+ String altTarget = PATH_TARGET + "2";
+ String altSource = PATH_SOURCE + "2";
+ context.create().resource(altTarget + PATH_CHILD_A);
+
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ RelayInfo newRelay = newRelayInfo(altSource, altTarget);
+ provider.update(newRelay);
+
+ ResolveContext resolveContext = newResolveContext();
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ // Old source path no longer matches after the relay was updated
+ Resource oldResult = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null);
+ assertNull(oldResult);
+
+ // New source path resolves to a RelayResource
+ Resource newResult = provider.getResource(resolveContext, altSource + PATH_CHILD_A, resourceContext, null);
+ assertNotNull(newResult);
+ assertTrue(newResult instanceof RelayResource);
+ assertEquals(altSource + PATH_CHILD_A, newResult.getPath());
+ }
+
+ /* ---------------------
+ User resolver mapping
+ --------------------- */
+
+ @Test
+ public void shouldUseOriginalResolverWhenNoUserMapping() {
+ context.create().resource(PATH_TARGET + PATH_CHILD_A);
+
+ RelayProvider provider = newProvider(newRelayInfo(PATH_SOURCE, PATH_TARGET));
+ ResolveContext resolveContext = newResolveContext();
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null);
+
+ assertNotNull(result);
+ assertTrue(result instanceof RelayResource);
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath());
+ }
+
+ @Test
+ public void shouldUseMappedUserResolver() throws LoginException {
+ String userService = "eak-service";
+ RelayInfo relay = newRelayInfo(
+ PATH_SOURCE, PATH_TARGET,
+ Collections.singletonList(newMapping(USER_AUTHOR, userService)),
+ Collections.emptyList());
+
+ Resource mockTargetResource = Mockito.mock(Resource.class);
+ Mockito.when(mockTargetResource.getPath()).thenReturn(PATH_TARGET + PATH_CHILD_A);
+ Mockito.when(mockTargetResource.getResourceMetadata()).thenReturn(new ResourceMetadata());
+
+ ResourceResolver mappedResolver = Mockito.mock(ResourceResolver.class);
+ Mockito.when(mappedResolver.getResource(PATH_TARGET + PATH_CHILD_A)).thenReturn(mockTargetResource);
+ Mockito.when(mappedResolver.getUserID()).thenReturn(userService);
+
+ ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class);
+ Mockito.when(factory.getServiceResourceResolver(Mockito.any())).thenReturn(mappedResolver);
+
+ Map propertyMap = new HashMap<>();
+ ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class);
+ Mockito.when(basicResolver.getUserID()).thenReturn(USER_AUTHOR);
+ Mockito.when(basicResolver.getPropertyMap()).thenReturn(propertyMap);
+
+ ResolveContext resolveContext = newResolveContext(basicResolver);
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ RelayProvider provider = newProvider(factory, relay);
+ Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null);
+
+ assertNotNull(result);
+ assertTrue(result instanceof RelayResource);
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath());
+ Mockito.verify(mappedResolver).getResource(PATH_TARGET + PATH_CHILD_A);
+ }
+
+ @Test
+ public void shouldUseOriginalResolver() {
+ RelayInfo relay = newRelayInfo(
+ PATH_SOURCE, PATH_TARGET,
+ Collections.singletonList(newMapping(USER_AUTHOR, USER_AUTHOR)),
+ Collections.emptyList());
+
+ Resource mockTargetResource = Mockito.mock(Resource.class);
+ Mockito.when(mockTargetResource.getPath()).thenReturn(PATH_TARGET + PATH_CHILD_A);
+ Mockito.when(mockTargetResource.getResourceMetadata()).thenReturn(new ResourceMetadata());
+
+ ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class);
+ Mockito.when(basicResolver.getUserID()).thenReturn(USER_AUTHOR);
+ Mockito.when(basicResolver.getResource(PATH_TARGET + PATH_CHILD_A)).thenReturn(mockTargetResource);
+
+ ResolveContext resolveContext = newResolveContext(basicResolver);
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ RelayProvider provider = newProvider(context.getService(ResourceResolverFactory.class), relay);
+ Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null);
+
+ assertNotNull(result);
+ assertTrue(result instanceof RelayResource);
+ assertEquals(PATH_SOURCE + PATH_CHILD_A, result.getPath());
+ }
+
+ @Test
+ public void shouldReturnNullForInvalidMapping() throws LoginException {
+ RelayInfo relay = newRelayInfo(
+ PATH_SOURCE, PATH_TARGET,
+ Collections.singletonList(newMapping(USER_AUTHOR, "broken-service")),
+ Collections.emptyList());
+
+ Resource mockTargetResource = Mockito.mock(Resource.class);
+ Mockito.when(mockTargetResource.getPath()).thenReturn(PATH_TARGET + PATH_CHILD_A);
+ Mockito.when(mockTargetResource.getResourceMetadata()).thenReturn(new ResourceMetadata());
+
+ ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class);
+ Mockito
+ .when(factory.getServiceResourceResolver(Mockito.any()))
+ .thenThrow(new LoginException("No service user"));
+
+ ResourceResolver basicResolver = Mockito.mock(ResourceResolver.class);
+ Mockito.when(basicResolver.getUserID()).thenReturn(USER_AUTHOR);
+ Mockito.when(basicResolver.getResource(PATH_TARGET + PATH_CHILD_A)).thenReturn(mockTargetResource);
+
+ ResolveContext resolveContext = newResolveContext(basicResolver);
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ RelayProvider provider = newProvider(factory, relay);
+ Resource result = provider.getResource(resolveContext, PATH_SOURCE + PATH_CHILD_A, resourceContext, null);
+
+ assertNull(result);
+ }
+
+ /* ---------------
+ Utility methods
+ --------------- */
+
+ private RelayProvider newProvider(RelayInfo relay) {
+ return new RelayProvider(context.getService(ResourceResolverFactory.class), relay);
+ }
+
+ private static RelayProvider newProvider(ResourceResolverFactory factory, RelayInfo relay) {
+ return new RelayProvider(factory, relay);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ResourceProvider newMockProvider() {
+ return Mockito.mock(ResourceProvider.class);
+ }
+
+ private static RelayInfo newRelayInfo(String source, String target) {
+ return newRelayInfo(source, target, Collections.emptyList(), Collections.emptyList());
+ }
+
+ private static RelayInfo newRelayInfo(
+ String source,
+ String target,
+ Collection userMappings,
+ Collection changeSamples) {
+ return new RelayInfo(newMapping(source, target), userMappings, changeSamples);
+ }
+
+ private static RelayMapping newMapping(String from, String to) {
+ return ObjectConversionUtil.toObject(
+ "{\"from\":\"" + from + "\",\"to\":\"" + to + "\"}",
+ RelayMapping.class);
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private static ChangeSample newChangeSample(String path) {
+ return ObjectConversionUtil.toObject(
+ "{\"path\":\"" + path + "\"}",
+ ChangeSample.class);
+ }
+
+ private ResolveContext newResolveContext() {
+ return newResolveContext(context.resourceResolver());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ResolveContext newResolveContext(ResourceResolver resolver) {
+ ResolveContext resolveContext = Mockito.mock(ResolveContext.class);
+ Mockito.when(resolveContext.getResourceResolver()).thenReturn(resolver);
+ return resolveContext;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ResolveContext newMockResolveContext() {
+ return Mockito.mock(ResolveContext.class);
+ }
+
+ private static ProviderContext newMockProviderContext(ObservationReporter reporter) {
+ ProviderContext providerContext = Mockito.mock(ProviderContext.class);
+ Mockito.when(providerContext.getObservationReporter()).thenReturn(reporter);
+ return providerContext;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ArgumentCaptor> changeCaptor() {
+ return (ArgumentCaptor>) (ArgumentCaptor>) ArgumentCaptor.forClass(Collection.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ArgumentCaptor> listChangeCaptor() {
+ return (ArgumentCaptor>) (ArgumentCaptor>) ArgumentCaptor.forClass(List.class);
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelperTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelperTest.java
new file mode 100644
index 000000000..6f7b9ef8c
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayPathHelperTest.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.utils;
+
+import org.apache.commons.lang3.StringUtils;
+import org.junit.Test;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+public class RelayPathHelperTest {
+
+ private static final String PATH_SOURCE = "/content/source";
+ private static final String PATH_TARGET = "/content/target";
+
+ @Test
+ public void shouldCheckForSamePathOrSubpath() {
+ assertTrue(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE, PATH_SOURCE));
+ assertTrue(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE + "/child", PATH_SOURCE));
+ assertTrue(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE + "/a/b/c", PATH_SOURCE));
+ assertTrue(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE + "/", PATH_SOURCE));
+ assertTrue(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE, PATH_SOURCE + "/"));
+
+ assertFalse(RelayPathHelper.isSamePathOrSubpath("/content/other", PATH_SOURCE));
+ assertFalse(RelayPathHelper.isSamePathOrSubpath(PATH_SOURCE + "-extra", PATH_SOURCE));
+ assertFalse(RelayPathHelper.isSamePathOrSubpath(null, PATH_SOURCE));
+ assertTrue(RelayPathHelper.isSamePathOrSubpath(StringUtils.EMPTY, null));
+ }
+
+ @Test
+ public void shouldReplacePrefix() {
+ assertEquals(PATH_TARGET, RelayPathHelper.replace(PATH_SOURCE, PATH_SOURCE, PATH_TARGET));
+ assertEquals(PATH_TARGET + "/child", RelayPathHelper.replace(PATH_SOURCE + "/child", PATH_SOURCE, PATH_TARGET));
+ assertEquals(PATH_TARGET + "/a/b/c", RelayPathHelper.replace(PATH_SOURCE + "/a/b/c", PATH_SOURCE, PATH_TARGET));
+ }
+
+ @Test
+ public void shouldReturnPathUnchanged() {
+ assertEquals("/content/other", RelayPathHelper.replace("/content/other", PATH_SOURCE, PATH_TARGET));
+ assertEquals(PATH_SOURCE + "-extra", RelayPathHelper.replace(PATH_SOURCE + "-extra", PATH_SOURCE, PATH_TARGET));
+ assertNull(RelayPathHelper.replace(null, PATH_SOURCE, PATH_TARGET));
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelperTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelperTest.java
new file mode 100644
index 000000000..73687d22b
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/relay/utils/RelayResourceHelperTest.java
@@ -0,0 +1,258 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.relay.utils;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+
+import org.apache.sling.api.resource.LoginException;
+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.spi.resource.provider.ResolveContext;
+import org.apache.sling.spi.resource.provider.ResourceContext;
+import org.apache.sling.spi.resource.provider.ResourceProvider;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mockito;
+import io.wcm.testing.mock.aem.junit.AemContext;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import com.exadel.aem.toolkit.core.AemContextFactory;
+import com.exadel.aem.toolkit.core.relay.models.RelayResource;
+
+public class RelayResourceHelperTest {
+
+ private static final String PATH_TARGET = "/content/target";
+ private static final String PATH_CHILD = "/child";
+ private static final String PATH_EXPOSED = "/content/exposed";
+
+ @Rule
+ public final AemContext context = AemContextFactory.newInstance();
+
+ @Test
+ public void shouldResolveResource() {
+ context.create().resource(PATH_TARGET);
+ ResourceResolver resolver = context.resourceResolver();
+ ResourceResolverFactory resolverFactory = context.getService(ResourceResolverFactory.class);
+
+ assertNotNull(resolverFactory);
+
+ // userId matches resolver's own ID → uses original resolver
+ Resource sameUserResult = RelayResourceHelper.getResource(resolver, resolverFactory, resolver.getUserID(), PATH_TARGET);
+ assertNotNull(sameUserResult);
+ assertEquals(PATH_TARGET, sameUserResult.getPath());
+
+ // userId is null → no subsidiary resolver lookup; uses original resolver
+ Resource nullUserResult = RelayResourceHelper.getResource(resolver, resolverFactory, null, PATH_TARGET);
+ assertNotNull(nullUserResult);
+ assertEquals(PATH_TARGET, nullUserResult.getPath());
+
+ // resource not found → null
+ Resource missingTarget = RelayResourceHelper.getResource(resolver, resolverFactory, resolver.getUserID(), PATH_TARGET + "/missing");
+ assertNull(missingTarget);
+ }
+
+ @Test
+ public void shouldTrackSubsidiaryResolver() {
+ Map propertyMap = new HashMap<>();
+ ResourceResolver resolver = Mockito.mock(ResourceResolver.class);
+ Mockito.when(resolver.getPropertyMap()).thenReturn(propertyMap);
+
+ ResourceResolverFactory resolverFactory = context.getService(ResourceResolverFactory.class);
+ assertNotNull(resolverFactory);
+
+ RelayResourceHelper.getResource(resolver, resolverFactory, "modified-user", PATH_TARGET);
+
+ Object stored = propertyMap.get(ResourceResolver.class.getName() + "@modified-user");
+ assertTrue(stored instanceof ResourceResolver);
+ }
+
+ @Test
+ public void shouldReuseExistingSubsidiaryResolver() {
+ Map propertyMap = new HashMap<>();
+ ResourceResolver resolver = Mockito.mock(ResourceResolver.class);
+ Mockito.when(resolver.getPropertyMap()).thenReturn(propertyMap);
+
+ ResourceResolverFactory resolverFactory = context.getService(ResourceResolverFactory.class);
+ assertNotNull(resolverFactory);
+
+ RelayResourceHelper.getResource(resolver, resolverFactory, "modified-user", PATH_TARGET);
+ Object firstResolver = propertyMap.get(ResourceResolver.class.getName() + "@modified-user");
+
+ RelayResourceHelper.getResource(resolver, resolverFactory, "modified-user", PATH_TARGET);
+ Object secondResolver = propertyMap.get(ResourceResolver.class.getName() + "@modified-user");
+
+ assertSame(firstResolver, secondResolver);
+ }
+
+ @Test
+ public void shouldStoreSentinelWhenLoginFails() throws LoginException {
+ Map propertyMap = new HashMap<>();
+ ResourceResolver resolver = Mockito.mock(ResourceResolver.class);
+ Mockito.when(resolver.getPropertyMap()).thenReturn(propertyMap);
+
+ ResourceResolverFactory resolverFactory = Mockito.mock(ResourceResolverFactory.class);
+ Mockito.when(resolverFactory.getServiceResourceResolver(Mockito.any()))
+ .thenThrow(new LoginException("Simulated login failure"));
+
+ // First call: LoginException → sentinel stored; original resolver used as fallback
+ RelayResourceHelper.getResource(resolver, resolverFactory, "blocked-user", PATH_TARGET);
+
+ Object stored = propertyMap.get(ResourceResolver.class.getName() + "@blocked-user");
+ assertNotNull(stored);
+ assertFalse(stored instanceof ResourceResolver); // sentinel, not a resolver
+ Mockito.verify(resolverFactory, Mockito.times(1)).getServiceResourceResolver(Mockito.any());
+
+ // Second call with same userId: sentinel already in map, factory not called again
+ RelayResourceHelper.getResource(resolver, resolverFactory, "blocked-user", PATH_TARGET);
+ Mockito.verify(resolverFactory, Mockito.times(1)).getServiceResourceResolver(Mockito.any());
+ }
+
+ @Test
+ public void shouldDelegateToParentProvider() {
+ Resource fallback = context.create().resource(PATH_TARGET + PATH_CHILD);
+ ResourceProvider mockProvider = newMockProvider();
+ ResolveContext parentCtx = newMockResolveContext();
+ Mockito.when(mockProvider.getResource(Mockito.same(parentCtx), Mockito.eq(PATH_TARGET + PATH_CHILD), Mockito.any(), Mockito.isNull()))
+ .thenReturn(fallback);
+
+ ResolveContext resolveContext = newMockResolveContext();
+ Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider();
+ Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext();
+
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ Resource result = RelayResourceHelper.getResource(resolveContext, PATH_TARGET + PATH_CHILD, resourceContext, null);
+
+ assertNotNull(result);
+ assertEquals(PATH_TARGET + PATH_CHILD, result.getPath());
+ Mockito.verify(mockProvider).getResource(Mockito.same(parentCtx), Mockito.eq(PATH_TARGET + PATH_CHILD), Mockito.same(resourceContext), Mockito.isNull());
+ }
+
+ @Test
+ public void shouldReturnNullWithoutContext() {
+ ResourceContext resourceContext = Mockito.mock(ResourceContext.class);
+
+ // null resolveContext
+ assertNull(RelayResourceHelper.getResource(null, PATH_TARGET, resourceContext, null));
+
+ // missing parent resource provider
+ ResolveContext noProvider = newMockResolveContext();
+ Mockito.doReturn(null).when(noProvider).getParentResourceProvider();
+ Mockito.doReturn(newMockResolveContext()).when(noProvider).getParentResolveContext();
+ assertNull(RelayResourceHelper.getResource(noProvider, PATH_TARGET, resourceContext, null));
+
+ // missing parent resolve context
+ ResolveContext noParentCtx = newMockResolveContext();
+ Mockito.doReturn(newMockProvider()).when(noParentCtx).getParentResourceProvider();
+ Mockito.doReturn(null).when(noParentCtx).getParentResolveContext();
+ assertNull(RelayResourceHelper.getResource(noParentCtx, PATH_TARGET, resourceContext, null));
+ }
+
+ @Test
+ public void shouldListChildrenWithOverriddenPath() {
+ Resource parent = context.create().resource(PATH_TARGET);
+ context.create().resource(PATH_TARGET + PATH_CHILD);
+
+ Iterator children = RelayResourceHelper.listChildren(parent, PATH_EXPOSED);
+
+ assertNotNull(children);
+ assertTrue(children.hasNext());
+ Resource child = children.next();
+ assertEquals(PATH_EXPOSED + PATH_CHILD, child.getPath());
+ assertTrue(child instanceof RelayResource);
+ assertFalse(children.hasNext());
+ }
+
+ @Test
+ public void shouldListChildrenOfUnwrappedRelay() {
+ Resource original = context.create().resource(PATH_TARGET);
+ context.create().resource(PATH_TARGET + PATH_CHILD);
+ RelayResource relay = new RelayResource(original, PATH_EXPOSED);
+
+ // target is a RelayResource → helper must unwrap and use the underlying resource's children
+ Iterator children = RelayResourceHelper.listChildren(relay, PATH_EXPOSED);
+
+ assertNotNull(children);
+ assertTrue(children.hasNext());
+ Resource child = children.next();
+ assertEquals(PATH_EXPOSED + PATH_CHILD, child.getPath());
+ assertTrue(child instanceof RelayResource);
+ assertFalse(children.hasNext());
+ }
+
+ @Test
+ public void shouldDelegateListChildrenToParent() {
+ Resource parent = context.create().resource(PATH_TARGET);
+ Resource expectedChild = context.create().resource(PATH_TARGET + PATH_CHILD);
+
+ ResourceProvider mockProvider = newMockProvider();
+ ResolveContext parentCtx = newMockResolveContext();
+ Mockito.when(mockProvider.listChildren(Mockito.same(parentCtx), Mockito.same(parent)))
+ .thenReturn(Collections.singletonList(expectedChild).iterator());
+
+ ResolveContext resolveContext = newMockResolveContext();
+ Mockito.doReturn(mockProvider).when(resolveContext).getParentResourceProvider();
+ Mockito.doReturn(parentCtx).when(resolveContext).getParentResolveContext();
+
+ Iterator children = RelayResourceHelper.listChildren(resolveContext, parent);
+
+ assertNotNull(children);
+ assertTrue(children.hasNext());
+ assertEquals(PATH_TARGET + PATH_CHILD, children.next().getPath());
+ Mockito.verify(mockProvider).listChildren(Mockito.same(parentCtx), Mockito.same(parent));
+ }
+
+ @Test
+ public void shouldReturnNullChildrenWithoutContext() {
+ Resource parent = context.create().resource(PATH_TARGET);
+
+ // null resolveContext
+ assertNull(RelayResourceHelper.listChildren(null, parent));
+
+ // missing parent resource provider
+ ResolveContext noProvider = newMockResolveContext();
+ Mockito.doReturn(null).when(noProvider).getParentResourceProvider();
+ Mockito.doReturn(newMockResolveContext()).when(noProvider).getParentResolveContext();
+ assertNull(RelayResourceHelper.listChildren(noProvider, parent));
+
+ // missing parent resolve context
+ ResolveContext noParentCtx = newMockResolveContext();
+ Mockito.doReturn(newMockProvider()).when(noParentCtx).getParentResourceProvider();
+ Mockito.doReturn(null).when(noParentCtx).getParentResolveContext();
+ assertNull(RelayResourceHelper.listChildren(noParentCtx, parent));
+ }
+
+ /* ---------------
+ Utility methods
+ --------------- */
+
+ @SuppressWarnings("unchecked")
+ private static ResourceProvider newMockProvider() {
+ return Mockito.mock(ResourceProvider.class);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ResolveContext newMockResolveContext() {
+ return Mockito.mock(ResolveContext.class);
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtilTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtilTest.java
new file mode 100644
index 000000000..bcdc5be53
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ObjectConversionUtilTest.java
@@ -0,0 +1,280 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.utils;
+
+import java.beans.Transient;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.Test;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.databind.JsonNode;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.exadel.aem.toolkit.core.CoreConstants;
+
+@SuppressWarnings("unused")
+public class ObjectConversionUtilTest {
+
+ private static final String PN_VISIBLE = "visible";
+ private static final String VALUE_VISIBLE = "visible-value";
+ private static final String VALUE_HIDDEN = "hidden-value";
+
+ @Test
+ public void shouldDetectValidJson() {
+ assertTrue(ObjectConversionUtil.isJson("{\"key\": \"value\"}"));
+ assertTrue(ObjectConversionUtil.isJson("[1, 2, 3]"));
+ assertTrue(ObjectConversionUtil.isJson("42"));
+ assertTrue(ObjectConversionUtil.isJson("null"));
+ assertTrue(ObjectConversionUtil.isJson("true"));
+ assertTrue(ObjectConversionUtil.isJson("false"));
+ assertTrue(ObjectConversionUtil.isJson("\"hello\""));
+ }
+
+ @Test
+ public void shouldRejectBlankOrInvalidJson() {
+ assertFalse(ObjectConversionUtil.isJson(null));
+ assertFalse(ObjectConversionUtil.isJson(""));
+ assertFalse(ObjectConversionUtil.isJson(" "));
+ assertFalse(ObjectConversionUtil.isJson("{not valid json}"));
+ assertFalse(ObjectConversionUtil.isJson("{\"key\": \"value\""));
+ }
+
+ @Test
+ public void shouldParseJsonPrimitivesToTypedObjects() {
+ assertEquals("hello", ObjectConversionUtil.toObject("\"hello\"", String.class));
+ assertEquals(Integer.valueOf(42), ObjectConversionUtil.toObject("42", Integer.class));
+ assertEquals(Boolean.TRUE, ObjectConversionUtil.toObject("true", Boolean.class));
+ }
+
+ @Test
+ public void shouldParseJsonObjectToBean() {
+ SimpleBean bean = ObjectConversionUtil.toObject("{\"name\":\"test\",\"value\":7}", SimpleBean.class);
+ assertNotNull(bean);
+ assertEquals("test", bean.getName());
+ assertEquals(7, bean.getValue());
+ }
+
+ @Test
+ public void shouldReturnNullForBlankNullOrUnparsableInput() {
+ assertNull(ObjectConversionUtil.toObject(null, String.class));
+ assertNull(ObjectConversionUtil.toObject("", String.class));
+ assertNull(ObjectConversionUtil.toObject(" ", String.class));
+ // Bare word is not a valid JSON
+ assertNull(ObjectConversionUtil.toObject("not-a-number", Integer.class));
+ }
+
+ @Test
+ public void shouldConvertPojoToPropertyMap() {
+ Map simple = ObjectConversionUtil.toPropertyMap(new SimpleBean("hello", 42));
+ assertNotNull(simple);
+ assertEquals(2, simple.size());
+ assertEquals("hello", simple.get(CoreConstants.PN_NAME));
+ assertEquals(42, simple.get(CoreConstants.PN_VALUE));
+
+ // Bean with nested object serialized as a sub-map
+ Map withNested = ObjectConversionUtil.toPropertyMap(
+ new BeanWithNestedObject("outer", new SimpleBean("inner", 5)));
+ assertNotNull(withNested);
+ assertEquals(2, withNested.size());
+ assertEquals("outer", withNested.get("label"));
+ Object childValue = withNested.get("child");
+ assertTrue(childValue instanceof Map);
+ Map, ?> childMap = (Map, ?>) childValue;
+ assertEquals("inner", childMap.get(CoreConstants.PN_NAME));
+ assertEquals(5, childMap.get(CoreConstants.PN_VALUE));
+
+ // Map input passes through as an equivalent map
+ Map sourceMap = new HashMap<>();
+ sourceMap.put("a", 1);
+ sourceMap.put("b", "two");
+ Map fromMap = ObjectConversionUtil.toPropertyMap(sourceMap);
+ assertNotNull(fromMap);
+ assertEquals(2, fromMap.size());
+ assertEquals(1, fromMap.get("a"));
+ assertEquals("two", fromMap.get("b"));
+ }
+
+ @Test
+ public void shouldReturnNullForNullPropertyMapInput() {
+ assertNull(ObjectConversionUtil.toPropertyMap(null));
+ }
+
+ @Test
+ public void shouldReturnEmptyMapForNoPropertiesOrConversionError() {
+ // Empty bean has no serializable properties → empty map, not null
+ Map fromEmpty = ObjectConversionUtil.toPropertyMap(new EmptyBean());
+ assertNotNull(fromEmpty);
+ assertTrue(fromEmpty.isEmpty());
+
+ // A plain String cannot be deserialized as a Map → IllegalArgumentException caught → emptyMap
+ Map fromError = ObjectConversionUtil.toPropertyMap("a plain string");
+ assertNotNull(fromError);
+ assertTrue(fromError.isEmpty());
+ }
+
+ @Test
+ public void shouldExcludeIgnoredProperties() {
+ // @java.beans.Transient on getter
+ assertSingleVisibleProperty(ObjectConversionUtil.toPropertyMap(new BeanWithTransientAnnotation()));
+
+ // transient field modifier
+ assertSingleVisibleProperty(ObjectConversionUtil.toPropertyMap(new BeanWithTransientField()));
+
+ // @JsonIgnore triggers super._isIgnorable in LocalAnnotationIntrospector
+ assertSingleVisibleProperty(ObjectConversionUtil.toPropertyMap(new BeanWithJsonIgnore()));
+
+ // Both @Transient annotation and transient modifier present at once
+ assertSingleVisibleProperty(ObjectConversionUtil.toPropertyMap(new BeanWithBothTransientMechanisms()));
+ }
+
+ @Test
+ public void shouldParseJsonToNodeTree() throws IOException {
+ JsonNode objectNode = ObjectConversionUtil.toNodeTree("{\"key\": \"value\"}");
+ assertNotNull(objectNode);
+ JsonNode keyNode = objectNode.get("key");
+ assertNotNull(keyNode);
+ assertEquals("value", keyNode.asText());
+
+ // JSON array with two elements — validates caller usage pattern in InlineOptionSourceResolver
+ JsonNode arrayNode = ObjectConversionUtil.toNodeTree("[{\"name\":\"a\"},{\"name\":\"b\"}]");
+ assertNotNull(arrayNode);
+ assertTrue(arrayNode.isArray());
+ assertEquals(2, arrayNode.size());
+ JsonNode firstName = arrayNode.get(0).get(CoreConstants.PN_NAME);
+ assertNotNull(firstName);
+ assertEquals("a", firstName.asText());
+ JsonNode secondName = arrayNode.get(1).get(CoreConstants.PN_NAME);
+ assertNotNull(secondName);
+ assertEquals("b", secondName.asText());
+
+ // Nested JSON — validates traversal pattern used by HttpOptionSourceResolver
+ JsonNode nestedNode = ObjectConversionUtil.toNodeTree("{\"outer\": {\"inner\": \"deep\"}}");
+ assertNotNull(nestedNode);
+ JsonNode outerNode = nestedNode.get("outer");
+ assertNotNull(outerNode);
+ JsonNode innerNode = outerNode.get("inner");
+ assertNotNull(innerNode);
+ assertEquals("deep", innerNode.asText());
+ }
+
+ @Test(expected = IOException.class)
+ public void shouldThrowOnInvalidJsonInNodeTree() throws IOException {
+ ObjectConversionUtil.toNodeTree("{not valid}");
+ }
+
+ /* -------
+ Fixtures
+ ------- */
+
+ private static void assertSingleVisibleProperty(Map map) {
+ assertNotNull(map);
+ assertEquals(1, map.size());
+ assertEquals(VALUE_VISIBLE, map.get(PN_VISIBLE));
+ }
+
+ private static class SimpleBean {
+ private String name;
+ private int value;
+
+ // Required by Jackson for JSON deserialization
+ SimpleBean() {}
+
+ SimpleBean(String name, int value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public int getValue() {
+ return value;
+ }
+
+ public void setValue(int value) {
+ this.value = value;
+ }
+ }
+
+ private static class BeanWithNestedObject {
+ private final String label;
+ private final SimpleBean child;
+
+ BeanWithNestedObject(String label, SimpleBean child) {
+ this.label = label;
+ this.child = child;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public SimpleBean getChild() {
+ return child;
+ }
+ }
+
+ private static class EmptyBean {}
+
+ private static class BeanWithTransientAnnotation {
+ public String getVisible() {
+ return VALUE_VISIBLE;
+ }
+
+ @Transient
+ public String getHidden() {
+ return VALUE_HIDDEN;
+ }
+ }
+
+ private static class BeanWithTransientField {
+ public String visible = VALUE_VISIBLE;
+ public transient String hidden = VALUE_HIDDEN;
+ }
+
+ private static class BeanWithJsonIgnore {
+ public String getVisible() {
+ return VALUE_VISIBLE;
+ }
+
+ @JsonIgnore
+ public String getHidden() {
+ return VALUE_HIDDEN;
+ }
+ }
+
+ private static class BeanWithBothTransientMechanisms {
+ public transient String hiddenField = VALUE_HIDDEN;
+
+ public String getVisible() {
+ return VALUE_VISIBLE;
+ }
+
+ @Transient
+ public String getHiddenAnnotated() {
+ return VALUE_HIDDEN;
+ }
+ }
+}
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/utils/ResolverUtilTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ResolverUtilTest.java
new file mode 100644
index 000000000..753341c5d
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ResolverUtilTest.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.utils;
+
+import java.util.Collections;
+import java.util.HashMap;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.sling.api.SlingHttpServletRequest;
+import org.apache.sling.api.resource.LoginException;
+import org.apache.sling.api.resource.ResourceResolver;
+import org.apache.sling.api.resource.ResourceResolverFactory;
+import org.apache.sling.api.scripting.SlingBindings;
+import org.apache.sling.api.scripting.SlingScriptHelper;
+import org.apache.sling.testing.mock.sling.servlet.MockSlingHttpServletRequest;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceReference;
+import io.wcm.testing.mock.aem.junit.AemContext;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
+
+import com.exadel.aem.toolkit.core.AemContextFactory;
+
+public class ResolverUtilTest {
+
+ private static final String FACTORY_ERROR = "Could not obtain ResourceResolverFactory";
+ private static final String LOGIN_ERROR = "Test error";
+ private static final String OSGI_ERROR = "Not running in an OSGi container";
+ private static final String SERVICE_USER = "eak-service";
+ private static final String SERVICE_USER_CUSTOM = "custom-service-user";
+
+ @Rule
+ public final AemContext context = AemContextFactory.newInstance();
+
+ @Test
+ public void shouldCreateResolverWithServiceUser() throws LoginException {
+ ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class);
+ ResourceResolver expected = Mockito.mock(ResourceResolver.class);
+ Mockito.when(factory.getServiceResourceResolver(
+ Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER))).thenReturn(expected);
+
+ try (ResourceResolver resultFromFactory = ResolverUtil.newResolver(factory)) {
+ assertEquals(expected, resultFromFactory);
+ }
+ try (ResourceResolver resultFromRequest = ResolverUtil.newResolver(newRequestWithFactory(factory))) {
+ assertEquals(expected, resultFromRequest);
+ }
+ }
+
+ @Test
+ public void shouldHandleBlankUser() throws LoginException {
+ ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class);
+ ResourceResolver expected = Mockito.mock(ResourceResolver.class);
+ Mockito.when(factory.getServiceResourceResolver(
+ Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER))).thenReturn(expected);
+
+ for (String blankUser : new String[]{null, StringUtils.EMPTY, StringUtils.SPACE}) {
+ try (ResourceResolver result = ResolverUtil.newResolver(factory, blankUser)) {
+ assertEquals(expected, result);
+ }
+ }
+ }
+
+ @Test
+ public void shouldCreateResolverWithCustomUser() throws LoginException {
+ ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class);
+ ResourceResolver expected = Mockito.mock(ResourceResolver.class);
+ Mockito.when(factory.getServiceResourceResolver(
+ Collections.singletonMap(ResourceResolverFactory.SUBSERVICE, SERVICE_USER_CUSTOM))).thenReturn(expected);
+
+ try (ResourceResolver result = ResolverUtil.newResolver(factory, SERVICE_USER_CUSTOM)) {
+ assertEquals(expected, result);
+ }
+ }
+
+ @Test
+ public void shouldThrowWhenNotInOsgiContainer() {
+ ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class);
+ assertLoginException(factory, "user@some.bundle", OSGI_ERROR);
+ }
+
+ @Test
+ public void shouldThrowWhenDependenciesUnavailable() {
+ MockSlingHttpServletRequest requestNullBindings =
+ new MockSlingHttpServletRequest(context.resourceResolver(), context.bundleContext());
+ requestNullBindings.setAttribute(SlingBindings.class.getName(), null);
+ assertLoginException(requestNullBindings, FACTORY_ERROR);
+
+ SlingBindings noScriptHelper = new SlingBindings();
+ MockSlingHttpServletRequest requestNoHelper =
+ new MockSlingHttpServletRequest(context.resourceResolver(), context.bundleContext());
+ requestNoHelper.setAttribute(SlingBindings.class.getName(), noScriptHelper);
+ assertLoginException(requestNoHelper, FACTORY_ERROR);
+
+ SlingScriptHelper scriptHelper = Mockito.mock(SlingScriptHelper.class);
+ Mockito.when(scriptHelper.getService(ResourceResolverFactory.class)).thenReturn(null);
+ SlingBindings noFactory = new SlingBindings();
+ noFactory.put(SlingBindings.SLING, scriptHelper);
+ MockSlingHttpServletRequest requestNoFactory =
+ new MockSlingHttpServletRequest(context.resourceResolver(), context.bundleContext());
+ requestNoFactory.setAttribute(SlingBindings.class.getName(), noFactory);
+ assertLoginException(requestNoFactory, FACTORY_ERROR);
+ }
+
+ @Test
+ public void shouldThrowOnLoginException() throws LoginException {
+ ResourceResolverFactory factory = Mockito.mock(ResourceResolverFactory.class);
+ Mockito.when(factory.getServiceResourceResolver(Mockito.anyMap())).thenThrow(new LoginException(LOGIN_ERROR));
+ assertLoginException(factory, LOGIN_ERROR);
+ assertLoginException(factory, SERVICE_USER_CUSTOM, LOGIN_ERROR);
+ assertLoginException(newRequestWithFactory(factory), LOGIN_ERROR);
+ }
+
+ @Test
+ @SuppressWarnings({"resource", "unchecked"})
+ public void shouldGetUserFromForeignBundle() throws LoginException {
+ ResourceResolver mockResolver = Mockito.mock(ResourceResolver.class);
+ Mockito.when(mockResolver.getPropertyMap()).thenReturn(new HashMap<>());
+ ResourceResolverFactory mockResolverFactory = Mockito.mock(ResourceResolverFactory.class);
+ Mockito.when(mockResolverFactory.getServiceResourceResolver(Mockito.any())).thenReturn(mockResolver);
+
+ ServiceReference mockServiceReference =
+ (ServiceReference) Mockito.mock(ServiceReference.class);
+
+ BundleContext mockBundleContext = Mockito.mock(BundleContext.class);
+ Mockito
+ .when(mockBundleContext.getServiceReference(Mockito.eq(ResourceResolverFactory.class)))
+ .thenReturn(mockServiceReference);
+ Mockito
+ .when(mockBundleContext.getService(Mockito.eq(mockServiceReference)))
+ .thenReturn(mockResolverFactory);
+
+ Bundle foreignBundle = Mockito.mock(Bundle.class);
+ Mockito.when(foreignBundle.getSymbolicName()).thenReturn("foreign.bundle");
+ Mockito.when(foreignBundle.getBundleContext()).thenReturn(mockBundleContext);
+
+ Mockito.when(mockBundleContext.getBundles()).thenReturn(new Bundle[] {context.bundleContext().getBundle(), foreignBundle});
+
+ // Verify that resolver can be obtained for a user associated with the foreign bundle
+ try (ResourceResolver resolver = ResolverUtil.newResolver(
+ mockResolverFactory,
+ "user@foreign.bundle",
+ mockBundleContext)) {
+ assertNotNull(resolver);
+ }
+
+ // Verify that LoginException is thrown when the user is associated with a non-existent bundle
+ assertThrows(
+ LoginException.class,
+ () -> ResolverUtil.newResolver(mockResolverFactory, "user@other.bundle", mockBundleContext));
+
+ // Verify that LoginException is thrown when the user is not associated with any bundle
+ Mockito.when(mockResolverFactory.getServiceResourceResolver(Mockito.any())).thenThrow(new LoginException(LOGIN_ERROR));
+ assertThrows(
+ LoginException.class,
+ () -> ResolverUtil.newResolver(mockResolverFactory, "user@foreign.bundle", mockBundleContext));
+ }
+
+ private MockSlingHttpServletRequest newRequestWithFactory(ResourceResolverFactory factory) {
+ SlingScriptHelper scriptHelper = Mockito.mock(SlingScriptHelper.class);
+ Mockito.when(scriptHelper.getService(ResourceResolverFactory.class)).thenReturn(factory);
+ SlingBindings bindings = new SlingBindings();
+ bindings.put(SlingBindings.SLING, scriptHelper);
+ MockSlingHttpServletRequest request =
+ new MockSlingHttpServletRequest(context.resourceResolver(), context.bundleContext());
+ request.setAttribute(SlingBindings.class.getName(), bindings);
+ return request;
+ }
+
+ private static void assertLoginException(SlingHttpServletRequest request, String errorMessage) {
+ try (ResourceResolver ignored = ResolverUtil.newResolver(request)) {
+ Assert.fail("Expected LoginException");
+ } catch (LoginException e) {
+ assertEquals(errorMessage, e.getMessage());
+ }
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private static void assertLoginException(ResourceResolverFactory factory, String errorMessage) {
+ try (ResourceResolver ignored = ResolverUtil.newResolver(factory)) {
+ Assert.fail("Expected LoginException");
+ } catch (LoginException e) {
+ assertEquals(errorMessage, e.getMessage());
+ }
+ }
+
+ private static void assertLoginException(ResourceResolverFactory factory, String user, String errorMessage) {
+ try (ResourceResolver ignored = ResolverUtil.newResolver(factory, user)) {
+ Assert.fail("Expected LoginException");
+ } catch (LoginException e) {
+ assertEquals(errorMessage, e.getMessage());
+ }
+ }
+}
+
diff --git a/core/src/test/java/com/exadel/aem/toolkit/core/utils/ServiceUtilTest.java b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ServiceUtilTest.java
new file mode 100644
index 000000000..14640e22a
--- /dev/null
+++ b/core/src/test/java/com/exadel/aem/toolkit/core/utils/ServiceUtilTest.java
@@ -0,0 +1,322 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.exadel.aem.toolkit.core.utils;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.ServiceReference;
+import io.wcm.testing.mock.aem.junit.AemContext;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.exadel.aem.toolkit.core.AemContextFactory;
+
+public class ServiceUtilTest {
+
+ private static final String DEFAULT_VALUE = "fallback";
+ private static final String STUB_VALUE = "stub-value";
+
+ private static final String TEST_EXCEPTION_MESSAGE = "Something went wrong";
+
+ @Rule
+ public final AemContext context = AemContextFactory.newInstance();
+
+ /* ---------------------
+ Consumer and Function
+ --------------------- */
+
+ @Test
+ public void shouldInvokeConsumerAndReturnFunctionResult() {
+ AtomicReference capturedService = new AtomicReference<>();
+ StubService service = new StubServiceImpl();
+ context.registerService(StubService.class, service);
+
+ ServiceUtil.withService(StubService.class, context.bundleContext(), capturedService::set);
+ assertEquals(service, capturedService.get());
+
+ String functionResult = ServiceUtil.withService(
+ StubService.class,
+ context.bundleContext(),
+ StubService::getValue,
+ DEFAULT_VALUE);
+ assertEquals(STUB_VALUE, functionResult);
+ }
+
+ @Test
+ public void shouldSkipExecutionWhenServiceUnavailable() {
+ AtomicBoolean consumerInvoked = new AtomicBoolean(false);
+
+ // No service registered, so getServiceReference returns null
+ ServiceUtil.withService(StubService.class, context.bundleContext(), s -> consumerInvoked.set(true));
+ assertFalse(consumerInvoked.get());
+
+ String result1 = ServiceUtil.withService(
+ StubService.class,
+ context.bundleContext(),
+ StubService::getValue,
+ DEFAULT_VALUE);
+ assertEquals(DEFAULT_VALUE, result1);
+
+ // Non-null reference but getService returns null
+ BundleContext mockContext = newMockContextWithNullService();
+ ServiceUtil.withService(StubService.class, mockContext, s -> consumerInvoked.set(true));
+ assertFalse(consumerInvoked.get());
+
+ String result2 = ServiceUtil.withService(
+ StubService.class,
+ mockContext,
+ StubService::getValue,
+ DEFAULT_VALUE);
+ assertEquals(DEFAULT_VALUE, result2);
+ }
+
+ @Test
+ public void shouldSwallowExceptionsAndReleaseService() {
+ // Consumer throws IllegalArgumentException: exception swallowed, service still released in the finally block
+ MockContext consumerCtx = newMockContextReturningService();
+ ServiceUtil.withService(
+ StubService.class,
+ consumerCtx.bundleContext,
+ s -> { throw new IllegalArgumentException(TEST_EXCEPTION_MESSAGE); });
+ Mockito.verify(consumerCtx.bundleContext).ungetService(consumerCtx.serviceRef);
+
+ // Consumer throws generic RuntimeException: exception also swallowed
+ MockContext consumerRteCtx = newMockContextReturningService();
+ ServiceUtil.withService(
+ StubService.class,
+ consumerRteCtx.bundleContext,
+ s -> { throw new RuntimeException(TEST_EXCEPTION_MESSAGE); });
+ Mockito.verify(consumerRteCtx.bundleContext).ungetService(consumerRteCtx.serviceRef);
+
+ // Function throws IllegalStateException: returns default, service still released in the finally block
+ MockContext functionCtx = newMockContextReturningService();
+ String result = ServiceUtil.withService(
+ StubService.class,
+ functionCtx.bundleContext,
+ s -> { throw new IllegalStateException(TEST_EXCEPTION_MESSAGE); },
+ DEFAULT_VALUE);
+ assertEquals(DEFAULT_VALUE, result);
+ Mockito.verify(functionCtx.bundleContext).ungetService(functionCtx.serviceRef);
+
+ // Function throws generic RuntimeException: also returns default
+ MockContext functionRteCtx = newMockContextReturningService();
+ String result2 = ServiceUtil.withService(
+ StubService.class,
+ functionRteCtx.bundleContext,
+ s -> { throw new RuntimeException(TEST_EXCEPTION_MESSAGE); },
+ DEFAULT_VALUE);
+ assertEquals(DEFAULT_VALUE, result2);
+ Mockito.verify(functionRteCtx.bundleContext).ungetService(functionRteCtx.serviceRef);
+
+ // UngetService itself throws: exception is swallowed, prior consumer side effects are preserved
+ MockContext ungetCtx = newMockContextThrowingOnUnget();
+ AtomicBoolean consumerInvoked = new AtomicBoolean(false);
+ ServiceUtil.withService(StubService.class, ungetCtx.bundleContext, s -> consumerInvoked.set(true));
+ assertTrue(consumerInvoked.get());
+ }
+
+ @Test
+ public void shouldHandleMissingBundleContext() {
+ AtomicBoolean consumerInvoked = new AtomicBoolean(false);
+
+ // FrameworkUtil.getBundle() returns null outside an OSGi container: logs error, consumer not invoked
+ ServiceUtil.withService(StubService.class, s -> consumerInvoked.set(true));
+ assertFalse(consumerInvoked.get());
+
+ String result = ServiceUtil.withService(StubService.class, StubService::getValue, DEFAULT_VALUE);
+ assertEquals(DEFAULT_VALUE, result);
+ }
+
+ /* -------------------------
+ BiConsumer and BiFunction
+ ------------------------- */
+
+ @Test
+ public void shouldInvokeBiConsumerAndBiFunction() {
+ MockContext biConsumerCtx = newMockContextReturningService();
+ AtomicBoolean biConsumerInvoked = new AtomicBoolean(false);
+ ServiceUtil.withService(
+ StubService.class,
+ biConsumerCtx.bundleContext,
+ (s, release) -> {
+ biConsumerInvoked.set(true);
+ release.run();
+ });
+ assertTrue(biConsumerInvoked.get());
+ Mockito.verify(biConsumerCtx.bundleContext).ungetService(biConsumerCtx.serviceRef);
+
+ MockContext biFunctionCtx = newMockContextReturningService();
+ String result = ServiceUtil.withService(
+ StubService.class,
+ biFunctionCtx.bundleContext,
+ (s, release) -> {
+ release.run();
+ return s.getValue();
+ },
+ DEFAULT_VALUE);
+ assertEquals(STUB_VALUE, result);
+ Mockito.verify(biFunctionCtx.bundleContext).ungetService(biFunctionCtx.serviceRef);
+ }
+
+ @Test
+ public void shouldSkipBiVariantsWhenUnavailable() {
+ AtomicBoolean consumerInvoked = new AtomicBoolean(false);
+
+ // No service registered: null reference
+ ServiceUtil.withService(
+ StubService.class,
+ context.bundleContext(),
+ (s, release) -> consumerInvoked.set(true));
+ assertFalse(consumerInvoked.get());
+
+ String result1 = ServiceUtil.withService(
+ StubService.class,
+ context.bundleContext(),
+ (s, release) -> s.getValue(),
+ DEFAULT_VALUE);
+ assertEquals(DEFAULT_VALUE, result1);
+
+ // Non-null reference but getService returns null
+ BundleContext mockCtx = newMockContextWithNullService();
+ ServiceUtil.withService(StubService.class, mockCtx, (s, release) -> consumerInvoked.set(true));
+ assertFalse(consumerInvoked.get());
+
+ String result2 = ServiceUtil.withService(
+ StubService.class,
+ mockCtx,
+ (s, release) -> s.getValue(),
+ DEFAULT_VALUE);
+ assertEquals(DEFAULT_VALUE, result2);
+ }
+
+ @Test
+ public void shouldSwallowBiCallbackExceptions() {
+ // BiConsumer throws RuntimeException: exception swallowed, service released directly
+ MockContext biConsumerCtx = newMockContextReturningService();
+ ServiceUtil.withService(
+ StubService.class,
+ biConsumerCtx.bundleContext,
+ (s, release) -> { throw new RuntimeException(TEST_EXCEPTION_MESSAGE); });
+ Mockito.verify(biConsumerCtx.bundleContext).ungetService(biConsumerCtx.serviceRef);
+
+ // BiFunction throws RuntimeException: default returned, service released directly
+ MockContext biFunctionCtx = newMockContextReturningService();
+ String result = ServiceUtil.withService(
+ StubService.class,
+ biFunctionCtx.bundleContext,
+ (s, release) -> { throw new RuntimeException(TEST_EXCEPTION_MESSAGE); },
+ DEFAULT_VALUE);
+ assertEquals(DEFAULT_VALUE, result);
+ Mockito.verify(biFunctionCtx.bundleContext).ungetService(biFunctionCtx.serviceRef);
+ }
+
+ /* --------------
+ Context errors
+ -------------- */
+
+ @Test
+ public void shouldHandleContextException() {
+ AtomicBoolean consumerInvoked = new AtomicBoolean(false);
+ BundleContext throwingCtx = newMockContextThrowingOnGetReference();
+
+ // Consumer: getServiceReference throws -> consumer not invoked
+ ServiceUtil.withService(StubService.class, throwingCtx, s -> consumerInvoked.set(true));
+ assertFalse(consumerInvoked.get());
+
+ // Function: getServiceReference throws -> default returned
+ String result1 = ServiceUtil.withService(
+ StubService.class,
+ throwingCtx,
+ StubService::getValue,
+ DEFAULT_VALUE);
+ assertEquals(DEFAULT_VALUE, result1);
+
+ // BiConsumer: getServiceReference throws -> consumer not invoked
+ ServiceUtil.withService(
+ StubService.class,
+ throwingCtx,
+ (s, release) -> consumerInvoked.set(true));
+ assertFalse(consumerInvoked.get());
+
+ // BiFunction: getServiceReference throws -> default returned
+ String result2 = ServiceUtil.withService(
+ StubService.class,
+ throwingCtx,
+ (s, release) -> s.getValue(),
+ DEFAULT_VALUE);
+ assertEquals(DEFAULT_VALUE, result2);
+ }
+
+ private static BundleContext newMockContextWithNullService() {
+ BundleContext mockContext = Mockito.mock(BundleContext.class);
+ @SuppressWarnings("unchecked")
+ ServiceReference mockRef = Mockito.mock(ServiceReference.class);
+ Mockito.when(mockContext.getServiceReference(StubService.class)).thenReturn(mockRef);
+ Mockito.when(mockContext.getService(mockRef)).thenReturn(null);
+ return mockContext;
+ }
+
+ private static MockContext newMockContextReturningService() {
+ BundleContext mockContext = Mockito.mock(BundleContext.class);
+ @SuppressWarnings("unchecked")
+ ServiceReference mockRef = Mockito.mock(ServiceReference.class);
+ Mockito.when(mockContext.getServiceReference(StubService.class)).thenReturn(mockRef);
+ Mockito.when(mockContext.getService(mockRef)).thenReturn(new StubServiceImpl());
+ return new MockContext(mockContext, mockRef);
+ }
+
+ private static MockContext newMockContextThrowingOnUnget() {
+ BundleContext mockContext = Mockito.mock(BundleContext.class);
+ @SuppressWarnings("unchecked")
+ ServiceReference mockRef = Mockito.mock(ServiceReference.class);
+ Mockito.when(mockContext.getServiceReference(StubService.class)).thenReturn(mockRef);
+ Mockito.when(mockContext.getService(mockRef)).thenReturn(new StubServiceImpl());
+ Mockito.when(mockContext.ungetService(mockRef)).thenThrow(new IllegalStateException("Unget failed"));
+ return new MockContext(mockContext, mockRef);
+ }
+
+ private static BundleContext newMockContextThrowingOnGetReference() {
+ BundleContext mockContext = Mockito.mock(BundleContext.class);
+ Mockito.when(mockContext.getServiceReference(StubService.class))
+ .thenThrow(new IllegalStateException("Bundle context invalid"));
+ return mockContext;
+ }
+
+ private interface StubService {
+ String getValue();
+ }
+
+ private static class StubServiceImpl implements StubService {
+ @Override
+ public String getValue() {
+ return STUB_VALUE;
+ }
+ }
+
+ private static class MockContext {
+ final BundleContext bundleContext;
+ final ServiceReference serviceRef;
+
+ MockContext(BundleContext bundleContext, ServiceReference serviceRef) {
+ this.bundleContext = bundleContext;
+ this.serviceRef = serviceRef;
+ }
+ }
+}
diff --git a/docs/content/dev-tools/configurator.md b/docs/content/dev-tools/configurator.md
index a6f0dde9b..984eb88ce 100644
--- a/docs/content/dev-tools/configurator.md
+++ b/docs/content/dev-tools/configurator.md
@@ -8,7 +8,7 @@ order: 6
Configurator is a tool that allows editing OSGi configurations in a user-friendly manner.
-Note: As of Exadel Authoring Kit 2.7.2, Configurator is an experimental feature. You need to specially enable it through your own AEM project by adding a configuration file like the following:
+Note: As of Exadel Authoring Kit 2.8.0, Configurator is an experimental feature. You need to specially enable it through your own AEM project by adding a configuration file like the following:
Path: `ui.config/src/main/content/jcr_root/apps/your_app/osgiconfig/config/com.exadel.aem.toolkit.core.configurator.services.ConfigChangeListener.xml`
```xml
diff --git a/docs/website/package-lock.json b/docs/website/package-lock.json
index 930c2c126..c92af29ce 100644
--- a/docs/website/package-lock.json
+++ b/docs/website/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "eak-website",
- "version": "2.7.1-SNAPSHOT",
+ "version": "2.8.0-SNAPSHOT",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "eak-website",
- "version": "2.7.1-SNAPSHOT",
+ "version": "2.8.0-SNAPSHOT",
"license": "ISC",
"dependencies": {
"@exadel/esl": "^5.3.0",
diff --git a/it.tests/pom.xml b/it.tests/pom.xml
index f893d8791..e901acd56 100644
--- a/it.tests/pom.xml
+++ b/it.tests/pom.xml
@@ -20,7 +20,7 @@
com.exadel.etoolbox
etoolbox-authoring-kit
- 2.7.1-SNAPSHOT
+ 2.8.0-SNAPSHOT
etoolbox-authoring-kit-it.tests
diff --git a/plugin/pom.xml b/plugin/pom.xml
index 1421836b4..c6d8008f4 100644
--- a/plugin/pom.xml
+++ b/plugin/pom.xml
@@ -7,7 +7,7 @@
com.exadel.etoolbox
etoolbox-authoring-kit
- 2.7.1-SNAPSHOT
+ 2.8.0-SNAPSHOT
etoolbox-authoring-kit-plugin
@@ -40,11 +40,6 @@
org.apache.maven.plugins
maven-surefire-plugin
-
-
- **/AllTests.class
-
-
diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/AllTests.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/AllTests.java
deleted file mode 100644
index 71e3dba2e..000000000
--- a/plugin/src/test/com/exadel/aem/toolkit/plugin/AllTests.java
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
- * Licensed under the Apache License, Version 2.0 (the "License").
- * You may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.exadel.aem.toolkit.plugin;
-
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-import org.junit.runner.RunWith;
-import org.junit.runners.Suite;
-import org.junit.runners.Suite.SuiteClasses;
-
-import com.exadel.aem.toolkit.plugin.exceptions.TerminateOnTest;
-import com.exadel.aem.toolkit.plugin.handlers.common.AllowedChildrenTest;
-import com.exadel.aem.toolkit.plugin.handlers.common.ComponentsTest;
-import com.exadel.aem.toolkit.plugin.handlers.common.EditConfigTest;
-import com.exadel.aem.toolkit.plugin.handlers.common.IgnoreFreshnessTest;
-import com.exadel.aem.toolkit.plugin.handlers.common.MaxChildrenTest;
-import com.exadel.aem.toolkit.plugin.handlers.common.WriteModeTest;
-import com.exadel.aem.toolkit.plugin.handlers.dependson.DependsOnTest;
-import com.exadel.aem.toolkit.plugin.handlers.placement.CoincidenceTest;
-import com.exadel.aem.toolkit.plugin.handlers.placement.IgnoreTest;
-import com.exadel.aem.toolkit.plugin.handlers.placement.InheritanceTest;
-import com.exadel.aem.toolkit.plugin.handlers.placement.OrderingTest;
-import com.exadel.aem.toolkit.plugin.handlers.placement.ReplacementTest;
-import com.exadel.aem.toolkit.plugin.handlers.placement.layouts.LayoutTest;
-import com.exadel.aem.toolkit.plugin.handlers.widgets.WidgetsTest;
-import com.exadel.aem.toolkit.plugin.handlers.widgets.common.WidgetsMetaTest;
-import com.exadel.aem.toolkit.plugin.maven.PluginContextRule;
-import com.exadel.aem.toolkit.plugin.metadata.MetadataTest;
-import com.exadel.aem.toolkit.plugin.metadata.RenderingFilterTest;
-import com.exadel.aem.toolkit.plugin.metadata.scripting.ScriptingHelperTest;
-import com.exadel.aem.toolkit.plugin.sources.SourcesTest;
-import com.exadel.aem.toolkit.plugin.targets.TargetsTest;
-import com.exadel.aem.toolkit.plugin.utils.XmlMergeHelperTest;
-import com.exadel.aem.toolkit.plugin.utils.ordering.TopologicalSorterTest;
-import com.exadel.aem.toolkit.plugin.validators.ValidatorsTest;
-import com.exadel.aem.toolkit.plugin.writers.PackageInfoTest;
-
-/**
- * Shortcut class for running all available test cases in a batch
- */
-@RunWith(Suite.class)
-@SuiteClasses({
- SourcesTest.class,
- TargetsTest.class,
- RenderingFilterTest.class,
- ScriptingHelperTest.class,
- XmlMergeHelperTest.class,
- TerminateOnTest.class,
-
- ComponentsTest.class,
- EditConfigTest.class,
- WriteModeTest.class,
- WidgetsTest.class,
- WidgetsMetaTest.class,
- AllowedChildrenTest.class,
- DependsOnTest.class,
- IgnoreFreshnessTest.class,
- MaxChildrenTest.class,
-
- LayoutTest.class,
- ReplacementTest.class,
- IgnoreTest.class,
- InheritanceTest.class,
- CoincidenceTest.class,
- OrderingTest.class,
- TopologicalSorterTest.class,
-
- ValidatorsTest.class,
- MetadataTest.class,
- PackageInfoTest.class,
-})
-public class AllTests {
- @BeforeClass
- public static void setUp() {
- PluginContextRule.initializeContext();
- }
- @AfterClass
- public static void tearDown() {
- PluginContextRule.closeContext();
- }
-}
diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/EvaluationRule.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/EvaluationRule.java
deleted file mode 100644
index c98463e9c..000000000
--- a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/EvaluationRule.java
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * Licensed under the Apache License, Version 2.0 (the "License").
- * You may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.exadel.aem.toolkit.plugin.maven;
-
-import org.junit.runner.Description;
-import org.junit.runners.model.Statement;
-
-public class EvaluationRule extends PluginContextRule {
-
- public EvaluationRule() {
- super();
- }
-
- @Override
- public Statement apply(Statement base, Description description) {
- return new Statement() {
- @Override
- public void evaluate() throws Throwable {
- boolean adHocInitialization = !isContextInitialized();
- try {
- if (adHocInitialization) {
- initializeContext();
- }
- base.evaluate();
- } finally {
- if (adHocInitialization) {
- closeContext();
- }
- }
- }
- };
- }
-}
diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRenderingRule.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRenderingRule.java
index aebbb1502..3fb534aa3 100644
--- a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRenderingRule.java
+++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRenderingRule.java
@@ -21,6 +21,7 @@
import java.util.function.Consumer;
import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.RegExUtils;
import org.apache.commons.lang3.StringUtils;
import org.junit.Assert;
import org.slf4j.Logger;
@@ -51,10 +52,6 @@ public PluginContextRenderingRule(FileSystem fileSystem) {
}
public void test(Class> component) {
- boolean adHocInitialization = !isContextInitialized();
- if (adHocInitialization) {
- initializeContext();
- }
String subfolderName = TestConstants.RESOURCE_FOLDER_COMPONENTS + PATH_POSTFIX_GENERIC;
if (component.getSimpleName().endsWith(KEYWORD_WIDGET)) {
subfolderName = TestConstants.RESOURCE_FOLDER_WIDGETS;
@@ -63,10 +60,7 @@ public void test(Class> component) {
}
test(component,
subfolderName,
- StringUtils.uncapitalize(StringUtils.removePattern(component.getSimpleName(), SUFFIX_PATTERN)));
- if (adHocInitialization) {
- closeContext();
- }
+ StringUtils.uncapitalize(RegExUtils.removePattern(component.getSimpleName(), SUFFIX_PATTERN)));
}
public void test(Class> component, String... pathElements) {
diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRule.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRule.java
index 06a6b418b..65b739bdc 100644
--- a/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRule.java
+++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/maven/PluginContextRule.java
@@ -16,12 +16,13 @@
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.List;
+import java.util.function.UnaryOperator;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
-public abstract class PluginContextRule implements TestRule {
+public class PluginContextRule implements TestRule {
private static final String PLUGIN_MODULE_TARGET = Paths.get("target", "classes").toAbsolutePath().toString();
private static final String PLUGIN_MODULE_TEST_TARGET = Paths.get( "target", "test-classes").toAbsolutePath().toString();
@@ -33,37 +34,52 @@ public abstract class PluginContextRule implements TestRule {
API_MODULE_TARGET
);
+ private static final UnaryOperator PLAIN = (stmt) -> new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ try {
+ setUp();
+ stmt.evaluate();
+ } finally {
+ tearDown();
+ }
+ }
+ };
+
+ private static final UnaryOperator EXCEPTION_AWARE = (stmt) -> new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ try {
+ setUp();
+ exceptionHandler.unmute();
+ stmt.evaluate();
+ } finally {
+ if (exceptionHandler != null) {
+ exceptionHandler.mute();
+ }
+ tearDown();
+ }
+ }
+ };
+
+
private static MuteableExceptionHandler exceptionHandler;
@Override
public Statement apply(Statement statement, Description description) {
if (description.getAnnotation(ThrowsPluginException.class) == null
&& description.getTestClass().getAnnotation(ThrowsPluginException.class) == null) {
- return statement;
- }
- if (exceptionHandler != null) {
- exceptionHandler.unmute();
+ return PLAIN.apply(statement);
}
- return new Statement() {
- @Override
- public void evaluate() throws Throwable {
- try {
- statement.evaluate();
- } finally {
- if (exceptionHandler != null) {
- exceptionHandler.mute();
- }
- }
- }
- };
+ return EXCEPTION_AWARE.apply(statement);
}
- public static void initializeContext() {
+ static void setUp() {
PluginSettings settings = PluginSettings
.builder()
.defaultPathBase(TestConstants.PACKAGE_ROOT_PATH)
.build();
- exceptionHandler = new MuteableExceptionHandler();
+ exceptionHandler = exceptionHandler == null ? new MuteableExceptionHandler() : exceptionHandler;
PluginRuntime.contextBuilder()
.classPathElements(CLASSPATH_ELEMENTS)
.settings(settings)
@@ -71,11 +87,7 @@ public static void initializeContext() {
.build();
}
- public static void closeContext() {
+ static void tearDown() {
PluginRuntime.close();
}
-
- protected static boolean isContextInitialized() {
- return exceptionHandler != null;
- }
}
diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/MetadataTest.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/MetadataTest.java
index fd07d45e4..14d833b2e 100644
--- a/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/MetadataTest.java
+++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/MetadataTest.java
@@ -32,7 +32,6 @@
import org.apache.commons.lang3.StringUtils;
import org.junit.Assert;
-import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
@@ -45,8 +44,7 @@
import com.exadel.aem.toolkit.plugin.annotations.cases.NestedAnnotations;
import com.exadel.aem.toolkit.plugin.exceptions.PluginException;
import com.exadel.aem.toolkit.plugin.handlers.common.cases.components.ScriptedComponent;
-import com.exadel.aem.toolkit.plugin.maven.FileSystemRule;
-import com.exadel.aem.toolkit.plugin.maven.PluginContextRenderingRule;
+import com.exadel.aem.toolkit.plugin.maven.PluginContextRule;
import com.exadel.aem.toolkit.plugin.maven.ThrowsPluginException;
public class MetadataTest {
@@ -54,11 +52,8 @@ public class MetadataTest {
private static final String PN_OPTIONS = "moreOptions";
private static final String PN_NUMBERS = "numbers";
- @ClassRule
- public static FileSystemRule fileSystemHost = new FileSystemRule();
-
@Rule
- public PluginContextRenderingRule pluginContext = new PluginContextRenderingRule(fileSystemHost.getFileSystem());
+ public PluginContextRule context = new PluginContextRule();
@Test
public void testAnnotationCreation() {
diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/scripting/ScriptingHelperTest.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/scripting/ScriptingHelperTest.java
index 6e0c5f036..2c61d4c84 100644
--- a/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/scripting/ScriptingHelperTest.java
+++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/metadata/scripting/ScriptingHelperTest.java
@@ -18,6 +18,7 @@
import java.util.List;
import org.junit.Assert;
+import org.junit.Rule;
import org.junit.Test;
import com.exadel.aem.toolkit.api.annotations.main.Setting;
@@ -28,6 +29,7 @@
import com.exadel.aem.toolkit.core.CoreConstants;
import com.exadel.aem.toolkit.plugin.handlers.common.cases.components.ScriptedFieldset1;
import com.exadel.aem.toolkit.plugin.handlers.common.cases.components.ScriptedFieldset2;
+import com.exadel.aem.toolkit.plugin.maven.PluginContextRule;
import com.exadel.aem.toolkit.plugin.sources.Sources;
import com.exadel.aem.toolkit.plugin.utils.DialogConstants;
@@ -36,6 +38,9 @@ public class ScriptingHelperTest {
private static final String SCRIPT_CONTAINER = "Lorem {ipsum dolor} ${sit amet}, consectetur \"${adipiscing} elit\","
+ "sed do @{eiusmod tempor} incididunt ut @labore et @dolore 'magna @aliqua'";
+ @Rule
+ public PluginContextRule context = new PluginContextRule();
+
@Test
public void testInlineScriptExtraction() {
SubstringMatcher substringMatcher = new SubstringMatcher(
diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/sources/SourcesTest.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/sources/SourcesTest.java
index 070c772d5..7bfe56a16 100644
--- a/plugin/src/test/com/exadel/aem/toolkit/plugin/sources/SourcesTest.java
+++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/sources/SourcesTest.java
@@ -26,12 +26,12 @@
import com.exadel.aem.toolkit.plugin.handlers.common.cases.components.ComplexComponent1;
import com.exadel.aem.toolkit.plugin.handlers.common.cases.components.viewpattern.component1.views.DesignDialogView;
import com.exadel.aem.toolkit.plugin.handlers.common.cases.policies.AllowedChildrenTestCases;
-import com.exadel.aem.toolkit.plugin.maven.EvaluationRule;
+import com.exadel.aem.toolkit.plugin.maven.PluginContextRule;
public class SourcesTest {
@Rule
- public EvaluationRule evaluation = new EvaluationRule();
+ public PluginContextRule context = new PluginContextRule();
@Test
public void testCacheMetadata1() {
diff --git a/plugin/src/test/com/exadel/aem/toolkit/plugin/targets/TargetsTest.java b/plugin/src/test/com/exadel/aem/toolkit/plugin/targets/TargetsTest.java
index b53f67093..cc4596f93 100644
--- a/plugin/src/test/com/exadel/aem/toolkit/plugin/targets/TargetsTest.java
+++ b/plugin/src/test/com/exadel/aem/toolkit/plugin/targets/TargetsTest.java
@@ -20,6 +20,7 @@
import org.apache.commons.lang3.StringUtils;
import org.junit.Assert;
import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
@@ -28,6 +29,7 @@
import com.exadel.aem.toolkit.api.handlers.Target;
import com.exadel.aem.toolkit.core.CoreConstants;
import com.exadel.aem.toolkit.plugin.adapters.DomAdapter;
+import com.exadel.aem.toolkit.plugin.maven.PluginContextRule;
import com.exadel.aem.toolkit.plugin.maven.PluginRuntime;
public class TargetsTest {
@@ -43,6 +45,9 @@ public class TargetsTest {
private static final int TIER_1_CHILD_COUNT = 10;
+ @Rule
+ public PluginContextRule context = new PluginContextRule();
+
private Target testable;
@Before
diff --git a/pom.xml b/pom.xml
index 10f76f788..98039885c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
com.exadel.etoolbox
etoolbox-authoring-kit
- 2.7.1-SNAPSHOT
+ 2.8.0-SNAPSHOT
Exadel Toolbox Authoring Kit
Automates generating and managing rich and responsive authoring interfaces for Adobe Experience Manager
@@ -615,7 +615,7 @@
org.apache.sling
org.apache.sling.api
- 2.22.0
+ 2.24.0
provided
diff --git a/ui.apps/package-lock.json b/ui.apps/package-lock.json
index ae018f7c3..8ed00e28f 100644
--- a/ui.apps/package-lock.json
+++ b/ui.apps/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "depends-on",
- "version": "2.7.1-SNAPSHOT",
+ "version": "2.8.0-SNAPSHOT",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "depends-on",
- "version": "2.7.1-SNAPSHOT",
+ "version": "2.8.0-SNAPSHOT",
"license": "Apache-2.0",
"devDependencies": {
"eslint": "^9.28.0",
diff --git a/ui.apps/package.json b/ui.apps/package.json
index a06072973..3e4e6d15b 100644
--- a/ui.apps/package.json
+++ b/ui.apps/package.json
@@ -1,7 +1,7 @@
{
"name": "depends-on",
"description": "A clientlib that executes defined action on dependent fields",
- "version": "2.7.1-SNAPSHOT",
+ "version": "2.8.0-SNAPSHOT",
"private": true,
"devDependencies": {
"eslint": "^9.28.0",
diff --git a/ui.apps/pom.xml b/ui.apps/pom.xml
index d801d9dcd..c5d16e93b 100644
--- a/ui.apps/pom.xml
+++ b/ui.apps/pom.xml
@@ -7,7 +7,7 @@
com.exadel.etoolbox
etoolbox-authoring-kit
- 2.7.1-SNAPSHOT
+ 2.8.0-SNAPSHOT
etoolbox-authoring-kit-ui.apps
diff --git a/ui.config/pom.xml b/ui.config/pom.xml
index cb10148ad..1147356f2 100644
--- a/ui.config/pom.xml
+++ b/ui.config/pom.xml
@@ -8,7 +8,7 @@
com.exadel.etoolbox
etoolbox-authoring-kit
- 2.7.1-SNAPSHOT
+ 2.8.0-SNAPSHOT
etoolbox-authoring-kit-ui.config
@@ -57,13 +57,6 @@
true
-
- org.sonatype.plugins
- nexus-staging-maven-plugin
-
- true
-
-
diff --git a/ui.config/src/main/content/jcr_root/apps/etoolbox-authoring-kit/osgiconfig/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-eak.xml b/ui.config/src/main/content/jcr_root/apps/etoolbox-authoring-kit/osgiconfig/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-eak.xml
index 735ea2e1c..164f4b581 100644
--- a/ui.config/src/main/content/jcr_root/apps/etoolbox-authoring-kit/osgiconfig/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-eak.xml
+++ b/ui.config/src/main/content/jcr_root/apps/etoolbox-authoring-kit/osgiconfig/config/org.apache.sling.serviceusermapping.impl.ServiceUserMapperImpl.amended-eak.xml
@@ -3,5 +3,5 @@
xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
xmlns:jcr="http://www.jcp.org/jcr/1.0"
jcr:primaryType="sling:OsgiConfig"
- user.mapping="[com.exadel.etoolbox.authoring-kit-core:eak-service=[eak-service]]"
+ user.mapping="[com.exadel.etoolbox.authoring-kit-core:eak-service=eak-service,com.exadel.etoolbox.authoring-kit-core:eak-scripting=sling-scripting]"
/>
diff --git a/ui.content/pom.xml b/ui.content/pom.xml
index 595afd49c..401adf0d9 100644
--- a/ui.content/pom.xml
+++ b/ui.content/pom.xml
@@ -8,7 +8,7 @@
com.exadel.etoolbox
etoolbox-authoring-kit
- 2.7.1-SNAPSHOT
+ 2.8.0-SNAPSHOT
etoolbox-authoring-kit-ui.content