From 97f1cffca32821b60db88cd79be0a708631d4813 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:58:14 +0000 Subject: [PATCH] test: add Article Favorites spec coverage (AC1-AC14) for REST, GraphQL, read model --- .../io/spring/api/ArticleFavoriteApiTest.java | 205 +++++++++++++++--- .../article/ArticleQueryServiceTest.java | 51 +++++ .../graphql/ArticleFavoriteMutationTest.java | 123 +++++++++++ 3 files changed, 354 insertions(+), 25 deletions(-) create mode 100644 src/test/java/io/spring/graphql/ArticleFavoriteMutationTest.java diff --git a/src/test/java/io/spring/api/ArticleFavoriteApiTest.java b/src/test/java/io/spring/api/ArticleFavoriteApiTest.java index 7a609a255..24f485c91 100644 --- a/src/test/java/io/spring/api/ArticleFavoriteApiTest.java +++ b/src/test/java/io/spring/api/ArticleFavoriteApiTest.java @@ -4,6 +4,7 @@ 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.verify; import static org.mockito.Mockito.when; @@ -30,6 +31,13 @@ import org.springframework.context.annotation.Import; import org.springframework.test.web.servlet.MockMvc; +/** + * REST contract tests for the Article Favorites feature (issue #186). + * + *

Covers acceptance criteria AC1-AC10: POST /articles/{slug}/favorite and DELETE + * /articles/{slug}/favorite — success, count reporting, idempotency/no-op, 404 for missing + * articles, and 401 for unauthenticated requests. + */ @WebMvcTest(ArticleFavoriteApi.class) @Import({WebSecurityConfig.class, JacksonCustomizations.class}) public class ArticleFavoriteApiTest extends TestWithCurrentUser { @@ -42,38 +50,43 @@ public class ArticleFavoriteApiTest extends TestWithCurrentUser { @MockBean private ArticleQueryService articleQueryService; private Article article; + private User anotherUser; @BeforeEach public void setUp() throws Exception { super.setUp(); RestAssuredMockMvc.mockMvc(mvc); - User anotherUser = new User("other@test.com", "other", "123", "", ""); + 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(), - true, - 1, - 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)); } + private ArticleData articleDataWith(boolean favorited, int favoritesCount) { + return new ArticleData( + article.getId(), + article.getSlug(), + article.getTitle(), + article.getDescription(), + article.getBody(), + favorited, + favoritesCount, + article.getCreatedAt(), + article.getUpdatedAt(), + article.getTags().stream().map(Tag::getName).collect(Collectors.toList()), + new ProfileData( + anotherUser.getId(), + anotherUser.getUsername(), + anotherUser.getBio(), + anotherUser.getImage(), + false)); + } + + // AC1: authenticated user favoriting an existing article -> 200 and article.favorited == true. @Test - public void should_favorite_an_article_success() throws Exception { + public void should_favorite_article_returns_200_and_favorited_true() throws Exception { + when(articleQueryService.findBySlug(eq(article.getSlug()), eq(user))) + .thenReturn(Optional.of(articleDataWith(true, 1))); + given() .header("Authorization", "Token " + token) .when() @@ -81,15 +94,87 @@ public void should_favorite_an_article_success() throws Exception { .prettyPeek() .then() .statusCode(200) - .body("article.id", equalTo(article.getId())); + .body("article.favorited", equalTo(true)); verify(articleFavoriteRepository).save(any()); } + // AC2: the returned article.favoritesCount reflects the favorite count including this user. + @Test + public void should_favorite_article_returns_favorites_count() throws Exception { + when(articleQueryService.findBySlug(eq(article.getSlug()), eq(user))) + .thenReturn(Optional.of(articleDataWith(true, 1))); + + given() + .header("Authorization", "Token " + token) + .when() + .post("/articles/{slug}/favorite", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("article.favoritesCount", equalTo(1)); + } + + // AC3: favoriting an article that does not exist -> 404. + @Test + public void should_return_404_when_favoriting_missing_article() throws Exception { + when(articleRepository.findBySlug(eq("not-exists"))).thenReturn(Optional.empty()); + + given() + .header("Authorization", "Token " + token) + .when() + .post("/articles/{slug}/favorite", "not-exists") + .prettyPeek() + .then() + .statusCode(404); + + verify(articleFavoriteRepository, never()).save(any()); + } + + // AC4: an unauthenticated favorite request -> 401 and does not change favorite state. @Test - public void should_unfavorite_an_article_success() throws Exception { + public void should_return_401_and_not_favorite_when_unauthenticated() throws Exception { + given() + .when() + .post("/articles/{slug}/favorite", article.getSlug()) + .prettyPeek() + .then() + .statusCode(401); + + verify(articleFavoriteRepository, never()).save(any()); + } + + // AC5: favoriting an already favorited article is idempotent -> still 200, favorited=true, and + // the count is not double-incremented (count stays 1). The no-double-increment guarantee is + // enforced by the repository/read-model and is additionally covered in + // ArticleQueryServiceTest#should_not_double_count_when_same_user_favorites_twice. + @Test + public void should_be_idempotent_when_favoriting_already_favorited_article() throws Exception { + when(articleQueryService.findBySlug(eq(article.getSlug()), eq(user))) + .thenReturn(Optional.of(articleDataWith(true, 1))); + + given() + .header("Authorization", "Token " + token) + .when() + .post("/articles/{slug}/favorite", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("article.favorited", equalTo(true)) + .body("article.favoritesCount", equalTo(1)); + + verify(articleFavoriteRepository).save(any()); + } + + // AC6: authenticated user unfavoriting a previously favorited article -> 200 and + // article.favorited == false. + @Test + public void should_unfavorite_article_returns_200_and_favorited_false() throws Exception { when(articleFavoriteRepository.find(eq(article.getId()), eq(user.getId()))) .thenReturn(Optional.of(new ArticleFavorite(article.getId(), user.getId()))); + when(articleQueryService.findBySlug(eq(article.getSlug()), eq(user))) + .thenReturn(Optional.of(articleDataWith(false, 0))); + given() .header("Authorization", "Token " + token) .when() @@ -97,7 +182,77 @@ public void should_unfavorite_an_article_success() throws Exception { .prettyPeek() .then() .statusCode(200) - .body("article.id", equalTo(article.getId())); + .body("article.favorited", equalTo(false)); + verify(articleFavoriteRepository).remove(new ArticleFavorite(article.getId(), user.getId())); } + + // AC7: article.favoritesCount decreases accordingly after unfavoriting (1 -> 0). + @Test + public void should_decrease_favorites_count_after_unfavorite() throws Exception { + when(articleFavoriteRepository.find(eq(article.getId()), eq(user.getId()))) + .thenReturn(Optional.of(new ArticleFavorite(article.getId(), user.getId()))); + when(articleQueryService.findBySlug(eq(article.getSlug()), eq(user))) + .thenReturn(Optional.of(articleDataWith(false, 0))); + + given() + .header("Authorization", "Token " + token) + .when() + .delete("/articles/{slug}/favorite", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("article.favoritesCount", equalTo(0)); + } + + // AC8: unfavoriting an article that does not exist -> 404. + @Test + public void should_return_404_when_unfavoriting_missing_article() throws Exception { + when(articleRepository.findBySlug(eq("not-exists"))).thenReturn(Optional.empty()); + + given() + .header("Authorization", "Token " + token) + .when() + .delete("/articles/{slug}/favorite", "not-exists") + .prettyPeek() + .then() + .statusCode(404); + + verify(articleFavoriteRepository, never()).remove(any()); + } + + // AC9: an unauthenticated unfavorite request -> 401. + @Test + public void should_return_401_when_unauthenticated_unfavorite() throws Exception { + given() + .when() + .delete("/articles/{slug}/favorite", article.getSlug()) + .prettyPeek() + .then() + .statusCode(401); + + verify(articleFavoriteRepository, never()).remove(any()); + } + + // AC10: unfavoriting an article the user had not favorited is a no-op -> 200, favorited=false, + // count unchanged, and remove() is never invoked. + @Test + public void should_be_noop_when_unfavoriting_not_favorited_article() throws Exception { + when(articleFavoriteRepository.find(eq(article.getId()), eq(user.getId()))) + .thenReturn(Optional.empty()); + when(articleQueryService.findBySlug(eq(article.getSlug()), eq(user))) + .thenReturn(Optional.of(articleDataWith(false, 2))); + + given() + .header("Authorization", "Token " + token) + .when() + .delete("/articles/{slug}/favorite", article.getSlug()) + .prettyPeek() + .then() + .statusCode(200) + .body("article.favorited", equalTo(false)) + .body("article.favoritesCount", equalTo(2)); + + verify(articleFavoriteRepository, never()).remove(any()); + } } diff --git a/src/test/java/io/spring/application/article/ArticleQueryServiceTest.java b/src/test/java/io/spring/application/article/ArticleQueryServiceTest.java index 96229376c..3b1824542 100644 --- a/src/test/java/io/spring/application/article/ArticleQueryServiceTest.java +++ b/src/test/java/io/spring/application/article/ArticleQueryServiceTest.java @@ -211,6 +211,57 @@ public void should_show_following_if_user_followed_author() { Assertions.assertTrue(articleData.getProfileData().isFollowing()); } + // AC13: the favorite read model reports `favorited` per (article, viewer) — true only for the + // viewing user's own favorite; false for other users and for an anonymous viewer. + @Test + public void should_report_favorited_only_for_viewing_users_own_favorite() { + User favoritingUser = new User("fav@test.com", "favuser", "123", "", ""); + userRepository.save(favoritingUser); + User otherViewer = new User("viewer@test.com", "viewer", "123", "", ""); + userRepository.save(otherViewer); + + articleFavoriteRepository.save(new ArticleFavorite(article.getId(), favoritingUser.getId())); + + Assertions.assertTrue( + queryService.findById(article.getId(), favoritingUser).get().isFavorited(), + "the favoriting user should see favorited=true"); + Assertions.assertFalse( + queryService.findById(article.getId(), otherViewer).get().isFavorited(), + "a different viewer should see favorited=false"); + Assertions.assertFalse( + queryService.findById(article.getId(), null).get().isFavorited(), + "an anonymous viewer should see favorited=false"); + } + + // AC14: favoritesCount equals the number of distinct users who favorited the article. + @Test + public void should_count_distinct_users_who_favorited() { + User userA = new User("a@test.com", "usera", "123", "", ""); + userRepository.save(userA); + User userB = new User("b@test.com", "userb", "123", "", ""); + userRepository.save(userB); + + articleFavoriteRepository.save(new ArticleFavorite(article.getId(), userA.getId())); + articleFavoriteRepository.save(new ArticleFavorite(article.getId(), userB.getId())); + + Assertions.assertEquals( + 2, queryService.findById(article.getId(), userA).get().getFavoritesCount()); + } + + // AC5/AC14: favoriting the same article twice as the same user must not double-count. + @Test + public void should_not_double_count_when_same_user_favorites_twice() { + User favoritingUser = new User("fav@test.com", "favuser", "123", "", ""); + userRepository.save(favoritingUser); + + articleFavoriteRepository.save(new ArticleFavorite(article.getId(), favoritingUser.getId())); + articleFavoriteRepository.save(new ArticleFavorite(article.getId(), favoritingUser.getId())); + + ArticleData articleData = queryService.findById(article.getId(), favoritingUser).get(); + Assertions.assertTrue(articleData.isFavorited()); + Assertions.assertEquals(1, articleData.getFavoritesCount()); + } + @Test public void should_get_user_feed() { User anotherUser = new User("other@email.com", "other", "123", "", ""); diff --git a/src/test/java/io/spring/graphql/ArticleFavoriteMutationTest.java b/src/test/java/io/spring/graphql/ArticleFavoriteMutationTest.java new file mode 100644 index 000000000..0696f5e54 --- /dev/null +++ b/src/test/java/io/spring/graphql/ArticleFavoriteMutationTest.java @@ -0,0 +1,123 @@ +package io.spring.graphql; + +import com.netflix.graphql.dgs.DgsQueryExecutor; +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.favorite.ArticleFavorite; +import io.spring.core.favorite.ArticleFavoriteRepository; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +/** + * GraphQL parity tests for the Article Favorites feature (issue #186). + * + *

