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 f40c519..e6b4d8c 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -1,6 +1,7 @@ package auth_service.modules.user.api; import auth_service.shared.security.UserPrincipal; +import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest; import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.api.dto.response.UserSettingsResponse; @@ -45,4 +46,11 @@ public class UserController { ApiMapper.toSteamId(request), request.displayName(), ApiMapper.toDomain(request)); return ResponseEntity.ok(new UserSettingsResponse(token)); } + + @PutMapping(value = "/settings/appearance", version = "1.0") + public ResponseEntity updateAppearance(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody AppearanceRequest request) { + this.userService.updateAppearance(principal.getIdAsLong(), ApiMapper.toDomain(request)); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/auth_service/modules/user/api/dto/request/AppearanceRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/AppearanceRequest.java new file mode 100644 index 0000000..83b9dec --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/request/AppearanceRequest.java @@ -0,0 +1,15 @@ +package auth_service.modules.user.api.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; + +/** + * The frontend has a CSS preset for each allowed name. + */ +public record AppearanceRequest( + @NotNull @Pattern(regexp = "violet|blue|green|orange|pink|red") String accent, + @NotNull @Pattern(regexp = "sharp|default|round") String corners, + Boolean wideLayout, + @Positive Long backgroundAppid) { +} diff --git a/src/main/java/auth_service/modules/user/api/dto/response/AppearanceResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/AppearanceResponse.java new file mode 100644 index 0000000..6cc5cc3 --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/response/AppearanceResponse.java @@ -0,0 +1,4 @@ +package auth_service.modules.user.api.dto.response; + +public record AppearanceResponse(String accent, String corners, boolean wideLayout, Long backgroundAppid) { +} diff --git a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java index d9fff1b..2310517 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java @@ -3,5 +3,6 @@ package auth_service.modules.user.api.dto.response; import lombok.Builder; @Builder -public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile) { +public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, + AppearanceResponse appearance) { } 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 59ffb12..a67d1b4 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 @@ -1,9 +1,12 @@ package auth_service.modules.user.api.mapper; +import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest; +import auth_service.modules.user.api.dto.response.AppearanceResponse; import auth_service.modules.user.api.dto.response.SettingsResponse; import auth_service.modules.user.api.dto.response.UserProfileResponse; +import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; @@ -25,10 +28,21 @@ public abstract class ApiMapper { .useDarkTheme(user.getSettings().getTheme().equals("dark")) .language(user.getSettings().getLanguage()) .publicProfile(user.getSettings().isPublicProfile()) + .appearance(toApi(user.getSettings().getAppearance())) .build()) .build(); } + private static AppearanceResponse toApi(Appearance appearance) { + return new AppearanceResponse(appearance.accent(), appearance.corners(), appearance.wideLayout(), + appearance.backgroundAppid()); + } + + public static Appearance toDomain(AppearanceRequest request) { + return new Appearance(request.accent(), request.corners(), Boolean.TRUE.equals(request.wideLayout()), + request.backgroundAppid()); + } + public static Long toSteamId(UserSettingsRequest request) { return request.steamId() == null ? null : Long.valueOf(request.steamId()); } diff --git a/src/main/java/auth_service/modules/user/model/Appearance.java b/src/main/java/auth_service/modules/user/model/Appearance.java new file mode 100644 index 0000000..1f4ab93 --- /dev/null +++ b/src/main/java/auth_service/modules/user/model/Appearance.java @@ -0,0 +1,9 @@ +package auth_service.modules.user.model; + +/** + * @param backgroundAppid the Steam app whose art fills the page background; null for none + */ +public record Appearance(String accent, String corners, boolean wideLayout, Long backgroundAppid) { + + public static final Appearance DEFAULT = new Appearance("violet", "default", false, null); +} diff --git a/src/main/java/auth_service/modules/user/model/UserSettings.java b/src/main/java/auth_service/modules/user/model/UserSettings.java index d7162f0..8be8da0 100644 --- a/src/main/java/auth_service/modules/user/model/UserSettings.java +++ b/src/main/java/auth_service/modules/user/model/UserSettings.java @@ -9,4 +9,6 @@ public class UserSettings { private String language; private String theme; private boolean publicProfile; + @Builder.Default + private Appearance appearance = Appearance.DEFAULT; } 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 221d8c4..8e00d11 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepository.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepository.java @@ -1,6 +1,7 @@ package auth_service.modules.user.persistence; +import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import jakarta.transaction.Transactional; @@ -26,6 +27,9 @@ public interface UserRepository { @Transactional boolean updateSettings(long userId, Long steamId, String displayName, UserSettings settings); + @Transactional + void updateAppearance(long userId, Appearance appearance); + 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 410b452..cd00063 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java @@ -1,6 +1,7 @@ package auth_service.modules.user.persistence; import auth_service.modules.user.exception.UserNotFoundException; +import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.entities.UserEntity; @@ -86,6 +87,30 @@ public class UserRepositoryImpl implements UserRepository { return steamIdChanged; } + @Transactional + @Override + public void updateAppearance(long userId, Appearance appearance) { + var userEntity = entityManager.find(UserEntity.class, userId); + if (userEntity == null) { + throw new UserNotFoundException("Could not find user with id " + userId); + } + + var settingsEntity = userEntity.getSettings(); + if (settingsEntity == null) { + // Same values as the defaults of the user_settings columns. + settingsEntity = new UserSettingsEntity(); + settingsEntity.setUserId(userId); + settingsEntity.setLanguage(""); + settingsEntity.setTheme("light"); + userEntity.setSettings(settingsEntity); + } + + settingsEntity.setAccent(appearance.accent()); + settingsEntity.setCorners(appearance.corners()); + settingsEntity.setWideLayout(appearance.wideLayout()); + settingsEntity.setBackgroundAppid(appearance.backgroundAppid()); + } + @Override public boolean isSteamIdUsedByOtherUser(long userId, long steamId) { var count = entityManager.createQuery( diff --git a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java index ca08633..2240516 100644 --- a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java +++ b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java @@ -1,5 +1,6 @@ package auth_service.modules.user.persistence.entities; +import auth_service.modules.user.model.Appearance; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -26,4 +27,17 @@ public class UserSettingsEntity { @Column(name = "public_profile", nullable = false) private boolean publicProfile; + + // Java defaults as well: a row created here, not by the migration, must not insert null. + @Column(nullable = false) + private String accent = Appearance.DEFAULT.accent(); + + @Column(nullable = false) + private String corners = Appearance.DEFAULT.corners(); + + @Column(name = "wide_layout", nullable = false) + private boolean wideLayout; + + @Column(name = "background_appid") + private Long backgroundAppid; } 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 92ce8b3..4d44b49 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 @@ -1,5 +1,6 @@ package auth_service.modules.user.persistence.mapper; +import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.entities.UserEntity; @@ -22,6 +23,8 @@ public abstract class EntityMapper { .language(entity.getSettings().getLanguage()) .theme(entity.getSettings().getTheme()) .publicProfile(entity.getSettings().isPublicProfile()) + .appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(), + entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid())) .build(); } else { // Fallback default settings for users without an entry in the database 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 50b72c1..ec31e7b 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -3,6 +3,7 @@ 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.Appearance; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.UserRepository; @@ -49,4 +50,10 @@ public class UserService { log.info("User settings updated for user {}", userId); return tokenGenerator.generateToken(String.valueOf(userId), email, steamId); } + + @Transactional + @CacheEvict(value = "usersByEmail", allEntries = true) + public void updateAppearance(long userId, Appearance appearance) { + userRepository.updateAppearance(userId, appearance); + } } diff --git a/src/main/resources/db/migration/V8__Add_Appearance.sql b/src/main/resources/db/migration/V8__Add_Appearance.sql new file mode 100644 index 0000000..18f617c --- /dev/null +++ b/src/main/resources/db/migration/V8__Add_Appearance.sql @@ -0,0 +1,6 @@ +-- How the frontend looks for the user. The API accepts only known preset names, never raw CSS. +ALTER TABLE user_settings + ADD COLUMN accent VARCHAR(16) NOT NULL DEFAULT 'violet', + ADD COLUMN corners VARCHAR(16) NOT NULL DEFAULT 'default', + ADD COLUMN wide_layout BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN background_appid BIGINT; 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 565f25e..5394b79 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -6,6 +6,7 @@ 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.Appearance; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.service.UserService; @@ -401,4 +402,88 @@ class UserControllerTest extends AbstractWebMvcTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.settings.publicProfile").value(true)); } + + @Test + void getCurrentUserV1_ShowsAppearance() throws Exception { + var email = "test@example.com"; + var settings = UserSettings.builder().language("en").theme("dark") + .appearance(new Appearance("blue", "round", true, 620L)).build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList()) + ); + when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build()); + + mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.settings.appearance.accent").value("blue")) + .andExpect(jsonPath("$.settings.appearance.corners").value("round")) + .andExpect(jsonPath("$.settings.appearance.wideLayout").value(true)) + .andExpect(jsonPath("$.settings.appearance.backgroundAppid").value(620)); + } + + @Test + void getCurrentUserV1_WithoutBackground_SendsNull() throws Exception { + var email = "test@example.com"; + var settings = UserSettings.builder().language("en").theme("dark").build(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList()) + ); + when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build()); + + mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.settings.appearance.accent").value("violet")) + .andExpect(jsonPath("$.settings.appearance.corners").value("default")) + .andExpect(jsonPath("$.settings.appearance.wideLayout").value(false)) + .andExpect(jsonPath("$.settings.appearance.backgroundAppid").isEmpty()); + } + + @Test + void updateAppearance_StoresTheValues() throws Exception { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList())); + + mockMvc.perform(put("/api/users/settings/appearance") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"accent\":\"green\",\"corners\":\"sharp\",\"wideLayout\":true,\"backgroundAppid\":620}")) + .andExpect(status().isNoContent()); + + verify(userService).updateAppearance(1L, new Appearance("green", "sharp", true, 620L)); + } + + @Test + void updateAppearance_MissingWideLayoutAndBackground_AreOffAndNone() throws Exception { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList())); + + mockMvc.perform(put("/api/users/settings/appearance") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"accent\":\"violet\",\"corners\":\"default\"}")) + .andExpect(status().isNoContent()); + + verify(userService).updateAppearance(1L, new Appearance("violet", "default", false, null)); + } + + // Each body differs from a valid one in one field only, so the 400 comes from that field's constraint. + @ParameterizedTest + @ValueSource(strings = { + "{\"accent\":\"hotpink\",\"corners\":\"default\"}", + "{\"accent\":\"red;background:url(x)\",\"corners\":\"default\"}", + "{\"corners\":\"default\"}", + "{\"accent\":\"violet\",\"corners\":\"pointy\"}", + "{\"accent\":\"violet\"}", + "{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":0}", + "{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":-620}" + }) + void updateAppearance_UnknownOrMissingValue_IsRejected(String body) throws Exception { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList())); + + mockMvc.perform(put("/api/users/settings/appearance") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(userService); + } } diff --git a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java index ae641e3..8c45b5a 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -1,6 +1,7 @@ package auth_service.modules.user.persistence; import auth_service.modules.user.exception.UserNotFoundException; +import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.shared.test.AbstractPersistenceTest; @@ -198,4 +199,62 @@ class UserRepositoryTest extends AbstractPersistenceTest { assertTrue(userRepository.findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile()); } + + @Test + void updateAppearance_WithoutSettingsRow_CreatesItAndFindByEmailReadsIt() { + var userEntity = entityManager.persist(UserEntity.builder() + .email("appearance@example.com") + .password("password") + .enabled(true) + .registrationDate(LocalDateTime.now()) + .build()); + entityManager.flush(); + + userRepository.updateAppearance(userEntity.getId(), new Appearance("orange", "round", true, 620L)); + entityManager.flush(); + entityManager.clear(); + + var settings = userRepository.findByEmail("appearance@example.com").orElseThrow().getSettings(); + assertEquals(new Appearance("orange", "round", true, 620L), settings.getAppearance()); + } + + @Test + void updateSettings_KeepsTheAppearance() { + var userEntity = entityManager.persist(UserEntity.builder() + .email("keep@example.com") + .password("password") + .enabled(true) + .registrationDate(LocalDateTime.now()) + .build()); + entityManager.flush(); + userRepository.updateAppearance(userEntity.getId(), new Appearance("pink", "sharp", false, 220L)); + entityManager.flush(); + + userRepository.updateSettings(userEntity.getId(), null, null, + UserSettings.builder().language("de").theme("dark").build()); + entityManager.flush(); + entityManager.clear(); + + var settings = userRepository.findByEmail("keep@example.com").orElseThrow().getSettings(); + assertEquals(new Appearance("pink", "sharp", false, 220L), settings.getAppearance()); + } + + @Test + void findByEmail_WithoutSettingsRow_HasTheDefaultAppearance() { + entityManager.persist(UserEntity.builder() + .email("defaults@example.com") + .password("password") + .enabled(true) + .registrationDate(LocalDateTime.now()) + .build()); + entityManager.flush(); + + assertEquals(Appearance.DEFAULT, userRepository.findByEmail("defaults@example.com").orElseThrow() + .getSettings().getAppearance()); + } + + @Test + void updateAppearance_UnknownUser() { + assertThrows(UserNotFoundException.class, () -> userRepository.updateAppearance(-1L, Appearance.DEFAULT)); + } } diff --git a/src/test/java/auth_service/modules/user/service/UserServiceTest.java b/src/test/java/auth_service/modules/user/service/UserServiceTest.java index e59c004..ffb2646 100644 --- a/src/test/java/auth_service/modules/user/service/UserServiceTest.java +++ b/src/test/java/auth_service/modules/user/service/UserServiceTest.java @@ -3,6 +3,7 @@ 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.Appearance; import auth_service.modules.user.model.User; import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.UserRepository; @@ -131,4 +132,14 @@ class UserServiceTest extends AbstractUnitTest { // Assert verify(userRepository).updateSettings(1L, null, "Lobo", userSettings); } + + @Test + void updateAppearance_StoresItWithoutANewToken() { + var appearance = new Appearance("red", "round", false, null); + + userService.updateAppearance(1L, appearance); + + verify(userRepository).updateAppearance(1L, appearance); + verifyNoInteractions(tokenGenerator); + } }