Skip to content
Merged
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
62 changes: 62 additions & 0 deletions src/main/java/io/spring/api/ArticleBookmarkApi.java
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +34 to +35

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Race condition in bookmark save (check-then-insert without atomicity)

The MyBatisArticleBookmarkRepository.save() at src/main/java/io/spring/infrastructure/repository/MyBatisArticleBookmarkRepository.java:20-23 performs a check-then-insert: it calls mapper.find() and only inserts if null. Two concurrent bookmark requests for the same user+article could both pass the find check and both attempt to insert. Whether this causes a duplicate row or a constraint violation depends on the DB schema. This is a pre-existing pattern inherited from MyBatisArticleFavoriteRepository, so it's not introduced by this PR, but worth noting since the new code replicates it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — this check-then-insert pattern lives in MyBatisArticleBookmarkRepository.save() (MTM-6 foundation) and is mirrored from MyBatisArticleFavoriteRepository. It's pre-existing and outside MTM-2's scope, so I'm not changing it here. The DB has a composite primary key (article_id, user_id), so a concurrent double-insert would surface as a constraint violation rather than a duplicate row. If hardening this is desired, the right fix is an idempotent upsert (INSERT ... ON CONFLICT DO NOTHING) applied consistently to both the favorite and bookmark repositories — worth a separate ticket since it touches shared foundation code.

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<HashMap<String, Object>> responseArticleData(
final ArticleData articleData) {
return ResponseEntity.ok(
new HashMap<String, Object>() {
{
put("article", articleData);
}
});
}
}
33 changes: 33 additions & 0 deletions src/main/java/io/spring/graphql/ArticleMutation.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +32,7 @@ public class ArticleMutation {

private ArticleCommandService articleCommandService;
private ArticleFavoriteRepository articleFavoriteRepository;
private ArticleBookmarkRepository articleBookmarkRepository;
private ArticleRepository articleRepository;

@DgsMutation(field = MUTATION.CreateArticle)
Expand Down Expand Up @@ -99,6 +102,36 @@ public DataFetcherResult<ArticlePayload> unfavoriteArticle(@InputArgument("slug"
.build();
}

@DgsMutation(field = MUTATION.BookmarkArticle)
public DataFetcherResult<ArticlePayload> 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.<ArticlePayload>newResult()
.data(ArticlePayload.newBuilder().build())
.localContext(article)
.build();
}

@DgsMutation(field = MUTATION.UnbookmarkArticle)
public DataFetcherResult<ArticlePayload> 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.<ArticlePayload>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);
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/schema/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
193 changes: 193 additions & 0 deletions src/test/java/io/spring/api/ArticleBookmarkApiTest.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading
Loading