Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 180 additions & 25 deletions src/test/java/io/spring/api/ArticleFavoriteApiTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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).
*
* <p>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 {
Expand All @@ -42,62 +50,209 @@ 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()
.post("/articles/{slug}/favorite", article.getSlug())
.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()
.delete("/articles/{slug}/favorite", article.getSlug())
.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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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", "", "");
Expand Down
Loading
Loading