Public Access
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:
1 parent
4ebca75be4
commit
05e87b4396
29 files changed
+321
-24
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user