diff --git a/pom.xml b/pom.xml index 0a09381..03ca8ee 100644 --- a/pom.xml +++ b/pom.xml @@ -207,7 +207,7 @@ org.apache.maven.plugins maven-surefire-plugin - @{argLine} -javaagent:${org.mockito:mockito-core:jar} + @{argLine} "-javaagent:${org.mockito:mockito-core:jar}" diff --git a/src/main/java/xyz/subho/clone/twitter/constant/SearchV1Constants.java b/src/main/java/xyz/subho/clone/twitter/constant/SearchV1Constants.java new file mode 100644 index 0000000..a9cce7a --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/constant/SearchV1Constants.java @@ -0,0 +1,31 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.constant; + +/** SearchV1Constants - API endpoint constants for Search V1 Controller. */ +public class SearchV1Constants { + + public static final String BASE_PATH = ApiVersion.V1 + "/search"; + public static final String POSTS = "/posts"; + public static final String USERS = "/users"; + + private SearchV1Constants() { + // Prevent instantiation + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/controller/SearchController.java b/src/main/java/xyz/subho/clone/twitter/controller/SearchController.java new file mode 100644 index 0000000..41d6e55 --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/controller/SearchController.java @@ -0,0 +1,92 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.controller; + +import io.micrometer.core.annotation.Counted; +import io.micrometer.core.annotation.Timed; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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 xyz.subho.clone.twitter.constant.SearchV1Constants; +import xyz.subho.clone.twitter.model.PostModel; +import xyz.subho.clone.twitter.model.SearchResultModel; +import xyz.subho.clone.twitter.model.UserModel; +import xyz.subho.clone.twitter.service.SearchService; +import xyz.subho.clone.twitter.utility.Utility; + +@RestController +@RequestMapping(SearchV1Constants.BASE_PATH) +@Timed(value = "moo.search.timer", description = "Time taken to process search requests") +public class SearchController { + + private final SearchService searchService; + + public SearchController(SearchService searchService) { + this.searchService = searchService; + } + + @GetMapping + @Counted(value = "moo.search.all", description = "Number of combined search requests") + public ResponseEntity searchAll( + @RequestParam("q") String query, + @PageableDefault( + size = 20, + sort = {"createdAt", "id"}, + direction = Sort.Direction.DESC) + Pageable pageable) { + Utility.validatePageable(pageable, "createdAt", "id"); + SearchResultModel result = searchService.searchAll(query, pageable); + return new ResponseEntity<>(result, HttpStatus.OK); + } + + @GetMapping(SearchV1Constants.POSTS) + @Counted(value = "moo.search.posts", description = "Number of post search requests") + public ResponseEntity> searchPosts( + @RequestParam("q") String query, + @PageableDefault( + size = 20, + sort = {"createdAt", "id"}, + direction = Sort.Direction.DESC) + Pageable pageable) { + Utility.validatePageable(pageable, "createdAt", "id"); + Page posts = searchService.searchPosts(query, pageable); + return new ResponseEntity<>(posts, HttpStatus.OK); + } + + @GetMapping(SearchV1Constants.USERS) + @Counted(value = "moo.search.users", description = "Number of user search requests") + public ResponseEntity> searchUsers( + @RequestParam("q") String query, + @PageableDefault( + size = 20, + sort = {"createdAt", "id"}, + direction = Sort.Direction.DESC) + Pageable pageable) { + Utility.validatePageable(pageable, "createdAt", "id", "followerCount", "name", "username"); + Page users = searchService.searchUsers(query, pageable); + return new ResponseEntity<>(users, HttpStatus.OK); + } +} diff --git a/src/main/java/xyz/subho/clone/twitter/exception/ControllerExceptionHandler.java b/src/main/java/xyz/subho/clone/twitter/exception/ControllerExceptionHandler.java index 4310510..80b50e5 100644 --- a/src/main/java/xyz/subho/clone/twitter/exception/ControllerExceptionHandler.java +++ b/src/main/java/xyz/subho/clone/twitter/exception/ControllerExceptionHandler.java @@ -29,6 +29,7 @@ import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; @@ -82,7 +83,8 @@ public ResponseEntity validationExceptionHandler( @ExceptionHandler({ HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class, - ConstraintViolationException.class + ConstraintViolationException.class, + MissingServletRequestParameterException.class }) public ResponseEntity malformedRequestExceptionHandler(WebRequest request) { return errorResponse(HttpStatus.BAD_REQUEST, "Malformed request", request); diff --git a/src/main/java/xyz/subho/clone/twitter/model/SearchResultModel.java b/src/main/java/xyz/subho/clone/twitter/model/SearchResultModel.java new file mode 100644 index 0000000..0a1bb6e --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/model/SearchResultModel.java @@ -0,0 +1,23 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.model; + +import org.springframework.data.domain.Page; + +public record SearchResultModel(Page users, Page posts) {} diff --git a/src/main/java/xyz/subho/clone/twitter/repository/PostsRepository.java b/src/main/java/xyz/subho/clone/twitter/repository/PostsRepository.java index dd82149..3dda954 100644 --- a/src/main/java/xyz/subho/clone/twitter/repository/PostsRepository.java +++ b/src/main/java/xyz/subho/clone/twitter/repository/PostsRepository.java @@ -21,6 +21,7 @@ import jakarta.persistence.LockModeType; import java.util.Optional; import java.util.UUID; +import org.jspecify.annotations.NonNull; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -44,4 +45,9 @@ public interface PostsRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select p from Posts p where p.id = :id") Optional findByIdForUpdate(@Param("id") UUID id); + + @Query( + "select p from Posts p where " + + "(p.text is not null and lower(p.text) like lower(concat('%', :query, '%')) escape '\\')") + Page searchPosts(@Param("query") @NonNull String query, @NonNull Pageable pageable); } diff --git a/src/main/java/xyz/subho/clone/twitter/repository/UsersRepository.java b/src/main/java/xyz/subho/clone/twitter/repository/UsersRepository.java index 4033077..fb41ee4 100644 --- a/src/main/java/xyz/subho/clone/twitter/repository/UsersRepository.java +++ b/src/main/java/xyz/subho/clone/twitter/repository/UsersRepository.java @@ -21,6 +21,9 @@ import jakarta.persistence.LockModeType; import java.util.Optional; import java.util.UUID; +import org.jspecify.annotations.NonNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -38,4 +41,11 @@ public interface UsersRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select u from Users u where u.id = :id") Optional findByIdForUpdate(@Param("id") UUID id); + + @Query( + "select u from Users u where " + + "lower(u.username) like lower(concat('%', :query, '%')) escape '\\' or " + + "lower(u.name) like lower(concat('%', :query, '%')) escape '\\' or " + + "(u.bio is not null and lower(u.bio) like lower(concat('%', :query, '%')) escape '\\')") + Page searchUsers(@Param("query") @NonNull String query, @NonNull Pageable pageable); } diff --git a/src/main/java/xyz/subho/clone/twitter/service/SearchService.java b/src/main/java/xyz/subho/clone/twitter/service/SearchService.java new file mode 100644 index 0000000..bb73859 --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/service/SearchService.java @@ -0,0 +1,35 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.service; + +import org.jspecify.annotations.NonNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import xyz.subho.clone.twitter.model.PostModel; +import xyz.subho.clone.twitter.model.SearchResultModel; +import xyz.subho.clone.twitter.model.UserModel; + +public interface SearchService { + + public @NonNull Page searchPosts(@NonNull String query, @NonNull Pageable pageable); + + public @NonNull Page searchUsers(@NonNull String query, @NonNull Pageable pageable); + + public @NonNull SearchResultModel searchAll(@NonNull String query, @NonNull Pageable pageable); +} diff --git a/src/main/java/xyz/subho/clone/twitter/service/impl/SearchServiceImpl.java b/src/main/java/xyz/subho/clone/twitter/service/impl/SearchServiceImpl.java new file mode 100644 index 0000000..d6e9eb0 --- /dev/null +++ b/src/main/java/xyz/subho/clone/twitter/service/impl/SearchServiceImpl.java @@ -0,0 +1,103 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package xyz.subho.clone.twitter.service.impl; + +import org.jspecify.annotations.NonNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import xyz.subho.clone.twitter.exception.BadRequestException; +import xyz.subho.clone.twitter.model.PostModel; +import xyz.subho.clone.twitter.model.SearchResultModel; +import xyz.subho.clone.twitter.model.UserModel; +import xyz.subho.clone.twitter.repository.PostsRepository; +import xyz.subho.clone.twitter.repository.UsersRepository; +import xyz.subho.clone.twitter.service.SearchService; +import xyz.subho.clone.twitter.utility.PostMapper; +import xyz.subho.clone.twitter.utility.UserMapper; + +@Service +public class SearchServiceImpl implements SearchService { + + private static final int MAX_QUERY_LENGTH = 100; + + private final PostsRepository postsRepository; + private final UsersRepository usersRepository; + private final PostMapper postMapper; + private final UserMapper userMapper; + + public SearchServiceImpl( + PostsRepository postsRepository, + UsersRepository usersRepository, + PostMapper postMapper, + UserMapper userMapper) { + this.postsRepository = postsRepository; + this.usersRepository = usersRepository; + this.postMapper = postMapper; + this.userMapper = userMapper; + } + + @Override + @Transactional(readOnly = true) + public @NonNull Page searchPosts(@NonNull String query, @NonNull Pageable pageable) { + String sanitizedQuery = validateAndSanitizeQuery(query); + var postsPage = postsRepository.searchPosts(sanitizedQuery, pageable); + return postsPage.map(postMapper::toModel); + } + + @Override + @Transactional(readOnly = true) + public @NonNull Page searchUsers(@NonNull String query, @NonNull Pageable pageable) { + String rawQuery = query != null ? query.trim() : ""; + if (rawQuery.startsWith("@") && rawQuery.length() > 1) { + rawQuery = rawQuery.substring(1); + } + String sanitizedQuery = validateAndSanitizeQuery(rawQuery); + var usersPage = usersRepository.searchUsers(sanitizedQuery, pageable); + return usersPage.map(userMapper::toModel); + } + + @Override + @Transactional(readOnly = true) + public @NonNull SearchResultModel searchAll(@NonNull String query, @NonNull Pageable pageable) { + Page users = searchUsers(query, pageable); + Page posts = searchPosts(query, pageable); + return new SearchResultModel(users, posts); + } + + private String validateAndSanitizeQuery(String query) { + if (query == null) { + throw new BadRequestException("Search query must not be blank"); + } + String trimmed = query.trim(); + if (trimmed.isEmpty()) { + throw new BadRequestException("Search query must not be blank"); + } + if (trimmed.length() > MAX_QUERY_LENGTH) { + throw new BadRequestException( + "Search query must not exceed " + MAX_QUERY_LENGTH + " characters"); + } + return escapeLikeWildcards(trimmed); + } + + private String escapeLikeWildcards(String input) { + return input.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } +} diff --git a/src/test/java/xyz/subho/clone/twitter/SecurityMvcTest.java b/src/test/java/xyz/subho/clone/twitter/SecurityMvcTest.java index 8aa8b53..ea30d25 100644 --- a/src/test/java/xyz/subho/clone/twitter/SecurityMvcTest.java +++ b/src/test/java/xyz/subho/clone/twitter/SecurityMvcTest.java @@ -25,6 +25,7 @@ import org.springframework.test.web.servlet.MockMvc; import xyz.subho.clone.twitter.constant.AuthV1Constants; import xyz.subho.clone.twitter.constant.PostV1Constants; +import xyz.subho.clone.twitter.constant.SearchV1Constants; import xyz.subho.clone.twitter.constant.UserV1Constants; @SpringBootTest @@ -62,6 +63,13 @@ void malformedBearerTokensDoNotBecomeServerErrors() throws Exception { .andExpect(status().isUnauthorized()); } + @Test + void searchEndpointsRequireAuthentication() throws Exception { + mockMvc + .perform(get(SearchV1Constants.BASE_PATH + SearchV1Constants.POSTS).param("q", "hello")) + .andExpect(status().isUnauthorized()); + } + @Test void openApiDocumentIsPublicAndAvailable() throws Exception { mockMvc diff --git a/src/test/java/xyz/subho/clone/twitter/TwitterBackendApplicationTests.java b/src/test/java/xyz/subho/clone/twitter/TwitterBackendApplicationTests.java index 63eaa29..ccf1917 100644 --- a/src/test/java/xyz/subho/clone/twitter/TwitterBackendApplicationTests.java +++ b/src/test/java/xyz/subho/clone/twitter/TwitterBackendApplicationTests.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; import xyz.subho.clone.twitter.entity.HashtagPosts; @@ -37,6 +38,7 @@ import xyz.subho.clone.twitter.repository.PostsRepository; import xyz.subho.clone.twitter.repository.UsersRepository; import xyz.subho.clone.twitter.service.PostService; +import xyz.subho.clone.twitter.service.SearchService; @SpringBootTest @ActiveProfiles("test") @@ -47,11 +49,34 @@ class TwitterBackendApplicationTests { @Autowired private LikesRepository likesRepository; @Autowired private PostsRepository postsRepository; @Autowired private PostService postService; + @Autowired private SearchService searchService; @Autowired private UsersRepository usersRepository; @Test void contextLoads() {} + @Test + @Transactional + void searchFindsUsersAndPostsCaseInsensitively() { + Users user = user("search-dev", "search-dev@example.com"); + user.setBio("Passionate Spring Boot and Java Engineer"); + usersRepository.saveAndFlush(user); + + Posts post = new Posts(); + post.setText("Hello world, this is a special #SpringFramework update!"); + post.setUsers(user); + postsRepository.saveAndFlush(post); + + var userResults = searchService.searchUsers("spring boot", PageRequest.of(0, 10)); + assertThat(userResults.getContent()).extracting("username").contains("search-dev"); + + var userHandleResults = searchService.searchUsers("@search-dev", PageRequest.of(0, 10)); + assertThat(userHandleResults.getContent()).extracting("username").contains("search-dev"); + + var postResults = searchService.searchPosts("SPRINGFRAMEWORK", PageRequest.of(0, 10)); + assertThat(postResults.getContent()).extracting("text").contains(post.getText()); + } + @Test @Transactional void deletingAPostCanDecrementItsPersistedHashtagCount() { diff --git a/src/test/java/xyz/subho/clone/twitter/controller/SearchControllerTest.java b/src/test/java/xyz/subho/clone/twitter/controller/SearchControllerTest.java new file mode 100644 index 0000000..7b82382 --- /dev/null +++ b/src/test/java/xyz/subho/clone/twitter/controller/SearchControllerTest.java @@ -0,0 +1,130 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package xyz.subho.clone.twitter.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import xyz.subho.clone.twitter.exception.BadRequestException; +import xyz.subho.clone.twitter.model.PostModel; +import xyz.subho.clone.twitter.model.SearchResultModel; +import xyz.subho.clone.twitter.model.UserModel; +import xyz.subho.clone.twitter.service.SearchService; + +@ExtendWith(MockitoExtension.class) +class SearchControllerTest { + + @Mock private SearchService searchService; + + private SearchController controller; + private final Pageable pageable = + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "createdAt", "id")); + + @BeforeEach + void setUp() { + controller = new SearchController(searchService); + } + + @Test + void searchPostsDelegatesToService() { + PostModel post = + new PostModel( + UUID.randomUUID(), + "Search test", + UUID.randomUUID(), + List.of(), + 0L, + 0L, + null, + null, + null, + List.of(), + List.of()); + when(searchService.searchPosts("test", pageable)).thenReturn(new PageImpl<>(List.of(post))); + + var response = controller.searchPosts("test", pageable); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getContent()).containsExactly(post); + verify(searchService).searchPosts("test", pageable); + } + + @Test + void searchUsersDelegatesToService() { + UserModel user = + new UserModel( + UUID.randomUUID(), + "alice", + "Alice Smith", + null, + "alice@example.com", + null, + null, + 1L, + 1L, + false); + when(searchService.searchUsers("alice", pageable)).thenReturn(new PageImpl<>(List.of(user))); + + var response = controller.searchUsers("alice", pageable); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getContent()).containsExactly(user); + verify(searchService).searchUsers("alice", pageable); + } + + @Test + void searchAllDelegatesToService() { + SearchResultModel searchResult = + new SearchResultModel(new PageImpl<>(List.of()), new PageImpl<>(List.of())); + when(searchService.searchAll("tech", pageable)).thenReturn(searchResult); + + ResponseEntity response = controller.searchAll("tech", pageable); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(searchResult); + verify(searchService).searchAll("tech", pageable); + } + + @Test + void invalidSortFieldForPostsThrowsBadRequestException() { + Pageable invalidPageable = PageRequest.of(0, 10, Sort.by("password")); + + assertThatThrownBy(() -> controller.searchPosts("test", invalidPageable)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Unsupported sort property: password"); + } + + @Test + void invalidSortFieldForUsersThrowsBadRequestException() { + Pageable invalidPageable = PageRequest.of(0, 10, Sort.by("email")); + + assertThatThrownBy(() -> controller.searchUsers("test", invalidPageable)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Unsupported sort property: email"); + } +} diff --git a/src/test/java/xyz/subho/clone/twitter/service/SearchServiceImplTest.java b/src/test/java/xyz/subho/clone/twitter/service/SearchServiceImplTest.java new file mode 100644 index 0000000..c44f4cc --- /dev/null +++ b/src/test/java/xyz/subho/clone/twitter/service/SearchServiceImplTest.java @@ -0,0 +1,236 @@ +/* + * Twitter Backend - Moo: Twitter Clone Application Backend by Scaler + * Copyright © 2021-2026 Subhrodip Mohanta (hello@subho.xyz) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +package xyz.subho.clone.twitter.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import xyz.subho.clone.twitter.entity.Posts; +import xyz.subho.clone.twitter.entity.Users; +import xyz.subho.clone.twitter.exception.BadRequestException; +import xyz.subho.clone.twitter.model.PostModel; +import xyz.subho.clone.twitter.model.SearchResultModel; +import xyz.subho.clone.twitter.model.UserModel; +import xyz.subho.clone.twitter.repository.PostsRepository; +import xyz.subho.clone.twitter.repository.UsersRepository; +import xyz.subho.clone.twitter.service.impl.SearchServiceImpl; +import xyz.subho.clone.twitter.utility.PostMapper; +import xyz.subho.clone.twitter.utility.UserMapper; + +@ExtendWith(MockitoExtension.class) +class SearchServiceImplTest { + + @Mock private PostsRepository postsRepository; + @Mock private UsersRepository usersRepository; + @Mock private PostMapper postMapper; + @Mock private UserMapper userMapper; + + private SearchServiceImpl searchService; + private final Pageable pageable = PageRequest.of(0, 10); + + @BeforeEach + void setUp() { + searchService = new SearchServiceImpl(postsRepository, usersRepository, postMapper, userMapper); + } + + @Test + void blankOrNullQueriesThrowBadRequestException() { + assertThatThrownBy(() -> searchService.searchPosts(null, pageable)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Search query must not be blank"); + + assertThatThrownBy(() -> searchService.searchPosts("", pageable)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Search query must not be blank"); + + assertThatThrownBy(() -> searchService.searchPosts(" ", pageable)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Search query must not be blank"); + + assertThatThrownBy(() -> searchService.searchUsers(null, pageable)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Search query must not be blank"); + + assertThatThrownBy(() -> searchService.searchUsers(" ", pageable)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Search query must not be blank"); + + verify(postsRepository, never()).searchPosts(any(), any()); + verify(usersRepository, never()).searchUsers(any(), any()); + } + + @Test + void overlyLongQueriesThrowBadRequestException() { + String longQuery = "a".repeat(101); + + assertThatThrownBy(() -> searchService.searchPosts(longQuery, pageable)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Search query must not exceed 100 characters"); + + assertThatThrownBy(() -> searchService.searchUsers(longQuery, pageable)) + .isInstanceOf(BadRequestException.class) + .hasMessage("Search query must not exceed 100 characters"); + } + + @Test + void searchPostsTrimsWhitespaceAndEscapesWildcards() { + Posts postEntity = new Posts(); + PostModel postModel = + new PostModel( + UUID.randomUUID(), + "100% win", + UUID.randomUUID(), + List.of(), + 0L, + 0L, + null, + null, + null, + List.of(), + List.of()); + + when(postsRepository.searchPosts(eq("100\\%\\_win\\\\"), eq(pageable))) + .thenReturn(new PageImpl<>(List.of(postEntity))); + when(postMapper.toModel(postEntity)).thenReturn(postModel); + + Page result = searchService.searchPosts(" 100%_win\\ ", pageable); + + assertThat(result.getContent()).containsExactly(postModel); + verify(postsRepository).searchPosts("100\\%\\_win\\\\", pageable); + } + + @Test + void searchUsersStripsLeadingAtSymbolAndEscapesWildcards() { + Users userEntity = new Users(); + UserModel userModel = + new UserModel( + UUID.randomUUID(), + "alice", + "Alice Smith", + null, + "alice@example.com", + null, + null, + 10L, + 5L, + false); + + when(usersRepository.searchUsers(eq("alice\\_smith"), eq(pageable))) + .thenReturn(new PageImpl<>(List.of(userEntity))); + when(userMapper.toModel(userEntity)).thenReturn(userModel); + + Page result = searchService.searchUsers(" @alice_smith ", pageable); + + assertThat(result.getContent()).containsExactly(userModel); + verify(usersRepository).searchUsers("alice\\_smith", pageable); + } + + @Test + void searchUsersWithoutAtSymbolSearchesDirectly() { + Users userEntity = new Users(); + UserModel userModel = + new UserModel( + UUID.randomUUID(), + "bob", + "Bob Jones", + null, + "bob@example.com", + null, + null, + 2L, + 1L, + false); + + when(usersRepository.searchUsers(eq("bob"), eq(pageable))) + .thenReturn(new PageImpl<>(List.of(userEntity))); + when(userMapper.toModel(userEntity)).thenReturn(userModel); + + Page result = searchService.searchUsers("bob", pageable); + + assertThat(result.getContent()).containsExactly(userModel); + verify(usersRepository).searchUsers("bob", pageable); + } + + @Test + void searchAllReturnsBothUsersAndPosts() { + Users userEntity = new Users(); + UserModel userModel = + new UserModel( + UUID.randomUUID(), + "coder", + "Coder Guy", + null, + "coder@example.com", + null, + null, + 100L, + 50L, + true); + + Posts postEntity = new Posts(); + PostModel postModel = + new PostModel( + UUID.randomUUID(), + "Learning Java", + UUID.randomUUID(), + List.of(), + 5L, + 2L, + null, + null, + null, + List.of(), + List.of()); + + when(usersRepository.searchUsers(eq("java"), eq(pageable))) + .thenReturn(new PageImpl<>(List.of(userEntity))); + when(userMapper.toModel(userEntity)).thenReturn(userModel); + + when(postsRepository.searchPosts(eq("java"), eq(pageable))) + .thenReturn(new PageImpl<>(List.of(postEntity))); + when(postMapper.toModel(postEntity)).thenReturn(postModel); + + SearchResultModel result = searchService.searchAll("java", pageable); + + assertThat(result.users().getContent()).containsExactly(userModel); + assertThat(result.posts().getContent()).containsExactly(postModel); + } + + @Test + void emptyResultsReturnEmptyPages() { + when(postsRepository.searchPosts(eq("unknown"), eq(pageable))) + .thenReturn(new PageImpl<>(List.of())); + when(usersRepository.searchUsers(eq("unknown"), eq(pageable))) + .thenReturn(new PageImpl<>(List.of())); + + Page posts = searchService.searchPosts("unknown", pageable); + Page users = searchService.searchUsers("unknown", pageable); + + assertThat(posts).isEmpty(); + assertThat(users).isEmpty(); + } +}