diff --git a/src/main/java/io/spring/core/bookmark/ArticleBookmark.java b/src/main/java/io/spring/core/bookmark/ArticleBookmark.java new file mode 100644 index 000000000..cf2b4b471 --- /dev/null +++ b/src/main/java/io/spring/core/bookmark/ArticleBookmark.java @@ -0,0 +1,18 @@ +package io.spring.core.bookmark; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +@EqualsAndHashCode +public class ArticleBookmark { + private String articleId; + private String userId; + + public ArticleBookmark(String articleId, String userId) { + this.articleId = articleId; + this.userId = userId; + } +} diff --git a/src/main/java/io/spring/core/bookmark/ArticleBookmarkRepository.java b/src/main/java/io/spring/core/bookmark/ArticleBookmarkRepository.java new file mode 100644 index 000000000..545123e8a --- /dev/null +++ b/src/main/java/io/spring/core/bookmark/ArticleBookmarkRepository.java @@ -0,0 +1,11 @@ +package io.spring.core.bookmark; + +import java.util.Optional; + +public interface ArticleBookmarkRepository { + void save(ArticleBookmark bookmark); + + Optional find(String articleId, String userId); + + void remove(ArticleBookmark bookmark); +} diff --git a/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleBookmarkMapper.java b/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleBookmarkMapper.java new file mode 100644 index 000000000..77eec8d45 --- /dev/null +++ b/src/main/java/io/spring/infrastructure/mybatis/mapper/ArticleBookmarkMapper.java @@ -0,0 +1,14 @@ +package io.spring.infrastructure.mybatis.mapper; + +import io.spring.core.bookmark.ArticleBookmark; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ArticleBookmarkMapper { + ArticleBookmark find(@Param("articleId") String articleId, @Param("userId") String userId); + + void insert(@Param("bookmark") ArticleBookmark bookmark); + + void delete(@Param("bookmark") ArticleBookmark bookmark); +} diff --git a/src/main/java/io/spring/infrastructure/mybatis/readservice/ArticleBookmarksReadService.java b/src/main/java/io/spring/infrastructure/mybatis/readservice/ArticleBookmarksReadService.java new file mode 100644 index 000000000..f783fa400 --- /dev/null +++ b/src/main/java/io/spring/infrastructure/mybatis/readservice/ArticleBookmarksReadService.java @@ -0,0 +1,18 @@ +package io.spring.infrastructure.mybatis.readservice; + +import io.spring.application.CursorPageParameter; +import io.spring.core.user.User; +import java.util.List; +import java.util.Set; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface ArticleBookmarksReadService { + boolean isUserBookmark(@Param("userId") String userId, @Param("articleId") String articleId); + + Set userBookmarks(@Param("ids") List ids, @Param("currentUser") User currentUser); + + List findUserBookmarkedArticleIdsWithCursor( + @Param("userId") String userId, @Param("page") CursorPageParameter page); +} diff --git a/src/main/java/io/spring/infrastructure/repository/MyBatisArticleBookmarkRepository.java b/src/main/java/io/spring/infrastructure/repository/MyBatisArticleBookmarkRepository.java new file mode 100644 index 000000000..f95b20ab9 --- /dev/null +++ b/src/main/java/io/spring/infrastructure/repository/MyBatisArticleBookmarkRepository.java @@ -0,0 +1,35 @@ +package io.spring.infrastructure.repository; + +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; +import io.spring.infrastructure.mybatis.mapper.ArticleBookmarkMapper; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +@Repository +public class MyBatisArticleBookmarkRepository implements ArticleBookmarkRepository { + private ArticleBookmarkMapper mapper; + + @Autowired + public MyBatisArticleBookmarkRepository(ArticleBookmarkMapper mapper) { + this.mapper = mapper; + } + + @Override + public void save(ArticleBookmark bookmark) { + if (mapper.find(bookmark.getArticleId(), bookmark.getUserId()) == null) { + mapper.insert(bookmark); + } + } + + @Override + public Optional find(String articleId, String userId) { + return Optional.ofNullable(mapper.find(articleId, userId)); + } + + @Override + public void remove(ArticleBookmark bookmark) { + mapper.delete(bookmark); + } +} diff --git a/src/main/resources/db/migration/V3__create_article_bookmarks.sql b/src/main/resources/db/migration/V3__create_article_bookmarks.sql new file mode 100644 index 000000000..bc1ba80fa --- /dev/null +++ b/src/main/resources/db/migration/V3__create_article_bookmarks.sql @@ -0,0 +1,8 @@ +create table article_bookmarks ( + article_id varchar(255) not null, + user_id varchar(255) not null, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + primary key (article_id, user_id) +); +create index idx_article_bookmarks_user_created + on article_bookmarks (user_id, created_at desc); diff --git a/src/main/resources/mapper/ArticleBookmarkMapper.xml b/src/main/resources/mapper/ArticleBookmarkMapper.xml new file mode 100644 index 000000000..1c6ba958f --- /dev/null +++ b/src/main/resources/mapper/ArticleBookmarkMapper.xml @@ -0,0 +1,21 @@ + + + + + insert into article_bookmarks (article_id, user_id) values (#{bookmark.articleId}, #{bookmark.userId}) + + + delete from article_bookmarks where article_id = #{bookmark.articleId} and user_id = #{bookmark.userId} + + + + + + + diff --git a/src/main/resources/mapper/ArticleBookmarksReadService.xml b/src/main/resources/mapper/ArticleBookmarksReadService.xml new file mode 100644 index 000000000..56fbf46cb --- /dev/null +++ b/src/main/resources/mapper/ArticleBookmarksReadService.xml @@ -0,0 +1,38 @@ + + + + + + + diff --git a/src/test/java/io/spring/infrastructure/bookmark/ArticleBookmarksReadServiceTest.java b/src/test/java/io/spring/infrastructure/bookmark/ArticleBookmarksReadServiceTest.java new file mode 100644 index 000000000..375c496d1 --- /dev/null +++ b/src/test/java/io/spring/infrastructure/bookmark/ArticleBookmarksReadServiceTest.java @@ -0,0 +1,141 @@ +package io.spring.infrastructure.bookmark; + +import io.spring.application.CursorPageParameter; +import io.spring.application.CursorPager.Direction; +import io.spring.core.user.User; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.mybatis.readservice.ArticleBookmarksReadService; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import javax.sql.DataSource; +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.jdbc.core.JdbcTemplate; + +public class ArticleBookmarksReadServiceTest extends DbTestBase { + @Autowired private ArticleBookmarksReadService readService; + + @Autowired private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + jdbcTemplate = new JdbcTemplate(dataSource); + jdbcTemplate.execute( + "create table if not exists article_bookmarks (" + + "article_id varchar(255) not null," + + "user_id varchar(255) not null," + + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," + + "primary key (article_id, user_id))"); + } + + private void insertBookmark(String articleId, String userId, String createdAt) { + jdbcTemplate.update( + "insert into article_bookmarks (article_id, user_id, created_at) values (?, ?, ?)", + articleId, + userId, + createdAt); + } + + private void insertArticle(String id, String slug) { + jdbcTemplate.update( + "insert into articles (id, user_id, slug, title, description, body, created_at, updated_at)" + + " values (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))", + id, + "author", + slug, + "title", + "desc", + "body"); + } + + @Test + public void should_report_whether_user_bookmarked_article() { + insertBookmark("a1", "u1", "2020-01-01 00:00:01"); + + Assertions.assertTrue(readService.isUserBookmark("u1", "a1")); + Assertions.assertFalse(readService.isUserBookmark("u1", "a2")); + Assertions.assertFalse(readService.isUserBookmark("u2", "a1")); + } + + @Test + public void should_return_only_current_users_bookmarks() { + insertArticle("a1", "slug-1"); + insertArticle("a2", "slug-2"); + + User currentUser = new User("cur@example.com", "cur", "123", "", ""); + insertBookmark("a1", currentUser.getId(), "2020-01-01 00:00:01"); + insertBookmark("a2", "other-user", "2020-01-01 00:00:02"); + + Set bookmarks = readService.userBookmarks(Arrays.asList("a1", "a2"), currentUser); + + Assertions.assertEquals(1, bookmarks.size()); + Assertions.assertTrue(bookmarks.contains("a1")); + Assertions.assertFalse(bookmarks.contains("a2")); + } + + @Test + public void should_return_bookmarked_ids_ordered_by_created_at_desc_with_cursor() { + String userId = "cursor-user"; + insertBookmark("a1", userId, "2020-01-01 00:00:01"); + insertBookmark("a2", userId, "2020-01-01 00:00:02"); + insertBookmark("a3", userId, "2020-01-01 00:00:03"); + insertBookmark("a4", userId, "2020-01-01 00:00:04"); + + List firstPage = + readService.findUserBookmarkedArticleIdsWithCursor( + userId, new CursorPageParameter<>(null, 2, Direction.NEXT)); + Assertions.assertEquals(Arrays.asList("a4", "a3", "a2"), firstPage); + + List nextPage = + readService.findUserBookmarkedArticleIdsWithCursor( + userId, new CursorPageParameter<>("2020-01-01 00:00:03", 2, Direction.NEXT)); + Assertions.assertEquals(Arrays.asList("a2", "a1"), nextPage); + + List prevPage = + readService.findUserBookmarkedArticleIdsWithCursor( + userId, new CursorPageParameter<>("2020-01-01 00:00:02", 2, Direction.PREV)); + Assertions.assertEquals(Arrays.asList("a3", "a4"), prevPage); + } + + @Test + public void should_return_nearest_page_on_prev_when_results_exceed_limit() { + String userId = "cursor-user"; + insertBookmark("a1", userId, "2020-01-01 00:00:01"); + insertBookmark("a2", userId, "2020-01-01 00:00:02"); + insertBookmark("a3", userId, "2020-01-01 00:00:03"); + insertBookmark("a4", userId, "2020-01-01 00:00:04"); + insertBookmark("a5", userId, "2020-01-01 00:00:05"); + + // PREV from the oldest cursor: 4 rows (a2..a5) are above it but the page size is 2. + // The primitive must return the rows NEAREST the cursor ascending (a2, a3, [+1 extra a4]), + // not the farthest (a5, a4, ...). queryLimit = limit + 1 = 3. + List prevPage = + readService.findUserBookmarkedArticleIdsWithCursor( + userId, new CursorPageParameter<>("2020-01-01 00:00:01", 2, Direction.PREV)); + + Assertions.assertEquals(Arrays.asList("a2", "a3", "a4"), prevPage); + Assertions.assertFalse(prevPage.contains("a5")); + + // Mirror the query-service trimming + reverse the cursor wiring (MTM-3) applies for PREV: + // drop the extra row, reverse -> stable newest-bookmarked-first page that does not overlap + // the next page. + List trimmed = new ArrayList<>(prevPage.subList(0, 2)); + Collections.reverse(trimmed); + Assertions.assertEquals(Arrays.asList("a3", "a2"), trimmed); + } + + @Test + public void should_return_empty_for_user_without_bookmarks() { + List result = + readService.findUserBookmarkedArticleIdsWithCursor( + "nobody", new CursorPageParameter<>(null, 20, Direction.NEXT)); + Assertions.assertTrue(result.isEmpty()); + } +} diff --git a/src/test/java/io/spring/infrastructure/bookmark/MyBatisArticleBookmarkRepositoryTest.java b/src/test/java/io/spring/infrastructure/bookmark/MyBatisArticleBookmarkRepositoryTest.java new file mode 100644 index 000000000..f2d32967f --- /dev/null +++ b/src/test/java/io/spring/infrastructure/bookmark/MyBatisArticleBookmarkRepositoryTest.java @@ -0,0 +1,70 @@ +package io.spring.infrastructure.bookmark; + +import io.spring.core.bookmark.ArticleBookmark; +import io.spring.core.bookmark.ArticleBookmarkRepository; +import io.spring.infrastructure.DbTestBase; +import io.spring.infrastructure.repository.MyBatisArticleBookmarkRepository; +import javax.sql.DataSource; +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.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +@Import({MyBatisArticleBookmarkRepository.class}) +public class MyBatisArticleBookmarkRepositoryTest extends DbTestBase { + @Autowired private ArticleBookmarkRepository articleBookmarkRepository; + + @Autowired private DataSource dataSource; + + private JdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + jdbcTemplate = new JdbcTemplate(dataSource); + jdbcTemplate.execute( + "create table if not exists article_bookmarks (" + + "article_id varchar(255) not null," + + "user_id varchar(255) not null," + + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," + + "primary key (article_id, user_id))"); + } + + @Test + public void should_save_and_fetch_articleBookmark_success() { + ArticleBookmark bookmark = new ArticleBookmark("123", "456"); + articleBookmarkRepository.save(bookmark); + Assertions.assertTrue(articleBookmarkRepository.find("123", "456").isPresent()); + } + + @Test + public void should_be_idempotent_when_saving_same_bookmark_twice() { + ArticleBookmark bookmark = new ArticleBookmark("123", "456"); + articleBookmarkRepository.save(bookmark); + articleBookmarkRepository.save(bookmark); + + Integer rows = + jdbcTemplate.queryForObject( + "select count(1) from article_bookmarks where article_id = ? and user_id = ?", + Integer.class, + "123", + "456"); + Assertions.assertEquals(1, rows); + } + + @Test + public void should_remove_bookmark_success() { + ArticleBookmark bookmark = new ArticleBookmark("123", "456"); + articleBookmarkRepository.save(bookmark); + articleBookmarkRepository.remove(bookmark); + Assertions.assertFalse(articleBookmarkRepository.find("123", "456").isPresent()); + } + + @Test + public void should_be_noop_when_removing_non_existent_bookmark() { + ArticleBookmark bookmark = new ArticleBookmark("does-not", "exist"); + Assertions.assertDoesNotThrow(() -> articleBookmarkRepository.remove(bookmark)); + Assertions.assertFalse(articleBookmarkRepository.find("does-not", "exist").isPresent()); + } +}