From 2fa6af630494d56ffe05a3348e9927425204ee91 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:52:02 +0800 Subject: [PATCH] feat: add search module --- .../openisle/controller/SearchController.java | 96 +++++++++++++++++++ .../repository/CommentRepository.java | 1 + .../openisle/repository/PostRepository.java | 3 + .../openisle/repository/UserRepository.java | 1 + .../com/openisle/service/SearchService.java | 60 ++++++++++++ .../controller/SearchControllerTest.java | 66 +++++++++++++ .../integration/SearchIntegrationTest.java | 77 +++++++++++++++ 7 files changed, 304 insertions(+) create mode 100644 src/main/java/com/openisle/controller/SearchController.java create mode 100644 src/main/java/com/openisle/service/SearchService.java create mode 100644 src/test/java/com/openisle/controller/SearchControllerTest.java create mode 100644 src/test/java/com/openisle/integration/SearchIntegrationTest.java diff --git a/src/main/java/com/openisle/controller/SearchController.java b/src/main/java/com/openisle/controller/SearchController.java new file mode 100644 index 000000000..1068bec3c --- /dev/null +++ b/src/main/java/com/openisle/controller/SearchController.java @@ -0,0 +1,96 @@ +package com.openisle.controller; + +import com.openisle.model.Post; +import com.openisle.model.Comment; +import com.openisle.model.User; +import com.openisle.service.SearchService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/search") +@RequiredArgsConstructor +public class SearchController { + private final SearchService searchService; + + @GetMapping("/users") + public List searchUsers(@RequestParam String keyword) { + return searchService.searchUsers(keyword).stream() + .map(this::toUserDto) + .collect(Collectors.toList()); + } + + @GetMapping("/posts") + public List searchPosts(@RequestParam String keyword) { + return searchService.searchPosts(keyword).stream() + .map(this::toPostDto) + .collect(Collectors.toList()); + } + + @GetMapping("/posts/content") + public List searchPostsByContent(@RequestParam String keyword) { + return searchService.searchPostsByContent(keyword).stream() + .map(this::toPostDto) + .collect(Collectors.toList()); + } + + @GetMapping("/posts/title") + public List searchPostsByTitle(@RequestParam String keyword) { + return searchService.searchPostsByTitle(keyword).stream() + .map(this::toPostDto) + .collect(Collectors.toList()); + } + + @GetMapping("/global") + public List global(@RequestParam String keyword) { + return searchService.globalSearch(keyword).stream() + .map(r -> { + SearchResultDto dto = new SearchResultDto(); + dto.setType(r.type()); + dto.setId(r.id()); + dto.setText(r.text()); + return dto; + }) + .collect(Collectors.toList()); + } + + private UserDto toUserDto(User user) { + UserDto dto = new UserDto(); + dto.setId(user.getId()); + dto.setUsername(user.getUsername()); + return dto; + } + + private PostDto toPostDto(Post post) { + PostDto dto = new PostDto(); + dto.setId(post.getId()); + dto.setTitle(post.getTitle()); + return dto; + } + + @Data + private static class UserDto { + private Long id; + private String username; + } + + @Data + private static class PostDto { + private Long id; + private String title; + } + + @Data + private static class SearchResultDto { + private String type; + private Long id; + private String text; + } +} diff --git a/src/main/java/com/openisle/repository/CommentRepository.java b/src/main/java/com/openisle/repository/CommentRepository.java index af028a4d5..70a219d7e 100644 --- a/src/main/java/com/openisle/repository/CommentRepository.java +++ b/src/main/java/com/openisle/repository/CommentRepository.java @@ -12,4 +12,5 @@ public interface CommentRepository extends JpaRepository { List findByPostAndParentIsNullOrderByCreatedAtAsc(Post post); List findByParentOrderByCreatedAtAsc(Comment parent); List findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable); + List findByContentContainingIgnoreCase(String keyword); } diff --git a/src/main/java/com/openisle/repository/PostRepository.java b/src/main/java/com/openisle/repository/PostRepository.java index 00c2b158c..717180578 100644 --- a/src/main/java/com/openisle/repository/PostRepository.java +++ b/src/main/java/com/openisle/repository/PostRepository.java @@ -15,4 +15,7 @@ public interface PostRepository extends JpaRepository { List findByAuthorAndStatusOrderByCreatedAtDesc(User author, PostStatus status, Pageable pageable); List findByCategoryInAndStatus(List categories, PostStatus status); List findByCategoryInAndStatus(List categories, PostStatus status, Pageable pageable); + List findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(String titleKeyword, String contentKeyword, PostStatus status); + List findByContentContainingIgnoreCaseAndStatus(String keyword, PostStatus status); + List findByTitleContainingIgnoreCaseAndStatus(String keyword, PostStatus status); } diff --git a/src/main/java/com/openisle/repository/UserRepository.java b/src/main/java/com/openisle/repository/UserRepository.java index 79f3db1f9..91cd4b8b2 100644 --- a/src/main/java/com/openisle/repository/UserRepository.java +++ b/src/main/java/com/openisle/repository/UserRepository.java @@ -7,4 +7,5 @@ import java.util.Optional; public interface UserRepository extends JpaRepository { Optional findByUsername(String username); Optional findByEmail(String email); + java.util.List findByUsernameContainingIgnoreCase(String keyword); } diff --git a/src/main/java/com/openisle/service/SearchService.java b/src/main/java/com/openisle/service/SearchService.java new file mode 100644 index 000000000..517467058 --- /dev/null +++ b/src/main/java/com/openisle/service/SearchService.java @@ -0,0 +1,60 @@ +package com.openisle.service; + +import com.openisle.model.Post; +import com.openisle.model.PostStatus; +import com.openisle.model.Comment; +import com.openisle.model.User; +import com.openisle.repository.PostRepository; +import com.openisle.repository.CommentRepository; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +public class SearchService { + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + + public List searchUsers(String keyword) { + return userRepository.findByUsernameContainingIgnoreCase(keyword); + } + + public List searchPosts(String keyword) { + return postRepository + .findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(keyword, keyword, PostStatus.PUBLISHED); + } + + public List searchPostsByContent(String keyword) { + return postRepository + .findByContentContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED); + } + + public List searchPostsByTitle(String keyword) { + return postRepository + .findByTitleContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED); + } + + public List searchComments(String keyword) { + return commentRepository.findByContentContainingIgnoreCase(keyword); + } + + public List globalSearch(String keyword) { + Stream users = searchUsers(keyword).stream() + .map(u -> new SearchResult("user", u.getId(), u.getUsername())); + Stream posts = searchPosts(keyword).stream() + .map(p -> new SearchResult("post", p.getId(), p.getTitle())); + Stream titles = searchPostsByTitle(keyword).stream() + .map(p -> new SearchResult("post_title", p.getId(), p.getTitle())); + Stream comments = searchComments(keyword).stream() + .map(c -> new SearchResult("comment", c.getId(), c.getContent())); + return Stream.concat(Stream.concat(Stream.concat(users, posts), titles), comments) + .toList(); + } + + public record SearchResult(String type, Long id, String text) {} +} diff --git a/src/test/java/com/openisle/controller/SearchControllerTest.java b/src/test/java/com/openisle/controller/SearchControllerTest.java new file mode 100644 index 000000000..e12a73206 --- /dev/null +++ b/src/test/java/com/openisle/controller/SearchControllerTest.java @@ -0,0 +1,66 @@ +package com.openisle.controller; + +import com.openisle.model.Comment; +import com.openisle.model.Post; +import com.openisle.model.User; +import com.openisle.model.PostStatus; +import com.openisle.service.SearchService; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(SearchController.class) +@AutoConfigureMockMvc(addFilters = false) +class SearchControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private SearchService searchService; + + @Test + void userSearchEndpoint() throws Exception { + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + Mockito.when(searchService.searchUsers("ali")).thenReturn(List.of(user)); + + mockMvc.perform(get("/api/search/users").param("keyword", "ali")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].username").value("alice")); + } + + @Test + void globalSearchAggregatesTypes() throws Exception { + User u = new User(); + u.setId(1L); + u.setUsername("bob"); + Post p = new Post(); + p.setId(2L); + p.setTitle("hello"); + p.setStatus(PostStatus.PUBLISHED); + Comment c = new Comment(); + c.setId(3L); + c.setContent("nice"); + Mockito.when(searchService.globalSearch("n")).thenReturn(List.of( + new SearchService.SearchResult("user", 1L, "bob"), + new SearchService.SearchResult("post", 2L, "hello"), + new SearchService.SearchResult("comment", 3L, "nice") + )); + + mockMvc.perform(get("/api/search/global").param("keyword", "n")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].type").value("user")) + .andExpect(jsonPath("$[1].type").value("post")) + .andExpect(jsonPath("$[2].type").value("comment")); + } +} diff --git a/src/test/java/com/openisle/integration/SearchIntegrationTest.java b/src/test/java/com/openisle/integration/SearchIntegrationTest.java new file mode 100644 index 000000000..7761f9cf0 --- /dev/null +++ b/src/test/java/com/openisle/integration/SearchIntegrationTest.java @@ -0,0 +1,77 @@ +package com.openisle.integration; + +import com.openisle.model.Role; +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import com.openisle.service.EmailSender; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.*; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class SearchIntegrationTest { + @Autowired + private TestRestTemplate rest; + @Autowired + private UserRepository users; + @MockBean + private EmailSender emailService; + + private String registerAndLogin(String username, String email) { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + rest.postForEntity("/api/auth/register", new HttpEntity<>( + Map.of("username", username, "email", email, "password", "pass123"), h), Map.class); + User u = users.findByUsername(username).orElseThrow(); + rest.postForEntity("/api/auth/verify", new HttpEntity<>( + Map.of("username", username, "code", u.getVerificationCode()), h), Map.class); + ResponseEntity resp = rest.postForEntity("/api/auth/login", new HttpEntity<>( + Map.of("username", username, "password", "pass123"), h), Map.class); + return (String) resp.getBody().get("token"); + } + + private String registerAndLoginAsAdmin(String username, String email) { + String token = registerAndLogin(username, email); + User u = users.findByUsername(username).orElseThrow(); + u.setRole(Role.ADMIN); + users.save(u); + return token; + } + + private ResponseEntity postJson(String url, Map body, String token) { + HttpHeaders h = new HttpHeaders(); + h.setContentType(MediaType.APPLICATION_JSON); + if (token != null) h.setBearerAuth(token); + return rest.exchange(url, HttpMethod.POST, new HttpEntity<>(body, h), Map.class); + } + + @Test + void globalSearchReturnsMixedResults() { + String admin = registerAndLoginAsAdmin("admin", "a@a.com"); + String user = registerAndLogin("bob", "b@b.com"); + + ResponseEntity catResp = postJson("/api/categories", Map.of("name", "misc"), admin); + Long catId = ((Number)catResp.getBody().get("id")).longValue(); + + ResponseEntity postResp = postJson("/api/posts", + Map.of("title", "Hello World", "content", "Some content", "categoryId", catId), user); + Long postId = ((Number)postResp.getBody().get("id")).longValue(); + + postJson("/api/posts/" + postId + "/comments", + Map.of("content", "Nice article"), admin); + + List> results = rest.getForObject("/api/search/global?keyword=nic", List.class); + assertEquals(3, results.size()); + assertTrue(results.stream().anyMatch(m -> "user".equals(m.get("type")))); + assertTrue(results.stream().anyMatch(m -> "post".equals(m.get("type")))); + assertTrue(results.stream().anyMatch(m -> "comment".equals(m.get("type")))); + } +}