From 8ab7e70e448499d1731604af7ee24d6e8068563a Mon Sep 17 00:00:00 2001 From: Bennett Date: Thu, 10 Sep 2026 19:20:54 +0300 Subject: [PATCH 1/2] release: Add public endpoint to search usernames --- .../core/dtos/UsernameAvailabilityDto.java | 8 ++ .../core/repositories/UserRepository.java | 7 ++ .../flextuma/core/senders/WhatsAppSender.java | 24 ++++++ .../PublicEndpointRateLimitService.java | 60 ++++++++++++++ .../controllers/PublicUserController.java | 42 ++++++++++ .../modules/auth/services/UserService.java | 54 ++++++++++++- .../services/WhatsAppInboxMessageService.java | 18 +++++ .../services/WhatsAppMediaService.java | 5 +- src/main/resources/application.properties | 4 + .../PublicEndpointRateLimitServiceTest.java | 78 +++++++++++++++++++ .../controllers/PublicUserControllerTest.java | 70 +++++++++++++++++ .../auth/services/UserServiceTest.java | 58 ++++++++++++++ .../WhatsAppInboxMessageServiceTest.java | 51 ++++++++++++ 13 files changed, 473 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityDto.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/services/PublicEndpointRateLimitService.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserController.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/core/services/PublicEndpointRateLimitServiceTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserControllerTest.java diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityDto.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityDto.java new file mode 100644 index 0000000..f0552a7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityDto.java @@ -0,0 +1,8 @@ +package com.flexcodelabs.flextuma.core.dtos; + +import java.util.List; + +/** Suggestions is empty when {@code available} is true -- there's nothing to suggest an + * alternative to. */ +public record UsernameAvailabilityDto(String username, boolean available, List suggestions) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/UserRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/UserRepository.java index 50b21e2..0bc1719 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/UserRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/UserRepository.java @@ -16,7 +16,14 @@ public interface UserRepository extends JpaRepository, JpaSpecificat @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles LEFT JOIN FETCH u.roles.privileges WHERE u.username = :username") Optional findByUsername(@Param("username") String username); + // Mirrors findByUsername's eager fetch: login() needs roles+privileges loaded before the + // transaction closes (spring.jpa.open-in-view=false), whichever of the two this resolves via. + @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles LEFT JOIN FETCH u.roles.privileges WHERE u.email = :email") + Optional findByEmailWithRoles(@Param("email") String email); + Optional findByEmail(String email); Optional findByPhoneNumber(String phoneNumber); + + boolean existsByUsername(String username); } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java b/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java index 9ff5706..3519aaf 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java @@ -6,6 +6,7 @@ import com.flexcodelabs.flextuma.core.services.SmsSendResult; import com.flexcodelabs.flextuma.core.services.SmsSender; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -17,6 +18,7 @@ import java.util.Map; /** WhatsApp Cloud API text-message sender. The connector key is a Meta access token. */ +@Slf4j @Service @RequiredArgsConstructor public class WhatsAppSender implements SmsSender { @@ -56,6 +58,28 @@ public SmsSendResult sendSms(SmsConnector config, String to, String message) { } } + /** Tells Meta a message was read, so the sender sees blue double-ticks. Never throws -- + * this is best-effort: Meta's API hiccuping shouldn't block marking a message read locally. */ + public boolean markAsRead(SmsConnector config, String providerMessageId) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(config.getKey()); + + Map body = new LinkedHashMap<>(); + body.put("messaging_product", "whatsapp"); + body.put("status", "read"); + body.put("message_id", providerMessageId); + + ResponseEntity response = restTemplate.postForEntity(messageUrl(config), + new HttpEntity<>(body, headers), Map.class); + return response.getStatusCode().is2xxSuccessful(); + } catch (Exception e) { + log.warn("Failed to send WhatsApp read receipt for message [{}]: {}", providerMessageId, e.getMessage()); + return false; + } + } + private String messageUrl(SmsConnector config) { String base = config.getUrl().replaceAll("/$", ""); if (base.contains("{phoneNumberId}")) { diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/PublicEndpointRateLimitService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/PublicEndpointRateLimitService.java new file mode 100644 index 0000000..e32bdbb --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/PublicEndpointRateLimitService.java @@ -0,0 +1,60 @@ +package com.flexcodelabs.flextuma.core.services; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.stereotype.Service; + +import com.flexcodelabs.flextuma.core.exceptions.RateLimitExceededException; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Fixed-window request-volume limiter for unauthenticated public endpoints (no session/user to + * key off, so this is IP-based like {@link AuthRateLimitService}). Unlike that service -- which + * only counts failed login/registration attempts and forgives on success -- every call here + * counts against the caller's window regardless of outcome, since abuse of a public read + * endpoint (scraping, enumeration) looks like volume, not failures. + */ +@Service +public class PublicEndpointRateLimitService { + + private final ConcurrentHashMap requestCounts = new ConcurrentHashMap<>(); + private final ConcurrentHashMap windowStarts = new ConcurrentHashMap<>(); + + /** Throws {@link RateLimitExceededException} once {@code bucket}'s caller has made more than + * {@code maxRequests} calls within {@code windowSeconds}, otherwise records this call. */ + public void checkAndRecord(String bucket, HttpServletRequest request, int maxRequests, int windowSeconds) { + String key = bucket + ":" + clientKey(request); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime windowStart = windowStarts.get(key); + + if (windowStart == null || windowStart.isBefore(now.minusSeconds(windowSeconds))) { + windowStarts.put(key, now); + requestCounts.put(key, new AtomicInteger(1)); + return; + } + + int count = requestCounts.computeIfAbsent(key, k -> new AtomicInteger(0)).incrementAndGet(); + if (count > maxRequests) { + long secondsRemaining = windowSeconds - Duration.between(windowStart, now).getSeconds(); + throw new RateLimitExceededException("Too many requests.", Math.max(1, secondsRemaining)); + } + } + + private String clientKey(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + + String xRealIp = request.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isEmpty()) { + return xRealIp; + } + + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserController.java new file mode 100644 index 0000000..f612069 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserController.java @@ -0,0 +1,42 @@ +package com.flexcodelabs.flextuma.modules.auth.controllers; + +import org.springframework.beans.factory.annotation.Value; +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 com.flexcodelabs.flextuma.core.dtos.UsernameAvailabilityDto; +import com.flexcodelabs.flextuma.core.services.PublicEndpointRateLimitService; +import com.flexcodelabs.flextuma.modules.auth.services.UserService; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +/** Unauthenticated signup-time lookups -- see SecurityConfig's /api/public/** matcher. Being + * unauthenticated and free of any per-user throttling elsewhere, every endpoint here must rate + * limit itself against scraping/enumeration. */ +@RestController +@RequestMapping("/api/public/users") +@RequiredArgsConstructor +public class PublicUserController { + + private static final String USERNAME_AVAILABILITY_BUCKET = "username-availability"; + + private final UserService userService; + private final PublicEndpointRateLimitService rateLimitService; + + @Value("${flextuma.rate-limit.username-availability.max-requests:20}") + private int maxRequestsPerWindow; + + @Value("${flextuma.rate-limit.username-availability.window-seconds:60}") + private int windowSeconds; + + @GetMapping("/username-availability") + public ResponseEntity checkUsernameAvailability( + @RequestParam("username") String username, HttpServletRequest httpRequest) { + rateLimitService.checkAndRecord(USERNAME_AVAILABILITY_BUCKET, httpRequest, maxRequestsPerWindow, windowSeconds); + return ResponseEntity.ok(userService.checkUsernameAvailability(username)); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java index 4112329..7a304e3 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java @@ -1,5 +1,9 @@ package com.flexcodelabs.flextuma.modules.auth.services; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; import java.util.UUID; import jakarta.servlet.http.HttpServletRequest; @@ -21,6 +25,7 @@ import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.dtos.RegisterDto; import com.flexcodelabs.flextuma.core.dtos.ProfileUpdateDto; +import com.flexcodelabs.flextuma.core.dtos.UsernameAvailabilityDto; import com.flexcodelabs.flextuma.core.repositories.UserRepository; import com.flexcodelabs.flextuma.core.services.BaseService; @@ -30,6 +35,9 @@ @RequiredArgsConstructor public class UserService extends BaseService { + private static final int MAX_USERNAME_SUGGESTIONS = 5; + private static final SecureRandom RANDOM = new SecureRandom(); + private final UserRepository repository; private final PasswordEncoder passwordEncoder; @@ -95,10 +103,13 @@ protected void validateDelete(User user) { } } - public User login(String username, String password) { - User user = repository.findByUsername(username) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, - "Invalid username or password")); + public User login(String identifier, String password) { + boolean looksLikeEmail = identifier != null && identifier.contains("@"); + Optional found = looksLikeEmail + ? repository.findByEmailWithRoles(identifier) + : repository.findByUsername(identifier); + User user = found.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, + "Invalid username or password")); if (!user.validatePassword(password)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password"); } @@ -136,6 +147,41 @@ public User findByUsername(String username) { "User with username " + username + " not found")); } + /** Public, unauthenticated username-availability check backing the signup form's live + * validation. When taken, suggests alternatives so the caller isn't left to guess one. */ + public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername) { + String username = rawUsername == null ? "" : rawUsername.trim(); + if (username.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is required"); + } + if (username.length() > 50) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is too long"); + } + + boolean available = !repository.existsByUsername(username); + List suggestions = available ? List.of() : generateAvailableUsernames(username); + return new UsernameAvailabilityDto(username, available, suggestions); + } + + private List generateAvailableUsernames(String requested) { + String base = requested.toLowerCase().replaceAll("[^a-z0-9_]", ""); + if (base.isBlank()) { + base = "user"; + } + + List suggestions = new ArrayList<>(); + // Bounded so a base that happens to collide with every random suffix (astronomically + // unlikely, but not impossible) can't spin this into an unbounded loop. + int maxAttempts = MAX_USERNAME_SUGGESTIONS * 20; + for (int attempt = 0; suggestions.size() < MAX_USERNAME_SUGGESTIONS && attempt < maxAttempts; attempt++) { + String candidate = base + (1000 + RANDOM.nextInt(9000)); + if (!suggestions.contains(candidate) && !repository.existsByUsername(candidate)) { + suggestions.add(candidate); + } + } + return suggestions; + } + public User register(RegisterDto request) { repository.findByUsername(request.getUsername()).ifPresent(u -> { throw new ResponseStatusException(HttpStatus.CONFLICT, diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java index 4a39a4c..232b969 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java @@ -10,11 +10,13 @@ import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository; import com.flexcodelabs.flextuma.core.security.SecurityUtils; +import com.flexcodelabs.flextuma.core.senders.WhatsAppSender; import com.flexcodelabs.flextuma.core.services.BaseService; import com.flexcodelabs.flextuma.modules.sms.services.SmsLogService; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppConversationDTO; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; @@ -32,6 +34,7 @@ import java.util.Optional; import java.util.UUID; +@Slf4j @Service @RequiredArgsConstructor public class WhatsAppInboxMessageService extends BaseService { private static final String SUPER_ADMIN = "SUPER_ADMIN"; @@ -40,6 +43,7 @@ public class WhatsAppInboxMessageService extends BaseService service.checkAndRecord("bucket", request, 5, 60)); + } + } + + @Test + void checkAndRecord_shouldThrow_onceLimitExceededWithinWindow() { + when(request.getRemoteAddr()).thenReturn("10.0.0.2"); + + for (int i = 0; i < 5; i++) { + service.checkAndRecord("bucket", request, 5, 60); + } + + assertThrows(RateLimitExceededException.class, () -> service.checkAndRecord("bucket", request, 5, 60)); + } + + @Test + void checkAndRecord_shouldTrackBucketsIndependently_forTheSameClient() { + when(request.getRemoteAddr()).thenReturn("10.0.0.3"); + + for (int i = 0; i < 5; i++) { + service.checkAndRecord("bucket-a", request, 5, 60); + } + + // A different bucket for the same IP must not be affected by bucket-a's exhausted limit. + assertDoesNotThrow(() -> service.checkAndRecord("bucket-b", request, 5, 60)); + } + + @Test + void checkAndRecord_shouldTrackClientsIndependently_forTheSameBucket() { + when(request.getRemoteAddr()).thenReturn("10.0.0.4"); + for (int i = 0; i < 5; i++) { + service.checkAndRecord("bucket", request, 5, 60); + } + + // A different client IP must not be affected by the first client's exhausted limit. + when(request.getRemoteAddr()).thenReturn("10.0.0.5"); + assertDoesNotThrow(() -> service.checkAndRecord("bucket", request, 5, 60)); + } + + @Test + void checkAndRecord_shouldPreferForwardedForHeader_overRemoteAddr() { + when(request.getHeader("X-Forwarded-For")).thenReturn("203.0.113.5, 10.0.0.1"); + + for (int i = 0; i < 5; i++) { + service.checkAndRecord("bucket", request, 5, 60); + } + + assertThrows(RateLimitExceededException.class, () -> service.checkAndRecord("bucket", request, 5, 60)); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserControllerTest.java new file mode 100644 index 0000000..9947276 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserControllerTest.java @@ -0,0 +1,70 @@ +package com.flexcodelabs.flextuma.modules.auth.controllers; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +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.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.flexcodelabs.flextuma.core.dtos.UsernameAvailabilityDto; +import com.flexcodelabs.flextuma.core.exceptions.RateLimitExceededException; +import com.flexcodelabs.flextuma.core.services.PublicEndpointRateLimitService; +import com.flexcodelabs.flextuma.modules.auth.services.UserService; + +@ExtendWith(MockitoExtension.class) +class PublicUserControllerTest { + + @Mock + private UserService userService; + + @Mock + private PublicEndpointRateLimitService rateLimitService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + PublicUserController controller = new PublicUserController(userService, rateLimitService); + ReflectionTestUtils.setField(controller, "maxRequestsPerWindow", 20); + ReflectionTestUtils.setField(controller, "windowSeconds", 60); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void checkUsernameAvailability_shouldRecordAgainstRateLimiter_beforeReturningResult() throws Exception { + when(userService.checkUsernameAvailability("jane")) + .thenReturn(new UsernameAvailabilityDto("jane", true, List.of())); + + mockMvc.perform(get("/api/public/users/username-availability").param("username", "jane")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.available").value(true)); + + verify(rateLimitService).checkAndRecord(eq("username-availability"), any(), eq(20), eq(60)); + } + + @Test + void checkUsernameAvailability_shouldRejectWithTooManyRequests_whenRateLimited() throws Exception { + doThrow(new RateLimitExceededException("Too many requests.", 30)) + .when(rateLimitService).checkAndRecord(eq("username-availability"), any(), eq(20), eq(60)); + + mockMvc.perform(get("/api/public/users/username-availability").param("username", "jane")) + .andExpect(status().isTooManyRequests()); + + verify(userService, never()).checkUsernameAvailability(any()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/UserServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/UserServiceTest.java index ecdc48b..e9c1f16 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/UserServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/auth/services/UserServiceTest.java @@ -104,6 +104,64 @@ void login_shouldThrowException_whenUserNotFound() { assertThrows(ResponseStatusException.class, () -> service.login(username, "password")); } + @Test + void login_shouldLookUpByEmail_whenIdentifierLooksLikeEmail() { + String email = "jane@example.com"; + String password = "password"; + String hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt()); + + User user = new User(); + user.setUsername("jane"); + user.setEmail(email); + user.setPassword(hashedPassword); + + when(repository.findByEmailWithRoles(email)).thenReturn(Optional.of(user)); + + User result = service.login(email, password); + + assertNotNull(result); + assertEquals(email, result.getEmail()); + verify(repository, never()).findByUsername(any()); + } + + @Test + void login_shouldNotLookUpByUsername_whenIdentifierLooksLikeEmailButUnknown() { + String email = "unknown@example.com"; + when(repository.findByEmailWithRoles(email)).thenReturn(Optional.empty()); + + assertThrows(ResponseStatusException.class, () -> service.login(email, "password")); + verify(repository, never()).findByUsername(any()); + } + + @Test + void checkUsernameAvailability_shouldReportAvailable_withNoSuggestions_whenUsernameIsFree() { + when(repository.existsByUsername("newname")).thenReturn(false); + + var result = service.checkUsernameAvailability("newname"); + + assertTrue(result.available()); + assertEquals(List.of(), result.suggestions()); + } + + @Test + void checkUsernameAvailability_shouldSuggestAlternatives_whenUsernameIsTaken() { + when(repository.existsByUsername("jane")).thenReturn(true); + when(repository.existsByUsername(argThat(candidate -> candidate.startsWith("jane") && !candidate.equals("jane")))) + .thenReturn(false); + + var result = service.checkUsernameAvailability("jane"); + + assertFalse(result.available()); + assertEquals(5, result.suggestions().size()); + assertTrue(result.suggestions().stream().allMatch(s -> s.startsWith("jane") && !s.equals("jane"))); + assertEquals(result.suggestions().size(), Set.copyOf(result.suggestions()).size()); + } + + @Test + void checkUsernameAvailability_shouldThrow_whenUsernameBlank() { + assertThrows(ResponseStatusException.class, () -> service.checkUsernameAvailability(" ")); + } + @Test void delete_shouldThrowException_whenUserIsSystem() { mockPermissions(Set.of(User.DELETE)); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java index ef03e01..8b35c3d 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java @@ -9,6 +9,7 @@ import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository; +import com.flexcodelabs.flextuma.core.senders.WhatsAppSender; import com.flexcodelabs.flextuma.modules.sms.services.SmsLogService; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppConversationDTO; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO; @@ -35,10 +36,13 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -56,6 +60,9 @@ class WhatsAppInboxMessageServiceTest { @Mock private SmsLogService smsLogService; + @Mock + private WhatsAppSender whatsAppSender; + @InjectMocks private WhatsAppInboxMessageService service; @@ -230,6 +237,50 @@ void getMedia_shouldThrowNotFound_whenStoredFileIsMissing() { assertThrows(ResponseStatusException.class, () -> service.getMedia(message.getId())); } + @Test + void markAsRead_shouldSendReadReceiptToMeta_whenConnectorResolves() { + WhatsAppWebhookConfig config = new WhatsAppWebhookConfig(); + config.setId(UUID.randomUUID()); + SmsConnector connector = new SmsConnector(); + connector.setKey("meta-token"); + connector.setUrl("https://graph.facebook.com/v22.0"); + + WhatsAppInboxMessage message = message(config, "255700000001", "Hello", LocalDateTime.now(), false); + message.setProviderMessageId("wamid.abc123"); + when(repository.findOne(any(Specification.class))).thenReturn(Optional.of(message)); + when(repository.save(any(WhatsAppInboxMessage.class))).thenAnswer(inv -> inv.getArgument(0)); + when(mediaService.resolveConnector(config)).thenReturn(connector); + + service.markAsRead(message.getId()); + + verify(whatsAppSender).markAsRead(connector, "wamid.abc123"); + } + + @Test + void markAsRead_shouldNotSendReadReceiptAgain_whenAlreadyRead() { + WhatsAppInboxMessage message = message(new WhatsAppWebhookConfig(), "255700000001", "Hello", LocalDateTime.now(), true); + when(repository.findOne(any(Specification.class))).thenReturn(Optional.of(message)); + + service.markAsRead(message.getId()); + + verify(whatsAppSender, never()).markAsRead(any(), any()); + } + + @Test + void markAsRead_shouldSkipReadReceipt_whenNoConnectorResolves() { + WhatsAppWebhookConfig config = new WhatsAppWebhookConfig(); + config.setId(UUID.randomUUID()); + WhatsAppInboxMessage message = message(config, "255700000001", "Hello", LocalDateTime.now(), false); + when(repository.findOne(any(Specification.class))).thenReturn(Optional.of(message)); + when(repository.save(any(WhatsAppInboxMessage.class))).thenAnswer(inv -> inv.getArgument(0)); + when(mediaService.resolveConnector(config)).thenReturn(null); + + WhatsAppInboxMessage result = service.markAsRead(message.getId()); + + assertNotNull(result.getReadAt()); + verify(whatsAppSender, never()).markAsRead(any(), any()); + } + @Test void listConversations_shouldReturnEmptyPage_whenNoMessages() { when(repository.findAll(any(Specification.class), any(Pageable.class))) From d09a522eec76a3f932a6644600a46e6893ca0bfc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 10 Sep 2026 16:21:30 +0000 Subject: [PATCH 2/2] Release v0.0.70 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 3e3a448..510d1b8 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.69' +version = '0.0.70' description = 'Flextuma App' java {