From 9c3338a8fe4bfb2be155e142a74b229b8ed00839 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 03:05:34 +0000 Subject: [PATCH] MTM-2: add/remove article bookmark (REST + GraphQL) --- .../io/spring/api/ArticleBookmarkApi.java | 62 +++++ .../io/spring/graphql/ArticleMutation.java | 33 +++ src/main/resources/schema/schema.graphqls | 2 + .../io/spring/api/ArticleBookmarkApiTest.java | 193 ++++++++++++++++ .../spring/graphql/BookmarkMutationTest.java | 215 ++++++++++++++++++ 5 files changed, 505 insertions(+) create mode 100644 src/main/java/io/spring/api/ArticleBookmarkApi.java create mode 100644 src/test/java/io/spring/api/ArticleBookmarkApiTest.java create mode 100644 src/test/java/io/spring/graphql/BookmarkMutationTest.java diff --git a/src/main/java/io/spring/api/ArticleBookmarkApi.java b/src/main/java/io/spring/api/ArticleBookmarkApi.java new file mode 100644 index 000000000..49a572982 --- /dev/null +++ b/src/main/java/io/spring/api/ArticleBookmarkApi.java @@ -0,0 +1,62 @@ +package io.spring.api; + +import io.spring.api.exception.ResourceNotFoundException; +import io.spring.application.ArticleQueryService; +import io.spring.application.data.ArticleData; +import io.spring.core.article.Article; +import io.spring.core.article.ArticleRepository; +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; +import io.spring.core.user.User; +import java.util.HashMap; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "articles/{slug}/bookmark") +@AllArgsConstructor +public class ArticleBookmarkApi { + private ArticleBookmarkRepository articleBookmarkRepository; + private ArticleRepository articleRepository; + private ArticleQueryService articleQueryService; + + @PostMapping + public ResponseEntity bookmarkArticle( + @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + ArticleBookmark articleBookmark = new ArticleBookmark(article.getId(), user.getId()); + articleBookmarkRepository.save(articleBookmark); + return responseArticleData(articleQueryService.findBySlug(slug, user).get()); + } + + @DeleteMapping + public ResponseEntity unbookmarkArticle( + @PathVariable("slug") String slug, @AuthenticationPrincipal User user) { + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + articleBookmarkRepository + .find(article.getId(), user.getId()) + .ifPresent( + bookmark -> { + articleBookmarkRepository.remove(bookmark); + }); + return responseArticleData(articleQueryService.findBySlug(slug, user).get()); + } + + private ResponseEntity> responseArticleData( + final ArticleData articleData) { + return ResponseEntity.ok( + new HashMap() { + { + put("article", articleData); + } + }); + } +} diff --git a/src/main/java/io/spring/graphql/ArticleMutation.java b/src/main/java/io/spring/graphql/ArticleMutation.java index 6b7b6eb2c..67255498a 100644 --- a/src/main/java/io/spring/graphql/ArticleMutation.java +++ b/src/main/java/io/spring/graphql/ArticleMutation.java @@ -11,6 +11,8 @@ import io.spring.application.article.UpdateArticleParam; import io.spring.core.article.Article; import io.spring.core.article.ArticleRepository; +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; import io.spring.core.favorite.ArticleFavorite; import io.spring.core.favorite.ArticleFavoriteRepository; import io.spring.core.service.AuthorizationService; @@ -30,6 +32,7 @@ public class ArticleMutation { private ArticleCommandService articleCommandService; private ArticleFavoriteRepository articleFavoriteRepository; + private ArticleBookmarkRepository articleBookmarkRepository; private ArticleRepository articleRepository; @DgsMutation(field = MUTATION.CreateArticle) @@ -99,6 +102,36 @@ public DataFetcherResult unfavoriteArticle(@InputArgument("slug" .build(); } + @DgsMutation(field = MUTATION.BookmarkArticle) + public DataFetcherResult bookmarkArticle(@InputArgument("slug") String slug) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + ArticleBookmark articleBookmark = new ArticleBookmark(article.getId(), user.getId()); + articleBookmarkRepository.save(articleBookmark); + return DataFetcherResult.newResult() + .data(ArticlePayload.newBuilder().build()) + .localContext(article) + .build(); + } + + @DgsMutation(field = MUTATION.UnbookmarkArticle) + public DataFetcherResult unbookmarkArticle(@InputArgument("slug") String slug) { + User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); + Article article = + articleRepository.findBySlug(slug).orElseThrow(ResourceNotFoundException::new); + articleBookmarkRepository + .find(article.getId(), user.getId()) + .ifPresent( + bookmark -> { + articleBookmarkRepository.remove(bookmark); + }); + return DataFetcherResult.newResult() + .data(ArticlePayload.newBuilder().build()) + .localContext(article) + .build(); + } + @DgsMutation(field = MUTATION.DeleteArticle) public DeletionStatus deleteArticle(@InputArgument("slug") String slug) { User user = SecurityUtil.getCurrentUser().orElseThrow(AuthenticationException::new); diff --git a/src/main/resources/schema/schema.graphqls b/src/main/resources/schema/schema.graphqls index a3f6be557..286f75233 100644 --- a/src/main/resources/schema/schema.graphqls +++ b/src/main/resources/schema/schema.graphqls @@ -31,6 +31,8 @@ type Mutation { updateArticle(slug: String!, changes: UpdateArticleInput!): ArticlePayload favoriteArticle(slug: String!): ArticlePayload unfavoriteArticle(slug: String!): ArticlePayload + bookmarkArticle(slug: String!): ArticlePayload + unbookmarkArticle(slug: String!): ArticlePayload deleteArticle(slug: String!): DeletionStatus ### Comment diff --git a/src/test/java/io/spring/api/ArticleBookmarkApiTest.java b/src/test/java/io/spring/api/ArticleBookmarkApiTest.java new file mode 100644 index 000000000..b8b7b1dd3 --- /dev/null +++ b/src/test/java/io/spring/api/ArticleBookmarkApiTest.java @@ -0,0 +1,193 @@ +package io.spring.api; + +import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.restassured.module.mockmvc.RestAssuredMockMvc; +import io.spring.JacksonCustomizations; +import io.spring.api.security.WebSecurityConfig; +import io.spring.application.ArticleQueryService; +import io.spring.application.data.ArticleData; +import io.spring.application.data.ProfileData; +import io.spring.core.article.Article; +import io.spring.core.article.ArticleRepository; +import io.spring.core.article.Tag; +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; +import io.spring.core.user.User; +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ArticleBookmarkApi.class) +@Import({WebSecurityConfig.class, JacksonCustomizations.class}) +public class ArticleBookmarkApiTest extends TestWithCurrentUser { + @Autowired private MockMvc mvc; + + @MockBean private ArticleBookmarkRepository articleBookmarkRepository; + + @MockBean private ArticleRepository articleRepository; + + @MockBean private ArticleQueryService articleQueryService; + + private Article article; + + @BeforeEach + public void setUp() throws Exception { + super.setUp(); + RestAssuredMockMvc.mockMvc(mvc); + User anotherUser = new User("other@test.com", "other", "123", "", ""); + article = new Article("title", "desc", "body", Arrays.asList("java"), anotherUser.getId()); + when(articleRepository.findBySlug(eq(article.getSlug()))).thenReturn(Optional.of(article)); + ArticleData articleData = + new ArticleData( + article.getId(), + article.getSlug(), + article.getTitle(), + article.getDescription(), + article.getBody(), + false, + 0, + article.getCreatedAt(), + article.getUpdatedAt(), + article.getTags().stream().map(Tag::getName).collect(Collectors.toList()), + new ProfileData( + anotherUser.getId(), + anotherUser.getUsername(), + anotherUser.getBio(), + anotherUser.getImage(), + false)); + when(articleQueryService.findBySlug(eq(articleData.getSlug()), eq(user))) + .thenReturn(Optional.of(articleData)); + } + + @Test + public void should_bookmark_an_article_success() throws Exception { + given() + .header("Authorization", "Token " + token) + .when() + .post("/articles/{slug}/bookmark", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("article.id", equalTo(article.getId())); + + verify(articleBookmarkRepository).save(any()); + } + + @Test + public void should_unbookmark_an_article_success() throws Exception { + when(articleBookmarkRepository.find(eq(article.getId()), eq(user.getId()))) + .thenReturn(Optional.of(new ArticleBookmark(article.getId(), user.getId()))); + given() + .header("Authorization", "Token " + token) + .when() + .delete("/articles/{slug}/bookmark", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("article.id", equalTo(article.getId())); + verify(articleBookmarkRepository).remove(new ArticleBookmark(article.getId(), user.getId())); + } + + @Test + public void should_404_when_bookmarking_unknown_article() throws Exception { + when(articleRepository.findBySlug(eq("unknown"))).thenReturn(Optional.empty()); + given() + .header("Authorization", "Token " + token) + .when() + .post("/articles/{slug}/bookmark", "unknown") + .prettyPeek() + .then() + .statusCode(404); + } + + @Test + public void should_404_when_unbookmarking_unknown_article() throws Exception { + when(articleRepository.findBySlug(eq("unknown"))).thenReturn(Optional.empty()); + given() + .header("Authorization", "Token " + token) + .when() + .delete("/articles/{slug}/bookmark", "unknown") + .prettyPeek() + .then() + .statusCode(404); + } + + @Test + public void should_401_when_anonymous_bookmark() throws Exception { + given() + .when() + .post("/articles/{slug}/bookmark", article.getSlug()) + .prettyPeek() + .then() + .statusCode(401); + verify(articleBookmarkRepository, never()).save(any()); + } + + @Test + public void should_401_when_anonymous_unbookmark() throws Exception { + given() + .when() + .delete("/articles/{slug}/bookmark", article.getSlug()) + .prettyPeek() + .then() + .statusCode(401); + verify(articleBookmarkRepository, never()).remove(any()); + } + + @Test + public void should_be_idempotent_on_double_bookmark() throws Exception { + for (int i = 0; i < 2; i++) { + given() + .header("Authorization", "Token " + token) + .when() + .post("/articles/{slug}/bookmark", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200); + } + verify(articleBookmarkRepository, times(2)).save(any()); + } + + @Test + public void should_be_noop_when_unbookmarking_not_bookmarked() throws Exception { + when(articleBookmarkRepository.find(eq(article.getId()), eq(user.getId()))) + .thenReturn(Optional.empty()); + given() + .header("Authorization", "Token " + token) + .when() + .delete("/articles/{slug}/bookmark", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("article.id", equalTo(article.getId())); + verify(articleBookmarkRepository, never()).remove(any()); + } + + @Test + public void should_not_change_favorites_metadata_on_bookmark() throws Exception { + given() + .header("Authorization", "Token " + token) + .when() + .post("/articles/{slug}/bookmark", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("article.favorited", equalTo(false)) + .body("article.favoritesCount", equalTo(0)); + } +} diff --git a/src/test/java/io/spring/graphql/BookmarkMutationTest.java b/src/test/java/io/spring/graphql/BookmarkMutationTest.java new file mode 100644 index 000000000..fbb15fd69 --- /dev/null +++ b/src/test/java/io/spring/graphql/BookmarkMutationTest.java @@ -0,0 +1,215 @@ +package io.spring.graphql; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.netflix.graphql.dgs.DgsQueryExecutor; +import com.netflix.graphql.dgs.autoconfig.DgsAutoConfiguration; +import io.spring.application.ArticleQueryService; +import io.spring.application.article.ArticleCommandService; +import io.spring.application.data.ArticleData; +import io.spring.application.data.ProfileData; +import io.spring.core.article.Article; +import io.spring.core.article.ArticleRepository; +import io.spring.core.article.Tag; +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; +import io.spring.core.favorite.ArticleFavoriteRepository; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import io.spring.graphql.exception.GraphQLCustomizeExceptionHandler; +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; + +@SpringBootTest( + classes = { + DgsAutoConfiguration.class, + ArticleMutation.class, + ArticleDatafetcher.class, + GraphQLCustomizeExceptionHandler.class + }) +public class BookmarkMutationTest { + + @Autowired private DgsQueryExecutor dgsQueryExecutor; + + @MockBean private ArticleCommandService articleCommandService; + @MockBean private ArticleFavoriteRepository articleFavoriteRepository; + @MockBean private ArticleBookmarkRepository articleBookmarkRepository; + @MockBean private ArticleRepository articleRepository; + @MockBean private ArticleQueryService articleQueryService; + @MockBean private UserRepository userRepository; + + private User user; + private Article article; + + @BeforeEach + public void setUp() { + user = new User("john@jacob.com", "johnjacob", "123", "", ""); + User anotherUser = new User("other@test.com", "other", "123", "", ""); + article = new Article("title", "desc", "body", Arrays.asList("java"), anotherUser.getId()); + when(articleRepository.findBySlug(eq(article.getSlug()))).thenReturn(Optional.of(article)); + + ArticleData articleData = + new ArticleData( + article.getId(), + article.getSlug(), + article.getTitle(), + article.getDescription(), + article.getBody(), + false, + 0, + article.getCreatedAt(), + article.getUpdatedAt(), + article.getTags().stream().map(Tag::getName).collect(Collectors.toList()), + new ProfileData( + anotherUser.getId(), + anotherUser.getUsername(), + anotherUser.getBio(), + anotherUser.getImage(), + false)); + when(articleQueryService.findById(eq(article.getId()), eq(user))) + .thenReturn(Optional.of(articleData)); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void authenticate(User u) { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken(u, null, Collections.emptyList())); + } + + private void anonymous() { + SecurityContextHolder.getContext() + .setAuthentication( + new AnonymousAuthenticationToken( + "key", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); + } + + @Test + public void should_bookmark_article_success() { + authenticate(user); + String slug = + dgsQueryExecutor.executeAndExtractJsonPath( + "mutation { bookmarkArticle(slug: \"" + + article.getSlug() + + "\") { article { slug } } }", + "data.bookmarkArticle.article.slug"); + + org.junit.jupiter.api.Assertions.assertEquals(article.getSlug(), slug); + verify(articleBookmarkRepository).save(any()); + } + + @Test + public void should_unbookmark_article_success() { + authenticate(user); + when(articleBookmarkRepository.find(eq(article.getId()), eq(user.getId()))) + .thenReturn(Optional.of(new ArticleBookmark(article.getId(), user.getId()))); + + String slug = + dgsQueryExecutor.executeAndExtractJsonPath( + "mutation { unbookmarkArticle(slug: \"" + + article.getSlug() + + "\") { article { slug } } }", + "data.unbookmarkArticle.article.slug"); + + org.junit.jupiter.api.Assertions.assertEquals(article.getSlug(), slug); + verify(articleBookmarkRepository).remove(new ArticleBookmark(article.getId(), user.getId())); + } + + @Test + public void should_error_when_anonymous_bookmark() { + anonymous(); + org.junit.jupiter.api.Assertions.assertFalse( + dgsQueryExecutor + .execute( + "mutation { bookmarkArticle(slug: \"" + + article.getSlug() + + "\") { article { slug } } }") + .getErrors() + .isEmpty()); + verify(articleBookmarkRepository, never()).save(any()); + } + + @Test + public void should_error_when_anonymous_unbookmark() { + anonymous(); + org.junit.jupiter.api.Assertions.assertFalse( + dgsQueryExecutor + .execute( + "mutation { unbookmarkArticle(slug: \"" + + article.getSlug() + + "\") { article { slug } } }") + .getErrors() + .isEmpty()); + verify(articleBookmarkRepository, never()).remove(any()); + } + + @Test + public void should_error_when_bookmark_unknown_slug() { + authenticate(user); + when(articleRepository.findBySlug(eq("unknown"))).thenReturn(Optional.empty()); + org.junit.jupiter.api.Assertions.assertFalse( + dgsQueryExecutor + .execute("mutation { bookmarkArticle(slug: \"unknown\") { article { slug } } }") + .getErrors() + .isEmpty()); + } + + @Test + public void should_be_noop_when_unbookmark_not_bookmarked() { + authenticate(user); + when(articleBookmarkRepository.find(eq(article.getId()), eq(user.getId()))) + .thenReturn(Optional.empty()); + + String slug = + dgsQueryExecutor.executeAndExtractJsonPath( + "mutation { unbookmarkArticle(slug: \"" + + article.getSlug() + + "\") { article { slug } } }", + "data.unbookmarkArticle.article.slug"); + + org.junit.jupiter.api.Assertions.assertEquals(article.getSlug(), slug); + verify(articleBookmarkRepository, never()).remove(any()); + } + + @Test + public void should_not_change_favorites_metadata_on_bookmark() { + authenticate(user); + Boolean favorited = + dgsQueryExecutor.executeAndExtractJsonPath( + "mutation { bookmarkArticle(slug: \"" + + article.getSlug() + + "\") { article { favorited favoritesCount } } }", + "data.bookmarkArticle.article.favorited"); + Integer favoritesCount = + dgsQueryExecutor.executeAndExtractJsonPath( + "mutation { bookmarkArticle(slug: \"" + + article.getSlug() + + "\") { article { favorited favoritesCount } } }", + "data.bookmarkArticle.article.favoritesCount"); + + org.junit.jupiter.api.Assertions.assertEquals(false, favorited); + org.junit.jupiter.api.Assertions.assertEquals(0, favoritesCount); + verify(articleBookmarkRepository, times(2)).save(any()); + } +}