MTM-2: add/remove article bookmark (REST + GraphQL)#182
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| ArticleBookmark articleBookmark = new ArticleBookmark(article.getId(), user.getId()); | ||
| articleBookmarkRepository.save(articleBookmark); |
There was a problem hiding this comment.
🚩 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
Summary
Implements MTM-2 ("add/remove an article from my reading list") on top of the MTM-6 bookmark persistence foundation. Mirrors the existing
favoritefeature exactly — no new domain/repository/migration code, no read-model changes.MTM-12 — REST
api/ArticleBookmarkApi(mirror ofArticleFavoriteApi):POST /articles/{slug}/bookmark→ find article by slug (404ResourceNotFoundExceptionif missing) →bookmarkRepository.save(new ArticleBookmark(articleId, userId))(idempotent) →200 { "article": ArticleData }.DELETE /articles/{slug}/bookmark→ find article (404 if missing) →find(articleId, userId).ifPresent(remove)(idempotent no-op) →200 { "article": ArticleData }.anyRequest().authenticated()already covers POST/DELETE → anonymous returns 401 (asserted).MTM-13 — GraphQL mutations added to
type Mutationinschema.graphqls:Resolvers added to
ArticleMutation(DGS codegen regeneratedMUTATION.BookmarkArticle/UnbookmarkArticle). They mirrorfavoriteArticle/unfavoriteArticle:getCurrentUser().orElseThrow(AuthenticationException::new), find article →ResourceNotFoundException, save/remove theArticleBookmark, and returnDataFetcherResult<ArticlePayload>with.localContext(article)so the existingArticleDatafetcherarticle resolver hydrates the payload.MTM-14 — tests:
ArticleBookmarkApiTest(9): POST→200, DELETE→200, unknown slug→404 (both verbs), anonymous→401 (both verbs), double-POST idempotent (save called twice, 200), un-bookmark-when-absent→200 no remove, and favorited/favoritesCount unchanged.BookmarkMutationTest(7, DGSDgsQueryExecutorslice): bookmark/unbookmark succeed, anonymous→error, unknown slug→error, un-bookmark-when-absent no-op, favorited/favoritesCount unchanged.Scope guard (decision D5)
Per D5, the per-article
bookmarkedflag is owned by MTM-4. This PR does not addbookmarkedtoArticleData/ the SDL and does not touchArticleQueryService.fillExtraInfo. Endpoints return the standard article envelope only; tests assert only what this story owns (row save/remove, auth, idempotency, 404, and favorites metadata untouched).Verification gate (green)
./gradlew clean build -x test→ BUILD SUCCESSFUL./gradlew clean test -x jacocoTestCoverageVerification(CI command) → BUILD SUCCESSFUL, 0 failures./gradlew spotlessCheck→ BUILD SUCCESSFULArticleBookmarkApi100% (82/82),ArticleMutation#bookmarkArticle33/33,#unbookmarkArticle31/31.Note: the repo-wide
jacocoTestCoverageVerification80% rule sits at ~37% across the whole bundle and is excluded by CI (-x jacocoTestCoverageVerificationin.github/workflows/gradle.yml); this is a preexisting baseline, unaffected by this PR.Files
src/main/java/io/spring/api/ArticleBookmarkApi.javasrc/main/java/io/spring/graphql/ArticleMutation.javasrc/main/resources/schema/schema.graphqlssrc/test/java/io/spring/api/ArticleBookmarkApiTest.javasrc/test/java/io/spring/graphql/BookmarkMutationTest.javaLink to Devin session: https://partner-workshops.devinenterprise.com/sessions/fede124c059649638f662014b032d677