diff --git a/build.gradle b/build.gradle index df396cf..8efc076 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.71' +version = '0.0.72' description = 'Flextuma App' 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 index f0552a7..8b15656 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityDto.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityDto.java @@ -3,6 +3,7 @@ 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) { + * alternative to. {@code emailAvailable} is null when no email was passed to the check. */ +public record UsernameAvailabilityDto(String username, boolean available, List suggestions, + Boolean emailAvailable) { } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityRequestDto.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityRequestDto.java new file mode 100644 index 0000000..c801ab0 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UsernameAvailabilityRequestDto.java @@ -0,0 +1,5 @@ +package com.flexcodelabs.flextuma.core.dtos; + +/** Request body for the username-availability check. {@code email} is optional. */ +public record UsernameAvailabilityRequestDto(String username, String email) { +} 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 0bc1719..979dd11 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/UserRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/UserRepository.java @@ -26,4 +26,6 @@ public interface UserRepository extends JpaRepository, JpaSpecificat Optional findByPhoneNumber(String phoneNumber); boolean existsByUsername(String username); + + boolean existsByEmail(String email); } \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java index 8dd507b..27d2ccc 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java @@ -57,6 +57,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { .requestMatchers(org.springframework.http.HttpMethod.POST, "/api/webhooks/whatsapp/**").permitAll() .requestMatchers(org.springframework.http.HttpMethod.POST, "/api/webhooks/*").permitAll() .requestMatchers(org.springframework.http.HttpMethod.GET, "/api/public/**").permitAll() + .requestMatchers(org.springframework.http.HttpMethod.POST, "/api/public/**").permitAll() .requestMatchers("/").permitAll() .requestMatchers("/assets/**").permitAll() .requestMatchers(new RegexRequestMatcher("^/(?!api(?:/|$)).*", null)).permitAll() 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 index f612069..bc5bc63 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserController.java @@ -2,12 +2,13 @@ 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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; 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.dtos.UsernameAvailabilityRequestDto; import com.flexcodelabs.flextuma.core.services.PublicEndpointRateLimitService; import com.flexcodelabs.flextuma.modules.auth.services.UserService; @@ -33,10 +34,10 @@ public class PublicUserController { @Value("${flextuma.rate-limit.username-availability.window-seconds:60}") private int windowSeconds; - @GetMapping("/username-availability") + @PostMapping("/username-availability") public ResponseEntity checkUsernameAvailability( - @RequestParam("username") String username, HttpServletRequest httpRequest) { + @RequestBody UsernameAvailabilityRequestDto request, HttpServletRequest httpRequest) { rateLimitService.checkAndRecord(USERNAME_AVAILABILITY_BUCKET, httpRequest, maxRequestsPerWindow, windowSeconds); - return ResponseEntity.ok(userService.checkUsernameAvailability(username)); + return ResponseEntity.ok(userService.checkUsernameAvailability(request.username(), request.email())); } } 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 7a304e3..9d138f8 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 @@ -148,8 +148,10 @@ public User findByUsername(String username) { } /** 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) { + * validation. When taken, suggests alternatives so the caller isn't left to guess one -- + * built from the email's local part (before '@') when an email was passed and it's still + * free, since that's more likely to read as "theirs" than a random suffix. */ + public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername, String rawEmail) { String username = rawUsername == null ? "" : rawUsername.trim(); if (username.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is required"); @@ -158,9 +160,28 @@ public UsernameAvailabilityDto checkUsernameAvailability(String rawUsername) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is too long"); } + String email = rawEmail == null ? "" : rawEmail.trim().toLowerCase(); + Boolean emailAvailable = null; + String emailLocalPart = null; + if (!email.isBlank()) { + emailAvailable = !repository.existsByEmail(email); + int at = email.indexOf('@'); + if (at > 0) { + emailLocalPart = email.substring(0, at); + } + } + boolean available = !repository.existsByUsername(username); - List suggestions = available ? List.of() : generateAvailableUsernames(username); - return new UsernameAvailabilityDto(username, available, suggestions); + List suggestions; + if (available) { + suggestions = List.of(); + } else { + String suggestionBase = Boolean.TRUE.equals(emailAvailable) && emailLocalPart != null + ? emailLocalPart + : username; + suggestions = generateAvailableUsernames(suggestionBase); + } + return new UsernameAvailabilityDto(username, available, suggestions, emailAvailable); } private List generateAvailableUsernames(String requested) { @@ -187,6 +208,12 @@ public User register(RegisterDto request) { throw new ResponseStatusException(HttpStatus.CONFLICT, "User with username " + request.getUsername() + " already exists"); }); + if (request.getEmail() != null && !request.getEmail().isBlank()) { + repository.findByEmail(request.getEmail()).ifPresent(u -> { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "User with email " + request.getEmail() + " already exists"); + }); + } User user = new User(); user.setName(request.getName()); 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 index 9947276..be63a80 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/PublicUserControllerTest.java @@ -6,12 +6,14 @@ 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.request.MockMvcRequestBuilders.post; 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.springframework.http.MediaType; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -47,10 +49,12 @@ void setUp() { @Test void checkUsernameAvailability_shouldRecordAgainstRateLimiter_beforeReturningResult() throws Exception { - when(userService.checkUsernameAvailability("jane")) - .thenReturn(new UsernameAvailabilityDto("jane", true, List.of())); + when(userService.checkUsernameAvailability("jane", null)) + .thenReturn(new UsernameAvailabilityDto("jane", true, List.of(), null)); - mockMvc.perform(get("/api/public/users/username-availability").param("username", "jane")) + mockMvc.perform(post("/api/public/users/username-availability") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"jane\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.available").value(true)); @@ -62,9 +66,11 @@ void checkUsernameAvailability_shouldRejectWithTooManyRequests_whenRateLimited() 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")) + mockMvc.perform(post("/api/public/users/username-availability") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"jane\"}")) .andExpect(status().isTooManyRequests()); - verify(userService, never()).checkUsernameAvailability(any()); + verify(userService, never()).checkUsernameAvailability(any(), 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 e9c1f16..d925c05 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 @@ -137,10 +137,11 @@ void login_shouldNotLookUpByUsername_whenIdentifierLooksLikeEmailButUnknown() { void checkUsernameAvailability_shouldReportAvailable_withNoSuggestions_whenUsernameIsFree() { when(repository.existsByUsername("newname")).thenReturn(false); - var result = service.checkUsernameAvailability("newname"); + var result = service.checkUsernameAvailability("newname", null); assertTrue(result.available()); assertEquals(List.of(), result.suggestions()); + assertNull(result.emailAvailable()); } @Test @@ -149,7 +150,7 @@ void checkUsernameAvailability_shouldSuggestAlternatives_whenUsernameIsTaken() { when(repository.existsByUsername(argThat(candidate -> candidate.startsWith("jane") && !candidate.equals("jane")))) .thenReturn(false); - var result = service.checkUsernameAvailability("jane"); + var result = service.checkUsernameAvailability("jane", null); assertFalse(result.available()); assertEquals(5, result.suggestions().size()); @@ -157,9 +158,38 @@ void checkUsernameAvailability_shouldSuggestAlternatives_whenUsernameIsTaken() { assertEquals(result.suggestions().size(), Set.copyOf(result.suggestions()).size()); } + @Test + void checkUsernameAvailability_shouldSuggestFromEmailLocalPart_whenUsernameTakenAndEmailFree() { + when(repository.existsByUsername("jane")).thenReturn(true); + when(repository.existsByEmail("jane.doe@example.com")).thenReturn(false); + when(repository.existsByUsername(argThat(candidate -> candidate.startsWith("janedoe") && !candidate.equals("janedoe")))) + .thenReturn(false); + + var result = service.checkUsernameAvailability("jane", "jane.doe@example.com"); + + assertFalse(result.available()); + assertTrue(result.emailAvailable()); + assertEquals(5, result.suggestions().size()); + assertTrue(result.suggestions().stream().allMatch(s -> s.startsWith("janedoe"))); + } + + @Test + void checkUsernameAvailability_shouldSuggestFromUsername_whenUsernameAndEmailBothTaken() { + when(repository.existsByUsername("jane")).thenReturn(true); + when(repository.existsByEmail("jane@example.com")).thenReturn(true); + when(repository.existsByUsername(argThat(candidate -> candidate.startsWith("jane") && !candidate.equals("jane")))) + .thenReturn(false); + + var result = service.checkUsernameAvailability("jane", "jane@example.com"); + + assertFalse(result.available()); + assertFalse(result.emailAvailable()); + assertTrue(result.suggestions().stream().allMatch(s -> s.startsWith("jane"))); + } + @Test void checkUsernameAvailability_shouldThrow_whenUsernameBlank() { - assertThrows(ResponseStatusException.class, () -> service.checkUsernameAvailability(" ")); + assertThrows(ResponseStatusException.class, () -> service.checkUsernameAvailability(" ", null)); } @Test