Skip to content

MTM-2: add/remove article bookmark (REST + GraphQL)#182

Merged
devin-ai-integration[bot] merged 1 commit into
feat/reading-listfrom
devin/1782442972-mtm-2-bookmark-add-remove
Jun 26, 2026
Merged

MTM-2: add/remove article bookmark (REST + GraphQL)#182
devin-ai-integration[bot] merged 1 commit into
feat/reading-listfrom
devin/1782442972-mtm-2-bookmark-add-remove

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 26, 2026

Copy link
Copy Markdown

Summary

Implements MTM-2 ("add/remove an article from my reading list") on top of the MTM-6 bookmark persistence foundation. Mirrors the existing favorite feature exactly — no new domain/repository/migration code, no read-model changes.

MTM-12 — REST api/ArticleBookmarkApi (mirror of ArticleFavoriteApi):

  • POST /articles/{slug}/bookmark → find article by slug (404 ResourceNotFoundException if 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 }.
  • No security changes: anyRequest().authenticated() already covers POST/DELETE → anonymous returns 401 (asserted).

MTM-13 — GraphQL mutations added to type Mutation in schema.graphqls:

bookmarkArticle(slug: String!): ArticlePayload
unbookmarkArticle(slug: String!): ArticlePayload

Resolvers added to ArticleMutation (DGS codegen regenerated MUTATION.BookmarkArticle / UnbookmarkArticle). They mirror favoriteArticle/unfavoriteArticle: getCurrentUser().orElseThrow(AuthenticationException::new), find article → ResourceNotFoundException, save/remove the ArticleBookmark, and return DataFetcherResult<ArticlePayload> with .localContext(article) so the existing ArticleDatafetcher article 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, DGS DgsQueryExecutor slice): 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 bookmarked flag is owned by MTM-4. This PR does not add bookmarked to ArticleData / the SDL and does not touch ArticleQueryService.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 SUCCESSFUL
  • New-code coverage: ArticleBookmarkApi 100% (82/82), ArticleMutation#bookmarkArticle 33/33, #unbookmarkArticle 31/31.

Note: the repo-wide jacocoTestCoverageVerification 80% rule sits at ~37% across the whole bundle and is excluded by CI (-x jacocoTestCoverageVerification in .github/workflows/gradle.yml); this is a preexisting baseline, unaffected by this PR.

Files

  • add src/main/java/io/spring/api/ArticleBookmarkApi.java
  • mod src/main/java/io/spring/graphql/ArticleMutation.java
  • mod src/main/resources/schema/schema.graphqls
  • add src/test/java/io/spring/api/ArticleBookmarkApiTest.java
  • add src/test/java/io/spring/graphql/BookmarkMutationTest.java

Link to Devin session: https://partner-workshops.devinenterprise.com/sessions/fede124c059649638f662014b032d677


Open in Devin Review

@mbatchelor81 mbatchelor81 self-assigned this Jun 26, 2026
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +34 to +35
ArticleBookmark articleBookmark = new ArticleBookmark(article.getId(), user.getId());
articleBookmarkRepository.save(articleBookmark);

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.

@devin-ai-integration devin-ai-integration Bot merged commit 1f006f1 into feat/reading-list Jun 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant