diff --git a/src/test/java/io/spring/BookmarkCrossSurfaceTest.java b/src/test/java/io/spring/BookmarkCrossSurfaceTest.java new file mode 100644 index 000000000..3ec6a64e7 --- /dev/null +++ b/src/test/java/io/spring/BookmarkCrossSurfaceTest.java @@ -0,0 +1,340 @@ +package io.spring; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.jayway.jsonpath.JsonPath; +import com.netflix.graphql.dgs.DgsQueryExecutor; +import io.spring.core.article.Article; +import io.spring.core.article.ArticleRepository; +import io.spring.core.service.JwtService; +import io.spring.core.user.User; +import io.spring.core.user.UserRepository; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import javax.sql.DataSource; +import org.junit.jupiter.api.AfterEach; +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.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +/** + * MTM-5 / MTM-23: cross-surface equivalence and end-to-end coverage for the reading-list (bookmark) + * feature. Drives the SAME application stack (real repositories + DB) through BOTH the REST + * controllers (via {@link MockMvc}) and the GraphQL adapter (via {@link DgsQueryExecutor}) and + * asserts that the two surfaces expose the same underlying data, membership, ordering, auth/privacy + * rules, the per-article {@code bookmarked} flag, and independence from favorites. + * + *
The intentional pagination-mechanics divergence (D4: REST offset/limit vs GraphQL Relay
+ * cursor) is honored: equivalence is asserted on the resulting set and newest-bookmarked-first
+ * ordering, not on the paging idiom. Ordering is made deterministic by stamping controlled bookmark
+ * {@code created_at} values after each write.
+ */
+@ActiveProfiles("test")
+@SpringBootTest
+@AutoConfigureMockMvc
+@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
+@Transactional
+public class BookmarkCrossSurfaceTest {
+
+ @Autowired private MockMvc mvc;
+ @Autowired private DgsQueryExecutor dgsQueryExecutor;
+ @Autowired private UserRepository userRepository;
+ @Autowired private ArticleRepository articleRepository;
+ @Autowired private JwtService jwtService;
+ @Autowired private DataSource dataSource;
+
+ private JdbcTemplate jdbcTemplate;
+ private User user;
+ private User other;
+ private String token;
+ private String otherToken;
+ private Article a1;
+ private Article a2;
+ private Article a3;
+
+ @BeforeEach
+ public void setUp() {
+ jdbcTemplate = new JdbcTemplate(dataSource);
+ // V3 migration is skipped under the test profile (flyway.target=1); create the table here,
+ // mirroring the existing bookmark infrastructure tests.
+ 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))");
+
+ user = new User("e2e@test.com", "e2euser", "123", "", "");
+ other = new User("other@test.com", "otheruser", "123", "", "");
+ userRepository.save(user);
+ userRepository.save(other);
+ token = jwtService.toToken(user);
+ otherToken = jwtService.toToken(other);
+
+ a1 = new Article("Title One", "desc one", "body one", Arrays.asList("java"), user.getId());
+ a2 = new Article("Title Two", "desc two", "body two", Arrays.asList("kotlin"), user.getId());
+ a3 =
+ new Article(
+ "Title Three", "desc three", "body three", Arrays.asList("spring"), user.getId());
+ articleRepository.save(a1);
+ articleRepository.save(a2);
+ articleRepository.save(a3);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ // ---- Cross-surface E2E ---------------------------------------------------
+
+ @Test
+ public void bookmark_via_rest_is_visible_via_graphql_and_removable() throws Exception {
+ restPost("/articles/" + a1.getSlug() + "/bookmark", token)
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.article.bookmarked").value(true));
+ setBookmarkTime(a1, user, "2020-01-01 00:00:01");
+
+ // GraphQL sees the per-article flag and the reading-list membership.
+ Assertions.assertTrue(graphqlArticleBookmarked(a1, user));
+ Assertions.assertEquals(Collections.singletonList(a1.getSlug()), graphqlBookmarkedSlugs(user));
+
+ // Remove via REST -> disappears on GraphQL.
+ restDelete("/articles/" + a1.getSlug() + "/bookmark", token)
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.article.bookmarked").value(false));
+ Assertions.assertFalse(graphqlArticleBookmarked(a1, user));
+ Assertions.assertTrue(graphqlBookmarkedSlugs(user).isEmpty());
+ }
+
+ @Test
+ public void bookmark_via_graphql_is_visible_via_rest_and_removable() throws Exception {
+ graphqlMutation("bookmarkArticle", a2.getSlug(), user);
+ setBookmarkTime(a2, user, "2020-01-01 00:00:02");
+
+ // REST reading list and REST per-article flag both see it.
+ Assertions.assertEquals(Collections.singletonList(a2.getSlug()), restBookmarkedSlugs(token));
+ restGet("/articles/" + a2.getSlug(), token)
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.article.bookmarked").value(true));
+
+ // Remove via GraphQL -> disappears on REST.
+ graphqlMutation("unbookmarkArticle", a2.getSlug(), user);
+ Assertions.assertTrue(restBookmarkedSlugs(token).isEmpty());
+ restGet("/articles/" + a2.getSlug(), token)
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.article.bookmarked").value(false));
+ }
+
+ // ---- Equivalence: same set + same newest-first ordering -----------------
+
+ @Test
+ public void both_surfaces_return_same_set_and_newest_first_ordering() throws Exception {
+ // Mix the write surfaces; ordering must depend only on bookmark time, not on how it was added.
+ restPost("/articles/" + a1.getSlug() + "/bookmark", token).andExpect(status().isOk());
+ graphqlMutation("bookmarkArticle", a2.getSlug(), user);
+ restPost("/articles/" + a3.getSlug() + "/bookmark", token).andExpect(status().isOk());
+
+ setBookmarkTime(a1, user, "2020-01-01 00:00:01");
+ setBookmarkTime(a2, user, "2020-01-01 00:00:02");
+ setBookmarkTime(a3, user, "2020-01-01 00:00:03");
+
+ List