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();
+ }
+}