Merge pull request #37 from nagisa77/codex/add-search-module-with-multiple-features

Add search module
This commit is contained in:
Tim
2025-07-01 17:52:16 +08:00
committed by GitHub
7 changed files with 304 additions and 0 deletions

View File

@@ -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<UserDto> searchUsers(@RequestParam String keyword) {
return searchService.searchUsers(keyword).stream()
.map(this::toUserDto)
.collect(Collectors.toList());
}
@GetMapping("/posts")
public List<PostDto> searchPosts(@RequestParam String keyword) {
return searchService.searchPosts(keyword).stream()
.map(this::toPostDto)
.collect(Collectors.toList());
}
@GetMapping("/posts/content")
public List<PostDto> searchPostsByContent(@RequestParam String keyword) {
return searchService.searchPostsByContent(keyword).stream()
.map(this::toPostDto)
.collect(Collectors.toList());
}
@GetMapping("/posts/title")
public List<PostDto> searchPostsByTitle(@RequestParam String keyword) {
return searchService.searchPostsByTitle(keyword).stream()
.map(this::toPostDto)
.collect(Collectors.toList());
}
@GetMapping("/global")
public List<SearchResultDto> 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;
}
}

View File

@@ -12,4 +12,5 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
List<Comment> findByContentContainingIgnoreCase(String keyword);
}

View File

@@ -15,4 +15,7 @@ public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(User author, PostStatus status, Pageable pageable);
List<Post> findByCategoryInAndStatus(List<Category> categories, PostStatus status);
List<Post> findByCategoryInAndStatus(List<Category> categories, PostStatus status, Pageable pageable);
List<Post> findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(String titleKeyword, String contentKeyword, PostStatus status);
List<Post> findByContentContainingIgnoreCaseAndStatus(String keyword, PostStatus status);
List<Post> findByTitleContainingIgnoreCaseAndStatus(String keyword, PostStatus status);
}

View File

@@ -7,4 +7,5 @@ import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
java.util.List<User> findByUsernameContainingIgnoreCase(String keyword);
}

View File

@@ -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<User> searchUsers(String keyword) {
return userRepository.findByUsernameContainingIgnoreCase(keyword);
}
public List<Post> searchPosts(String keyword) {
return postRepository
.findByTitleContainingIgnoreCaseOrContentContainingIgnoreCaseAndStatus(keyword, keyword, PostStatus.PUBLISHED);
}
public List<Post> searchPostsByContent(String keyword) {
return postRepository
.findByContentContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED);
}
public List<Post> searchPostsByTitle(String keyword) {
return postRepository
.findByTitleContainingIgnoreCaseAndStatus(keyword, PostStatus.PUBLISHED);
}
public List<Comment> searchComments(String keyword) {
return commentRepository.findByContentContainingIgnoreCase(keyword);
}
public List<SearchResult> globalSearch(String keyword) {
Stream<SearchResult> users = searchUsers(keyword).stream()
.map(u -> new SearchResult("user", u.getId(), u.getUsername()));
Stream<SearchResult> posts = searchPosts(keyword).stream()
.map(p -> new SearchResult("post", p.getId(), p.getTitle()));
Stream<SearchResult> titles = searchPostsByTitle(keyword).stream()
.map(p -> new SearchResult("post_title", p.getId(), p.getTitle()));
Stream<SearchResult> 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) {}
}

View File

@@ -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"));
}
}

View File

@@ -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<Map> 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<Map> 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<Map> catResp = postJson("/api/categories", Map.of("name", "misc"), admin);
Long catId = ((Number)catResp.getBody().get("id")).longValue();
ResponseEntity<Map> 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<Map<String, Object>> 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"))));
}
}