Skip to content

Commit

Permalink
Add the ability to delete unused category
Browse files Browse the repository at this point in the history
  • Loading branch information
mohammed-ezzedine committed Dec 25, 2023
1 parent fefc605 commit fe1df66
Show file tree
Hide file tree
Showing 18 changed files with 355 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package me.ezzedine.mohammed.personalspace;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.ezzedine.mohammed.personalspace.article.infra.ArticleEntity;
import me.ezzedine.mohammed.personalspace.article.infra.ArticleMongoRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class DataBackwardsCompatibilityEnabler implements CommandLineRunner {

private final ArticleMongoRepository articleMongoRepository;

@Override
public void run(String... args) {
List<ArticleEntity> articles = articleMongoRepository.findAll();
for (ArticleEntity article : articles) {

if (article.getCreatedDate() == null) {
article.setCreatedDate(LocalDateTime.now());
}

if (article.getLastModifiedDate() == null) {
article.setLastModifiedDate(LocalDateTime.now());
}

if (article.getVersion() == null) {
articleMongoRepository.deleteById(article.getId());
}

articleMongoRepository.save(article);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package me.ezzedine.mohammed.personalspace.article.core;

import lombok.RequiredArgsConstructor;
import me.ezzedine.mohammed.personalspace.category.core.deletion.CategoryDeletionPermission;
import me.ezzedine.mohammed.personalspace.category.core.deletion.CategoryDeletionPermissionGranter;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class ArticleCategoryDeletionAdvisor implements CategoryDeletionPermissionGranter {

private final ArticleStorage articleStorage;

@Override
public CategoryDeletionPermission canDeleteCategory(String categoryId) {
List<Article> articles = articleStorage.fetchByCategory(categoryId);
if (articles.isEmpty()) {
return CategoryDeletionPermission.allowed();
}

return CategoryDeletionPermission.notAllowed(getReasonMessage(articles));
}

private String getReasonMessage(List<Article> articles) {
String commaSeparatedArticleIds = articles.stream().map(Article::getId).reduce((id1, id2) -> id1 + ", " + id2).orElse("");
return "Category cannot be deleted since it is linked to the following articles: [" + commaSeparatedArticleIds + "]";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import me.ezzedine.mohammed.personalspace.util.pagination.FetchCriteria;
import me.ezzedine.mohammed.personalspace.util.pagination.Page;

import java.util.List;
import java.util.Optional;

public interface ArticleStorage {
void save(Article article);
Optional<Article> fetch(String id);
Page<Article> fetchAll(FetchCriteria criteria);
List<Article> fetchByCategory(String categoryId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@

import org.springframework.data.mongodb.repository.MongoRepository;

import java.util.List;

public interface ArticleMongoRepository extends MongoRepository<ArticleEntity, String> {
List<ArticleEntity> findByCategoryId(String categoryId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ public Page<Article> fetchAll(FetchCriteria criteria) {
return Page.<Article>builder().items(articles).totalSize(totalNumberOfArticles).build();
}

@Override
public List<Article> fetchByCategory(String categoryId) {
return repository.findByCategoryId(categoryId).stream().map(ArticleStorageManager::fromEntity).toList();
}

private static ArticleEntity toEntity(Article article) {
return ArticleEntity.builder().id(article.getId()).title(article.getTitle()).description(article.getDescription())
.content(article.getContent()).categoryId(article.getCategoryId()).thumbnailImageUrl(article.getThumbnailImageUrl())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import me.ezzedine.mohammed.personalspace.category.core.CategoryIdAlreadyExistsException;
import me.ezzedine.mohammed.personalspace.category.core.CategoryNotFoundException;
import me.ezzedine.mohammed.personalspace.category.core.CategoryValidationViolationException;
import me.ezzedine.mohammed.personalspace.category.core.deletion.CategoryDeletionRejectedException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand All @@ -22,4 +23,7 @@ public interface CategoryApi {

@PutMapping("orders")
ResponseEntity<Void> updateCategoriesOrders(@RequestBody UpdateCategoriesOrdersApiRequest request);

@DeleteMapping("{id}")
ResponseEntity<Void> delete(@PathVariable String id) throws CategoryNotFoundException, CategoryDeletionRejectedException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.ezzedine.mohammed.personalspace.category.core.*;
import me.ezzedine.mohammed.personalspace.category.core.deletion.CategoryDeletionRejectedException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -16,6 +17,7 @@ public class CategoryController implements CategoryApi {

private final CategoryFetcher fetcher;
private final CategoryPersister persister;
private final CategoryDeleter deleter;

@Override
public ResponseEntity<List<CategorySummaryApiModel>> fetchCategoriesSummaries() {
Expand Down Expand Up @@ -46,6 +48,13 @@ public ResponseEntity<Void> updateCategoriesOrders(UpdateCategoriesOrdersApiRequ
return ResponseEntity.noContent().build();
}

@Override
public ResponseEntity<Void> delete(String id) throws CategoryNotFoundException, CategoryDeletionRejectedException {
log.info("Received a request to delete the category with ID {}", id);
deleter.delete(id);
return ResponseEntity.noContent().build();
}

private static UpdateCategoriesOrdersRequest fromApiModel(UpdateCategoriesOrdersApiRequest request) {
return UpdateCategoriesOrdersRequest.builder()
.categoryOrders(request.getCategoriesOrders().stream().map(CategoryController::fromApiModel).toList()).build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package me.ezzedine.mohammed.personalspace.category.api.advice;

import me.ezzedine.mohammed.personalspace.category.core.deletion.CategoryDeletionRejectedException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@ControllerAdvice
public class CategoryDeletionRejectedAdvice extends ResponseEntityExceptionHandler {

@ExceptionHandler(CategoryDeletionRejectedException.class)
protected ResponseEntity<Object> handle(CategoryDeletionRejectedException exception, WebRequest request) {
return handleExceptionInternal(exception, exception.getMessage(), new HttpHeaders(), HttpStatus.BAD_REQUEST, request);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package me.ezzedine.mohammed.personalspace.category.core;

import me.ezzedine.mohammed.personalspace.category.core.deletion.CategoryDeletionRejectedException;

public interface CategoryDeleter {
void delete(String id) throws CategoryNotFoundException, CategoryDeletionRejectedException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,4 @@
public interface CategoryPersister {
CategoryCreationResult persist(PersistCategoryRequest request) throws CategoryValidationViolationException, CategoryIdAlreadyExistsException;
void updateCategoriesOrders(UpdateCategoriesOrdersRequest request);
void delete(String id) throws CategoryNotFoundException;
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package me.ezzedine.mohammed.personalspace.category.core;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.ezzedine.mohammed.personalspace.category.core.deletion.CategoryDeletionPermission;
import me.ezzedine.mohammed.personalspace.category.core.deletion.CategoryDeletionPermissionGranter;
import me.ezzedine.mohammed.personalspace.category.core.deletion.CategoryDeletionRejectedException;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Slf4j
@Service
@RequiredArgsConstructor
public class CategoryService implements CategoryFetcher, CategoryPersister {
public class CategoryService implements CategoryFetcher, CategoryPersister, CategoryDeleter {

private final CategoryStorage storage;
private final CategoryNameValidator nameValidator;
private final CategoryIdGenerator idGenerator;
private final CategoryOrderResolver orderResolver;
private final List<CategoryDeletionPermissionGranter> categoryDeletionPermissionGranters;

@Override
public List<Category> fetchAll() {
Expand Down Expand Up @@ -45,11 +51,27 @@ public void updateCategoriesOrders(UpdateCategoriesOrdersRequest request) {
}

@Override
public void delete(String id) throws CategoryNotFoundException {
public void delete(String id) throws CategoryNotFoundException, CategoryDeletionRejectedException {
validateCategoryExists(id);

validateThatCategoryCanBeDeleted(id);

storage.delete(id);
}

private void validateThatCategoryCanBeDeleted(String id) throws CategoryDeletionRejectedException {
Optional<CategoryDeletionPermission> ungrantedPermission = categoryDeletionPermissionGranters.stream()
.map(granter -> granter.canDeleteCategory(id))
.filter(permission -> !permission.isAllowed())
.findAny();

if (ungrantedPermission.isPresent()) {
String rejectionReason = ungrantedPermission.get().getMessage().orElse("Category cannot be deleted at the moment.");
log.info("Category with ID {} cannot be deleted. Reason {}", id, rejectionReason);
throw new CategoryDeletionRejectedException(rejectionReason);
}
}

private void updateCategoryOrder(UpdateCategoriesOrdersRequest.CategoryOrder categoryOrder) {
Optional<Category> category = storage.fetch(categoryOrder.getCategoryId());
category.ifPresent(c -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package me.ezzedine.mohammed.personalspace.category.core.deletion;

import lombok.Builder;
import lombok.Data;

import java.util.Optional;

@Data
@Builder
public class CategoryDeletionPermission {
private boolean allowed;
private String message;

public Optional<String> getMessage() {
return Optional.ofNullable(message);
}

public static CategoryDeletionPermission allowed() {
return CategoryDeletionPermission.builder().allowed(true).build();
}

public static CategoryDeletionPermission notAllowed(String reason) {
return CategoryDeletionPermission.builder().allowed(false).message(reason).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package me.ezzedine.mohammed.personalspace.category.core.deletion;

public interface CategoryDeletionPermissionGranter {
CategoryDeletionPermission canDeleteCategory(String categoryId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package me.ezzedine.mohammed.personalspace.category.core.deletion;

public class CategoryDeletionRejectedException extends Exception {
public CategoryDeletionRejectedException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package me.ezzedine.mohammed.personalspace.article.core;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.Collections;
import java.util.List;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class ArticleCategoryDeletionAdvisorTest {

public static final String CATEGORY_ID = UUID.randomUUID().toString();
private ArticleStorage articleStorage;
private ArticleCategoryDeletionAdvisor deletionAdvisor;

@BeforeEach
void setUp() {
articleStorage = mock(ArticleStorage.class);
deletionAdvisor = new ArticleCategoryDeletionAdvisor(articleStorage);
}

@Test
@DisplayName("the user should not be able to delete a category that has at least one article linked to it")
void the_user_should_not_be_able_to_delete_a_category_that_has_at_least_one_article_linked_to_it() {
Article article = getArticleMock(UUID.randomUUID().toString());
when(articleStorage.fetchByCategory(CATEGORY_ID)).thenReturn(List.of(article));
assertFalse(deletionAdvisor.canDeleteCategory(CATEGORY_ID).isAllowed());
}

@Test
@DisplayName("the user should get a message including the list of conflicting articles IDs when the deletion is rejected")
void the_user_should_get_a_message_including_the_list_of_conflicting_articles_IDs_when_the_deletion_is_rejected() {
String firstArticleId = UUID.randomUUID().toString();
Article firstArticle = getArticleMock(firstArticleId);
String secondArticleId = UUID.randomUUID().toString();
Article secondArticle = getArticleMock(secondArticleId);

when(articleStorage.fetchByCategory(CATEGORY_ID)).thenReturn(List.of(firstArticle, secondArticle));
assertTrue(deletionAdvisor.canDeleteCategory(CATEGORY_ID).getMessage().isPresent());
String message = "Category cannot be deleted since it is linked to the following articles: [" + firstArticleId + ", " + secondArticleId + "]";
assertEquals(message, deletionAdvisor.canDeleteCategory(CATEGORY_ID).getMessage().get());
}

@Test
@DisplayName("the user should be able to delete a category that is not linked to any article")
void the_user_should_be_able_to_delete_a_category_that_is_not_linked_to_any_article() {
when(articleStorage.fetchByCategory(CATEGORY_ID)).thenReturn(Collections.emptyList());
assertTrue(deletionAdvisor.canDeleteCategory(CATEGORY_ID).isAllowed());
}

private Article getArticleMock(String id) {
Article article = mock(Article.class);
when(article.getId()).thenReturn(id);
return article;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,39 @@ void should_return_the_articles_sorted_in_descending_order_of_creation_date() {
}
}

@Nested
@DisplayName("When fetching the articles by categoryId")
class FetchingArticlesByCategoryIntegrationTest {
@Test
@DisplayName("should return an empty when list none exist")
void should_return_an_empty_when_list_none_exist() {
repository.save(getEntity(ID, CATEGORY_ID));
assertEquals(0, storageManager.fetchByCategory(UUID.randomUUID().toString()).size());
}

@Test
@DisplayName("should return the list of matching articles")
void should_return_the_list_of_matching_articles() {
String categoryA = UUID.randomUUID().toString();
String categoryB = UUID.randomUUID().toString();
ArticleEntity firstEntity = getEntity(UUID.randomUUID().toString(), categoryA);
ArticleEntity secondEntity = getEntity(UUID.randomUUID().toString(), categoryB);
ArticleEntity thirdEntity = getEntity(UUID.randomUUID().toString(), categoryA);

repository.saveAll(List.of(firstEntity, secondEntity, thirdEntity));
List<Article> articles = storageManager.fetchByCategory(categoryA);

assertEquals(2, articles.size());
assertEquals(firstEntity.getId(), articles.get(0).getId());
assertEquals(thirdEntity.getId(), articles.get(1).getId());
}

private ArticleEntity getEntity(String id, String categoryId) {
return ArticleEntity.builder().id(id).categoryId(categoryId).title(TITLE).description(DESCRIPTION)
.content(CONTENT).thumbnailImageUrl(THUMBNAIL_IMAGE_URL).keywords(List.of(KEYWORD)).hidden(HIDDEN).build();
}
}

private ArticleEntity getEntity(String id) {
return ArticleEntity.builder().id(id).categoryId(CATEGORY_ID).title(TITLE).description(DESCRIPTION)
.content(CONTENT).thumbnailImageUrl(THUMBNAIL_IMAGE_URL).keywords(List.of(KEYWORD)).hidden(HIDDEN).build();
Expand Down
Loading

0 comments on commit fe1df66

Please sign in to comment.