diff --git a/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/model/RestCategoryLinkBodyModel.java b/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/model/RestCategoryLinkBodyModel.java index 03d22185ab..2b5f6fe245 100644 --- a/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/model/RestCategoryLinkBodyModel.java +++ b/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/model/RestCategoryLinkBodyModel.java @@ -25,16 +25,16 @@ public class RestCategoryLinkBodyModel extends TestModel implements IRestModel RestRequest request = RestRequest.requestWithBody(HttpMethod.POST, body.toJson(), "nodes/{nodeId}/rule-executions", repoModel.getNodeRef()); return restWrapper.processModel(RestRuleExecutionModel.class, request); } + + /** + * Link content to category performing POST call on "/nodes/{nodeId}/category-links" + * + * @param categoryLink - contains category ID + * @return linked to category + */ + public RestCategoryModel linkToCategory(RestCategoryLinkBodyModel categoryLink) + { + RestRequest request = RestRequest.requestWithBody(HttpMethod.POST, categoryLink.toJson(), "nodes/{nodeId}/category-links", repoModel.getNodeRef()); + return restWrapper.processModel(RestCategoryModel.class, request); + } + + /** + * Link content to many categories performing POST call on "/nodes/{nodeId}/category-links" + * + * @param categoryLinks - contains categories IDs + * @return linked to categories + */ + public RestCategoryModelsCollection linkToCategories(List categoryLinks) + { + RestRequest request = RestRequest.requestWithBody(HttpMethod.POST, arrayToJson(categoryLinks), "nodes/{nodeId}/category-links", repoModel.getNodeRef()); + return restWrapper.processModels(RestCategoryModelsCollection.class, request); + } } diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/categories/LinkToCategoriesTests.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/categories/LinkToCategoriesTests.java new file mode 100644 index 0000000000..c26fc31415 --- /dev/null +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/categories/LinkToCategoriesTests.java @@ -0,0 +1,350 @@ +/* + * #%L + * Alfresco Remote API + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ + +package org.alfresco.rest.categories; + +import static org.alfresco.utility.constants.UserRole.SiteManager; +import static org.alfresco.utility.report.log.Step.STEP; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.METHOD_NOT_ALLOWED; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import javax.json.Json; +import java.util.Collections; +import java.util.List; + +import org.alfresco.dataprep.CMISUtil; +import org.alfresco.rest.model.RestCategoryLinkBodyModel; +import org.alfresco.rest.model.RestCategoryModel; +import org.alfresco.rest.model.RestCategoryModelsCollection; +import org.alfresco.rest.model.RestNodeModel; +import org.alfresco.rest.model.RestTagModel; +import org.alfresco.utility.model.FileModel; +import org.alfresco.utility.model.FolderModel; +import org.alfresco.utility.model.RepoTestModel; +import org.alfresco.utility.model.SiteModel; +import org.alfresco.utility.model.TestGroup; +import org.alfresco.utility.model.UserModel; +import org.apache.commons.lang3.StringUtils; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +public class LinkToCategoriesTests extends CategoriesRestTest +{ + private static final String ASPECTS_FIELD = "aspectNames"; + private static final String PROPERTIES_FIELD = "properties"; + + private UserModel user; + private SiteModel site; + private FolderModel folder; + private FileModel file; + private RestCategoryModel category; + + @BeforeClass(alwaysRun = true) + public void dataPreparation() + { + STEP("Create user and a site"); + user = dataUser.createRandomTestUser(); + site = dataSite.usingUser(user).createPublicRandomSite(); + } + + @BeforeMethod(alwaysRun = true) + public void setUp() + { + STEP("Create a folder, file in it and a category under root"); + folder = dataContent.usingUser(user).usingSite(site).createFolder(); + file = dataContent.usingUser(user).usingResource(folder).createContent(CMISUtil.DocumentType.TEXT_PLAIN); + category = prepareCategoryUnderRoot(); + } + + /** + * Link content to category and verify if this category is present in node's properties + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory() + { + STEP("Check if file is not linked to any category"); + RestNodeModel fileNode = restClient.authenticateUser(user).withCoreAPI().usingNode(file).getNode(); + + fileNode.assertThat().field(ASPECTS_FIELD).notContains("cm:generalclassifiable"); + fileNode.assertThat().field(PROPERTIES_FIELD).notContains("cm:categories"); + + STEP("Link content to created category and expect 201"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(category.getId()); + final RestCategoryModel linkedCategory = restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(CREATED); + linkedCategory.assertThat().isEqualTo(category); + + STEP("Verify if category is present in file metadata"); + fileNode = restClient.authenticateUser(user).withCoreAPI().usingNode(file).getNode(); + + fileNode.assertThat().field(ASPECTS_FIELD).contains("cm:generalclassifiable"); + fileNode.assertThat().field(PROPERTIES_FIELD).contains("cm:categories"); + fileNode.assertThat().field(PROPERTIES_FIELD).contains(category.getId()); + } + + /** + * Link content to two categories and verify if both are present in node's properties + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToMultipleCategories() + { + STEP("Check if file is not linked to any category"); + RestNodeModel fileNode = restClient.authenticateUser(user).withCoreAPI().usingNode(file).getNode(); + + fileNode.assertThat().field(ASPECTS_FIELD).notContains("cm:generalclassifiable"); + fileNode.assertThat().field(PROPERTIES_FIELD).notContains("cm:categories"); + + STEP("Create second category under root"); + final RestCategoryModel secondCategory = prepareCategoryUnderRoot(); + + STEP("Link content to created categories and expect 201"); + final List categoryLinks = List.of( + createCategoryLinkWithId(category.getId()), + createCategoryLinkWithId(secondCategory.getId()) + ); + final RestCategoryModelsCollection linkedCategories = restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategories(categoryLinks); + + restClient.assertStatusCodeIs(CREATED); + linkedCategories.getEntries().get(0).onModel().assertThat().isEqualTo(category); + linkedCategories.getEntries().get(1).onModel().assertThat().isEqualTo(secondCategory); + + STEP("Verify if both categories are present in file metadata"); + fileNode = restClient.authenticateUser(user).withCoreAPI().usingNode(file).getNode(); + + fileNode.assertThat().field(ASPECTS_FIELD).contains("cm:generalclassifiable"); + fileNode.assertThat().field(PROPERTIES_FIELD).contains("cm:categories"); + fileNode.assertThat().field(PROPERTIES_FIELD).contains(category.getId()); + fileNode.assertThat().field(PROPERTIES_FIELD).contains(secondCategory.getId()); + } + + /** + * Link content, which already has some linked category to new ones and verify if all categories are present in node's properties + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory_usingContentWithAlreadyLinkedCategories() + { + STEP("Link content to created category"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(category.getId()); + restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategory(categoryLink); + restClient.assertStatusCodeIs(CREATED); + + STEP("Create second and third category under root, link content to them and expect 201"); + final RestCategoryModel secondCategory = prepareCategoryUnderRoot(); + final RestCategoryModel thirdCategory = prepareCategoryUnderRoot(); + final List categoryLinks = List.of( + createCategoryLinkWithId(secondCategory.getId()), + createCategoryLinkWithId(thirdCategory.getId()) + ); + final RestCategoryModelsCollection linkedCategories = restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategories(categoryLinks); + + restClient.assertStatusCodeIs(CREATED); + linkedCategories.assertThat().entriesListCountIs(2); + linkedCategories.getEntries().get(0).onModel().assertThat().isEqualTo(secondCategory); + linkedCategories.getEntries().get(1).onModel().assertThat().isEqualTo(thirdCategory); + + STEP("Verify if all three categories are present in file metadata"); + final RestNodeModel fileNode = restClient.authenticateUser(user).withCoreAPI().usingNode(file).getNode(); + + fileNode.assertThat().field(PROPERTIES_FIELD).contains("cm:categories"); + fileNode.assertThat().field(PROPERTIES_FIELD).contains(category.getId()); + fileNode.assertThat().field(PROPERTIES_FIELD).contains(secondCategory.getId()); + fileNode.assertThat().field(PROPERTIES_FIELD).contains(thirdCategory.getId()); + } + + /** + * Try to link content to category as a user without read permission and expect 403 (Forbidden) + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory_asUserWithoutReadPermissionAndExpect403() + { + STEP("Try to link content to a category using user without read permission and expect 403"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(category.getId()); + final UserModel userWithoutRights = dataUser.createRandomTestUser(); + restClient.authenticateUser(userWithoutRights).withCoreAPI().usingNode(file).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(FORBIDDEN); + } + + /** + * Try to link content to category as a user without change and expect 403 (Forbidden) + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory_asUserWithoutChangePermissionAndExpect403() + { + STEP("Create another user as a consumer for file"); + final UserModel consumer = dataUser.createRandomTestUser(); + addPermissionsForUser(consumer.getUsername(), "Consumer", file); + + STEP("Try to link content to a category using user without change permission and expect 403"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(category.getId()); + restClient.authenticateUser(consumer).withCoreAPI().usingNode(file).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(FORBIDDEN); + } + + /** + * Try to link content to category as owner and expect 201 + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory_asOwner() + { + STEP("Use admin to create a private site"); + final SiteModel privateSite = dataSite.usingAdmin().createPrivateRandomSite(); + + STEP("Add the user to the site, let him create a folder and then evict him from the site again"); + dataUser.addUserToSite(user, privateSite, SiteManager); + final FolderModel privateFolder = dataContent.usingUser(user).usingSite(privateSite).createFolder(); + final FileModel privateFile = dataContent.usingUser(user).usingResource(privateFolder).createContent(CMISUtil.DocumentType.TEXT_PLAIN); + dataUser.removeUserFromSite(user, privateSite); + + STEP("Try to link content to a category as owner and expect 201"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(category.getId()); + restClient.authenticateUser(user).withCoreAPI().usingNode(privateFile).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(CREATED); + } + + /** + * Try to link content to category using non-existing category and expect 404 (Not Found) + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory_usingNonExistingCategoryAndExpect404() + { + STEP("Try to link content to non-existing category and expect 404"); + final String nonExistingCategoryId = "non-existing-dummy-id"; + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(nonExistingCategoryId); + restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(NOT_FOUND); + } + + /** + * Try to link content to category passing empty list as input and expect 400 (Bad Request) + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory_passingEmptyListAndExpect400() + { + STEP("Try to call link content API with empty list and expect 400"); + restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategories(Collections.emptyList()); + + restClient.assertStatusCodeIs(BAD_REQUEST); + } + + /** + * Try to link content to category passing invalid ID in input list and expect 400 (Bad Request) + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory_passingEmptyIdAndExpect400() + { + STEP("Try to call link content API with empty category ID and expect 400"); + final String nonExistingCategoryId = StringUtils.EMPTY; + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(nonExistingCategoryId); + restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(BAD_REQUEST); + } + + /** + * Link folder node to category and expect 201 (Created) + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkFolderToCategory() + { + STEP("Link folder node to category"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(category.getId()); + restClient.authenticateUser(user).withCoreAPI().usingNode(folder).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(CREATED); + } + + /** + * Try to link non-content node to category and expect 405 (Method Not Allowed) + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory_usingTagInsteadOfContentAndExpect405() + { + STEP("Try to link a tag to category and expect 405"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(category.getId()); + final RestTagModel tag = restClient.authenticateUser(user).withCoreAPI().usingNode(file).addTag("someTag"); + final RepoTestModel tagNode = new RepoTestModel() {}; + tagNode.setNodeRef(tag.getId()); + restClient.authenticateUser(dataUser.getAdminUser()).withCoreAPI().usingNode(tagNode).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(METHOD_NOT_ALLOWED); + } + + /** + * Try to link content to non-category node and expect 400 (Bad Request) + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory_usingFolderInsteadOfCategoryAndExpect400() + { + STEP("Try to link content to non-category and expect 400"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(folder.getNodeRef()); + restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(BAD_REQUEST); + } + + /** + * Try to link content to root category and expect 400 (Bad Request) + */ + @Test(groups = { TestGroup.REST_API}) + public void testLinkContentToCategory_usingRootCategoryAndExpect400() + { + STEP("Try to link content to root category and expect 400"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkWithId(ROOT_CATEGORY_ID); + restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(BAD_REQUEST); + } + + private RestCategoryLinkBodyModel createCategoryLinkWithId(final String id) + { + final RestCategoryLinkBodyModel categoryLink = new RestCategoryLinkBodyModel(); + categoryLink.setCategoryId(id); + return categoryLink; + } + + private void addPermissionsForUser(final String username, final String role, final FileModel file) + { + final String putPermissionsBody = Json.createObjectBuilder().add("permissions", + Json.createObjectBuilder() + .add("isInheritanceEnabled", true) + .add("locallySet", Json.createObjectBuilder() + .add("authorityId", username) + .add("name", role).add("accessStatus", "ALLOWED"))) + .build() + .toString(); + + restClient.authenticateUser(user).withCoreAPI().usingNode(file).updateNode(putPermissionsBody); + } +} diff --git a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/categories/UpdateCategoriesTests.java b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/categories/UpdateCategoriesTests.java index f439c07b6a..969990c7de 100644 --- a/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/categories/UpdateCategoriesTests.java +++ b/packaging/tests/tas-restapi/src/test/java/org/alfresco/rest/categories/UpdateCategoriesTests.java @@ -2,7 +2,7 @@ * #%L * Alfresco Remote API * %% - * Copyright (C) 2005 - 2022 Alfresco Software Limited + * Copyright (C) 2005 - 2023 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -29,6 +29,7 @@ package org.alfresco.rest.categories; import static org.alfresco.utility.data.RandomData.getRandomName; import static org.alfresco.utility.report.log.Step.STEP; import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.OK; @@ -93,6 +94,27 @@ public class UpdateCategoriesTests extends CategoriesRestTest updatedCategory.assertThat().field(FIELD_NAME).is(categoryNewName); } + /** + * Try to update a category with a name, which is already present within the parent category + */ + @Test(groups = { TestGroup.REST_API}) + public void testUpdateCategory_usingRecurringName() + { + STEP("Prepare as admin two categories under root category"); + final RestCategoryModel createdCategory = prepareCategoryUnderRoot(); + final RestCategoryModel secondCreatedCategory = prepareCategoryUnderRoot(); + + STEP("Try to update as admin newly created category using name of already present, different category"); + final String categoryNewName = secondCreatedCategory.getName(); + final RestCategoryModel fixedCategoryModel = createCategoryModelWithName(categoryNewName); + restClient.authenticateUser(dataUser.getAdminUser()) + .withCoreAPI() + .usingCategory(createdCategory) + .updateCategory(fixedCategoryModel); + + restClient.assertStatusCodeIs(CONFLICT); + } + /** * Try to update a category as a user and expect 403 (Forbidden) in response */ diff --git a/remote-api/src/main/java/org/alfresco/rest/api/Categories.java b/remote-api/src/main/java/org/alfresco/rest/api/Categories.java index c5714f7acb..695d3f64f1 100644 --- a/remote-api/src/main/java/org/alfresco/rest/api/Categories.java +++ b/remote-api/src/main/java/org/alfresco/rest/api/Categories.java @@ -2,7 +2,7 @@ * #%L * Alfresco Remote API * %% - * Copyright (C) 2005 - 2022 Alfresco Software Limited + * Copyright (C) 2005 - 2023 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -53,4 +53,12 @@ public interface Categories void deleteCategoryById(String id, Parameters parameters); + /** + * Link node to categories. Node types allowed for categorization are specified within {@link org.alfresco.util.TypeConstraint}. + * + * @param nodeId Node ID. + * @param categoryLinks Category IDs to which content should be linked to. + * @return Linked to categories. + */ + List linkNodeToCategories(String nodeId, List categoryLinks); } diff --git a/remote-api/src/main/java/org/alfresco/rest/api/categories/NodesCategoryLinksRelation.java b/remote-api/src/main/java/org/alfresco/rest/api/categories/NodesCategoryLinksRelation.java new file mode 100644 index 0000000000..0eb7287e34 --- /dev/null +++ b/remote-api/src/main/java/org/alfresco/rest/api/categories/NodesCategoryLinksRelation.java @@ -0,0 +1,64 @@ +/* + * #%L + * Alfresco Remote API + * %% + * Copyright (C) 2005 - 2023 Alfresco Software Limited + * %% + * This file is part of the Alfresco software. + * If the software was purchased under a paid Alfresco license, the terms of + * the paid license agreement will prevail. Otherwise, the software is + * provided under the following open source license terms: + * + * Alfresco is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Alfresco is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Alfresco. If not, see . + * #L% + */ + +package org.alfresco.rest.api.categories; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +import org.alfresco.rest.api.Categories; +import org.alfresco.rest.api.model.Category; +import org.alfresco.rest.api.nodes.NodesEntityResource; +import org.alfresco.rest.framework.WebApiDescription; +import org.alfresco.rest.framework.resource.RelationshipResource; +import org.alfresco.rest.framework.resource.actions.interfaces.RelationshipResourceAction; +import org.alfresco.rest.framework.resource.parameters.Parameters; + +@RelationshipResource(name = "category-links", entityResource = NodesEntityResource.class, title = "Category links") +public class NodesCategoryLinksRelation implements RelationshipResourceAction.Create +{ + + private final Categories categories; + + public NodesCategoryLinksRelation(Categories categories) + { + this.categories = categories; + } + + /** + * POST /nodes/{nodeId}/category-links + */ + @WebApiDescription( + title = "Link content node to categories", + description = "Creates a link between a content node and categories", + successStatus = HttpServletResponse.SC_CREATED + ) + @Override + public List create(String nodeId, List categoryLinks, Parameters parameters) + { + return categories.linkNodeToCategories(nodeId, categoryLinks); + } +} diff --git a/remote-api/src/main/java/org/alfresco/rest/api/impl/CategoriesImpl.java b/remote-api/src/main/java/org/alfresco/rest/api/impl/CategoriesImpl.java index 515e2263e4..d8c6364862 100644 --- a/remote-api/src/main/java/org/alfresco/rest/api/impl/CategoriesImpl.java +++ b/remote-api/src/main/java/org/alfresco/rest/api/impl/CategoriesImpl.java @@ -2,7 +2,7 @@ * #%L * Alfresco Remote API * %% - * Copyright (C) 2005 - 2022 Alfresco Software Limited + * Copyright (C) 2005 - 2023 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -27,8 +27,15 @@ package org.alfresco.rest.api.impl; import static org.alfresco.rest.api.Nodes.PATH_ROOT; +import static org.alfresco.service.cmr.security.AccessStatus.ALLOWED; +import static org.alfresco.service.cmr.security.PermissionService.CHANGE_PERMISSIONS; +import java.io.Serializable; +import java.util.Collection; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import org.alfresco.model.ContentModel; @@ -39,6 +46,7 @@ import org.alfresco.rest.api.model.Node; import org.alfresco.rest.framework.core.exceptions.EntityNotFoundException; import org.alfresco.rest.framework.core.exceptions.InvalidArgumentException; import org.alfresco.rest.framework.core.exceptions.PermissionDeniedException; +import org.alfresco.rest.framework.core.exceptions.UnsupportedResourceOperationException; import org.alfresco.rest.framework.resource.parameters.CollectionWithPagingInfo; import org.alfresco.rest.framework.resource.parameters.ListPage; import org.alfresco.rest.framework.resource.parameters.Parameters; @@ -47,10 +55,13 @@ import org.alfresco.service.cmr.repository.ChildAssociationRef; import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.NodeService; import org.alfresco.service.cmr.repository.StoreRef; +import org.alfresco.service.cmr.repository.datatype.DefaultTypeConverter; import org.alfresco.service.cmr.search.CategoryService; import org.alfresco.service.cmr.security.AuthorityService; +import org.alfresco.service.cmr.security.PermissionService; import org.alfresco.service.namespace.QName; import org.alfresco.service.namespace.RegexQNamePattern; +import org.alfresco.util.TypeConstraint; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; @@ -59,20 +70,26 @@ public class CategoriesImpl implements Categories { static final String NOT_A_VALID_CATEGORY = "Node id does not refer to a valid category"; static final String NO_PERMISSION_TO_MANAGE_A_CATEGORY = "Current user does not have permission to manage a category"; + static final String NO_PERMISSION_TO_READ_CONTENT = "Current user does not have read permission to content"; static final String NOT_NULL_OR_EMPTY = "Category name must not be null or empty"; - static final String FIELD_NOT_MATCH = "Category field: %s does not match the original one"; + static final String INVALID_NODE_TYPE = "Cannot categorize this node type"; private final AuthorityService authorityService; private final CategoryService categoryService; private final Nodes nodes; private final NodeService nodeService; + private final PermissionService permissionService; + private final TypeConstraint typeConstraint; - public CategoriesImpl(AuthorityService authorityService, CategoryService categoryService, Nodes nodes, NodeService nodeService) + public CategoriesImpl(AuthorityService authorityService, CategoryService categoryService, Nodes nodes, NodeService nodeService, PermissionService permissionService, + TypeConstraint typeConstraint) { this.authorityService = authorityService; this.categoryService = categoryService; this.nodes = nodes; this.nodeService = nodeService; + this.permissionService = permissionService; + this.typeConstraint = typeConstraint; } @Override @@ -123,7 +140,7 @@ public class CategoriesImpl implements Categories throw new InvalidArgumentException(NOT_A_VALID_CATEGORY, new String[]{id}); } - verifyCategoryFields(fixedCategoryModel); + validateCategoryFields(fixedCategoryModel); return mapToCategory(changeCategoryName(categoryNodeRef, fixedCategoryModel.getName())); } @@ -141,6 +158,35 @@ public class CategoriesImpl implements Categories nodeService.deleteNode(nodeRef); } + @Override + public List linkNodeToCategories(final String nodeId, final List categoryLinks) + { + if (CollectionUtils.isEmpty(categoryLinks)) + { + throw new InvalidArgumentException(NOT_A_VALID_CATEGORY); + } + + final NodeRef contentNodeRef = nodes.validateNode(nodeId); + verifyChangePermission(contentNodeRef); + verifyNodeType(contentNodeRef); + + final Collection categoryNodeRefs = categoryLinks.stream() + .filter(Objects::nonNull) + .map(Category::getId) + .filter(StringUtils::isNotEmpty) + .map(this::getCategoryNodeRef) + .collect(Collectors.toList()); + + if (CollectionUtils.isEmpty(categoryNodeRefs) || isRootCategoryPresent(categoryNodeRefs)) + { + throw new InvalidArgumentException(NOT_A_VALID_CATEGORY); + } + + linkNodeToCategories(contentNodeRef, categoryNodeRefs); + + return categoryNodeRefs.stream().map(this::mapToCategory).collect(Collectors.toList()); + } + private void verifyAdminAuthority() { if (!authorityService.hasAdminAuthority()) @@ -149,6 +195,22 @@ public class CategoriesImpl implements Categories } } + private void verifyChangePermission(final NodeRef nodeRef) + { + if (permissionService.hasPermission(nodeRef, CHANGE_PERMISSIONS) != ALLOWED) + { + throw new PermissionDeniedException(NO_PERMISSION_TO_READ_CONTENT); + } + } + + private void verifyNodeType(final NodeRef nodeRef) + { + if (!typeConstraint.matches(nodeRef)) + { + throw new UnsupportedResourceOperationException(INVALID_NODE_TYPE); + } + } + /** * This method gets category NodeRef for a given category id. * If '-root-' is passed as category id, then it's retrieved as a call to {@link org.alfresco.service.cmr.search.CategoryService#getRootCategoryNodeRef} @@ -181,7 +243,7 @@ public class CategoriesImpl implements Categories private NodeRef createCategoryNodeRef(NodeRef parentNodeRef, Category c) { - verifyCategoryFields(c); + validateCategoryFields(c); return categoryService.createCategory(parentNodeRef, c.getName()); } @@ -236,15 +298,67 @@ public class CategoriesImpl implements Categories } /** - * Verify if fixed category name is not empty. + * Validate if fixed category name is not empty. * * @param fixedCategoryModel Fixed category model. */ - private void verifyCategoryFields(final Category fixedCategoryModel) + private void validateCategoryFields(final Category fixedCategoryModel) { if (StringUtils.isEmpty(fixedCategoryModel.getName())) { throw new InvalidArgumentException(NOT_NULL_OR_EMPTY); } } + + private boolean isRootCategoryPresent(final Collection categoryNodeRefs) + { + return categoryNodeRefs.stream().anyMatch(this::isRootCategory); + } + + private boolean isCategoryAspectMissing(final NodeRef nodeRef) + { + return !nodeService.hasAspect(nodeRef, ContentModel.ASPECT_GEN_CLASSIFIABLE); + } + + /** + * Merge already present and new categories ignoring repeating ones. + * + * @param currentCategories Already present categories. + * @param newCategories Categories which should be added. + * @return Merged categories. + */ + private Collection mergeCategories(final Serializable currentCategories, final Collection newCategories) + { + if (currentCategories == null) + { + return newCategories; + } + + final Collection actualCategories = DefaultTypeConverter.INSTANCE.getCollection(NodeRef.class, currentCategories); + final Collection allCategories = new HashSet<>(actualCategories); + allCategories.addAll(newCategories); + + return allCategories; + } + + /** + * Add to or update node's property cm:categories containing linked category references. + * + * @param nodeRef Node reference. + * @param categoryNodeRefs Category node references. + */ + private void linkNodeToCategories(final NodeRef nodeRef, final Collection categoryNodeRefs) + { + if (isCategoryAspectMissing(nodeRef)) + { + final Map properties = Map.of(ContentModel.PROP_CATEGORIES, (Serializable) categoryNodeRefs); + nodeService.addAspect(nodeRef, ContentModel.ASPECT_GEN_CLASSIFIABLE, properties); + } + else + { + final Serializable currentCategories = nodeService.getProperty(nodeRef, ContentModel.PROP_CATEGORIES); + final Collection allCategories = mergeCategories(currentCategories, categoryNodeRefs); + nodeService.setProperty(nodeRef, ContentModel.PROP_CATEGORIES, (Serializable) allCategories); + } + } } diff --git a/remote-api/src/main/java/org/alfresco/rest/api/model/Category.java b/remote-api/src/main/java/org/alfresco/rest/api/model/Category.java index b2f6eeb851..1d80e34410 100644 --- a/remote-api/src/main/java/org/alfresco/rest/api/model/Category.java +++ b/remote-api/src/main/java/org/alfresco/rest/api/model/Category.java @@ -45,6 +45,11 @@ public class Category this.id = id; } + public void setCategoryId(String categoryId) + { + this.id = categoryId; + } + public String getName() { return name; diff --git a/remote-api/src/main/resources/alfresco/public-rest-context.xml b/remote-api/src/main/resources/alfresco/public-rest-context.xml index 1899a35444..f7149c921e 100644 --- a/remote-api/src/main/resources/alfresco/public-rest-context.xml +++ b/remote-api/src/main/resources/alfresco/public-rest-context.xml @@ -834,6 +834,8 @@ + + @@ -1105,6 +1107,10 @@ + + + + diff --git a/remote-api/src/test/java/org/alfresco/rest/api/impl/CategoriesImplTest.java b/remote-api/src/test/java/org/alfresco/rest/api/impl/CategoriesImplTest.java index ccd35b5b17..f16a6b09d4 100644 --- a/remote-api/src/test/java/org/alfresco/rest/api/impl/CategoriesImplTest.java +++ b/remote-api/src/test/java/org/alfresco/rest/api/impl/CategoriesImplTest.java @@ -2,7 +2,7 @@ * #%L * Alfresco Remote API * %% - * Copyright (C) 2005 - 2022 Alfresco Software Limited + * Copyright (C) 2005 - 2023 Alfresco Software Limited * %% * This file is part of the Alfresco software. * If the software was purchased under a paid Alfresco license, the terms of @@ -27,24 +27,31 @@ package org.alfresco.rest.api.impl; import static org.alfresco.rest.api.Nodes.PATH_ROOT; +import static org.alfresco.rest.api.impl.CategoriesImpl.INVALID_NODE_TYPE; import static org.alfresco.rest.api.impl.CategoriesImpl.NOT_A_VALID_CATEGORY; import static org.alfresco.rest.api.impl.CategoriesImpl.NOT_NULL_OR_EMPTY; +import static org.alfresco.rest.api.impl.CategoriesImpl.NO_PERMISSION_TO_READ_CONTENT; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.catchThrowable; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; +import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -55,6 +62,7 @@ import org.alfresco.rest.api.model.Node; import org.alfresco.rest.framework.core.exceptions.EntityNotFoundException; import org.alfresco.rest.framework.core.exceptions.InvalidArgumentException; import org.alfresco.rest.framework.core.exceptions.PermissionDeniedException; +import org.alfresco.rest.framework.core.exceptions.UnsupportedResourceOperationException; import org.alfresco.rest.framework.resource.parameters.CollectionWithPagingInfo; import org.alfresco.rest.framework.resource.parameters.Parameters; import org.alfresco.service.cmr.repository.ChildAssociationRef; @@ -62,9 +70,14 @@ import org.alfresco.service.cmr.repository.NodeRef; import org.alfresco.service.cmr.repository.NodeService; import org.alfresco.service.cmr.repository.StoreRef; import org.alfresco.service.cmr.search.CategoryService; +import org.alfresco.service.cmr.security.AccessStatus; import org.alfresco.service.cmr.security.AuthorityService; +import org.alfresco.service.cmr.security.PermissionService; +import org.alfresco.service.namespace.NamespaceService; import org.alfresco.service.namespace.QName; import org.alfresco.service.namespace.RegexQNamePattern; +import org.alfresco.util.TypeConstraint; +import org.apache.commons.lang3.StringUtils; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -81,6 +94,8 @@ public class CategoriesImplTest private static final String CAT_ROOT_NODE_ID = "cat-root-node-id"; private static final NodeRef CATEGORY_NODE_REF = createNodeRefWithId(CATEGORY_ID); private static final Category CATEGORY = createDefaultCategoryWithName(CATEGORY_NAME); + private static final String CONTENT_NODE_ID = "content-node-id"; + private static final NodeRef CONTENT_NODE_REF = createNodeRefWithId(CONTENT_NODE_ID); @Mock private Nodes nodesMock; @@ -96,6 +111,10 @@ public class CategoriesImplTest private ChildAssociationRef dummyChildAssociationRefMock; @Mock private ChildAssociationRef categoryChildAssociationRefMock; + @Mock + private PermissionService permissionServiceMock; + @Mock + private TypeConstraint typeConstraint; @InjectMocks private CategoriesImpl objectUnderTest; @@ -104,8 +123,11 @@ public class CategoriesImplTest public void setUp() throws Exception { given(authorityServiceMock.hasAdminAuthority()).willReturn(true); - given(nodesMock.validateNode(eq(CATEGORY_ID))).willReturn(CATEGORY_NODE_REF); + given(nodesMock.validateNode(CATEGORY_ID)).willReturn(CATEGORY_NODE_REF); + given(nodesMock.validateNode(CONTENT_NODE_ID)).willReturn(CONTENT_NODE_REF); given(nodesMock.isSubClass(any(), any(), anyBoolean())).willReturn(true); + given(typeConstraint.matches(any())).willReturn(true); + given(permissionServiceMock.hasPermission(any(), any())).willReturn(AccessStatus.ALLOWED); } @Test @@ -774,6 +796,208 @@ public class CategoriesImplTest .isEqualTo(expectedCategory); } + @Test + public void testLinkNodeToCategories_withoutCategoryAspect() + { + final List categoryLinks = List.of(CATEGORY); + final NodeRef categoryParentNodeRef = createNodeRefWithId(PARENT_ID); + final ChildAssociationRef parentAssociation = createAssociationOf(categoryParentNodeRef, CATEGORY_NODE_REF); + given(nodesMock.getNode(any())).willReturn(prepareCategoryNode()); + given(nodeServiceMock.getPrimaryParent(any())).willReturn(parentAssociation); + + // when + final List actualLinkedCategories = objectUnderTest.linkNodeToCategories(CONTENT_NODE_ID, categoryLinks); + + then(nodesMock).should().validateNode(CONTENT_NODE_ID); + then(permissionServiceMock).should().hasPermission(CONTENT_NODE_REF, PermissionService.CHANGE_PERMISSIONS); + then(permissionServiceMock).shouldHaveNoMoreInteractions(); + then(typeConstraint).should().matches(CONTENT_NODE_REF); + then(typeConstraint).shouldHaveNoMoreInteractions(); + then(nodesMock).should().validateNode(CATEGORY_ID); + then(nodesMock).should().getNode(CATEGORY_ID); + then(nodesMock).should().isSubClass(CATEGORY_NODE_REF, ContentModel.TYPE_CATEGORY, false); + then(nodesMock).shouldHaveNoMoreInteractions(); + then(nodeServiceMock).should().getChildAssocs(CATEGORY_NODE_REF, RegexQNamePattern.MATCH_ALL, RegexQNamePattern.MATCH_ALL, false); + then(nodeServiceMock).should().getPrimaryParent(CATEGORY_NODE_REF); + then(nodeServiceMock).should().getParentAssocs(CATEGORY_NODE_REF); + then(nodeServiceMock).should().hasAspect(CONTENT_NODE_REF, ContentModel.ASPECT_GEN_CLASSIFIABLE); + final Map expectedProperties = Map.of(ContentModel.PROP_CATEGORIES, (Serializable) List.of(CATEGORY_NODE_REF)); + then(nodeServiceMock).should().addAspect(CONTENT_NODE_REF, ContentModel.ASPECT_GEN_CLASSIFIABLE, expectedProperties); + then(nodeServiceMock).should().getParentAssocs(categoryParentNodeRef); + then(nodeServiceMock).shouldHaveNoMoreInteractions(); + final List expectedLinkedCategories = List.of(CATEGORY); + assertThat(actualLinkedCategories) + .isNotNull().usingRecursiveComparison() + .isEqualTo(expectedLinkedCategories); + } + + @Test + public void testLinkNodeToCategories_withPresentCategoryAspect() + { + final NodeRef categoryParentNodeRef = createNodeRefWithId(PARENT_ID); + final ChildAssociationRef parentAssociation = createAssociationOf(categoryParentNodeRef, CATEGORY_NODE_REF); + given(nodesMock.getNode(any())).willReturn(prepareCategoryNode()); + given(nodeServiceMock.getPrimaryParent(any())).willReturn(parentAssociation); + given(nodeServiceMock.hasAspect(any(), any())).willReturn(true); + + // when + final List actualLinkedCategories = objectUnderTest.linkNodeToCategories(CONTENT_NODE_ID, List.of(CATEGORY)); + + then(nodesMock).should().getNode(CATEGORY_ID); + then(nodeServiceMock).should().getChildAssocs(CATEGORY_NODE_REF, RegexQNamePattern.MATCH_ALL, RegexQNamePattern.MATCH_ALL, false); + then(nodeServiceMock).should().getPrimaryParent(CATEGORY_NODE_REF); + then(nodeServiceMock).should().getParentAssocs(CATEGORY_NODE_REF); + then(nodeServiceMock).should().hasAspect(CONTENT_NODE_REF, ContentModel.ASPECT_GEN_CLASSIFIABLE); + then(nodeServiceMock).should().getProperty(CONTENT_NODE_REF, ContentModel.PROP_CATEGORIES); + final Serializable expectedCategories = (Serializable) List.of(CATEGORY_NODE_REF); + then(nodeServiceMock).should().setProperty(CONTENT_NODE_REF, ContentModel.PROP_CATEGORIES, expectedCategories); + then(nodeServiceMock).should().getParentAssocs(categoryParentNodeRef); + then(nodeServiceMock).shouldHaveNoMoreInteractions(); + final List expectedLinkedCategories = List.of(CATEGORY); + assertThat(actualLinkedCategories) + .isNotNull().usingRecursiveComparison() + .isEqualTo(expectedLinkedCategories); + } + + @Test + public void testLinkNodeToCategories_withMultipleCategoryIds() + { + final String secondCategoryId = "second-category-id"; + final String secondCategoryName = "secondCategoryName"; + final NodeRef secondCategoryNodeRef = createNodeRefWithId(secondCategoryId); + final Category secondCategoryLink = Category.builder().id(secondCategoryId).create(); + final List categoryLinks = List.of(CATEGORY, secondCategoryLink); + final NodeRef categoryParentNodeRef = createNodeRefWithId(PARENT_ID); + final ChildAssociationRef categoryParentAssociation = createAssociationOf(categoryParentNodeRef, CATEGORY_NODE_REF); + final ChildAssociationRef secondCategoryParentAssociation = createAssociationOf(categoryParentNodeRef, secondCategoryNodeRef); + given(nodesMock.validateNode(secondCategoryId)).willReturn(secondCategoryNodeRef); + given(nodesMock.getNode(any())).willReturn(prepareCategoryNode(), prepareCategoryNode(secondCategoryName)); + given(nodeServiceMock.getPrimaryParent(any())).willReturn(categoryParentAssociation, secondCategoryParentAssociation); + + // when + final List actualLinkedCategories = objectUnderTest.linkNodeToCategories(CONTENT_NODE_ID, categoryLinks); + + then(nodesMock).should().validateNode(CATEGORY_ID); + then(nodesMock).should().validateNode(secondCategoryId); + then(nodesMock).should().isSubClass(CATEGORY_NODE_REF, ContentModel.TYPE_CATEGORY, false); + then(nodesMock).should().isSubClass(secondCategoryNodeRef, ContentModel.TYPE_CATEGORY, false); + final Map expectedProperties = Map.of(ContentModel.PROP_CATEGORIES, (Serializable) List.of(CATEGORY_NODE_REF, secondCategoryNodeRef)); + then(nodeServiceMock).should().addAspect(CONTENT_NODE_REF, ContentModel.ASPECT_GEN_CLASSIFIABLE, expectedProperties); + final List expectedLinkedCategories = List.of( + CATEGORY, + Category.builder().id(secondCategoryId).name(secondCategoryName).parentId(PARENT_ID).create() + ); + assertThat(actualLinkedCategories) + .isNotNull().usingRecursiveComparison() + .isEqualTo(expectedLinkedCategories); + } + + @Test + public void testLinkNodeToCategories_withPreviouslyLinkedCategories() + { + final String otherCategoryId = "other-category-id"; + final NodeRef otherCategoryNodeRef = createNodeRefWithId(otherCategoryId); + final Serializable previousCategories = (Serializable) List.of(otherCategoryNodeRef); + final NodeRef categoryParentNodeRef = createNodeRefWithId(PARENT_ID); + final ChildAssociationRef parentAssociation = createAssociationOf(categoryParentNodeRef, CATEGORY_NODE_REF); + given(nodesMock.getNode(any())).willReturn(prepareCategoryNode()); + given(nodeServiceMock.getPrimaryParent(any())).willReturn(parentAssociation); + given(nodeServiceMock.hasAspect(any(), any())).willReturn(true); + given(nodeServiceMock.getProperty(any(), eq(ContentModel.PROP_CATEGORIES))).willReturn(previousCategories); + + // when + final List actualLinkedCategories = objectUnderTest.linkNodeToCategories(CONTENT_NODE_ID, List.of(CATEGORY)); + + final Serializable expectedCategories = (Serializable) Set.of(otherCategoryNodeRef, CATEGORY_NODE_REF); + then(nodeServiceMock).should().setProperty(CONTENT_NODE_REF, ContentModel.PROP_CATEGORIES, expectedCategories); + final List expectedLinkedCategories = List.of(CATEGORY); + assertThat(actualLinkedCategories) + .isNotNull().usingRecursiveComparison() + .isEqualTo(expectedLinkedCategories); + } + + @Test + public void testLinkNodeToCategories_withInvalidNodeId() + { + given(nodesMock.validateNode(CONTENT_NODE_ID)).willThrow(EntityNotFoundException.class); + + // when + final Throwable actualException = catchThrowable(() -> objectUnderTest.linkNodeToCategories(CONTENT_NODE_ID, List.of(CATEGORY))); + + then(nodesMock).should().validateNode(CONTENT_NODE_ID); + then(permissionServiceMock).shouldHaveNoInteractions(); + then(nodeServiceMock).shouldHaveNoInteractions(); + assertThat(actualException) + .isInstanceOf(EntityNotFoundException.class); + } + + @Test + public void testLinkNodeToCategories_withoutPermission() + { + given(permissionServiceMock.hasPermission(any(), any())).willReturn(AccessStatus.DENIED); + + // when + final Throwable actualException = catchThrowable(() -> objectUnderTest.linkNodeToCategories(CONTENT_NODE_ID, List.of(CATEGORY))); + + then(nodesMock).should().validateNode(CONTENT_NODE_ID); + then(permissionServiceMock).should().hasPermission(CONTENT_NODE_REF, PermissionService.CHANGE_PERMISSIONS); + then(nodeServiceMock).shouldHaveNoInteractions(); + assertThat(actualException) + .isInstanceOf(PermissionDeniedException.class) + .hasMessageContaining(NO_PERMISSION_TO_READ_CONTENT); + } + + @Test + public void testLinkContentNodeToCategories_withInvalidNodeType() + { + given(typeConstraint.matches(any())).willReturn(false); + + + // when + final Throwable actualException = catchThrowable(() -> objectUnderTest.linkNodeToCategories(CONTENT_NODE_ID, List.of(CATEGORY))); + + then(typeConstraint).should().matches(CONTENT_NODE_REF); + then(nodeServiceMock).shouldHaveNoInteractions(); + assertThat(actualException) + .isInstanceOf(UnsupportedResourceOperationException.class) + .hasMessageContaining(INVALID_NODE_TYPE); + } + + @Test + public void testLinkNodeToCategories_withEmptyLinks() + { + final List categoryLinks = Collections.emptyList(); + + // when + final Throwable actualException = catchThrowable(() -> objectUnderTest.linkNodeToCategories(CONTENT_NODE_ID, categoryLinks)); + + then(nodesMock).shouldHaveNoInteractions(); + then(permissionServiceMock).shouldHaveNoInteractions(); + then(nodeServiceMock).shouldHaveNoInteractions(); + assertThat(actualException) + .isInstanceOf(InvalidArgumentException.class) + .hasMessageContaining(NOT_A_VALID_CATEGORY); + } + + @Test + public void testLinkNodeToCategories_withInvalidCategoryIds() + { + final Category categoryLinkWithNullId = Category.builder().id(null).create(); + final Category categoryLinkWithEmptyId = Category.builder().id(StringUtils.EMPTY).create(); + final List categoryLinks = new ArrayList<>(); + categoryLinks.add(categoryLinkWithNullId); + categoryLinks.add(null); + categoryLinks.add(categoryLinkWithEmptyId); + + // when + final Throwable actualException = catchThrowable(() -> objectUnderTest.linkNodeToCategories(CONTENT_NODE_ID, categoryLinks)); + + then(nodeServiceMock).shouldHaveNoInteractions(); + assertThat(actualException) + .isInstanceOf(InvalidArgumentException.class) + .hasMessageContaining(NOT_A_VALID_CATEGORY); + } + private Node prepareCategoryNode(final String name, final String id, final NodeRef parentNodeRef) { final Node categoryNode = new Node(); @@ -785,8 +1009,7 @@ public class CategoriesImplTest private Node prepareCategoryNode(final String name) { - final NodeRef parentNodeRef = new NodeRef(StoreRef.STORE_REF_WORKSPACE_SPACESSTORE, PARENT_ID); - return prepareCategoryNode(name, CATEGORY_ID, parentNodeRef); + return prepareCategoryNode(name, CATEGORY_ID, createNodeRefWithId(PARENT_ID)); } private Node prepareCategoryNode() @@ -858,7 +1081,12 @@ public class CategoriesImplTest private static QName createCmQNameOf(final String name) { - return QName.createQName(ContentModel.TYPE_CATEGORY.getNamespaceURI(), QName.createValidLocalName(name)); + return QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, QName.createValidLocalName(name)); + } + + private static ChildAssociationRef createAssociationOf(final NodeRef parentNode, final NodeRef childNode) + { + return createAssociationOf(parentNode, childNode, null); } private static ChildAssociationRef createAssociationOf(final NodeRef parentNode, final NodeRef childNode, final QName childNodeName)