From 2941e3f9052a06d208f18fc13e8d1622aa6ff9ea Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 24 Sep 2026 10:39:04 +0200 Subject: [PATCH] feat(user): optional display name (player tag) users.display_name (V6), 3-24 letters, digits, space, _ . -, unique ignoring case. Set in settings (409 DISPLAY_NAME_ALREADY_IN_USE, also for the DB constraint race) and optionally at registration. Returned by /api/users/me. --- .../api/RegistrationExceptionController.java | 7 ++ .../api/dto/mapper/ApiMapper.java | 1 + .../api/dto/request/RegistrationRequest.java | 4 +- ...DisplayNameAlreadyRegisteredException.java | 7 ++ .../registration/model/Registration.java | 1 + .../persistence/RegisteredUserRepository.java | 2 + .../RegisteredUserRepositoryImpl.java | 10 ++ .../entities/RegisteredUserEntity.java | 3 + .../persistence/mapper/EntityMapper.java | 1 + .../service/RegistrationService.java | 5 + .../modules/user/api/UserController.java | 2 +- .../user/api/UserExceptionController.java | 17 ++- .../api/dto/request/UserSettingsRequest.java | 7 +- .../api/dto/response/UserProfileResponse.java | 2 +- .../modules/user/api/mapper/ApiMapper.java | 1 + .../DisplayNameAlreadyInUseException.java | 7 ++ .../auth_service/modules/user/model/User.java | 1 + .../user/persistence/UserRepository.java | 4 +- .../user/persistence/UserRepositoryImpl.java | 13 ++- .../user/persistence/entities/UserEntity.java | 3 + .../user/persistence/mapper/EntityMapper.java | 1 + .../modules/user/service/UserService.java | 8 +- .../api/validation/DisplayNameFormat.java | 11 ++ .../db/migration/V6__Add_Display_Name.sql | 3 + .../api/RegistrationControllerTest.java | 29 +++++ .../service/RegistrationServiceTest.java | 15 +++ .../modules/user/api/UserControllerTest.java | 107 +++++++++++++++++- .../user/persistence/UserRepositoryTest.java | 35 +++++- .../modules/user/service/UserServiceTest.java | 38 ++++++- 29 files changed, 321 insertions(+), 24 deletions(-) create mode 100644 src/main/java/auth_service/modules/registration/exception/DisplayNameAlreadyRegisteredException.java create mode 100644 src/main/java/auth_service/modules/user/exception/DisplayNameAlreadyInUseException.java create mode 100644 src/main/java/auth_service/shared/api/validation/DisplayNameFormat.java create mode 100644 src/main/resources/db/migration/V6__Add_Display_Name.sql diff --git a/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java b/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java index 7ca129a..19f642f 100644 --- a/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java +++ b/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java @@ -3,6 +3,7 @@ package auth_service.modules.registration.api; import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.InvalidInvitationCodeException; +import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @@ -35,6 +36,12 @@ public class RegistrationExceptionController { return response(body, HttpStatus.BAD_REQUEST); } + @ExceptionHandler(DisplayNameAlreadyRegisteredException.class) + public ResponseEntity clientError(DisplayNameAlreadyRegisteredException ex) { + var body = new ErrorResponse("DISPLAY_NAME_ALREADY_IN_USE", ex.getMessage()); + return response(body, HttpStatus.BAD_REQUEST); + } + @ExceptionHandler(SteamIdAlreadyRegisteredException.class) public ResponseEntity clientError(SteamIdAlreadyRegisteredException ex) { var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", ex.getMessage()); diff --git a/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java b/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java index 5c55cdc..a490269 100644 --- a/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java @@ -12,6 +12,7 @@ public abstract class ApiMapper { email(registrationRequest.email()). rawPassword(registrationRequest.password()). inviteCode(registrationRequest.inviteCode()). + displayName(registrationRequest.displayName()). steamId(registrationRequest.steamId() == null ? null : Long.valueOf(registrationRequest.steamId())). build(); } diff --git a/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java b/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java index 1c1efaa..00f5b3b 100644 --- a/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java +++ b/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java @@ -1,5 +1,6 @@ package auth_service.modules.registration.api.dto.request; +import auth_service.shared.api.validation.DisplayNameFormat; import auth_service.shared.api.validation.SteamIdDeserializer; import auth_service.shared.api.validation.SteamIdFormat; import jakarta.validation.constraints.Pattern; @@ -9,5 +10,6 @@ import tools.jackson.databind.annotation.JsonDeserialize; @Builder public record RegistrationRequest(String email, String password, String inviteCode, @Pattern(regexp = SteamIdFormat.PATTERN) - @JsonDeserialize(using = SteamIdDeserializer.class) String steamId) { + @JsonDeserialize(using = SteamIdDeserializer.class) String steamId, + @Pattern(regexp = DisplayNameFormat.PATTERN) String displayName) { } diff --git a/src/main/java/auth_service/modules/registration/exception/DisplayNameAlreadyRegisteredException.java b/src/main/java/auth_service/modules/registration/exception/DisplayNameAlreadyRegisteredException.java new file mode 100644 index 0000000..67965da --- /dev/null +++ b/src/main/java/auth_service/modules/registration/exception/DisplayNameAlreadyRegisteredException.java @@ -0,0 +1,7 @@ +package auth_service.modules.registration.exception; + +public class DisplayNameAlreadyRegisteredException extends RuntimeException { + public DisplayNameAlreadyRegisteredException(String message) { + super(message); + } +} diff --git a/src/main/java/auth_service/modules/registration/model/Registration.java b/src/main/java/auth_service/modules/registration/model/Registration.java index 201ae41..b35d8d0 100644 --- a/src/main/java/auth_service/modules/registration/model/Registration.java +++ b/src/main/java/auth_service/modules/registration/model/Registration.java @@ -11,4 +11,5 @@ public class Registration { private String encryptedPassword; private String inviteCode; private Long steamId; + private String displayName; } diff --git a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java index 4722dd1..1b2ec7d 100644 --- a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java +++ b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java @@ -7,5 +7,7 @@ public interface RegisteredUserRepository { boolean existsBySteamId(long steamId); + boolean existsByDisplayName(String displayName); + long save(Registration registration); } diff --git a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java index 66d5da7..2ccc905 100644 --- a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java @@ -34,6 +34,16 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository { return count != null && count > 0; } + @Override + public boolean existsByDisplayName(String displayName) { + var jpql = "SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(:displayName)"; + var count = entityManager.createQuery(jpql, Long.class) + .setParameter("displayName", displayName) + .getSingleResult(); + + return count != null && count > 0; + } + @Override public long save(Registration registration) { var userToRegisterEntity = EntityMapper.mapToPersistence(registration); diff --git a/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java b/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java index 2481226..12f47c8 100644 --- a/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java +++ b/src/main/java/auth_service/modules/registration/persistence/entities/RegisteredUserEntity.java @@ -30,6 +30,9 @@ public class RegisteredUserEntity { @Column(name = "steam_id", unique = true) private Long steamId; + @Column(name = "display_name", length = 24) + private String displayName; + @Column(name = "registration_date", nullable = false) private LocalDateTime registrationDate; diff --git a/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java index f6c8bee..e458269 100644 --- a/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/registration/persistence/mapper/EntityMapper.java @@ -18,6 +18,7 @@ public abstract class EntityMapper { password(userModel.getEncryptedPassword()). enabled(true). steamId(userModel.getSteamId()). + displayName(userModel.getDisplayName()). lastLoginDate(null). build(); } diff --git a/src/main/java/auth_service/modules/registration/service/RegistrationService.java b/src/main/java/auth_service/modules/registration/service/RegistrationService.java index a90171b..89fc261 100644 --- a/src/main/java/auth_service/modules/registration/service/RegistrationService.java +++ b/src/main/java/auth_service/modules/registration/service/RegistrationService.java @@ -2,6 +2,7 @@ package auth_service.modules.registration.service; import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.InvalidInvitationCodeException; +import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException; import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.persistence.RegisteredUserRepository; @@ -30,6 +31,10 @@ public class RegistrationService { throw new SteamIdAlreadyRegisteredException("The steam id is already in use."); } + if (registration.getDisplayName() != null && registeredUserRepository.existsByDisplayName(registration.getDisplayName())) { + throw new DisplayNameAlreadyRegisteredException("The display name is already in use."); + } + var rawPassword = registration.getRawPassword(); registration.setEncryptedPassword(passwordEncoder.encode(rawPassword)); diff --git a/src/main/java/auth_service/modules/user/api/UserController.java b/src/main/java/auth_service/modules/user/api/UserController.java index a84c79c..f40c519 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -42,7 +42,7 @@ public class UserController { public ResponseEntity updateUserSettings(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody UserSettingsRequest request) { var token = this.userService.updateUserSettings(principal.getIdAsLong(), principal.email(), - ApiMapper.toSteamId(request), ApiMapper.toDomain(request)); + ApiMapper.toSteamId(request), request.displayName(), ApiMapper.toDomain(request)); return ResponseEntity.ok(new UserSettingsResponse(token)); } } diff --git a/src/main/java/auth_service/modules/user/api/UserExceptionController.java b/src/main/java/auth_service/modules/user/api/UserExceptionController.java index c4dc905..dd47983 100644 --- a/src/main/java/auth_service/modules/user/api/UserExceptionController.java +++ b/src/main/java/auth_service/modules/user/api/UserExceptionController.java @@ -1,6 +1,7 @@ package auth_service.modules.user.api; import auth_service.shared.api.dto.response.ErrorResponse; +import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.SteamIdAlreadyInUseException; import auth_service.modules.user.exception.UserNotFoundException; import lombok.NonNull; @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; @Slf4j public class UserExceptionController { private static final String STEAM_ID_CONSTRAINT = "uc_steam_id"; + private static final String DISPLAY_NAME_CONSTRAINT = "uq_users_display_name_lower"; private static @NonNull ResponseEntity response(ErrorResponse body, HttpStatus status) { return ResponseEntity.status(status.value()) @@ -42,17 +44,30 @@ public class UserExceptionController { return steamIdInUse(); } - // A parallel request can save the same Steam ID after the service check. Then the DB constraint fails. + // A parallel request can save the same Steam ID or display name after the service check. Then the DB constraint fails. @ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity dataIntegrityError(DataIntegrityViolationException ex) { var cause = ex.getMostSpecificCause().getMessage(); if (cause != null && cause.contains(STEAM_ID_CONSTRAINT)) { return steamIdInUse(); } + if (cause != null && cause.contains(DISPLAY_NAME_CONSTRAINT)) { + return displayNameInUse(); + } log.error("Data integrity violation in user request", ex); return response(new ErrorResponse("INTERNAL_ERROR", "Unexpected database error."), HttpStatus.INTERNAL_SERVER_ERROR); } + @ExceptionHandler(DisplayNameAlreadyInUseException.class) + public ResponseEntity clientError(DisplayNameAlreadyInUseException ex) { + return displayNameInUse(); + } + + private static ResponseEntity displayNameInUse() { + var body = new ErrorResponse("DISPLAY_NAME_ALREADY_IN_USE", "The display name is already in use."); + return response(body, HttpStatus.CONFLICT); + } + private static ResponseEntity steamIdInUse() { var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", "The steam id is already in use."); return response(body, HttpStatus.CONFLICT); diff --git a/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java index bfb0c78..aaf830e 100644 --- a/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java +++ b/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java @@ -1,14 +1,17 @@ package auth_service.modules.user.api.dto.request; +import auth_service.shared.api.validation.DisplayNameFormat; import auth_service.shared.api.validation.SteamIdDeserializer; import auth_service.shared.api.validation.SteamIdFormat; import jakarta.validation.constraints.Pattern; import tools.jackson.databind.annotation.JsonDeserialize; /** - * @param steamId null removes the Steam ID + * @param steamId null removes the Steam ID + * @param displayName null removes the display name */ public record UserSettingsRequest(boolean useDarkTheme, String language, @Pattern(regexp = SteamIdFormat.PATTERN) - @JsonDeserialize(using = SteamIdDeserializer.class) String steamId) { + @JsonDeserialize(using = SteamIdDeserializer.class) String steamId, + @Pattern(regexp = DisplayNameFormat.PATTERN) String displayName) { } diff --git a/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java index d4c6df4..408e41e 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java @@ -5,5 +5,5 @@ import lombok.Builder; import java.time.LocalDateTime; @Builder -public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, SettingsResponse settings) { +public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, String displayName, SettingsResponse settings) { } diff --git a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java index 046cba6..1719c43 100644 --- a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java @@ -18,6 +18,7 @@ public abstract class ApiMapper { .createdDate(user.getCreatedDate()) .lastLogin(user.getLastLogin()) .steamId(user.getSteamId() == null ? null : String.valueOf(user.getSteamId())) + .displayName(user.getDisplayName()) .role("USER") .settings(SettingsResponse.builder() .id(user.getId()) diff --git a/src/main/java/auth_service/modules/user/exception/DisplayNameAlreadyInUseException.java b/src/main/java/auth_service/modules/user/exception/DisplayNameAlreadyInUseException.java new file mode 100644 index 0000000..3388aed --- /dev/null +++ b/src/main/java/auth_service/modules/user/exception/DisplayNameAlreadyInUseException.java @@ -0,0 +1,7 @@ +package auth_service.modules.user.exception; + +public class DisplayNameAlreadyInUseException extends RuntimeException { + public DisplayNameAlreadyInUseException(String message) { + super(message); + } +} diff --git a/src/main/java/auth_service/modules/user/model/User.java b/src/main/java/auth_service/modules/user/model/User.java index 811e7a3..6df10d9 100644 --- a/src/main/java/auth_service/modules/user/model/User.java +++ b/src/main/java/auth_service/modules/user/model/User.java @@ -14,6 +14,7 @@ public class User { private LocalDateTime lastLogin; private LocalDateTime createdDate; private Long steamId; + private String displayName; private UserSettings settings; } diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepository.java b/src/main/java/auth_service/modules/user/persistence/UserRepository.java index 01a7179..221d8c4 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepository.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepository.java @@ -24,7 +24,9 @@ public interface UserRepository { UserSettings findSettingsByUserId(String userId); @Transactional - boolean updateSettings(long userId, Long steamId, UserSettings settings); + boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings); boolean isSteamIdUsedByOtherUser(long userId, long steamId); + + boolean isDisplayNameUsedByOtherUser(long userId, String displayName); } diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java index 61e21c0..c996637 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java @@ -62,7 +62,7 @@ public class UserRepositoryImpl implements UserRepository { @Transactional @Override - public boolean updateSettings(long userId, Long steamId, UserSettings settings) { + public boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings) { var userEntity = entityManager.find(UserEntity.class, userId); if (userEntity == null) { throw new UserNotFoundException("Could not find user with id " + userId); @@ -80,6 +80,7 @@ public class UserRepositoryImpl implements UserRepository { boolean steamIdChanged = !Objects.equals(userEntity.getSteamId(), steamId); userEntity.setSteamId(steamId); + userEntity.setDisplayName(displayName); return steamIdChanged; } @@ -93,4 +94,14 @@ public class UserRepositoryImpl implements UserRepository { .getSingleResult(); return count > 0; } + + @Override + public boolean isDisplayNameUsedByOtherUser(long userId, String displayName) { + var count = entityManager.createQuery( + "SELECT COUNT(u) FROM UserEntity u WHERE LOWER(u.displayName) = LOWER(:displayName) AND u.id <> :userId", Long.class) + .setParameter("displayName", displayName) + .setParameter("userId", userId) + .getSingleResult(); + return count > 0; + } } diff --git a/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java index 1215b24..5be5a78 100644 --- a/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java +++ b/src/main/java/auth_service/modules/user/persistence/entities/UserEntity.java @@ -33,6 +33,9 @@ public class UserEntity { @Column(name = "steam_id", unique = true) private Long steamId; + @Column(name = "display_name", length = 24) + private String displayName; + @Column(name = "registration_date", nullable = false) private LocalDateTime registrationDate; diff --git a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java index eafd82e..8046496 100644 --- a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java @@ -39,6 +39,7 @@ public abstract class EntityMapper { .lastLogin(entity.getLastLoginDate()) .createdDate(entity.getRegistrationDate()) .steamId(entity.getSteamId()) + .displayName(entity.getDisplayName()) .settings(settingsModel) .build(); } diff --git a/src/main/java/auth_service/modules/user/service/UserService.java b/src/main/java/auth_service/modules/user/service/UserService.java index fe5d215..50b72c1 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -1,5 +1,6 @@ package auth_service.modules.user.service; +import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.SteamIdAlreadyInUseException; import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.model.User; @@ -34,11 +35,14 @@ public class UserService { @Transactional @CacheEvict(value = "usersByEmail", allEntries = true) - public String updateUserSettings(long userId, String email, Long steamId, UserSettings userSettings) { + public String updateUserSettings(long userId, String email, Long steamId, String displayName, UserSettings userSettings) { if (steamId != null && userRepository.isSteamIdUsedByOtherUser(userId, steamId)) { throw new SteamIdAlreadyInUseException("The steam id is already in use."); } - var updated = userRepository.updateSettings(userId, steamId, userSettings); + if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) { + throw new DisplayNameAlreadyInUseException("The display name is already in use."); + } + var updated = userRepository.updateSettings(userId, steamId, displayName, userSettings); if (updated) { log.info("Steam id has changed for user {}", userId); } diff --git a/src/main/java/auth_service/shared/api/validation/DisplayNameFormat.java b/src/main/java/auth_service/shared/api/validation/DisplayNameFormat.java new file mode 100644 index 0000000..9ec0fb8 --- /dev/null +++ b/src/main/java/auth_service/shared/api/validation/DisplayNameFormat.java @@ -0,0 +1,11 @@ +package auth_service.shared.api.validation; + +/** + * 3 to 24 letters, digits, spaces, "_", "." or "-". No space at the start or end. + */ +public final class DisplayNameFormat { + public static final String PATTERN = "[\\p{L}\\p{N}_.-][\\p{L}\\p{N} _.-]{1,22}[\\p{L}\\p{N}_.-]"; + + private DisplayNameFormat() { + } +} diff --git a/src/main/resources/db/migration/V6__Add_Display_Name.sql b/src/main/resources/db/migration/V6__Add_Display_Name.sql new file mode 100644 index 0000000..1be99ac --- /dev/null +++ b/src/main/resources/db/migration/V6__Add_Display_Name.sql @@ -0,0 +1,3 @@ +-- Player tag shown instead of the email prefix. Optional, unique ignoring case. +ALTER TABLE users ADD COLUMN display_name VARCHAR(24); +CREATE UNIQUE INDEX uq_users_display_name_lower ON users (LOWER(display_name)); diff --git a/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java b/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java index 186c59b..cffe1fc 100644 --- a/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java +++ b/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java @@ -168,4 +168,33 @@ class RegistrationControllerTest extends AbstractWebMvcTest { .contentType(MediaType.APPLICATION_JSON) .content(registrationRequestJson.write(request).getJson()))); } + + @Test + void register_InvalidDisplayName() throws Exception { + mockMvc.perform(post("/api/registration/new") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"x"}""")) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(registrationService); + } + + @Test + void register_PassesDisplayName() throws Exception { + // Arrange + when(registrationService.register(any(Registration.class))).thenReturn(42L); + + // Act + mockMvc.perform(post("/api/registration/new") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"Lobo"}""")) + .andExpect(status().isOk()); + + // Assert + var captor = ArgumentCaptor.forClass(Registration.class); + verify(registrationService).register(captor.capture()); + assertEquals("Lobo", captor.getValue().getDisplayName()); + } } diff --git a/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java b/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java index 0092d76..1dcde65 100644 --- a/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java +++ b/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java @@ -2,6 +2,7 @@ package auth_service.modules.registration.service; import auth_service.modules.registration.exception.EmailAlreadyInUseException; import auth_service.modules.registration.exception.InvalidInvitationCodeException; +import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; import auth_service.modules.registration.exception.SteamIdAlreadyRegisteredException; import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.persistence.RegisteredUserRepository; @@ -120,4 +121,18 @@ class RegistrationServiceTest extends AbstractUnitTest { assertEquals(42L, result); verify(registeredUserRepository, never()).existsBySteamId(anyLong()); } + + @Test + void register_DisplayNameTaken() { + // Arrange + validRegistration.setDisplayName("Lobo"); + when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true); + when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); + when(registeredUserRepository.existsBySteamId(validRegistration.getSteamId())).thenReturn(false); + when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true); + + // Act & Assert + assertThrows(DisplayNameAlreadyRegisteredException.class, () -> registrationService.register(validRegistration)); + verify(registeredUserRepository, never()).save(any()); + } } diff --git a/src/test/java/auth_service/modules/user/api/UserControllerTest.java b/src/test/java/auth_service/modules/user/api/UserControllerTest.java index 20265fc..03e91a4 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -3,6 +3,7 @@ package auth_service.modules.user.api; import auth_service.configuration.security.JwtAuthenticationFilter; import auth_service.configuration.security.JwtService; import auth_service.shared.security.UserPrincipal; +import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.SteamIdAlreadyInUseException; import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.model.User; @@ -11,6 +12,8 @@ import auth_service.modules.user.service.UserService; import auth_service.shared.test.AbstractWebMvcTest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; @@ -136,7 +139,7 @@ class UserControllerTest extends AbstractWebMvcTest { SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) ); - when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198005679788L), any())).thenReturn("new-token"); + when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198005679788L), any(), any())).thenReturn("new-token"); // Act & Assert mockMvc.perform(put("/api/users/settings") @@ -163,7 +166,7 @@ class UserControllerTest extends AbstractWebMvcTest { {"useDarkTheme":true,"language":"en","steamId":null}""")) .andExpect(status().isOk()); - verify(userService).updateUserSettings(eq(1L), eq(email), isNull(), any()); + verify(userService).updateUserSettings(eq(1L), eq(email), isNull(), any(), any()); } @Test @@ -192,7 +195,7 @@ class UserControllerTest extends AbstractWebMvcTest { SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) ); - when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198005679788L), any())) + when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198005679788L), any(), any())) .thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uc_steam_id\"")); // Act & Assert @@ -230,7 +233,7 @@ class UserControllerTest extends AbstractWebMvcTest { SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) ); - when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198005679788L), any())) + when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198005679788L), any(), any())) .thenThrow(new SteamIdAlreadyInUseException("taken")); // Act & Assert @@ -250,7 +253,7 @@ class UserControllerTest extends AbstractWebMvcTest { SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) ); - when(userService.updateUserSettings(eq(1L), eq(email), isNull(), any())) + when(userService.updateUserSettings(eq(1L), eq(email), isNull(), any(), any())) .thenThrow(new DataIntegrityViolationException("value too long for type character varying(10)")); // Act & Assert @@ -261,4 +264,98 @@ class UserControllerTest extends AbstractWebMvcTest { .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.code").value("INTERNAL_ERROR")); } + + @Test + void getCurrentUserV1_ReturnsDisplayName() throws Exception { + // Arrange + var email = "test@example.com"; + var settings = UserSettings.builder().language("en").theme("dark").build(); + var user = User.builder().email(email).displayName("Lobo").settings(settings).build(); + var principal = new UserPrincipal("1", email, null); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) + ); + when(userService.getUserByEmail(email)).thenReturn(user); + + // Act & Assert + mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayName").value("Lobo")); + } + + @Test + void updateUserSettings_PassesDisplayName() throws Exception { + // Arrange + var email = "test@example.com"; + var principal = new UserPrincipal("1", email, null); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) + ); + when(userService.updateUserSettings(eq(1L), eq(email), isNull(), eq("Lobo Corp_1"), any())).thenReturn("t"); + + // Act & Assert + mockMvc.perform(put("/api/users/settings") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"useDarkTheme":true,"language":"en","steamId":null,"displayName":"Lobo Corp_1"}""")) + .andExpect(status().isOk()); + } + + @ParameterizedTest + @ValueSource(strings = {"ab", " Lobo", "Lobo ", "