diff --git a/celements-navigation-rest/pom.xml b/celements-navigation-rest/pom.xml
new file mode 100644
index 000000000..0c24d994f
--- /dev/null
+++ b/celements-navigation-rest/pom.xml
@@ -0,0 +1,211 @@
+
+
+
+ com.celements
+ celements-parent
+ 7.1-SNAPSHOT
+
+ 4.0.0
+ celements-navigation-rest
+ 7.1-SNAPSHOT
+ Celements Navigation REST API
+
+
+ com.celements
+ celements-core
+ provided
+
+
+ com.celements
+ celements-model
+ provided
+
+
+
+ com.celements
+ celements-spring-security
+ provided
+
+
+ com.celements
+ celements-xwiki-core
+ provided
+
+
+ com.celements
+ celements-servlet
+ provided
+
+
+ org.xwiki.platform
+ xwiki-core-model
+ provided
+
+
+ org.springframework
+ spring-context
+ provided
+
+
+ org.springframework
+ spring-beans
+ provided
+
+
+ org.springframework
+ spring-core
+ provided
+
+
+ org.springframework
+ spring-web
+ provided
+
+
+ org.springframework
+ spring-webmvc
+ provided
+
+
+ org.springframework.security
+ spring-security-core
+ provided
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ provided
+
+
+ io.swagger.core.v3
+ swagger-annotations
+ provided
+
+
+ javax.inject
+ javax.inject
+ provided
+
+
+ com.google.code.findbugs
+ jsr305
+ provided
+
+
+ org.slf4j
+ slf4j-api
+ provided
+
+
+ javax.servlet
+ javax.servlet-api
+ provided
+
+
+ com.celements
+ celements-component
+ test
+
+
+ com.celements
+ celements-commons
+ test
+
+
+
+ com.celements
+ celements-reference
+ test
+
+
+ com.celements
+ celements-observation
+ test
+
+
+ com.celements
+ celements-shared-tests
+ test
+
+
+ com.celements
+ celements-subsystem-migration-manager
+ test
+
+
+ com.celements
+ celements-xwiki-velocity
+ test
+
+
+ org.springframework
+ spring-test
+ test
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ test
+
+
+ org.springdoc
+ springdoc-openapi-webmvc-core
+ test
+
+
+ org.springdoc
+ springdoc-openapi-common
+ 1.8.0
+ test
+
+
+ org.springframework.security
+ spring-security-config
+ test
+
+
+
+ javax.xml.bind
+ jaxb-api
+ test
+
+
+ org.easymock
+ easymock
+ test
+
+
+ junit
+ junit
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+
+ com.celements:celements-servlet
+ com.celements:celements-commons
+ com.celements:celements-reference
+ com.celements:celements-observation
+ com.celements:celements-subsystem-migration-manager
+ com.celements:celements-xwiki-velocity
+ javax.xml.bind:jaxb-api
+
+
+
+
+
+
+ scm:git:git@github.com:celements/celements-base.git
+ scm:git:git@github.com:celements/celements-base.git
+ https://github.com/celements/celements-base/celements-navigation-rest
+ HEAD
+
+
diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java
new file mode 100644
index 000000000..7b9c0dfad
--- /dev/null
+++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/DefaultNavigationNodeValueResolver.java
@@ -0,0 +1,48 @@
+package com.celements.navigation.api;
+
+import javax.inject.Inject;
+
+import org.springframework.stereotype.Component;
+import org.xwiki.model.reference.DocumentReference;
+
+import com.celements.model.context.ModelContext;
+import com.celements.model.util.ModelUtils;
+import com.celements.navigation.cmd.MultilingualMenuNameCommand;
+import com.celements.url.UrlService;
+
+@Component
+class DefaultNavigationNodeValueResolver {
+
+ private final ModelUtils modelUtils;
+ private final ModelContext modelContext;
+ private final UrlService urlService;
+ private final MultilingualMenuNameCommand menuNameCommand;
+
+ @Inject
+ DefaultNavigationNodeValueResolver(ModelUtils modelUtils, ModelContext modelContext,
+ UrlService urlService) {
+ this(modelUtils, modelContext, urlService, new MultilingualMenuNameCommand());
+ }
+
+ DefaultNavigationNodeValueResolver(ModelUtils modelUtils, ModelContext modelContext,
+ UrlService urlService, MultilingualMenuNameCommand menuNameCommand) {
+ this.modelUtils = modelUtils;
+ this.modelContext = modelContext;
+ this.urlService = urlService;
+ this.menuNameCommand = menuNameCommand;
+ }
+
+ public String serialize(DocumentReference docRef) {
+ return modelUtils.serializeRefLocal(docRef);
+ }
+
+ public String resolveTitle(DocumentReference docRef, String language) {
+ return menuNameCommand.getMultilingualMenuName(modelUtils.serializeRefLocal(docRef), language,
+ modelContext.getXWikiContext());
+ }
+
+ public String resolveUrl(DocumentReference docRef, String language) {
+ return urlService.getURL(docRef, "view", "language=" + language);
+ }
+
+}
diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationApiException.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationApiException.java
new file mode 100644
index 000000000..4dd3dd55b
--- /dev/null
+++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationApiException.java
@@ -0,0 +1,26 @@
+package com.celements.navigation.api;
+
+import org.springframework.http.HttpStatus;
+
+final class NavigationApiException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final HttpStatus status;
+ private final String code;
+
+ NavigationApiException(HttpStatus status, String code, String message) {
+ super(message);
+ this.status = status;
+ this.code = code;
+ }
+
+ HttpStatus status() {
+ return status;
+ }
+
+ String code() {
+ return code;
+ }
+
+}
diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java
new file mode 100644
index 000000000..5af643f1f
--- /dev/null
+++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationController.java
@@ -0,0 +1,104 @@
+package com.celements.navigation.api;
+
+import javax.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.convert.ConversionFailedException;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+
+@RestController
+@RequestMapping("/v1/navigation")
+public class NavigationController {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(NavigationController.class);
+ static final String CACHE_CONTROL = "private, no-store";
+
+ private final NavigationRequestResolver requestResolver;
+ private final NavigationTreeBuilder treeBuilder;
+
+ @Inject
+ NavigationController(NavigationRequestResolver requestResolver,
+ NavigationTreeBuilder treeBuilder) {
+ this.requestResolver = requestResolver;
+ this.treeBuilder = treeBuilder;
+ }
+
+ @GetMapping(path = "/{nodeSpace}", produces = MediaType.APPLICATION_JSON_VALUE)
+ @PreAuthorize("permitAll()")
+ @Operation(summary = "Get the current wiki's navigation tree")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "Navigation tree"),
+ @ApiResponse(responseCode = "400",
+ description = "Invalid reference or parameter, or unsupported language",
+ content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class))),
+ @ApiResponse(responseCode = "404",
+ description = "The active node is absent, inaccessible, out of root, or part-excluded",
+ content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class))),
+ @ApiResponse(responseCode = "500", description = "Navigation infrastructure is unavailable",
+ content = @Content(schema = @Schema(implementation = NavigationErrorResponse.class))) })
+ public ResponseEntity getNavigation(
+ @Parameter(description = "Canonical local space reference") @PathVariable String nodeSpace,
+ @Parameter(description = "Canonical local document reference") @RequestParam(
+ required = false) String currentNode,
+ @Parameter(
+ description = "Allowed wiki language; defaults to the request language") @RequestParam(
+ required = false) String language,
+ @Parameter(
+ description = "Case-sensitive root part filter; missing or blank returns all parts")
+ @RequestParam(required = false) String partName,
+ @Parameter(description = "Inactive expansion threshold from 0 through 100",
+ schema = @Schema(defaultValue = "0", minimum = "0", maximum = "100")) @RequestParam(
+ name = "show_inactive_to_level", defaultValue = "0") int showInactiveToLevel) {
+ var request = requestResolver.resolve(nodeSpace, currentNode, language, partName,
+ showInactiveToLevel);
+ var response = treeBuilder.build(request);
+ return ResponseEntity.ok().header(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL).body(response);
+ }
+
+ @ExceptionHandler(NavigationApiException.class)
+ public ResponseEntity handleNavigationApiException(
+ NavigationApiException exception) {
+ return errorResponse(exception.status(),
+ new NavigationErrorResponse(exception.code(), exception.getMessage()));
+ }
+
+ @ExceptionHandler({ MethodArgumentTypeMismatchException.class, ConversionFailedException.class })
+ public ResponseEntity handleInvalidParameter() {
+ return errorResponse(HttpStatus.BAD_REQUEST,
+ new NavigationErrorResponse("invalid_parameter", "The parameter is invalid."));
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleUnexpectedException(Exception exception) {
+ LOGGER.error("Navigation REST request failed.", exception);
+ return errorResponse(HttpStatus.INTERNAL_SERVER_ERROR, new NavigationErrorResponse(
+ "navigation_unavailable", "Navigation is currently unavailable."));
+ }
+
+ private ResponseEntity errorResponse(HttpStatus status,
+ NavigationErrorResponse body) {
+ return ResponseEntity.status(status)
+ .header(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL)
+ .body(body);
+ }
+
+}
diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java
new file mode 100644
index 000000000..782595eaf
--- /dev/null
+++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationErrorResponse.java
@@ -0,0 +1,5 @@
+package com.celements.navigation.api;
+
+public record NavigationErrorResponse(String code, String message) {
+
+}
diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java
new file mode 100644
index 000000000..28bfa15f4
--- /dev/null
+++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationNodeDto.java
@@ -0,0 +1,22 @@
+package com.celements.navigation.api;
+
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+public record NavigationNodeDto(
+ String docRef,
+ String url,
+ String title,
+ @JsonProperty("isLeaf") boolean isLeaf,
+ @JsonProperty("isActive") boolean isActive,
+ @JsonProperty("isOpen") boolean isOpen,
+ @Schema(description = "Expanded rights-visible child nodes") List children) {
+
+ public NavigationNodeDto {
+ children = List.copyOf(children);
+ }
+
+}
diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequest.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequest.java
new file mode 100644
index 000000000..4a1b16a35
--- /dev/null
+++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequest.java
@@ -0,0 +1,12 @@
+package com.celements.navigation.api;
+
+import java.util.Optional;
+
+import org.xwiki.model.reference.DocumentReference;
+import org.xwiki.model.reference.SpaceReference;
+
+record NavigationRequest(SpaceReference nodeSpace, String serializedNodeSpace,
+ Optional currentNode, Optional serializedCurrentNode,
+ String language, Optional partName, int showInactiveToLevel) {
+
+}
diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequestResolver.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequestResolver.java
new file mode 100644
index 000000000..6b84e0f62
--- /dev/null
+++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationRequestResolver.java
@@ -0,0 +1,105 @@
+package com.celements.navigation.api;
+
+import java.util.Optional;
+
+import javax.inject.Inject;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.xwiki.model.reference.DocumentReference;
+import org.xwiki.model.reference.EntityReference;
+import org.xwiki.model.reference.SpaceReference;
+
+import com.celements.model.context.ModelContext;
+import com.celements.model.util.ModelUtils;
+import com.celements.web.service.IWebUtilsService;
+
+@Component
+class NavigationRequestResolver {
+
+ private static final String INVALID_REFERENCE_MESSAGE = "The reference is invalid.";
+ private static final String INVALID_PARAMETER_MESSAGE = "The parameter is invalid.";
+ private static final String UNSUPPORTED_LANGUAGE_MESSAGE = "The language is not supported.";
+
+ private final ModelUtils modelUtils;
+ private final ModelContext modelContext;
+ private final IWebUtilsService webUtilsService;
+
+ @Inject
+ NavigationRequestResolver(ModelUtils modelUtils, ModelContext modelContext,
+ IWebUtilsService webUtilsService) {
+ this.modelUtils = modelUtils;
+ this.modelContext = modelContext;
+ this.webUtilsService = webUtilsService;
+ }
+
+ NavigationRequest resolve(String nodeSpace, String currentNode, String language, String partName,
+ int showInactiveToLevel) {
+ if (showInactiveToLevel < 0 || showInactiveToLevel > 100) {
+ throw invalidParameter();
+ }
+ var spaceRef = resolveCanonicalLocal(nodeSpace, SpaceReference.class);
+ var serializedCurrentNode = Optional.ofNullable(currentNode);
+ var currentNodeRef = serializedCurrentNode
+ .map(value -> resolveCanonicalLocal(value, DocumentReference.class));
+ var resolvedLanguage = resolveLanguage(language, spaceRef);
+ var normalizedPartName = normalizeOptional(partName);
+ return new NavigationRequest(spaceRef, nodeSpace, currentNodeRef, serializedCurrentNode,
+ resolvedLanguage, normalizedPartName, showInactiveToLevel);
+ }
+
+ private T resolveCanonicalLocal(String value, Class type) {
+ if (value == null || value.isBlank() || !value.equals(value.trim())) {
+ throw invalidReference();
+ }
+ var wikiRef = modelContext.getWikiRef();
+ final T reference;
+ try {
+ reference = modelUtils.resolveRef(value, type, wikiRef);
+ } catch (IllegalArgumentException exception) {
+ throw invalidReference();
+ }
+ if (reference == null || !value.equals(modelUtils.serializeRefLocal(reference))) {
+ throw invalidReference();
+ }
+ return reference;
+ }
+
+ private String resolveLanguage(String requestedLanguage, SpaceReference nodeSpace) {
+ var explicitLanguage = normalizeOptional(requestedLanguage);
+ if (explicitLanguage.isEmpty()) {
+ return modelContext.getLanguage().orElseGet(modelContext::getDefaultLanguage);
+ }
+ final String normalizedLanguage;
+ try {
+ normalizedLanguage = modelUtils.normalizeLang(explicitLanguage.orElseThrow());
+ } catch (IllegalArgumentException exception) {
+ throw unsupportedLanguage();
+ }
+ if (normalizedLanguage.isBlank()
+ || !webUtilsService.getAllowedLanguages(nodeSpace).contains(normalizedLanguage)) {
+ throw unsupportedLanguage();
+ }
+ return normalizedLanguage;
+ }
+
+ private Optional normalizeOptional(String value) {
+ return Optional.ofNullable(value).map(String::trim).filter(normalized -> !normalized.isEmpty());
+ }
+
+ private NavigationApiException invalidReference() {
+ return new NavigationApiException(HttpStatus.BAD_REQUEST, "invalid_reference",
+ INVALID_REFERENCE_MESSAGE);
+ }
+
+ private NavigationApiException invalidParameter() {
+ return new NavigationApiException(HttpStatus.BAD_REQUEST, "invalid_parameter",
+ INVALID_PARAMETER_MESSAGE);
+ }
+
+ private NavigationApiException unsupportedLanguage() {
+ return new NavigationApiException(HttpStatus.BAD_REQUEST, "unsupported_language",
+ UNSUPPORTED_LANGUAGE_MESSAGE);
+ }
+
+}
diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java
new file mode 100644
index 000000000..c0854be67
--- /dev/null
+++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationSegmentDto.java
@@ -0,0 +1,17 @@
+package com.celements.navigation.api;
+
+import java.util.List;
+
+import javax.annotation.Nullable;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+public record NavigationSegmentDto(
+ @Nullable @Schema(nullable = true) String partName,
+ List nodes) {
+
+ public NavigationSegmentDto {
+ nodes = List.copyOf(nodes);
+ }
+
+}
diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java
new file mode 100644
index 000000000..bc2f3803c
--- /dev/null
+++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeBuilder.java
@@ -0,0 +1,193 @@
+package com.celements.navigation.api;
+
+import static java.util.Comparator.*;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+import javax.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.xwiki.model.reference.DocumentReference;
+
+import com.celements.navigation.TreeNode;
+import com.celements.navigation.filter.InternalRightsFilter;
+import com.celements.navigation.service.ITreeNodeService;
+import com.celements.parents.IDocumentParentsListerRole;
+
+@Component
+class NavigationTreeBuilder {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(NavigationTreeBuilder.class);
+ private static final Comparator PART_NAME_COMPARATOR = comparing(
+ (String value) -> value.toLowerCase(Locale.ROOT))
+ .thenComparing(naturalOrder());
+
+ private final ITreeNodeService treeNodeService;
+ private final IDocumentParentsListerRole parentsLister;
+ private final DefaultNavigationNodeValueResolver valueResolver;
+
+ @Inject
+ NavigationTreeBuilder(ITreeNodeService treeNodeService, IDocumentParentsListerRole parentsLister,
+ DefaultNavigationNodeValueResolver valueResolver) {
+ this.treeNodeService = treeNodeService;
+ this.parentsLister = parentsLister;
+ this.valueResolver = valueResolver;
+ }
+
+ NavigationTreeResponse build(NavigationRequest request) {
+ var rootFilter = new InternalRightsFilter();
+ rootFilter.setMenuPart(request.partName().orElse(""));
+ var descendantFilter = new InternalRightsFilter();
+ Set activePath = resolveActivePath(request);
+ List roots = readNodes(
+ treeNodeService.getSubNodesForParent(request.nodeSpace(), rootFilter), descendantFilter,
+ request.language(), new HashSet<>());
+ if (request.currentNode().isPresent() && roots.stream()
+ .noneMatch(root -> root.contains(request.currentNode().orElseThrow()))) {
+ throw nodeNotFound();
+ }
+ return new NavigationTreeResponse(request.serializedNodeSpace(),
+ request.serializedCurrentNode().orElse(null), request.language(),
+ request.partName().orElse(null), request.showInactiveToLevel(),
+ buildSegments(roots, activePath, request));
+ }
+
+ private Set resolveActivePath(NavigationRequest request) {
+ return request.currentNode()
+ .map(currentNode -> {
+ var path = new HashSet<>(
+ Objects.requireNonNull(parentsLister.getDocumentParentsList(currentNode, true)));
+ path.add(currentNode);
+ return Set.copyOf(path);
+ })
+ .orElseGet(Set::of);
+ }
+
+ private List buildSegments(List roots,
+ Set activePath, NavigationRequest request) {
+ if (request.partName().isPresent()) {
+ String partName = request.partName().orElseThrow();
+ return List.of(new NavigationSegmentDto(partName,
+ roots.stream()
+ .filter(root -> partName.equals(root.partName()))
+ .map(root -> toDto(root, 1, activePath, request))
+ .toList()));
+ }
+ Map> groupedRoots = new LinkedHashMap<>();
+ roots.forEach(root -> groupedRoots.computeIfAbsent(root.partName(),
+ ignored -> new ArrayList<>())
+ .add(root));
+ return groupedRoots.entrySet()
+ .stream()
+ .sorted(Map.Entry.comparingByKey(PART_NAME_COMPARATOR))
+ .map(entry -> new NavigationSegmentDto(emptyToNull(entry.getKey()),
+ entry.getValue()
+ .stream()
+ .map(root -> toDto(root, 1, activePath, request))
+ .toList()))
+ .toList();
+ }
+
+ private NavigationNodeDto toDto(InternalNode node, int level, Set activePath,
+ NavigationRequest request) {
+ boolean isActive = request.currentNode().filter(node.docRef()::equals).isPresent();
+ boolean onActivePath = activePath.contains(node.docRef());
+ boolean expandForInactiveLevel = level < request.showInactiveToLevel();
+ boolean isOpen = onActivePath || (expandForInactiveLevel && !node.children().isEmpty());
+ List children = isOpen
+ ? node.children()
+ .stream()
+ .map(child -> toDto(child, level + 1, activePath, request))
+ .toList()
+ : List.of();
+ return new NavigationNodeDto(node.serializedDocRef(), node.url(), node.title(),
+ node.children().isEmpty(), isActive, isOpen, children);
+ }
+
+ private List readNodes(List sourceNodes, InternalRightsFilter filter,
+ String language, Set ancestors) {
+ return Objects.requireNonNull(sourceNodes)
+ .stream()
+ .map(node -> readNode(node, filter, language, ancestors))
+ .flatMap(Optional::stream)
+ .toList();
+ }
+
+ private Optional readNode(TreeNode sourceNode, InternalRightsFilter filter,
+ String language, Set ancestors) {
+ DocumentReference docRef = sourceNode.getDocumentReference();
+ if (!ancestors.add(docRef)) {
+ throw new IllegalStateException("Navigation tree contains a cycle at " + docRef);
+ }
+ try {
+ String serializedDocRef = valueResolver.serialize(docRef);
+ if (isBlank(serializedDocRef)) {
+ return invalidNode(docRef, "docRef");
+ }
+ String title = valueResolver.resolveTitle(docRef, language);
+ if (isBlank(title)) {
+ return invalidNode(docRef, "title");
+ }
+ String url = valueResolver.resolveUrl(docRef, language);
+ if (isBlank(url)) {
+ return invalidNode(docRef, "url");
+ }
+ List sourceChildren = treeNodeService.getSubNodesForParent(docRef, filter);
+ if (sourceChildren == null) {
+ return invalidNode(docRef, "children");
+ }
+ List children = readNodes(sourceChildren, filter, language, ancestors);
+ return Optional.of(new InternalNode(docRef, serializedDocRef, sourceNode.getPartName(), url,
+ title, children));
+ } finally {
+ ancestors.remove(docRef);
+ }
+ }
+
+ private Optional invalidNode(DocumentReference docRef, String field) {
+ LOGGER.warn("Omitting navigation node [{}] because mandatory field [{}] is invalid.", docRef,
+ field);
+ return Optional.empty();
+ }
+
+ private boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+
+ private String emptyToNull(String value) {
+ return value.isEmpty() ? null : value;
+ }
+
+ private NavigationApiException nodeNotFound() {
+ return new NavigationApiException(HttpStatus.NOT_FOUND, "navigation_node_not_found",
+ "The navigation node was not found.");
+ }
+
+ private record InternalNode(DocumentReference docRef, String serializedDocRef, String partName,
+ String url, String title, List children) {
+
+ private InternalNode {
+ partName = Objects.requireNonNullElse(partName, "");
+ children = List.copyOf(children);
+ }
+
+ boolean contains(DocumentReference reference) {
+ return docRef.equals(reference) || children.stream()
+ .anyMatch(child -> child.contains(reference));
+ }
+
+ }
+
+}
diff --git a/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java
new file mode 100644
index 000000000..7217afe65
--- /dev/null
+++ b/celements-navigation-rest/src/main/java/com/celements/navigation/api/NavigationTreeResponse.java
@@ -0,0 +1,21 @@
+package com.celements.navigation.api;
+
+import java.util.List;
+
+import javax.annotation.Nullable;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+public record NavigationTreeResponse(
+ String nodeSpace,
+ @Nullable @Schema(nullable = true) String currentNode,
+ String language,
+ @Nullable @Schema(nullable = true) String partName,
+ int showInactiveToLevel,
+ List segments) {
+
+ public NavigationTreeResponse {
+ segments = List.copyOf(segments);
+ }
+
+}
diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java
new file mode 100644
index 000000000..9f8107d6e
--- /dev/null
+++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/DefaultNavigationNodeValueResolverTest.java
@@ -0,0 +1,68 @@
+package com.celements.navigation.api;
+
+import static org.easymock.EasyMock.*;
+import static org.junit.Assert.*;
+
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.xwiki.model.reference.DocumentReference;
+import org.xwiki.model.reference.SpaceReference;
+import org.xwiki.model.reference.WikiReference;
+
+import com.celements.common.test.AbstractComponentTest;
+import com.celements.model.access.IModelAccessFacade;
+import com.celements.model.context.ModelContext;
+import com.celements.model.util.ModelUtils;
+import com.celements.url.UrlService;
+import com.xpn.xwiki.doc.XWikiDocument;
+
+public class DefaultNavigationNodeValueResolverTest extends AbstractComponentTest {
+
+ private ModelUtils modelUtils;
+ private ModelContext modelContext;
+ private UrlService urlService;
+ private IModelAccessFacade modelAccess;
+ private DefaultNavigationNodeValueResolver resolver;
+
+ @Before
+ public void prepareTest() throws Exception {
+ registerComponentMocks(ModelUtils.class, ModelContext.class, UrlService.class,
+ IModelAccessFacade.class);
+ modelUtils = getMock(ModelUtils.class);
+ modelContext = getMock(ModelContext.class);
+ urlService = getMock(UrlService.class);
+ modelAccess = getMock(IModelAccessFacade.class);
+ var beanFactory = (DefaultListableBeanFactory) getBeanFactory();
+ beanFactory.destroySingleton(DefaultNavigationNodeValueResolver.class.getName());
+ beanFactory.registerResolvableDependency(ModelUtils.class, modelUtils);
+ beanFactory.registerResolvableDependency(ModelContext.class, modelContext);
+ beanFactory.registerResolvableDependency(UrlService.class, urlService);
+ resolver = getBeanFactory().getBean(DefaultNavigationNodeValueResolver.class.getName(),
+ DefaultNavigationNodeValueResolver.class);
+ }
+
+ @Test
+ public void test_resolvesCanonicalTitleAndLanguageStableViewUrl() {
+ var docRef = new DocumentReference("Home",
+ new SpaceReference("Content", new WikiReference("xwiki")));
+ XWikiDocument document = createDefaultMock(XWikiDocument.class);
+ expect(modelUtils.serializeRefLocal(docRef)).andReturn("Content.Home").times(2);
+ expect(modelContext.getXWikiContext()).andReturn(getXContext());
+ expect(modelUtils.resolveRef("Content.Home", DocumentReference.class)).andReturn(docRef);
+ expect(modelAccess.getOrCreateDocument(docRef)).andReturn(document);
+ expect(getXContext().getWiki().isMultiLingual(getXContext())).andReturn(false);
+ expect(document.getObject("Celements2.MenuName")).andReturn(null);
+ expect(modelAccess.getDocumentOpt(docRef, "de")).andReturn(Optional.of(document));
+ expect(document.getTitle()).andReturn("Startseite");
+ expect(urlService.getURL(docRef, "view", "language=de")).andReturn("/Content/Home?language=de");
+ replayDefault();
+ assertEquals("Content.Home", resolver.serialize(docRef));
+ assertEquals("Startseite", resolver.resolveTitle(docRef, "de"));
+ assertEquals("/Content/Home?language=de", resolver.resolveUrl(docRef, "de"));
+ verifyDefault();
+ }
+
+}
diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java
new file mode 100644
index 000000000..05eb301e8
--- /dev/null
+++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationControllerTest.java
@@ -0,0 +1,185 @@
+package com.celements.navigation.api;
+
+import static org.easymock.EasyMock.*;
+import static org.junit.Assert.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.test.util.AopTestUtils;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.xwiki.model.reference.SpaceReference;
+import org.xwiki.model.reference.WikiReference;
+
+import com.celements.common.test.AbstractComponentTest;
+import com.celements.spring.security.oauth2.IdentityService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class NavigationControllerTest extends AbstractComponentTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private NavigationRequestResolver requestResolver;
+ private NavigationTreeBuilder treeBuilder;
+ private MockMvc mockMvc;
+
+ @Override
+ protected void beforeSpringContextRefresh(ConfigurableApplicationContext context) {
+ super.beforeSpringContextRefresh(context);
+ context.addBeanFactoryPostProcessor(beanFactory -> beanFactory.registerSingleton(
+ "testIdentityService", createNiceMock(IdentityService.class)));
+ }
+
+ @Before
+ public void prepareTest() throws Exception {
+ registerComponentMocks(NavigationRequestResolver.class, NavigationTreeBuilder.class);
+ requestResolver = getMock(NavigationRequestResolver.class);
+ treeBuilder = getMock(NavigationTreeBuilder.class);
+ var beanFactory = (DefaultListableBeanFactory) getBeanFactory();
+ beanFactory.destroySingleton(NavigationController.class.getName());
+ beanFactory.registerResolvableDependency(NavigationRequestResolver.class, requestResolver);
+ beanFactory.registerResolvableDependency(NavigationTreeBuilder.class, treeBuilder);
+ var controllerProxy = getBeanFactory().getBean(NavigationController.class.getName(),
+ NavigationController.class);
+ var controller = AopTestUtils.getTargetObject(controllerProxy);
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+ }
+
+ @Test
+ public void test_getNavigation_isPublicThroughMethodSecurity() throws Exception {
+ var method = NavigationController.class.getMethod("getNavigation", String.class,
+ String.class, String.class, String.class, int.class);
+ assertEquals("permitAll()", method.getAnnotation(PreAuthorize.class).value());
+ }
+
+ @Test
+ public void test_getNavigation_returnsExactJsonAndPrivateNoStoreHeader() throws Exception {
+ var request = request();
+ expect(requestResolver.resolve("Content", null, "de", null, 0)).andReturn(request);
+ expect(treeBuilder.build(request)).andReturn(response());
+
+ replayDefault();
+ var result = mockMvc
+ .perform(get("/v1/navigation/Content").param("language", "de")
+ .accept(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Cache-Control", "private, no-store"))
+ .andExpect(content().contentType(MediaType.APPLICATION_JSON))
+ .andReturn();
+ verifyDefault();
+
+ JsonNode json = readJson(result.getResponse().getContentAsByteArray());
+ assertEquals("Content", json.get("nodeSpace").asText());
+ assertTrue(json.get("currentNode").isNull());
+ assertTrue(json.get("partName").isNull());
+ assertTrue(json.at("/segments/0/partName").isNull());
+ assertTrue(json.at("/segments/0/nodes/0/isLeaf").asBoolean());
+ assertFalse(json.at("/segments/0/nodes/0/isActive").asBoolean());
+ assertFalse(json.at("/segments/0/nodes/0/isOpen").asBoolean());
+ assertTrue(json.at("/segments/0/nodes/0/children").isEmpty());
+ }
+
+ @Test
+ public void test_getNavigation_rejectsNonnumericInactiveLevel() throws Exception {
+ replayDefault();
+ var result = mockMvc
+ .perform(get("/v1/navigation/Content").param("show_inactive_to_level", "not-a-number"))
+ .andExpect(status().isBadRequest())
+ .andExpect(header().string("Cache-Control", "private, no-store"))
+ .andReturn();
+ verifyDefault();
+
+ assertError(result.getResponse().getContentAsByteArray(), "invalid_parameter",
+ "The parameter is invalid.");
+ }
+
+ @Test
+ public void test_getNavigation_returnsStableSafeApiError() throws Exception {
+ expect(requestResolver.resolve("Content", null, null, null, 101))
+ .andThrow(new NavigationApiException(HttpStatus.BAD_REQUEST, "invalid_parameter",
+ "The parameter is invalid."));
+
+ replayDefault();
+ var result = mockMvc
+ .perform(get("/v1/navigation/Content").param("show_inactive_to_level", "101"))
+ .andExpect(status().isBadRequest())
+ .andExpect(header().string("Cache-Control", "private, no-store"))
+ .andReturn();
+ verifyDefault();
+
+ assertError(result.getResponse().getContentAsByteArray(), "invalid_parameter",
+ "The parameter is invalid.");
+ }
+
+ @Test
+ public void test_getNavigation_returnsPrivateNoStoreHeaderForNotFound() throws Exception {
+ var request = request();
+ expect(requestResolver.resolve("Content", "Content.Missing", null, null, 0)).andReturn(request);
+ expect(treeBuilder.build(request))
+ .andThrow(new NavigationApiException(HttpStatus.NOT_FOUND, "navigation_node_not_found",
+ "The navigation node was not found."));
+
+ replayDefault();
+ var result = mockMvc
+ .perform(get("/v1/navigation/Content").param("currentNode", "Content.Missing"))
+ .andExpect(status().isNotFound())
+ .andExpect(header().string("Cache-Control", "private, no-store"))
+ .andReturn();
+ verifyDefault();
+
+ assertError(result.getResponse().getContentAsByteArray(), "navigation_node_not_found",
+ "The navigation node was not found.");
+ }
+
+ @Test
+ public void test_getNavigation_mapsUnexpectedFailureWithoutLeakingDetails() throws Exception {
+ var request = request();
+ expect(requestResolver.resolve("Content", null, null, null, 0)).andReturn(request);
+ expect(treeBuilder.build(request))
+ .andThrow(new IllegalStateException("secret backend detail"));
+
+ replayDefault();
+ var result = mockMvc.perform(get("/v1/navigation/Content"))
+ .andExpect(status().isInternalServerError())
+ .andExpect(header().string("Cache-Control", "private, no-store"))
+ .andReturn();
+ verifyDefault();
+
+ assertFalse(result.getResponse().getContentAsString().contains("secret backend detail"));
+ assertError(result.getResponse().getContentAsByteArray(), "navigation_unavailable",
+ "Navigation is currently unavailable.");
+ }
+
+ private NavigationRequest request() {
+ return new NavigationRequest(new SpaceReference("Content", new WikiReference("xwiki")),
+ "Content", Optional.empty(), Optional.empty(), "de", Optional.empty(), 0);
+ }
+
+ private NavigationTreeResponse response() {
+ var node = new NavigationNodeDto("Content.Home", "/Content/Home?language=de", "Startseite",
+ true, false, false, List.of());
+ return new NavigationTreeResponse("Content", null, "de", null, 0,
+ List.of(new NavigationSegmentDto(null, List.of(node))));
+ }
+
+ private JsonNode readJson(byte[] content) throws Exception {
+ return objectMapper.readTree(content);
+ }
+
+ private void assertError(byte[] content, String code, String message) throws Exception {
+ JsonNode json = readJson(content);
+ assertEquals(code, json.get("code").asText());
+ assertEquals(message, json.get("message").asText());
+ }
+
+}
diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java
new file mode 100644
index 000000000..d12fb6928
--- /dev/null
+++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationJsonContractTest.java
@@ -0,0 +1,22 @@
+package com.celements.navigation.api;
+
+import static org.junit.Assert.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Test;
+
+public class NavigationJsonContractTest {
+
+ @Test
+ public void defensivelyCopiesAllDtoLists() {
+ var mutableChildren = new ArrayList();
+ var node = new NavigationNodeDto("Content.Home", "/Content/Home", "Home", true, false, false,
+ mutableChildren);
+ mutableChildren.add(node);
+ assertTrue(node.children().isEmpty());
+ assertThrows(UnsupportedOperationException.class, () -> node.children().add(node));
+ }
+
+}
diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java
new file mode 100644
index 000000000..9209fff7a
--- /dev/null
+++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationOpenApiContractTest.java
@@ -0,0 +1,133 @@
+package com.celements.navigation.api;
+
+import static org.easymock.EasyMock.*;
+import static org.junit.Assert.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.springdoc.core.SpringDocConfigProperties;
+import org.springdoc.core.SpringDocConfiguration;
+import org.springdoc.webmvc.core.SpringDocWebMvcConfiguration;
+import org.springframework.mock.web.MockServletContext;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
+import org.springframework.web.servlet.config.annotation.DelegatingWebMvcConfiguration;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class NavigationOpenApiContractTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private AnnotationConfigWebApplicationContext context;
+ private MockMvc mockMvc;
+
+ @Before
+ public void prepare() {
+ context = new AnnotationConfigWebApplicationContext();
+ context.setServletContext(new MockServletContext());
+ context.register(DelegatingWebMvcConfiguration.class, SpringDocConfiguration.class,
+ SpringDocWebMvcConfiguration.class);
+ context.addBeanFactoryPostProcessor(beanFactory -> {
+ beanFactory.registerSingleton(SpringDocConfigProperties.class.getName(),
+ new SpringDocConfigProperties());
+ beanFactory.registerSingleton(NavigationController.class.getName(),
+ new NavigationController(createNiceMock(NavigationRequestResolver.class),
+ createNiceMock(NavigationTreeBuilder.class)));
+ });
+ context.refresh();
+ mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
+ }
+
+ @After
+ public void cleanup() {
+ context.close();
+ }
+
+ @Test
+ public void apiDocsExposeNavigationContract() throws Exception {
+ var result = mockMvc.perform(get("/api/v3/api-docs").servletPath("/api"))
+ .andExpect(status().isOk())
+ .andReturn();
+ JsonNode json = objectMapper.readTree(result.getResponse().getContentAsByteArray());
+ JsonNode operation = json.at("/paths/~1v1~1navigation~1{nodeSpace}/get");
+ assertFalse(operation.isMissingNode());
+ assertEquals("Get the current wiki's navigation tree", operation.get("summary").asText());
+ assertEquals(
+ Set.of("nodeSpace", "currentNode", "language", "partName", "show_inactive_to_level"),
+ parameterNames(operation.get("parameters")));
+ assertTrue(findParameter(operation, "nodeSpace").get("required").asBoolean());
+ JsonNode inactiveLevelSchema = findParameter(operation, "show_inactive_to_level").get("schema");
+ assertEquals("0", inactiveLevelSchema.get("default").asText());
+ assertEquals(0, inactiveLevelSchema.get("minimum").asInt());
+ assertEquals(100, inactiveLevelSchema.get("maximum").asInt());
+ assertEquals(Set.of("200", "400", "404", "500"),
+ iterableFieldNames(operation.get("responses").fieldNames()));
+ for (String status : Set.of("200", "400", "404", "500")) {
+ JsonNode response = operation.get("responses").get(status);
+ JsonNode content = response.at("/content/application~1json");
+ assertTrue(content.get("schema").isObject());
+ }
+ JsonNode schemas = json.at("/components/schemas");
+ JsonNode treeSchema = findSchema(schemas, "NavigationTreeResponse");
+ JsonNode nodeSchema = findSchema(schemas, "NavigationNodeDto");
+ JsonNode segmentSchema = findSchema(schemas, "NavigationSegmentDto");
+ JsonNode errorSchema = findSchema(schemas, "NavigationErrorResponse");
+ assertNotNull(treeSchema);
+ assertNotNull(nodeSchema);
+ assertNotNull(segmentSchema);
+ assertNotNull(errorSchema);
+ assertTrue(treeSchema.at("/properties/segments").isObject());
+ assertTrue(treeSchema.at("/properties/currentNode/nullable").asBoolean());
+ assertTrue(treeSchema.at("/properties/partName/nullable").asBoolean());
+ assertTrue(segmentSchema.at("/properties/partName/nullable").asBoolean());
+ assertTrue(nodeSchema.at("/properties/isLeaf").isObject());
+ assertTrue(nodeSchema.at("/properties/isActive").isObject());
+ assertTrue(nodeSchema.at("/properties/isOpen").isObject());
+ assertTrue(nodeSchema.at("/properties/children").isObject());
+ assertTrue(errorSchema.at("/properties/code").isObject());
+ assertTrue(errorSchema.at("/properties/message").isObject());
+ }
+
+ private Set parameterNames(JsonNode parameters) {
+ var names = new HashSet();
+ parameters.forEach(parameter -> names.add(parameter.get("name").asText()));
+ return names;
+ }
+
+ private Set iterableFieldNames(Iterator fields) {
+ var names = new HashSet();
+ fields.forEachRemaining(names::add);
+ return names;
+ }
+
+ private JsonNode findParameter(JsonNode operation, String name) {
+ for (JsonNode parameter : operation.get("parameters")) {
+ if (name.equals(parameter.get("name").asText())) {
+ return parameter;
+ }
+ }
+ fail("Missing OpenAPI parameter " + name);
+ return null;
+ }
+
+ private JsonNode findSchema(JsonNode schemas, String simpleName) {
+ Iterator names = schemas.fieldNames();
+ while (names.hasNext()) {
+ String name = names.next();
+ if (name.endsWith(simpleName)) {
+ return schemas.get(name);
+ }
+ }
+ return null;
+ }
+
+}
diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java
new file mode 100644
index 000000000..36df4217e
--- /dev/null
+++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRequestResolverTest.java
@@ -0,0 +1,227 @@
+package com.celements.navigation.api;
+
+import static org.easymock.EasyMock.*;
+import static org.junit.Assert.*;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.http.HttpStatus;
+import org.xwiki.model.reference.DocumentReference;
+import org.xwiki.model.reference.SpaceReference;
+import org.xwiki.model.reference.WikiReference;
+
+import com.celements.common.test.AbstractComponentTest;
+import com.celements.model.context.ModelContext;
+import com.celements.model.util.ModelUtils;
+import com.celements.web.service.IWebUtilsService;
+
+public class NavigationRequestResolverTest extends AbstractComponentTest {
+
+ private final WikiReference wikiRef = new WikiReference("xwiki");
+ private final SpaceReference spaceRef = new SpaceReference("Content", wikiRef);
+ private ModelUtils modelUtils;
+ private ModelContext modelContext;
+ private IWebUtilsService webUtilsService;
+ private NavigationRequestResolver resolver;
+
+ @Before
+ public void prepareTest() throws Exception {
+ registerComponentMocks(ModelUtils.class, ModelContext.class, IWebUtilsService.class);
+ modelUtils = getMock(ModelUtils.class);
+ modelContext = getMock(ModelContext.class);
+ webUtilsService = getMock(IWebUtilsService.class);
+ resolver = getBeanFactory().getBean(NavigationRequestResolver.class);
+ }
+
+ @Test
+ public void test_resolve_acceptsCanonicalLocalReferencesAndRequestedLanguage() {
+ var currentRef = new DocumentReference("MyPage", spaceRef);
+ expectCanonicalSpace("Content");
+ expectCanonicalDocument("Content.MyPage", currentRef);
+ expect(modelUtils.normalizeLang("DE")).andReturn("de");
+ expect(webUtilsService.getAllowedLanguages(spaceRef)).andReturn(List.of("en", "de"));
+ replayDefault();
+ var request = resolver.resolve("Content", "Content.MyPage", "DE", "main", 2);
+ verifyDefault();
+ assertEquals(spaceRef, request.nodeSpace());
+ assertEquals(Optional.of(currentRef), request.currentNode());
+ assertEquals("de", request.language());
+ assertEquals(Optional.of("main"), request.partName());
+ assertEquals(2, request.showInactiveToLevel());
+ }
+
+ @Test
+ public void test_resolve_defaultsLanguageFromCurrentRequestAndNormalizesBlankPart() {
+ expectCanonicalSpace("Content");
+ expect(modelContext.getLanguage()).andReturn(Optional.of("fr"));
+ replayDefault();
+ var request = resolver.resolve("Content", null, null, " ", 0);
+ verifyDefault();
+ assertEquals("fr", request.language());
+ assertEquals(Optional.empty(), request.partName());
+ assertEquals(Optional.empty(), request.serializedCurrentNode());
+ }
+
+ @Test
+ public void test_resolve_defaultsLanguageFromWikiWhenRequestLanguageIsAbsent() {
+ expectCanonicalSpace("Content");
+ expect(modelContext.getLanguage()).andReturn(Optional.empty());
+ expect(modelContext.getDefaultLanguage()).andReturn("en");
+ replayDefault();
+ assertEquals("en", resolver.resolve("Content", null, null, null, 0).language());
+ verifyDefault();
+ }
+
+ @Test
+ public void test_resolve_rejectsCurrentWikiQualifiedSpace() {
+ expect(modelContext.getWikiRef()).andReturn(wikiRef);
+ expect(modelUtils.resolveRef("xwiki:Content", SpaceReference.class, wikiRef))
+ .andReturn(spaceRef);
+ expect(modelUtils.serializeRefLocal(spaceRef)).andReturn("Content");
+ replayDefault();
+ assertApiError("invalid_reference",
+ () -> resolver.resolve("xwiki:Content", null, null, null, 0));
+ verifyDefault();
+ }
+
+ @Test
+ public void test_resolve_rejectsOtherWikiQualifiedCurrentNode() {
+ var otherRef = new DocumentReference("MyPage",
+ new SpaceReference("Content", new WikiReference("other")));
+ expectCanonicalSpace("Content");
+ expect(modelContext.getWikiRef()).andReturn(wikiRef);
+ expect(modelUtils.resolveRef("other:Content.MyPage", DocumentReference.class, wikiRef))
+ .andReturn(otherRef);
+ expect(modelUtils.serializeRefLocal(otherRef)).andReturn("Content.MyPage");
+ replayDefault();
+ assertApiError("invalid_reference",
+ () -> resolver.resolve("Content", "other:Content.MyPage", null, null, 0));
+ verifyDefault();
+ }
+
+ @Test
+ public void test_resolve_rejectsCurrentWikiQualifiedCurrentNode() {
+ var currentRef = new DocumentReference("MyPage", spaceRef);
+ expectCanonicalSpace("Content");
+ expect(modelContext.getWikiRef()).andReturn(wikiRef);
+ expect(modelUtils.resolveRef("xwiki:Content.MyPage", DocumentReference.class, wikiRef))
+ .andReturn(currentRef);
+ expect(modelUtils.serializeRefLocal(currentRef)).andReturn("Content.MyPage");
+ replayDefault();
+ assertApiError("invalid_reference",
+ () -> resolver.resolve("Content", "xwiki:Content.MyPage", null, null, 0));
+ verifyDefault();
+ }
+
+ @Test
+ public void test_resolve_rejectsBlankAndNoncanonicalCurrentNode() {
+ expectCanonicalSpace("Content");
+ replayDefault();
+ assertApiError("invalid_reference", () -> resolver.resolve("Content", " ", null, null, 0));
+ verifyDefault();
+ resetDefault();
+ expectCanonicalSpace("Content");
+ replayDefault();
+ assertApiError("invalid_reference",
+ () -> resolver.resolve("Content", " Content.MyPage ", null, null, 0));
+ verifyDefault();
+ }
+
+ @Test
+ public void test_resolve_rejectsNoncanonicalNodeSpace() {
+ replayDefault();
+ assertApiError("invalid_reference", () -> resolver.resolve(" Content ", null, null, null, 0));
+ verifyDefault();
+ }
+
+ @Test
+ public void test_resolve_rejectsMalformedReference() {
+ expect(modelContext.getWikiRef()).andReturn(wikiRef);
+ expect(modelUtils.resolveRef("[", SpaceReference.class, wikiRef))
+ .andThrow(new IllegalArgumentException("malformed"));
+ replayDefault();
+ assertApiError("invalid_reference", () -> resolver.resolve("[", null, null, null, 0));
+ verifyDefault();
+ }
+
+ @Test
+ public void test_resolve_doesNotMisclassifyUnexpectedNullPointerException() {
+ expect(modelContext.getWikiRef()).andThrow(new NullPointerException("backend failure"));
+ replayDefault();
+ var exception = assertThrows(NullPointerException.class,
+ () -> resolver.resolve("Content", null, null, null, 0));
+ verifyDefault();
+ assertEquals("backend failure", exception.getMessage());
+ }
+
+ @Test
+ public void test_resolve_doesNotMisclassifyModelUtilsNullPointerException() {
+ expect(modelContext.getWikiRef()).andReturn(wikiRef);
+ expect(modelUtils.resolveRef("Content", SpaceReference.class, wikiRef)).andReturn(spaceRef);
+ expect(modelUtils.serializeRefLocal(spaceRef))
+ .andThrow(new NullPointerException("serialization failure"));
+ replayDefault();
+ var exception = assertThrows(NullPointerException.class,
+ () -> resolver.resolve("Content", null, null, null, 0));
+ verifyDefault();
+ assertEquals("serialization failure", exception.getMessage());
+ }
+
+ @Test
+ public void test_resolve_rejectsNullParseResult() {
+ expect(modelContext.getWikiRef()).andReturn(wikiRef);
+ expect(modelUtils.resolveRef("Content", SpaceReference.class, wikiRef)).andReturn(null);
+ replayDefault();
+ assertApiError("invalid_reference", () -> resolver.resolve("Content", null, null, null, 0));
+ verifyDefault();
+ }
+
+ @Test
+ public void test_resolve_rejectsUnsupportedAndInvalidLanguage() {
+ expectCanonicalSpace("Content");
+ expect(modelUtils.normalizeLang("it")).andReturn("it");
+ expect(webUtilsService.getAllowedLanguages(spaceRef)).andReturn(List.of("en", "de"));
+ replayDefault();
+ assertApiError("unsupported_language", () -> resolver.resolve("Content", null, "it", null, 0));
+ verifyDefault();
+ resetDefault();
+ expectCanonicalSpace("Content");
+ expect(modelUtils.normalizeLang("invalid"))
+ .andThrow(new IllegalArgumentException("invalid language"));
+ replayDefault();
+ assertApiError("unsupported_language",
+ () -> resolver.resolve("Content", null, "invalid", null, 0));
+ verifyDefault();
+ }
+
+ @Test
+ public void test_resolve_rejectsInactiveLevelsOutsideRange() {
+ replayDefault();
+ assertApiError("invalid_parameter", () -> resolver.resolve("Content", null, null, null, -1));
+ assertApiError("invalid_parameter", () -> resolver.resolve("Content", null, null, null, 101));
+ verifyDefault();
+ }
+
+ private void expectCanonicalSpace(String serialized) {
+ expect(modelContext.getWikiRef()).andReturn(wikiRef);
+ expect(modelUtils.resolveRef(serialized, SpaceReference.class, wikiRef)).andReturn(spaceRef);
+ expect(modelUtils.serializeRefLocal(spaceRef)).andReturn(serialized);
+ }
+
+ private void expectCanonicalDocument(String serialized, DocumentReference documentRef) {
+ expect(modelContext.getWikiRef()).andReturn(wikiRef);
+ expect(modelUtils.resolveRef(serialized, DocumentReference.class, wikiRef))
+ .andReturn(documentRef);
+ expect(modelUtils.serializeRefLocal(documentRef)).andReturn(serialized);
+ }
+
+ private void assertApiError(String code, Runnable invocation) {
+ var exception = assertThrows(NavigationApiException.class, invocation::run);
+ assertEquals(HttpStatus.BAD_REQUEST, exception.status());
+ assertEquals(code, exception.code());
+ }
+
+}
diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java
new file mode 100644
index 000000000..05353c872
--- /dev/null
+++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationRightsFilteringTest.java
@@ -0,0 +1,128 @@
+package com.celements.navigation.api;
+
+import static com.celements.rights.access.EAccessLevel.*;
+import static org.easymock.EasyMock.*;
+import static org.junit.Assert.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import org.easymock.IAnswer;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.xwiki.model.reference.DocumentReference;
+import org.xwiki.model.reference.SpaceReference;
+import org.xwiki.model.reference.WikiReference;
+
+import com.celements.common.test.AbstractComponentTest;
+import com.celements.navigation.TreeNode;
+import com.celements.navigation.filter.InternalRightsFilter;
+import com.celements.navigation.service.ITreeNodeService;
+import com.celements.parents.IDocumentParentsListerRole;
+import com.celements.rights.access.IRightsAccessFacadeRole;
+import com.xpn.xwiki.user.api.XWikiUser;
+
+public class NavigationRightsFilteringTest extends AbstractComponentTest {
+
+ private final WikiReference wikiRef = new WikiReference("xwiki");
+ private final SpaceReference spaceRef = new SpaceReference("Content", wikiRef);
+ private final List observedFilters = new ArrayList<>();
+ private ITreeNodeService treeService;
+ private IDocumentParentsListerRole parentsLister;
+ private DefaultNavigationNodeValueResolver values;
+ private IRightsAccessFacadeRole rightsAccess;
+ private NavigationTreeBuilder builder;
+
+ @Before
+ public void prepareTest() throws Exception {
+ registerComponentMocks(ITreeNodeService.class, IDocumentParentsListerRole.class,
+ DefaultNavigationNodeValueResolver.class, IRightsAccessFacadeRole.class);
+ treeService = getMock(ITreeNodeService.class);
+ parentsLister = getMock(IDocumentParentsListerRole.class);
+ values = getMock(DefaultNavigationNodeValueResolver.class);
+ rightsAccess = getMock(IRightsAccessFacadeRole.class);
+ var beanFactory = (DefaultListableBeanFactory) getBeanFactory();
+ beanFactory.destroySingleton(NavigationTreeBuilder.class.getName());
+ beanFactory.registerResolvableDependency(ITreeNodeService.class, treeService);
+ beanFactory.registerResolvableDependency(IDocumentParentsListerRole.class, parentsLister);
+ beanFactory.registerResolvableDependency(DefaultNavigationNodeValueResolver.class, values);
+ builder = getBeanFactory().getBean(NavigationTreeBuilder.class.getName(),
+ NavigationTreeBuilder.class);
+ }
+
+ @Test
+ public void test_guestRightsAreAppliedAtRootAndNestedLevelsWithoutLeafDisclosure()
+ throws Exception {
+ var response = buildForCaller("XWiki.XWikiGuest", false);
+ var root = response.segments().get(0).nodes().get(0);
+ assertTrue(root.isLeaf());
+ assertTrue(root.children().isEmpty());
+ assertEquals(2, observedFilters.size());
+ assertEquals("main", observedFilters.get(0).getMenuPart());
+ assertTrue(observedFilters.get(1).getMenuPart().isEmpty());
+ }
+
+ @Test
+ public void test_authenticatedRightsAreAppliedAtRootAndEveryReturnedNestedLevel()
+ throws Exception {
+ var response = buildForCaller("XWiki.Authenticated", true);
+ var root = response.segments().get(0).nodes().get(0);
+ assertFalse(root.isLeaf());
+ assertEquals(1, root.children().size());
+ assertTrue(root.children().get(0).isLeaf());
+ assertEquals(3, observedFilters.size());
+ assertSame(observedFilters.get(1), observedFilters.get(2));
+ assertEquals("main", observedFilters.get(0).getMenuPart());
+ assertTrue(observedFilters.get(1).getMenuPart().isEmpty());
+ }
+
+ private NavigationTreeResponse buildForCaller(String userName, boolean childVisible)
+ throws Exception {
+ var rootNode = new TreeNode(new DocumentReference("Root", spaceRef), null, 1, "main");
+ var childNode = new TreeNode(new DocumentReference("Restricted", spaceRef),
+ rootNode.getDocumentReference(), 1, "main");
+ getXContext().setUser(userName);
+ expect(
+ rightsAccess.hasAccessLevel(eq(rootNode.getDocumentReference()), eq(VIEW),
+ isA(XWikiUser.class)))
+ .andReturn(true);
+ expect(rightsAccess.hasAccessLevel(eq(childNode.getDocumentReference()), eq(VIEW),
+ isA(XWikiUser.class))).andReturn(childVisible);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andAnswer(rightsFiltered(List.of(rootNode)));
+ expectValues(rootNode);
+ expect(treeService.getSubNodesForParent(eq(rootNode.getDocumentReference()),
+ isA(InternalRightsFilter.class)))
+ .andAnswer(rightsFiltered(List.of(childNode)));
+ if (childVisible) {
+ expectValues(childNode);
+ expect(treeService.getSubNodesForParent(eq(childNode.getDocumentReference()),
+ isA(InternalRightsFilter.class))).andAnswer(rightsFiltered(List.of()));
+ }
+ replayDefault();
+ var response = builder.build(new NavigationRequest(spaceRef, "Content", Optional.empty(),
+ Optional.empty(), "de", Optional.of("main"), 100));
+ verifyDefault();
+ return response;
+ }
+
+ private IAnswer> rightsFiltered(List nodes) {
+ return () -> {
+ var filter = (InternalRightsFilter) getCurrentArguments()[1];
+ observedFilters.add(filter);
+ return nodes.stream().filter(node -> filter.includeTreeNode(node, getXContext())).toList();
+ };
+ }
+
+ private void expectValues(TreeNode node) {
+ String localRef = "Content." + node.getDocumentReference().getName();
+ expect(values.serialize(node.getDocumentReference())).andReturn(localRef);
+ expect(values.resolveTitle(node.getDocumentReference(), "de"))
+ .andReturn(node.getDocumentReference().getName());
+ expect(values.resolveUrl(node.getDocumentReference(), "de"))
+ .andReturn("/Content/" + node.getDocumentReference().getName() + "?language=de");
+ }
+
+}
diff --git a/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java
new file mode 100644
index 000000000..575df3b98
--- /dev/null
+++ b/celements-navigation-rest/src/test/java/com/celements/navigation/api/NavigationTreeBuilderTest.java
@@ -0,0 +1,401 @@
+package com.celements.navigation.api;
+
+import static org.easymock.EasyMock.*;
+import static org.junit.Assert.*;
+
+import java.util.List;
+import java.util.Optional;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.http.HttpStatus;
+import org.xwiki.model.reference.DocumentReference;
+import org.xwiki.model.reference.SpaceReference;
+import org.xwiki.model.reference.WikiReference;
+
+import com.celements.common.test.AbstractComponentTest;
+import com.celements.navigation.TreeNode;
+import com.celements.navigation.filter.InternalRightsFilter;
+import com.celements.navigation.service.ITreeNodeService;
+import com.celements.parents.IDocumentParentsListerRole;
+
+public class NavigationTreeBuilderTest extends AbstractComponentTest {
+
+ private final WikiReference wikiRef = new WikiReference("xwiki");
+ private final SpaceReference spaceRef = new SpaceReference("Content", wikiRef);
+ private ITreeNodeService treeService;
+ private IDocumentParentsListerRole parentsLister;
+ private DefaultNavigationNodeValueResolver values;
+ private NavigationTreeBuilder builder;
+
+ @Before
+ public void prepareTest() throws Exception {
+ registerComponentMocks(ITreeNodeService.class, IDocumentParentsListerRole.class,
+ DefaultNavigationNodeValueResolver.class);
+ treeService = getMock(ITreeNodeService.class);
+ parentsLister = getMock(IDocumentParentsListerRole.class);
+ values = getMock(DefaultNavigationNodeValueResolver.class);
+ var beanFactory = (DefaultListableBeanFactory) getBeanFactory();
+ beanFactory.destroySingleton(NavigationTreeBuilder.class.getName());
+ beanFactory.registerResolvableDependency(ITreeNodeService.class, treeService);
+ beanFactory.registerResolvableDependency(IDocumentParentsListerRole.class, parentsLister);
+ beanFactory.registerResolvableDependency(DefaultNavigationNodeValueResolver.class, values);
+ builder = getBeanFactory().getBean(NavigationTreeBuilder.class.getName(),
+ NavigationTreeBuilder.class);
+ }
+
+ @Test
+ public void test_build_groupsAndOrdersSegmentsWithoutReorderingNodes() {
+ var unnamed = node("Unnamed", null, 1);
+ var mainSecond = node("MainSecond", "main", 2);
+ var upperCase = node("Upper", "A", 1);
+ var lowerCase = node("Lower", "a", 1);
+ var mainFirst = node("MainFirst", "main", 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(unnamed, mainSecond, upperCase, lowerCase, mainFirst));
+ for (var node : List.of(unnamed, mainSecond, upperCase, lowerCase, mainFirst)) {
+ expectNode(node, List.of());
+ }
+ replayDefault();
+ var response = builder.build(request(Optional.empty(), Optional.empty(), 0));
+ verifyDefault();
+ assertEquals(4, response.segments().size());
+ assertNull(response.segments().get(0).partName());
+ assertEquals("A", response.segments().get(1).partName());
+ assertEquals("a", response.segments().get(2).partName());
+ assertEquals("main", response.segments().get(3).partName());
+ assertEquals(List.of("Content.MainSecond", "Content.MainFirst"),
+ response.segments().get(3).nodes().stream().map(NavigationNodeDto::docRef).toList());
+ assertTrue(response.segments().get(0).nodes().get(0).isLeaf());
+ assertFalse(response.segments().get(0).nodes().get(0).isOpen());
+ }
+
+ @Test
+ public void test_build_expandsActiveAncestorsNodeAndDirectChildren() {
+ var root = node("Root", "main", 1);
+ var active = childNode("Active", root, 1);
+ var child = childNode("Child", active, 1);
+ var grandchild = childNode("Grandchild", child, 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(root));
+ expectNode(root, List.of(active));
+ expectNode(active, List.of(child));
+ expectNode(child, List.of(grandchild));
+ expectNode(grandchild, List.of());
+ expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true))
+ .andReturn(List.of(active.getDocumentReference(), root.getDocumentReference()));
+ replayDefault();
+ var response = builder.build(
+ request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0));
+ verifyDefault();
+ var rootDto = response.segments().get(0).nodes().get(0);
+ var activeDto = rootDto.children().get(0);
+ var childDto = activeDto.children().get(0);
+ assertTrue(rootDto.isOpen());
+ assertFalse(rootDto.isActive());
+ assertTrue(activeDto.isOpen());
+ assertTrue(activeDto.isActive());
+ assertEquals(1, activeDto.children().size());
+ assertFalse(childDto.isOpen());
+ assertTrue(childDto.children().isEmpty());
+ assertFalse(childDto.isLeaf());
+ }
+
+ @Test
+ public void test_build_combinesActivePathAndInactiveThresholdExpansion() {
+ var activeRoot = node("ActiveRoot", "main", 1);
+ var active = childNode("Active", activeRoot, 1);
+ var activeChild = childNode("ActiveChild", active, 1);
+ var activeGrandchild = childNode("ActiveGrandchild", activeChild, 1);
+ var inactiveRoot = node("InactiveRoot", "main", 2);
+ var inactiveChild = childNode("InactiveChild", inactiveRoot, 1);
+ var inactiveGrandchild = childNode("InactiveGrandchild", inactiveChild, 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(activeRoot, inactiveRoot));
+ expectNode(activeRoot, List.of(active));
+ expectNode(active, List.of(activeChild));
+ expectNode(activeChild, List.of(activeGrandchild));
+ expectNode(activeGrandchild, List.of());
+ expectNode(inactiveRoot, List.of(inactiveChild));
+ expectNode(inactiveChild, List.of(inactiveGrandchild));
+ expectNode(inactiveGrandchild, List.of());
+ expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true))
+ .andReturn(List.of(active.getDocumentReference(), activeRoot.getDocumentReference()));
+ replayDefault();
+ var response = builder.build(
+ request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 2));
+ verifyDefault();
+ var activeRootDto = response.segments().get(0).nodes().get(0);
+ var activeDto = activeRootDto.children().get(0);
+ var activeChildDto = activeDto.children().get(0);
+ var inactiveRootDto = response.segments().get(0).nodes().get(1);
+ var inactiveChildDto = inactiveRootDto.children().get(0);
+ assertTrue(activeRootDto.isOpen());
+ assertTrue(activeDto.isOpen());
+ assertTrue(activeDto.isActive());
+ assertFalse(activeChildDto.isOpen());
+ assertTrue(activeChildDto.children().isEmpty());
+ assertTrue(inactiveRootDto.isOpen());
+ assertFalse(inactiveChildDto.isOpen());
+ assertTrue(inactiveChildDto.children().isEmpty());
+ }
+
+ @Test
+ public void test_build_appliesInactiveExpansionThresholds() {
+ assertEquals(1, expandedDepth(0));
+ assertEquals(1, expandedDepth(1));
+ assertEquals(2, expandedDepth(2));
+ assertEquals(3, expandedDepth(3));
+ assertEquals(4, expandedDepth(100));
+ }
+
+ @Test
+ public void test_build_unfilteredEmptyNodeSpaceReturnsEmptySegments() {
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of());
+ replayDefault();
+ var response = builder.build(request(Optional.empty(), Optional.empty(), 0));
+ verifyDefault();
+ assertTrue(response.segments().isEmpty());
+ }
+
+ @Test
+ public void test_build_omittedInvalidChildDoesNotDiscloseNonLeafState() {
+ var root = node("Root", "main", 1);
+ var invalid = childNode("HiddenByInvalidTitle", root, 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(root));
+ expectValues(root);
+ expect(treeService.getSubNodesForParent(eq(root.getDocumentReference()),
+ isA(InternalRightsFilter.class))).andReturn(List.of(invalid));
+ expect(values.serialize(invalid.getDocumentReference())).andReturn(local(invalid));
+ expect(values.resolveTitle(invalid.getDocumentReference(), "de")).andReturn(" ");
+ replayDefault();
+ var response = builder.build(request(Optional.empty(), Optional.empty(), 100));
+ verifyDefault();
+ var rootDto = response.segments().get(0).nodes().get(0);
+ assertTrue(rootDto.isLeaf());
+ assertFalse(rootDto.isOpen());
+ assertTrue(rootDto.children().isEmpty());
+ }
+
+ @Test
+ public void test_build_omitsNullAndBlankMandatoryNodeValues() {
+ for (String field : List.of("docRef", "title", "url")) {
+ assertInvalidNodeOmitted(field, null);
+ assertInvalidNodeOmitted(field, " ");
+ }
+ }
+
+ @Test
+ public void test_build_omittedActiveNodeReturnsNotFound() {
+ var active = node("InvalidActive", "main", 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(active));
+ expect(values.serialize(active.getDocumentReference())).andReturn(local(active));
+ expect(values.resolveTitle(active.getDocumentReference(), "de")).andReturn(null);
+ expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true))
+ .andReturn(List.of(active.getDocumentReference()));
+ replayDefault();
+ var exception = assertThrows(NavigationApiException.class,
+ () -> builder.build(
+ request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0)));
+ verifyDefault();
+ assertEquals("navigation_node_not_found", exception.code());
+ }
+
+ @Test
+ public void test_build_omitsInactiveNodeWithNullChildren() {
+ var root = node("InvalidRoot", "main", 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(root));
+ expectValues(root);
+ expect(treeService.getSubNodesForParent(eq(root.getDocumentReference()),
+ isA(InternalRightsFilter.class))).andReturn(null);
+ replayDefault();
+ var response = builder.build(request(Optional.empty(), Optional.empty(), 100));
+ verifyDefault();
+ assertTrue(response.segments().isEmpty());
+ }
+
+ @Test
+ public void test_build_activeNodeWithNullChildrenReturnsNotFound() {
+ var active = node("InvalidActive", "main", 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(active));
+ expectValues(active);
+ expect(treeService.getSubNodesForParent(eq(active.getDocumentReference()),
+ isA(InternalRightsFilter.class))).andReturn(null);
+ expect(parentsLister.getDocumentParentsList(active.getDocumentReference(), true))
+ .andReturn(List.of(active.getDocumentReference()));
+ replayDefault();
+ var exception = assertThrows(NavigationApiException.class,
+ () -> builder.build(
+ request(Optional.of(active.getDocumentReference()), Optional.of(local(active)), 0)));
+ verifyDefault();
+ assertEquals(HttpStatus.NOT_FOUND, exception.status());
+ assertEquals("navigation_node_not_found", exception.code());
+ }
+
+ @Test
+ public void test_build_inaccessibleCurrentNodeReturnsSafeNotFound() {
+ var current = node("Restricted", "main", 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of());
+ expect(parentsLister.getDocumentParentsList(current.getDocumentReference(), true))
+ .andReturn(List.of(current.getDocumentReference()));
+ replayDefault();
+ assertNodeNotFound(request(Optional.of(current.getDocumentReference()),
+ Optional.of(local(current)), 0));
+ }
+
+ @Test
+ public void test_build_outOfRootCurrentNodeReturnsSafeNotFound() {
+ var root = node("Root", "main", 1);
+ var otherSpace = new SpaceReference("Other", wikiRef);
+ var current = new DocumentReference("Current", otherSpace);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(root));
+ expectNode(root, List.of());
+ expect(parentsLister.getDocumentParentsList(current, true)).andReturn(List.of(current));
+ replayDefault();
+ assertNodeNotFound(request(Optional.of(current), Optional.of("Other.Current"), 0));
+ }
+
+ @Test
+ public void test_build_partExcludedCurrentNodeReturnsSafeNotFound() {
+ var current = node("OtherPart", "other", 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andAnswer(() -> {
+ var filter = (InternalRightsFilter) getCurrentArguments()[1];
+ return "main".equals(filter.getMenuPart()) ? List.of() : List.of(current);
+ });
+ expect(parentsLister.getDocumentParentsList(current.getDocumentReference(), true))
+ .andReturn(List.of(current.getDocumentReference()));
+ replayDefault();
+ var request = new NavigationRequest(spaceRef, "Content",
+ Optional.of(current.getDocumentReference()), Optional.of(local(current)), "de",
+ Optional.of("main"), 0);
+ assertNodeNotFound(request);
+ }
+
+ @Test
+ public void test_build_requestedPartAlwaysReturnsExactlyOneSegment() {
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of());
+ replayDefault();
+ var response = builder.build(new NavigationRequest(spaceRef, "Content", Optional.empty(),
+ Optional.empty(), "de", Optional.of("Main"), 0));
+ verifyDefault();
+ assertEquals(1, response.segments().size());
+ assertEquals("Main", response.segments().get(0).partName());
+ assertTrue(response.segments().get(0).nodes().isEmpty());
+ }
+
+ @Test
+ public void test_build_partFilterIsCaseSensitive() {
+ var lowerCasePart = node("Root", "main", 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(lowerCasePart));
+ expectNode(lowerCasePart, List.of());
+ replayDefault();
+ var response = builder.build(new NavigationRequest(spaceRef, "Content", Optional.empty(),
+ Optional.empty(), "de", Optional.of("Main"), 0));
+ verifyDefault();
+ assertEquals("Main", response.segments().get(0).partName());
+ assertTrue(response.segments().get(0).nodes().isEmpty());
+ }
+
+ private void assertInvalidNodeOmitted(String field, String invalidValue) {
+ var root = node("Root", "main", 1);
+ var invalid = childNode("Invalid", root, 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(root));
+ expectValues(root);
+ expect(treeService.getSubNodesForParent(eq(root.getDocumentReference()),
+ isA(InternalRightsFilter.class))).andReturn(List.of(invalid));
+ expect(values.serialize(invalid.getDocumentReference()))
+ .andReturn("docRef".equals(field) ? invalidValue : local(invalid));
+ if (!"docRef".equals(field)) {
+ expect(values.resolveTitle(invalid.getDocumentReference(), "de"))
+ .andReturn("title".equals(field) ? invalidValue : "Invalid");
+ }
+ if ("url".equals(field)) {
+ expect(values.resolveUrl(invalid.getDocumentReference(), "de")).andReturn(invalidValue);
+ }
+ replayDefault();
+ var response = builder.build(request(Optional.empty(), Optional.empty(), 100));
+ verifyDefault();
+ var rootDto = response.segments().get(0).nodes().get(0);
+ assertTrue(rootDto.isLeaf());
+ assertTrue(rootDto.children().isEmpty());
+ resetDefault();
+ }
+
+ private void assertNodeNotFound(NavigationRequest request) {
+ var exception = assertThrows(NavigationApiException.class,
+ () -> builder.build(request));
+ verifyDefault();
+ assertEquals(HttpStatus.NOT_FOUND, exception.status());
+ assertEquals("navigation_node_not_found", exception.code());
+ }
+
+ private int expandedDepth(int threshold) {
+ var level1 = node("Level1", "main", 1);
+ var level2 = childNode("Level2", level1, 1);
+ var level3 = childNode("Level3", level2, 1);
+ var level4 = childNode("Level4", level3, 1);
+ expect(treeService.getSubNodesForParent(eq(spaceRef), isA(InternalRightsFilter.class)))
+ .andReturn(List.of(level1));
+ expectNode(level1, List.of(level2));
+ expectNode(level2, List.of(level3));
+ expectNode(level3, List.of(level4));
+ expectNode(level4, List.of());
+ replayDefault();
+ var response = builder.build(request(Optional.empty(), Optional.empty(), threshold));
+ verifyDefault();
+ int depth = 1;
+ var current = response.segments().get(0).nodes().get(0);
+ while (!current.children().isEmpty()) {
+ current = current.children().get(0);
+ depth++;
+ }
+ resetDefault();
+ return depth;
+ }
+
+ private void expectNode(TreeNode node, List children) {
+ expectValues(node);
+ expect(treeService.getSubNodesForParent(eq(node.getDocumentReference()),
+ isA(InternalRightsFilter.class))).andReturn(children);
+ }
+
+ private void expectValues(TreeNode node) {
+ expect(values.serialize(node.getDocumentReference())).andReturn(local(node));
+ expect(values.resolveTitle(node.getDocumentReference(), "de"))
+ .andReturn(node.getDocumentReference().getName());
+ expect(values.resolveUrl(node.getDocumentReference(), "de"))
+ .andReturn("/Content/" + node.getDocumentReference().getName() + "?language=de");
+ }
+
+ private NavigationRequest request(Optional currentNode,
+ Optional serializedCurrentNode, int threshold) {
+ return new NavigationRequest(spaceRef, "Content", currentNode, serializedCurrentNode, "de",
+ Optional.empty(), threshold);
+ }
+
+ private TreeNode node(String name, String partName, int position) {
+ return new TreeNode(new DocumentReference(name, spaceRef), null, position, partName);
+ }
+
+ private TreeNode childNode(String name, TreeNode parent, int position) {
+ return new TreeNode(new DocumentReference(name, spaceRef), parent.getDocumentReference(),
+ position, parent.getPartName());
+ }
+
+ private String local(TreeNode node) {
+ return "Content." + node.getDocumentReference().getName();
+ }
+
+}