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.
This commit is contained in:
lobo committed 2026-09-24 10:39:04 +02:00
1 parent 4ebca75be4
commit 05e87b4396
29 files changed
+320 -23

No files matched your search

@@ -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<ErrorResponse> 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<ErrorResponse> clientError(SteamIdAlreadyRegisteredException ex) {
var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", ex.getMessage());
@@ -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();
}
@@ -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) {
}
@@ -0,0 +1,7 @@
package auth_service.modules.registration.exception;
public class DisplayNameAlreadyRegisteredException extends RuntimeException {
public DisplayNameAlreadyRegisteredException(String message) {
super(message);
}
}
@@ -11,4 +11,5 @@ public class Registration {
private String encryptedPassword;
private String inviteCode;
private Long steamId;
private String displayName;
}
@@ -7,5 +7,7 @@ public interface RegisteredUserRepository {
boolean existsBySteamId(long steamId);
boolean existsByDisplayName(String displayName);
long save(Registration registration);
}
@@ -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);
@@ -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;
@@ -18,6 +18,7 @@ public abstract class EntityMapper {
password(userModel.getEncryptedPassword()).
enabled(true).
steamId(userModel.getSteamId()).
displayName(userModel.getDisplayName()).
lastLoginDate(null).
build();
}
@@ -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));
@@ -42,7 +42,7 @@ public class UserController {
public ResponseEntity<UserSettingsResponse> 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));
}
}
@@ -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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> clientError(DisplayNameAlreadyInUseException ex) {
return displayNameInUse();
}
private static ResponseEntity<ErrorResponse> 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<ErrorResponse> steamIdInUse() {
var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", "The steam id is already in use.");
return response(body, HttpStatus.CONFLICT);
@@ -1,5 +1,6 @@
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;
@@ -7,8 +8,10 @@ import tools.jackson.databind.annotation.JsonDeserialize;
/**
* @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) {
}
@@ -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) {
}
@@ -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())
@@ -0,0 +1,7 @@
package auth_service.modules.user.exception;
public class DisplayNameAlreadyInUseException extends RuntimeException {
public DisplayNameAlreadyInUseException(String message) {
super(message);
}
}
@@ -14,6 +14,7 @@ public class User {
private LocalDateTime lastLogin;
private LocalDateTime createdDate;
private Long steamId;
private String displayName;
private UserSettings settings;
}
@@ -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);
}
@@ -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;
}
}
@@ -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;
@@ -39,6 +39,7 @@ public abstract class EntityMapper {
.lastLogin(entity.getLastLoginDate())
.createdDate(entity.getRegistrationDate())
.steamId(entity.getSteamId())
.displayName(entity.getDisplayName())
.settings(settingsModel)
.build();
}
@@ -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);
}
@@ -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() {
}
}
@@ -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));
@@ -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());
}
}
@@ -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());
}
}
@@ -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(76561198000000001L), any())).thenReturn("new-token");
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), 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(76561198000000001L), any()))
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), 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(76561198000000001L), any()))
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), 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 ", "<script>", "abcdefghijklmnopqrstuvwxy"})
void updateUserSettings_InvalidDisplayName(String displayName) throws Exception {
// Arrange
var principal = new UserPrincipal("1", "test@example.com", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"useDarkTheme\":true,\"language\":\"en\",\"displayName\":\"" + displayName + "\"}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(userService);
}
@Test
void updateUserSettings_DisplayNameTaken() 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), any(), eq("Lobo"), any()))
.thenThrow(new DisplayNameAlreadyInUseException("taken"));
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","displayName":"Lobo"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
}
@Test
void updateUserSettings_DisplayNameRaceIsConflict() 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), any(), eq("Lobo"), any()))
.thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uq_users_display_name_lower\""));
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","displayName":"Lobo"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
}
}
@@ -64,7 +64,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.build();
// Act
var updated = userRepository.updateSettings(userEntity.getId(), 654321L, settings);
var updated = userRepository.updateSettings(userEntity.getId(), 654321L, null, settings);
// Assert
assertTrue(updated);
@@ -109,7 +109,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
var settings = UserSettings.builder().language("en").theme("light").build();
// Act
userRepository.updateSettings(userEntity.getId(), null, settings);
userRepository.updateSettings(userEntity.getId(), null, null, settings);
// Assert
assertNull(entityManager.find(UserEntity.class, userEntity.getId()).getSteamId());
@@ -143,7 +143,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
void updateSettings_UnknownUser() {
var settings = UserSettings.builder().language("en").theme("light").build();
assertThrows(UserNotFoundException.class, () -> userRepository.updateSettings(-1L, null, settings));
assertThrows(UserNotFoundException.class, () -> userRepository.updateSettings(-1L, null, null, settings));
}
@Test
@@ -151,4 +151,33 @@ class UserRepositoryTest extends AbstractPersistenceTest {
// V1 seeded test@example.com with a known password; it must not exist after all migrations.
assertTrue(userRepository.findByEmail("test@example.com").isEmpty());
}
@Test
void displayName_StoredAndCheckedCaseInsensitive() {
// Arrange
var owner = entityManager.persist(UserEntity.builder()
.email("owner2@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
var other = entityManager.persist(UserEntity.builder()
.email("other2@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
var settings = UserSettings.builder().language("en").theme("light").build();
// Act
userRepository.updateSettings(owner.getId(), null, "Lobo", settings);
entityManager.flush();
// Assert
assertEquals("Lobo", userRepository.findByEmail("owner2@example.com").orElseThrow().getDisplayName());
assertFalse(userRepository.isDisplayNameUsedByOtherUser(owner.getId(), "lobo"));
assertTrue(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "LOBO"));
assertFalse(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "Wolf"));
}
}
@@ -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;
@@ -68,11 +69,11 @@ class UserServiceTest extends AbstractUnitTest {
when(tokenGenerator.generateToken("1", email, steamId)).thenReturn("new-token");
// Act
var token = userService.updateUserSettings(userId, email, steamId, userSettings);
var token = userService.updateUserSettings(userId, email, steamId, null, userSettings);
// Assert
assertEquals("new-token", token);
verify(userRepository).updateSettings(userId, steamId, userSettings);
verify(userRepository).updateSettings(userId, steamId, null, userSettings);
}
@Test
@@ -84,11 +85,11 @@ class UserServiceTest extends AbstractUnitTest {
when(tokenGenerator.generateToken("1", email, null)).thenReturn("new-token");
// Act
var token = userService.updateUserSettings(userId, email, null, userSettings);
var token = userService.updateUserSettings(userId, email, null, null, userSettings);
// Assert
assertEquals("new-token", token);
verify(userRepository).updateSettings(userId, null, userSettings);
verify(userRepository).updateSettings(userId, null, null, userSettings);
verify(userRepository, never()).isSteamIdUsedByOtherUser(anyLong(), anyLong());
}
@@ -102,7 +103,32 @@ class UserServiceTest extends AbstractUnitTest {
// Act & Assert
assertThrows(SteamIdAlreadyInUseException.class,
() -> userService.updateUserSettings(userId, "test@example.com", steamId, userSettings));
verify(userRepository, never()).updateSettings(anyLong(), any(), any());
() -> userService.updateUserSettings(userId, "test@example.com", steamId, null, userSettings));
verify(userRepository, never()).updateSettings(anyLong(), any(), any(), any());
}
@Test
void updateUserSettings_DisplayNameOfOtherUser() {
// Arrange
var userSettings = UserSettings.builder().language("en").build();
when(userRepository.isDisplayNameUsedByOtherUser(1L, "Lobo")).thenReturn(true);
// Act & Assert
assertThrows(DisplayNameAlreadyInUseException.class,
() -> userService.updateUserSettings(1L, "test@example.com", null, "Lobo", userSettings));
verify(userRepository, never()).updateSettings(anyLong(), any(), any(), any());
}
@Test
void updateUserSettings_StoresDisplayName() {
// Arrange
var userSettings = UserSettings.builder().language("en").build();
when(userRepository.isDisplayNameUsedByOtherUser(1L, "Lobo")).thenReturn(false);
// Act
userService.updateUserSettings(1L, "test@example.com", null, "Lobo", userSettings);
// Assert
verify(userRepository).updateSettings(1L, null, "Lobo", userSettings);
}
}