-
Notifications
You must be signed in to change notification settings - Fork 0
MTM-3: view reading list (most-recent-bookmarked first) #184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2fd0865
eebb5c9
23d1562
22e1ade
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,9 +2,11 @@ | |
|
|
||
| import static java.util.stream.Collectors.toList; | ||
|
|
||
| import io.spring.application.data.ArticleBookmarkDate; | ||
| import io.spring.application.data.ArticleData; | ||
| import io.spring.application.data.ArticleDataList; | ||
| import io.spring.application.data.ArticleFavoriteCount; | ||
| import io.spring.application.data.BookmarkedArticleData; | ||
| import io.spring.core.user.User; | ||
| import io.spring.infrastructure.mybatis.readservice.ArticleBookmarksReadService; | ||
| import io.spring.infrastructure.mybatis.readservice.ArticleFavoritesReadService; | ||
|
|
@@ -15,10 +17,13 @@ | |
| import java.util.HashMap; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Objects; | ||
| import java.util.Optional; | ||
| import java.util.Set; | ||
| import java.util.stream.Collectors; | ||
| import lombok.AllArgsConstructor; | ||
| import org.joda.time.DateTime; | ||
| import org.joda.time.DateTimeZone; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| @Service | ||
|
|
@@ -99,6 +104,77 @@ public CursorPager<ArticleData> findUserFeedWithCursor( | |
| } | ||
| } | ||
|
|
||
| public CursorPager<BookmarkedArticleData> findUserBookmarksWithCursor( | ||
| User currentUser, CursorPageParameter<DateTime> page) { | ||
| List<String> ids = | ||
| articleBookmarksReadService.findUserBookmarkedArticleIdsWithCursor( | ||
| currentUser.getId(), toTextCursor(page)); | ||
| if (ids.size() == 0) { | ||
| return new CursorPager<>(new ArrayList<>(), page.getDirection(), false); | ||
| } | ||
| boolean hasExtra = ids.size() > page.getLimit(); | ||
| if (hasExtra) { | ||
| ids.remove(page.getLimit()); | ||
| } | ||
| if (!page.isNext()) { | ||
| Collections.reverse(ids); | ||
| } | ||
|
|
||
| List<ArticleData> articles = findArticlesInIdOrder(ids); | ||
| if (!articles.isEmpty()) { | ||
| fillExtraInfo(articles, currentUser); | ||
| } | ||
|
|
||
| Map<String, DateTime> bookmarkedAt = bookmarkDates(currentUser.getId(), ids); | ||
| List<BookmarkedArticleData> data = | ||
| articles.stream() | ||
| .filter(article -> bookmarkedAt.get(article.getId()) != null) | ||
| .map(article -> new BookmarkedArticleData(article, bookmarkedAt.get(article.getId()))) | ||
| .collect(toList()); | ||
| return new CursorPager<>(data, page.getDirection(), hasExtra); | ||
| } | ||
|
|
||
| public ArticleDataList findUserBookmarks(User currentUser, Page page) { | ||
| List<String> ids = | ||
| articleBookmarksReadService.findUserBookmarkedArticleIds(currentUser.getId(), page); | ||
| int count = articleBookmarksReadService.countUserBookmarks(currentUser.getId()); | ||
| if (ids.size() == 0) { | ||
| return new ArticleDataList(new ArrayList<>(), count); | ||
| } | ||
| List<ArticleData> articles = findArticlesInIdOrder(ids); | ||
| if (!articles.isEmpty()) { | ||
| fillExtraInfo(articles, currentUser); | ||
| } | ||
| return new ArticleDataList(articles, count); | ||
| } | ||
|
Comment on lines
+137
to
+149
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 Bookmark count includes orphaned bookmarks, creating a mismatch with returned articles In Was this helpful? React with 👍 or 👎 to provide feedback.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is intentional, as the test documents. |
||
|
|
||
| private List<ArticleData> findArticlesInIdOrder(List<String> ids) { | ||
| Map<String, ArticleData> byId = | ||
| articleReadService.findArticles(ids).stream() | ||
| .collect(Collectors.toMap(ArticleData::getId, article -> article)); | ||
| return ids.stream().map(byId::get).filter(Objects::nonNull).collect(toList()); | ||
| } | ||
|
|
||
| /** | ||
| * {@code article_bookmarks.created_at} is persisted as a SQLite text timestamp (the column's | ||
| * {@code CURRENT_TIMESTAMP} default), whereas a {@link DateTime} cursor binds as epoch millis. | ||
| * Convert the cursor to the same text form so the bookmark-time comparison (D1) is correct; the | ||
| * opaque cursor exposed to clients remains the {@link DateTimeCursor} (millis). | ||
| */ | ||
| private CursorPageParameter<String> toTextCursor(CursorPageParameter<DateTime> page) { | ||
| String cursor = | ||
| page.getCursor() == null | ||
| ? null | ||
| : page.getCursor().withZone(DateTimeZone.UTC).toString("yyyy-MM-dd HH:mm:ss"); | ||
| return new CursorPageParameter<>(cursor, page.getLimit(), page.getDirection()); | ||
| } | ||
|
|
||
| private Map<String, DateTime> bookmarkDates(String userId, List<String> ids) { | ||
| return articleBookmarksReadService.findBookmarkDates(userId, ids).stream() | ||
| .collect( | ||
| Collectors.toMap(ArticleBookmarkDate::getArticleId, ArticleBookmarkDate::getCreatedAt)); | ||
| } | ||
|
|
||
| public ArticleDataList findRecentArticles( | ||
| String tag, String author, String favoritedBy, Page page, User currentUser) { | ||
| List<String> articleIds = articleReadService.queryArticles(tag, author, favoritedBy, page); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package io.spring.application.data; | ||
|
|
||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
| import lombok.Setter; | ||
| import org.joda.time.DateTime; | ||
|
|
||
| @Getter | ||
| @Setter | ||
| @NoArgsConstructor | ||
| public class ArticleBookmarkDate { | ||
| private String articleId; | ||
| private DateTime createdAt; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package io.spring.application.data; | ||
|
|
||
| import io.spring.application.DateTimeCursor; | ||
| import io.spring.application.Node; | ||
| import io.spring.application.PageCursor; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
| import org.joda.time.DateTime; | ||
|
|
||
| /** | ||
| * Read-model node for a current user's bookmarked article. Carries the bookmark's {@code | ||
| * created_at} so cursor pagination is anchored on bookmark time (newest-bookmarked first) rather | ||
| * than the article's own create/update time. | ||
| */ | ||
| @Getter | ||
| @AllArgsConstructor | ||
| public class BookmarkedArticleData implements Node { | ||
| private ArticleData article; | ||
| private DateTime bookmarkedAt; | ||
|
|
||
| @Override | ||
| public PageCursor getCursor() { | ||
| return new DateTimeCursor(bookmarkedAt); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| package io.spring.api; | ||
|
|
||
| import static io.restassured.module.mockmvc.RestAssuredMockMvc.given; | ||
| import static io.spring.TestHelper.articleDataFixture; | ||
| import static java.util.Arrays.asList; | ||
| import static org.mockito.ArgumentMatchers.eq; | ||
| 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.Page; | ||
| import io.spring.application.article.ArticleCommandService; | ||
| import io.spring.application.data.ArticleDataList; | ||
| import io.spring.core.article.ArticleRepository; | ||
| 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(ArticlesApi.class) | ||
| @Import({WebSecurityConfig.class, JacksonCustomizations.class}) | ||
| public class BookmarkedArticlesApiTest extends TestWithCurrentUser { | ||
| @MockBean private ArticleRepository articleRepository; | ||
|
|
||
| @MockBean private ArticleQueryService articleQueryService; | ||
|
|
||
| @MockBean private ArticleCommandService articleCommandService; | ||
|
|
||
| @Autowired private MockMvc mvc; | ||
|
|
||
| @Override | ||
| @BeforeEach | ||
| public void setUp() throws Exception { | ||
| super.setUp(); | ||
| RestAssuredMockMvc.mockMvc(mvc); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_get_401_without_login() throws Exception { | ||
| RestAssuredMockMvc.when().get("/articles/bookmarked").prettyPeek().then().statusCode(401); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_get_bookmarked_articles_with_envelope() throws Exception { | ||
| ArticleDataList articleDataList = | ||
| new ArticleDataList( | ||
| asList(articleDataFixture("1", user), articleDataFixture("2", user)), 2); | ||
| when(articleQueryService.findUserBookmarks(eq(user), eq(new Page(0, 20)))) | ||
| .thenReturn(articleDataList); | ||
|
|
||
| given() | ||
| .header("Authorization", "Token " + token) | ||
| .when() | ||
| .get("/articles/bookmarked") | ||
| .prettyPeek() | ||
| .then() | ||
| .statusCode(200) | ||
| .body("articlesCount", org.hamcrest.Matchers.is(2)) | ||
| .body("articles.size()", org.hamcrest.Matchers.is(2)); | ||
| } | ||
|
|
||
| @Test | ||
| public void should_pass_offset_and_limit_through() throws Exception { | ||
| when(articleQueryService.findUserBookmarks(eq(user), eq(new Page(5, 10)))) | ||
| .thenReturn(new ArticleDataList(asList(articleDataFixture("1", user)), 11)); | ||
|
|
||
| given() | ||
| .header("Authorization", "Token " + token) | ||
| .when() | ||
| .get("/articles/bookmarked?offset=5&limit=10") | ||
| .prettyPeek() | ||
| .then() | ||
| .statusCode(200) | ||
| .body("articlesCount", org.hamcrest.Matchers.is(11)) | ||
| .body("articles.size()", org.hamcrest.Matchers.is(1)); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.