diff --git a/readme.md b/readme.md index da89b6a..fcc63e3 100644 --- a/readme.md +++ b/readme.md @@ -35,7 +35,8 @@ To Be Played is split into three repositories: - Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs. - Opt-in public profile at `/u/`. Auth answers with a short-lived **profile ticket** (EdDSA JWT, - 10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The ticket is not a login: + 10 minutes, `typ=public-profile`) that Core accepts for the public library data only. Its `profileStats` claim + names the stat groups the user chose to show there (none by default). The ticket is not a login: both services reject every token with a `typ` claim on normal endpoints. ## API @@ -52,6 +53,7 @@ To Be Played is split into three repositories: | `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie | | `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `PUT` | `/api/users/settings/appearance` | access token | +| `PUT` | `/api/users/settings/profile-stats` (`{groups}`: `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; `204`) | access token | | `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/stats/public` | public | diff --git a/src/main/java/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java index ee592a7..5ba27c5 100644 --- a/src/main/java/auth_service/configuration/security/JwtService.java +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -77,12 +77,13 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe } @Override - public String generateProfileTicket(long userId) { + public String generateProfileTicket(long userId, List profileStats) { return Jwts.builder() .header().keyId(jwtConfig.getKid()).and() .subject(PROFILE_TICKET_TYPE) .claim(TYPE_CLAIM, PROFILE_TICKET_TYPE) .claim("profileUserId", String.valueOf(userId)) + .claim("profileStats", profileStats) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS)) .signWith(privateKey, Jwts.SIG.EdDSA) diff --git a/src/main/java/auth_service/modules/profile/model/PublicProfile.java b/src/main/java/auth_service/modules/profile/model/PublicProfile.java index b789e20..70641bf 100644 --- a/src/main/java/auth_service/modules/profile/model/PublicProfile.java +++ b/src/main/java/auth_service/modules/profile/model/PublicProfile.java @@ -1,5 +1,6 @@ package auth_service.modules.profile.model; import java.time.LocalDate; +import java.util.List; -public record PublicProfile(long userId, String displayName, LocalDate memberSince) {} +public record PublicProfile(long userId, String displayName, LocalDate memberSince, List profileStats) {} diff --git a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java index e9cc43b..cd7a958 100644 --- a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java +++ b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java @@ -4,6 +4,7 @@ import auth_service.modules.profile.model.PublicProfile; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -20,13 +21,14 @@ public class PublicProfileRepository { */ public Optional findPublicByDisplayName(String displayName) { return jdbc.query(""" - SELECT u.id, u.display_name, u.registration_date + SELECT u.id, u.display_name, u.registration_date, s.profile_stats FROM users u JOIN user_settings s ON s.user_id = u.id WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND s.public_profile """, (rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"), rs.getTimestamp("registration_date") != null - ? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null), + ? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null, + List.of((String[]) rs.getArray("profile_stats").getArray())), displayName).stream().findFirst(); } } diff --git a/src/main/java/auth_service/modules/profile/service/PublicProfileService.java b/src/main/java/auth_service/modules/profile/service/PublicProfileService.java index bd40fed..037c1c2 100644 --- a/src/main/java/auth_service/modules/profile/service/PublicProfileService.java +++ b/src/main/java/auth_service/modules/profile/service/PublicProfileService.java @@ -22,6 +22,6 @@ public class PublicProfileService { */ public Optional findPublicProfile(String displayName) { return publicProfileRepository.findPublicByDisplayName(displayName) - .map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(profile.userId()))); + .map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(profile.userId(), profile.profileStats()))); } } 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 ea8106b..e1804d1 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -2,6 +2,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.ProfileStatsRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest; import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.api.mapper.ApiMapper; @@ -45,6 +46,13 @@ public class UserController { return ResponseEntity.noContent().build(); } + @PutMapping(value = "/settings/profile-stats", version = "1.0") + public ResponseEntity updateProfileStats(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody ProfileStatsRequest request) { + this.userService.updateProfileStats(principal.getIdAsLong(), request.groups()); + return ResponseEntity.noContent().build(); + } + @PutMapping(value = "/settings/appearance", version = "1.0") public ResponseEntity updateAppearance(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody AppearanceRequest request) { diff --git a/src/main/java/auth_service/modules/user/api/dto/request/ProfileStatsRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/ProfileStatsRequest.java new file mode 100644 index 0000000..025a255 --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/request/ProfileStatsRequest.java @@ -0,0 +1,13 @@ +package auth_service.modules.user.api.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.util.List; + +/** + * @param groups the stat groups of Core the public profile shows; empty shows none + */ +public record ProfileStatsRequest( + @NotNull List<@NotNull @Pattern(regexp = "PILE|TOP_GAMES|TASTE|HABITS|FINISHED|TREND") String> groups) { +} 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 2310517..224588d 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 @@ -2,7 +2,9 @@ package auth_service.modules.user.api.dto.response; import lombok.Builder; +import java.util.List; + @Builder public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, - AppearanceResponse appearance) { + AppearanceResponse appearance, List profileStats) { } 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 2723124..930f148 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 @@ -30,6 +30,7 @@ public abstract class ApiMapper { .language(user.getSettings().getLanguage()) .publicProfile(user.getSettings().isPublicProfile()) .appearance(toApi(user.getSettings().getAppearance())) + .profileStats(user.getSettings().getProfileStats()) .build()) .build(); } 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 8be8da0..3aef6bd 100644 --- a/src/main/java/auth_service/modules/user/model/UserSettings.java +++ b/src/main/java/auth_service/modules/user/model/UserSettings.java @@ -3,6 +3,8 @@ package auth_service.modules.user.model; import lombok.Builder; import lombok.Data; +import java.util.List; + @Data @Builder public class UserSettings { @@ -11,4 +13,7 @@ public class UserSettings { private boolean publicProfile; @Builder.Default private Appearance appearance = Appearance.DEFAULT; + /** The stat groups of Core the public profile shows. */ + @Builder.Default + private List profileStats = List.of(); } 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 3953c14..6dda51d 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepository.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepository.java @@ -8,6 +8,7 @@ import auth_service.modules.user.model.UserSettings; import jakarta.transaction.Transactional; import jakarta.validation.constraints.NotNull; +import java.util.List; import java.util.Optional; /** @@ -31,6 +32,8 @@ public interface UserRepository { @Transactional void updateAppearance(long userId, Appearance appearance); + void updateProfileStats(long userId, List groups); + Optional findSteamIdHolder(long steamId); /** 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 cf938cd..bedaf15 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java @@ -15,6 +15,8 @@ import jakarta.transaction.Transactional; import jakarta.validation.constraints.NotNull; import org.springframework.stereotype.Repository; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -86,21 +88,7 @@ public class UserRepositoryImpl implements UserRepository { @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); - } - + UserSettingsEntity settingsEntity = settingsOf(userId); settingsEntity.setAccent(appearance.accent()); settingsEntity.setCorners(appearance.corners()); settingsEntity.setWideLayout(appearance.wideLayout()); @@ -109,6 +97,31 @@ public class UserRepositoryImpl implements UserRepository { settingsEntity.setShowBanner(appearance.showBanner()); } + @Transactional + @Override + public void updateProfileStats(long userId, List groups) { + settingsOf(userId).setProfileStats(new ArrayList<>(groups)); + } + + /** + * The settings row of the user, created with the defaults of the user_settings columns when there is none. + */ + private UserSettingsEntity settingsOf(long userId) { + UserEntity userEntity = entityManager.find(UserEntity.class, userId); + if (userEntity == null) { + throw new UserNotFoundException("Could not find user with id " + userId); + } + UserSettingsEntity settingsEntity = userEntity.getSettings(); + if (settingsEntity == null) { + settingsEntity = new UserSettingsEntity(); + settingsEntity.setUserId(userId); + settingsEntity.setLanguage(""); + settingsEntity.setTheme("light"); + userEntity.setSettings(settingsEntity); + } + return settingsEntity; + } + @Override public Optional findSteamIdHolder(long steamId) { // A projection, not the entity: the bulk updates that follow would leave a loaded entity stale. 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 0663f73..9feec95 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 @@ -7,6 +7,11 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import lombok.Getter; import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.ArrayList; +import java.util.List; @Entity @Table(name = "user_settings") @@ -46,4 +51,8 @@ public class UserSettingsEntity { @Column(name = "show_banner", nullable = false) private boolean showBanner = Appearance.DEFAULT.showBanner(); + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "profile_stats", nullable = false) + private List profileStats = new ArrayList<>(); } 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 1d8fbb7..aabc2b4 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 @@ -6,6 +6,8 @@ import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserSettingsEntity; +import java.util.List; + public abstract class EntityMapper { private EntityMapper() { @@ -26,6 +28,7 @@ public abstract class EntityMapper { .appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(), entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(), entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner())) + .profileStats(List.copyOf(entity.getSettings().getProfileStats())) .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 eef85a7..f90b350 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -16,6 +16,7 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; @Slf4j @@ -87,4 +88,13 @@ public class UserService { public void updateAppearance(long userId, Appearance appearance) { userRepository.updateAppearance(userId, appearance); } + + /** + * Stores each group once; the request may repeat one. + */ + @Transactional + @CacheEvict(value = "usersByEmail", allEntries = true) + public void updateProfileStats(long userId, List groups) { + userRepository.updateProfileStats(userId, groups.stream().distinct().toList()); + } } diff --git a/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java b/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java index 82ab04d..3321212 100644 --- a/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java +++ b/src/main/java/auth_service/shared/security/ProfileTicketIssuer.java @@ -1,9 +1,14 @@ package auth_service.shared.security; +import java.util.List; + /** * Port for the short-lived ticket that lets Core show the public profile data of one user. The ticket is signed * like a login token but is no login: the login filters of Auth and Core reject it. */ public interface ProfileTicketIssuer { - String generateProfileTicket(long userId); + /** + * @param profileStats the stat groups of Core the profile shows + */ + String generateProfileTicket(long userId, List profileStats); } diff --git a/src/main/resources/db/migration/V12__Add_Profile_Stats.sql b/src/main/resources/db/migration/V12__Add_Profile_Stats.sql new file mode 100644 index 0000000..da7196a --- /dev/null +++ b/src/main/resources/db/migration/V12__Add_Profile_Stats.sql @@ -0,0 +1,3 @@ +-- The stat groups of Core's stats page the user shows on the public profile, e.g. {PILE,TREND}. Empty for everyone +-- until they pick some. +ALTER TABLE user_settings ADD COLUMN profile_stats VARCHAR(16)[] NOT NULL DEFAULT '{}'; diff --git a/src/test/java/auth_service/configuration/security/JwtServiceTest.java b/src/test/java/auth_service/configuration/security/JwtServiceTest.java index d3429e7..29228ee 100644 --- a/src/test/java/auth_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/auth_service/configuration/security/JwtServiceTest.java @@ -3,6 +3,7 @@ package auth_service.configuration.security; import auth_service.shared.security.SessionProperties; import auth_service.shared.security.UserPrincipal; import auth_service.shared.test.AbstractUnitTest; +import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -104,7 +105,7 @@ class JwtServiceTest extends AbstractUnitTest { @Test void generateProfileTicket_NamesTheUserAndExpiresSoon() { - var ticket = jwtService.generateProfileTicket(42L); + var ticket = jwtService.generateProfileTicket(42L, List.of()); var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload(); assertEquals("public-profile", claims.get("typ", String.class)); @@ -114,9 +115,17 @@ class JwtServiceTest extends AbstractUnitTest { assertTrue(secondsLeft > 0 && secondsLeft <= 600, "expires in " + secondsLeft + "s"); } + @Test + void generateProfileTicket_CarriesTheSharedStatGroups() { + String ticket = jwtService.generateProfileTicket(42L, List.of("PILE", "TREND")); + + Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload(); + assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class)); + } + @Test void parseLoginToken_ProfileTicketIsNoLoginToken() { - assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L))); + assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, List.of()))); } @Test diff --git a/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java b/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java index c3492d0..8f7ad6b 100644 --- a/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java +++ b/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java @@ -1,6 +1,7 @@ package auth_service.modules.profile.api; import auth_service.shared.test.AbstractIntegrationTest; +import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -13,8 +14,10 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; import java.security.KeyFactory; +import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -66,6 +69,19 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { var key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey))); var claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload(); assertEquals(String.valueOf(publicUserId), claims.get("profileUserId", String.class)); + assertEquals(List.of(), claims.get("profileStats", List.class)); + } + + @Test + void publicProfile_TicketCarriesTheSharedStatGroups() throws Exception { + jdbc.update("update user_settings set profile_stats = '{PILE,TREND}' where user_id = ?", publicUserId); + + String body = mockMvc.perform(get("/api/public/profiles/Lobo")).andReturn().getResponse().getContentAsString(); + + String ticket = body.replaceAll(".*\"ticket\"\\s*:\\s*\"([^\"]+)\".*", "$1"); + PublicKey key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey))); + Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload(); + assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class)); } @Test 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 f0c9ca4..de05ee9 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -30,6 +30,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.util.Collections; +import java.util.List; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.doThrow; @@ -347,6 +348,48 @@ class UserControllerTest extends AbstractWebMvcTest { .andExpect(jsonPath("$.settings.appearance.showBanner").value(true)); } + @Test + void getCurrentUserV1_ShowsTheSharedStatGroups() throws Exception { + String email = "test@example.com"; + UserSettings settings = UserSettings.builder().language("en").theme("dark") + .profileStats(List.of("PILE", "TREND")).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.profileStats[0]").value("PILE")) + .andExpect(jsonPath("$.settings.profileStats[1]").value("TREND")); + } + + @Test + void updateProfileStats_StoresTheGroups() throws Exception { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList())); + + mockMvc.perform(put("/api/users/settings/profile-stats") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"groups\":[\"PILE\",\"TREND\"]}")) + .andExpect(status().isNoContent()); + + verify(userService).updateProfileStats(1L, List.of("PILE", "TREND")); + } + + // OVERVIEW is public anyway, so it is no choice. + @ParameterizedTest + @ValueSource(strings = {"{}", "{\"groups\":[\"OVERVIEW\"]}", "{\"groups\":[\"MONEY\"]}", "{\"groups\":[null]}"}) + void updateProfileStats_UnknownOrMissingGroups_Is400(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/profile-stats") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + @Test void updateAppearance_StoresTheValues() throws Exception { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( 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 ff4fa35..1d5f7be 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -12,6 +12,7 @@ import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager; import org.springframework.context.annotation.Import; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -253,6 +254,29 @@ class UserRepositoryTest extends AbstractPersistenceTest { .getSettings().getAppearance()); } + @Test + void updateProfileStats_IsReadBackAndKeptByUpdateSettings() { + UserEntity userEntity = entityManager.persist(UserEntity.builder() + .email("stats@example.com") + .password("password") + .enabled(true) + .registrationDate(LocalDateTime.now()) + .build()); + entityManager.flush(); + assertEquals(List.of(), userRepository.findByEmail("stats@example.com").orElseThrow() + .getSettings().getProfileStats()); + + userRepository.updateProfileStats(userEntity.getId(), List.of("PILE", "TREND")); + entityManager.flush(); + userRepository.updateSettings(userEntity.getId(), null, + UserSettings.builder().language("de").theme("dark").build()); + entityManager.flush(); + entityManager.clear(); + + assertEquals(List.of("PILE", "TREND"), userRepository.findByEmail("stats@example.com").orElseThrow() + .getSettings().getProfileStats()); + } + @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 9cfc277..736dc53 100644 --- a/src/test/java/auth_service/modules/user/service/UserServiceTest.java +++ b/src/test/java/auth_service/modules/user/service/UserServiceTest.java @@ -15,6 +15,7 @@ import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; +import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -61,6 +62,13 @@ class UserServiceTest extends AbstractUnitTest { assertThrows(UserNotFoundException.class, () -> userService.getUserByEmail(email)); } + @Test + void updateProfileStats_StoresEachGroupOnce() { + userService.updateProfileStats(1L, List.of("PILE", "TREND", "PILE")); + + verify(userRepository).updateProfileStats(1L, List.of("PILE", "TREND")); + } + @Test void updateUserSettings_NeverTouchesTheSteamIdAndIssuesNoToken() { var userSettings = UserSettings.builder().language("en").build();