diff --git a/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/requests/Node.java b/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/requests/Node.java index 63b63cfbea..1d7f0596b8 100644 --- a/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/requests/Node.java +++ b/packaging/tests/tas-restapi/src/main/java/org/alfresco/rest/requests/Node.java @@ -1146,4 +1146,15 @@ public class Node extends ModelRequest RestRequest request = RestRequest.requestWithBody(HttpMethod.POST, arrayToJson(categoryLinks), "nodes/{nodeId}/category-links", repoModel.getNodeRef()); return restWrapper.processModels(RestCategoryModelsCollection.class, request); } + + /** + * Unlink content from a category performing a DELETE call on "nodes/{nodeId}/category-links/{categoryId}" + * + * @param categoryId the id of the category to be unlinked from content + */ + public void unlinkFromCategory(String categoryId) + { + RestRequest request = RestRequest.simpleRequest(HttpMethod.DELETE, "nodes/{nodeId}/category-links/{categoryId}", repoModel.getNodeRef(), categoryId); + restWrapper.processEmptyModel(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 index 1831a3949f..8561576444 100644 --- 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 @@ -33,6 +33,7 @@ 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 static org.springframework.http.HttpStatus.NO_CONTENT; import javax.json.Json; import java.util.Collections; @@ -395,6 +396,115 @@ public class LinkToCategoriesTests extends CategoriesRestTest restClient.assertStatusCodeIs(BAD_REQUEST); } + /** + * Try to link and unlink content from a created category + */ + @Test(groups = {TestGroup.REST_API}) + public void testUnlinkContentFromCategory() + { + STEP("Link content to created category and expect 201"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkModelWithId(category.getId()); + final RestCategoryModel linkedCategory = restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(CREATED); + linkedCategory.assertThat().isEqualTo(category); + + STEP("Verify that category is present in file metadata"); + RestNodeModel 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()); + + STEP("Unlink content from created category and expect 204"); + restClient.authenticateUser(user).withCoreAPI().usingNode(file).unlinkFromCategory(category.getId()); + restClient.assertStatusCodeIs(NO_CONTENT); + + STEP("Verify that category isn't present in file metadata"); + fileNode = restClient.authenticateUser(user).withCoreAPI().usingNode(file).getNode(); + + fileNode.assertThat().field(ASPECTS_FIELD).notContains("cm:generalclassifiable"); + fileNode.assertThat().field(PROPERTIES_FIELD).notContains("cm:categories"); + fileNode.assertThat().field(PROPERTIES_FIELD).notContains(category.getId()); + } + + /** + * Try to link content to multiple categories and try to unlink content from a single category + * Other categories should remain intact and file should keep having "cm:generalclassifiable" aspect + */ + @Test(groups = {TestGroup.REST_API}) + public void testUnlinkContentFromCategory_multipleLinkedCategories() + { + STEP("Create second category under root"); + final RestCategoryModel secondCategory = prepareCategoryUnderRoot(); + + STEP("Link content to created categories and expect 201"); + final List categoryLinks = List.of( + createCategoryLinkModelWithId(category.getId()), + createCategoryLinkModelWithId(secondCategory.getId()) + ); + restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategories(categoryLinks); + restClient.assertStatusCodeIs(CREATED); + + STEP("Unlink content from first category and expect 204"); + restClient.authenticateUser(user).withCoreAPI().usingNode(file).unlinkFromCategory(category.getId()); + restClient.assertStatusCodeIs(NO_CONTENT); + + STEP("Verify that second category is still present in file metadata"); + RestNodeModel 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).notContains(category.getId()); + fileNode.assertThat().field(PROPERTIES_FIELD).contains(secondCategory.getId()); + } + + /** + * Link content to a category as user with permission and try to unlink content using a user without change permissions + */ + @Test(groups = {TestGroup.REST_API}) + public void testUnlinkContentFromCategory_asUserWithoutChangePermissionAndGet403() + { + STEP("Link content to created category and expect 201"); + final RestCategoryLinkBodyModel categoryLink = createCategoryLinkModelWithId(category.getId()); + final RestCategoryModel linkedCategory = restClient.authenticateUser(user).withCoreAPI().usingNode(file).linkToCategory(categoryLink); + + restClient.assertStatusCodeIs(CREATED); + linkedCategory.assertThat().isEqualTo(category); + + STEP("Create another user as a consumer for file"); + final UserModel consumer = dataUser.createRandomTestUser(); + allowPermissionsForUser(consumer.getUsername(), "Consumer", file); + + STEP("Try to unlink content to a category using user without change permission and expect 403"); + restClient.authenticateUser(consumer).withCoreAPI().usingNode(file).unlinkFromCategory(category.getId()); + restClient.assertStatusCodeIs(FORBIDDEN); + } + + /** + * Try to unlink content from a category that the node isn't assigned to and expect 404 + */ + @Test(groups = { TestGroup.REST_API}) + public void testUnlinkContentFromCategory_unlinkFromNonLinkedToNodeCategory() + { + STEP("Try to unlink content from a category that the node isn't assigned to"); + final RestCategoryModel nonLinkedToNodeCategory = createCategoryModelWithId("non-linked-category-dummy-id"); + restClient.authenticateUser(user).withCoreAPI().usingNode(file).unlinkFromCategory(nonLinkedToNodeCategory.getId()); + restClient.assertStatusCodeIs(NOT_FOUND); + } + + /** + * Try to unlink content from category using non-existing category id and expect 404 (Not Found) + */ + @Test(groups = { TestGroup.REST_API}) + public void testUnlinkContentFromCategory_usingNonExistingCategoryAndExpect404() + { + STEP("Try to unlink content from non-existent category and expect 404"); + final String nonExistentCategoryId = "non-existent-dummy-id"; + restClient.authenticateUser(user).withCoreAPI().usingNode(file).unlinkFromCategory(nonExistentCategoryId); + restClient.assertStatusCodeIs(NOT_FOUND); + } + private void allowPermissionsForUser(final String username, final String role, final FileModel file) { final String putPermissionsBody = Json.createObjectBuilder().add("permissions", 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 578722d3f5..e591bf9dd5 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 @@ -71,4 +71,12 @@ public interface Categories * @return Linked to categories. */ List linkNodeToCategories(String nodeId, List categoryLinks); + + /** + * Unlink node from a category. + * + * @param nodeId Node ID. + * @param categoryId Category ID from which content node should be unlinked from. + */ + void unlinkNodeFromCategory(String nodeId, String categoryId, Parameters parameters); } 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 index 1a96b53e66..9933a5431c 100644 --- 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 @@ -40,7 +40,9 @@ import org.alfresco.rest.framework.resource.parameters.ListPage; import org.alfresco.rest.framework.resource.parameters.Parameters; @RelationshipResource(name = "category-links", entityResource = NodesEntityResource.class, title = "Category links") -public class NodesCategoryLinksRelation implements RelationshipResourceAction.Read, RelationshipResourceAction.Create +public class NodesCategoryLinksRelation implements RelationshipResourceAction.Create, + RelationshipResourceAction.Read, + RelationshipResourceAction.Delete { private final Categories categories; @@ -77,4 +79,19 @@ public class NodesCategoryLinksRelation implements RelationshipResourceAction.Re { return categories.linkNodeToCategories(nodeId, categoryLinks); } + + /** + * DELETE /nodes/{nodeId}/category-links/{categoryId} + */ + @WebApiDescription( + title = "Unlink content node from category", + description = "Removes the link between a content node and a category", + successStatus = HttpServletResponse.SC_NO_CONTENT + ) + @Override + public void delete(String nodeId, String categoryId, Parameters parameters) + { + categories.unlinkNodeFromCategory(nodeId, categoryId, parameters); + } + } 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 2c45c7b79c..14a21651ea 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 @@ -207,6 +207,35 @@ public class CategoriesImpl implements Categories return categoryNodeRefs.stream().map(this::mapToCategory).collect(Collectors.toList()); } + @Override + public void unlinkNodeFromCategory(final String nodeId, final String categoryId, Parameters parameters) + { + final NodeRef categoryNodeRef = getCategoryNodeRef(categoryId); + final NodeRef contentNodeRef = nodes.validateNode(nodeId); + verifyChangePermission(contentNodeRef); + verifyNodeType(contentNodeRef); + + if (isCategoryAspectMissing(contentNodeRef)) + { + throw new InvalidArgumentException("Node with id: " + nodeId + " does not belong to a category"); + } + if (isRootCategory(categoryNodeRef)) + { + throw new InvalidArgumentException(NOT_A_VALID_CATEGORY, new String[]{categoryId}); + } + + final Collection allCategories = removeCategory(contentNodeRef, categoryNodeRef); + + if (allCategories.size()==0) + { + nodeService.removeAspect(contentNodeRef, ContentModel.ASPECT_GEN_CLASSIFIABLE); + nodeService.removeProperty(contentNodeRef, ContentModel.PROP_CATEGORIES); + return; + } + + nodeService.setProperty(contentNodeRef, ContentModel.PROP_CATEGORIES, (Serializable) allCategories); + } + private void verifyAdminAuthority() { if (!authorityService.hasAdminAuthority()) @@ -369,6 +398,22 @@ public class CategoriesImpl implements Categories return allCategories; } + /** + * Remove specified category from present categories. + * @param contentNodeRef the nodeRef that contains the categories. + * @param categoryToRemove category that should be removed. + * @return updated category list. + */ + private Collection removeCategory(final NodeRef contentNodeRef, final NodeRef categoryToRemove) + { + final Serializable currentCategories = nodeService.getProperty(contentNodeRef, ContentModel.PROP_CATEGORIES); + final Collection actualCategories = DefaultTypeConverter.INSTANCE.getCollection(NodeRef.class, currentCategories); + final Collection updatedCategories = new HashSet<>(actualCategories); + updatedCategories.remove(categoryToRemove); + + return updatedCategories; + } + /** * Add to or update node's property cm:categories containing linked category references. * 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 b788a26021..197dbd8f41 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 @@ -1020,6 +1020,40 @@ public class CategoriesImplTest .isEqualTo(expectedLinkedCategories); } + @Test + public void testUnlinkNodeFromCategory() + { + given(nodeServiceMock.hasAspect(CONTENT_NODE_REF,ContentModel.ASPECT_GEN_CLASSIFIABLE)).willReturn(true); + + // when + objectUnderTest.unlinkNodeFromCategory(CONTENT_NODE_ID, CATEGORY_ID, parametersMock); + + then(nodesMock).should().validateNode(CATEGORY_ID); + 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(nodeServiceMock).should().hasAspect(CONTENT_NODE_REF,ContentModel.ASPECT_GEN_CLASSIFIABLE); + then(nodeServiceMock).should().getProperty(CONTENT_NODE_REF, ContentModel.PROP_CATEGORIES); + then(nodeServiceMock).should().setProperty(eq(CONTENT_NODE_REF),eq(ContentModel.PROP_CATEGORIES),any()); + } + + @Test + public void testUnlinkNodeFromCategory_missingCategoryAspect() + { + given(nodeServiceMock.hasAspect(CONTENT_NODE_REF, ContentModel.ASPECT_GEN_CLASSIFIABLE)).willReturn(false); + + //when + final Throwable actualException = catchThrowable(() -> objectUnderTest.unlinkNodeFromCategory(CONTENT_NODE_ID,CATEGORY_ID, parametersMock)); + + then(nodeServiceMock).should().hasAspect(CONTENT_NODE_REF,ContentModel.ASPECT_GEN_CLASSIFIABLE); + then(nodeServiceMock).shouldHaveNoMoreInteractions(); + assertThat(actualException) + .isInstanceOf(InvalidArgumentException.class) + .hasMessageContaining("does not belong to a category"); + } + @Test public void testListCategoriesForNode() {