Covers AC11 and AC12: the favoriteArticle / unfavoriteArticle DGS mutations set the viewer's + * `favorited` flag and return a `favoritesCount` that matches the REST read model ({@link + * ArticleQueryService#findBySlug}) for the same user/article. + */ +@SpringBootTest( + properties = { + // SQLite in-memory DBs are per-connection; pin the pool to a single connection so the + // schema and data written via repositories are visible to the DGS query executor. + "spring.datasource.hikari.maximum-pool-size=1", + "spring.datasource.hikari.minimum-idle=1" + }) +@ActiveProfiles("test") +@Transactional +public class ArticleFavoriteMutationTest { + + @Autowired private DgsQueryExecutor dgsQueryExecutor; + + @Autowired private UserRepository userRepository; + + @Autowired private ArticleRepository articleRepository; + + @Autowired private ArticleFavoriteRepository articleFavoriteRepository; + + @Autowired private ArticleQueryService articleQueryService; + + private User user; + private Article article; + + @BeforeEach + public void setUp() { + user = new User("gql@test.com", "gqluser", "123", "", ""); + userRepository.save(user); + article = new Article("gql title", "desc", "body", Arrays.asList("java"), user.getId()); + articleRepository.save(article); + setCurrentUser(user); + } + + @AfterEach + public void tearDown() { + SecurityContextHolder.clearContext(); + } + + private void setCurrentUser(User currentUser) { + Authentication authentication = + new UsernamePasswordAuthenticationToken(currentUser, null, Collections.emptyList()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + // AC11: favoriteArticle(slug) sets favorited=true and returns the updated favoritesCount, + // matching the REST read model for the same user/article. + @Test + public void should_favorite_article_via_graphql_matching_rest() { + Boolean favorited = + dgsQueryExecutor.executeAndExtractJsonPath( + "mutation { favoriteArticle(slug: \"" + + article.getSlug() + + "\") { article { favorited favoritesCount } } }", + "data.favoriteArticle.article.favorited"); + Integer favoritesCount = + dgsQueryExecutor.executeAndExtractJsonPath( + "query { article(slug: \"" + article.getSlug() + "\") { favorited favoritesCount } }", + "data.article.favoritesCount"); + + Assertions.assertTrue(favorited); + Assertions.assertEquals(1, favoritesCount); + + ArticleData rest = articleQueryService.findBySlug(article.getSlug(), user).get(); + Assertions.assertTrue(rest.isFavorited()); + Assertions.assertEquals(rest.getFavoritesCount(), favoritesCount); + } + + // AC12: unfavoriteArticle(slug) sets favorited=false, matching the REST read model. + @Test + public void should_unfavorite_article_via_graphql_matching_rest() { + articleFavoriteRepository.save(new ArticleFavorite(article.getId(), user.getId())); + + Boolean favorited = + dgsQueryExecutor.executeAndExtractJsonPath( + "mutation { unfavoriteArticle(slug: \"" + + article.getSlug() + + "\") { article { favorited favoritesCount } } }", + "data.unfavoriteArticle.article.favorited"); + Integer favoritesCount = + dgsQueryExecutor.executeAndExtractJsonPath( + "query { article(slug: \"" + article.getSlug() + "\") { favorited favoritesCount } }", + "data.article.favoritesCount"); + + Assertions.assertFalse(favorited); + Assertions.assertEquals(0, favoritesCount); + + ArticleData rest = articleQueryService.findBySlug(article.getSlug(), user).get(); + Assertions.assertFalse(rest.isFavorited()); + Assertions.assertEquals(rest.getFavoritesCount(), favoritesCount); + } +}