feat(profile): let users share stat groups
Build, Test & Push Auth Service / quality-check (push) Successful in 30s
Build, Test & Push Auth Service / docker (push) Successful in 1m24s

Store the groups in user_settings.profile_stats and send them in the
profile ticket, so Core shows only these on the public profile.
This commit is contained in:
lobo committed 2026-09-27 07:16:10 +02:00
1 parent 4b2acef381
commit 4ec47f1b2c
22 files changed
+206 -25

No files matched your search

+3 -1
View File
@@ -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 - 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. 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/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT, - Opt-in public profile at `/u/<display name>`. 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. both services reject every token with a `typ` claim on normal endpoints.
## API ## 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 | | `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 | | `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/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/public/profiles/{displayName}` | public |
| `GET` | `/api/stats/public` | public | | `GET` | `/api/stats/public` | public |
@@ -77,12 +77,13 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, PublicKe
} }
@Override @Override
public String generateProfileTicket(long userId) { public String generateProfileTicket(long userId, List<String> profileStats) {
return Jwts.builder() return Jwts.builder()
.header().keyId(jwtConfig.getKid()).and() .header().keyId(jwtConfig.getKid()).and()
.subject(PROFILE_TICKET_TYPE) .subject(PROFILE_TICKET_TYPE)
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE) .claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
.claim("profileUserId", String.valueOf(userId)) .claim("profileUserId", String.valueOf(userId))
.claim("profileStats", profileStats)
.issuedAt(new Date(System.currentTimeMillis())) .issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS)) .expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS))
.signWith(privateKey, Jwts.SIG.EdDSA) .signWith(privateKey, Jwts.SIG.EdDSA)
@@ -1,5 +1,6 @@
package auth_service.modules.profile.model; package auth_service.modules.profile.model;
import java.time.LocalDate; 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<String> profileStats) {}
@@ -4,6 +4,7 @@ import auth_service.modules.profile.model.PublicProfile;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
@@ -20,13 +21,14 @@ public class PublicProfileRepository {
*/ */
public Optional<PublicProfile> findPublicByDisplayName(String displayName) { public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query(""" 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 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 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, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null 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(); displayName).stream().findFirst();
} }
} }
@@ -22,6 +22,6 @@ public class PublicProfileService {
*/ */
public Optional<TicketedProfile> findPublicProfile(String displayName) { public Optional<TicketedProfile> findPublicProfile(String displayName) {
return publicProfileRepository.findPublicByDisplayName(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())));
} }
} }
@@ -2,6 +2,7 @@ package auth_service.modules.user.api;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.AppearanceRequest; 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.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.api.dto.response.UserProfileResponse;
import auth_service.modules.user.api.mapper.ApiMapper; import auth_service.modules.user.api.mapper.ApiMapper;
@@ -45,6 +46,13 @@ public class UserController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping(value = "/settings/profile-stats", version = "1.0")
public ResponseEntity<Void> 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") @PutMapping(value = "/settings/appearance", version = "1.0")
public ResponseEntity<Void> updateAppearance(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<Void> updateAppearance(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody AppearanceRequest request) { @Valid @RequestBody AppearanceRequest request) {
@@ -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) {
}
@@ -2,7 +2,9 @@ package auth_service.modules.user.api.dto.response;
import lombok.Builder; import lombok.Builder;
import java.util.List;
@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) { AppearanceResponse appearance, List<String> profileStats) {
} }
@@ -30,6 +30,7 @@ public abstract class ApiMapper {
.language(user.getSettings().getLanguage()) .language(user.getSettings().getLanguage())
.publicProfile(user.getSettings().isPublicProfile()) .publicProfile(user.getSettings().isPublicProfile())
.appearance(toApi(user.getSettings().getAppearance())) .appearance(toApi(user.getSettings().getAppearance()))
.profileStats(user.getSettings().getProfileStats())
.build()) .build())
.build(); .build();
} }
@@ -3,6 +3,8 @@ package auth_service.modules.user.model;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import java.util.List;
@Data @Data
@Builder @Builder
public class UserSettings { public class UserSettings {
@@ -11,4 +13,7 @@ public class UserSettings {
private boolean publicProfile; private boolean publicProfile;
@Builder.Default @Builder.Default
private Appearance appearance = Appearance.DEFAULT; private Appearance appearance = Appearance.DEFAULT;
/** The stat groups of Core the public profile shows. */
@Builder.Default
private List<String> profileStats = List.of();
} }
@@ -8,6 +8,7 @@ import auth_service.modules.user.model.UserSettings;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -31,6 +32,8 @@ public interface UserRepository {
@Transactional @Transactional
void updateAppearance(long userId, Appearance appearance); void updateAppearance(long userId, Appearance appearance);
void updateProfileStats(long userId, List<String> groups);
Optional<SteamIdHolder> findSteamIdHolder(long steamId); Optional<SteamIdHolder> findSteamIdHolder(long steamId);
/** /**
@@ -15,6 +15,8 @@ import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -86,21 +88,7 @@ public class UserRepositoryImpl implements UserRepository {
@Transactional @Transactional
@Override @Override
public void updateAppearance(long userId, Appearance appearance) { public void updateAppearance(long userId, Appearance appearance) {
var userEntity = entityManager.find(UserEntity.class, userId); UserSettingsEntity settingsEntity = settingsOf(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.setAccent(appearance.accent());
settingsEntity.setCorners(appearance.corners()); settingsEntity.setCorners(appearance.corners());
settingsEntity.setWideLayout(appearance.wideLayout()); settingsEntity.setWideLayout(appearance.wideLayout());
@@ -109,6 +97,31 @@ public class UserRepositoryImpl implements UserRepository {
settingsEntity.setShowBanner(appearance.showBanner()); settingsEntity.setShowBanner(appearance.showBanner());
} }
@Transactional
@Override
public void updateProfileStats(long userId, List<String> 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 @Override
public Optional<SteamIdHolder> findSteamIdHolder(long steamId) { public Optional<SteamIdHolder> findSteamIdHolder(long steamId) {
// A projection, not the entity: the bulk updates that follow would leave a loaded entity stale. // A projection, not the entity: the bulk updates that follow would leave a loaded entity stale.
@@ -7,6 +7,11 @@ import jakarta.persistence.Id;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.util.ArrayList;
import java.util.List;
@Entity @Entity
@Table(name = "user_settings") @Table(name = "user_settings")
@@ -46,4 +51,8 @@ public class UserSettingsEntity {
@Column(name = "show_banner", nullable = false) @Column(name = "show_banner", nullable = false)
private boolean showBanner = Appearance.DEFAULT.showBanner(); private boolean showBanner = Appearance.DEFAULT.showBanner();
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_stats", nullable = false)
private List<String> profileStats = new ArrayList<>();
} }
@@ -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.UserEntity;
import auth_service.modules.user.persistence.entities.UserSettingsEntity; import auth_service.modules.user.persistence.entities.UserSettingsEntity;
import java.util.List;
public abstract class EntityMapper { public abstract class EntityMapper {
private EntityMapper() { private EntityMapper() {
@@ -26,6 +28,7 @@ public abstract class EntityMapper {
.appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(), .appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(),
entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(), entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(),
entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner())) entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner()))
.profileStats(List.copyOf(entity.getSettings().getProfileStats()))
.build(); .build();
} else { } else {
// Fallback default settings for users without an entry in the database // Fallback default settings for users without an entry in the database
@@ -16,6 +16,7 @@ import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional; import java.util.Optional;
@Slf4j @Slf4j
@@ -87,4 +88,13 @@ public class UserService {
public void updateAppearance(long userId, Appearance appearance) { public void updateAppearance(long userId, Appearance appearance) {
userRepository.updateAppearance(userId, 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<String> groups) {
userRepository.updateProfileStats(userId, groups.stream().distinct().toList());
}
} }
@@ -1,9 +1,14 @@
package auth_service.shared.security; 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 * 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. * like a login token but is no login: the login filters of Auth and Core reject it.
*/ */
public interface ProfileTicketIssuer { public interface ProfileTicketIssuer {
String generateProfileTicket(long userId); /**
* @param profileStats the stat groups of Core the profile shows
*/
String generateProfileTicket(long userId, List<String> profileStats);
} }
@@ -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 '{}';
@@ -3,6 +3,7 @@ package auth_service.configuration.security;
import auth_service.shared.security.SessionProperties; import auth_service.shared.security.SessionProperties;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -104,7 +105,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void generateProfileTicket_NamesTheUserAndExpiresSoon() { 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(); var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals("public-profile", claims.get("typ", String.class)); 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"); 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 @Test
void parseLoginToken_ProfileTicketIsNoLoginToken() { void parseLoginToken_ProfileTicketIsNoLoginToken() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L))); assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, List.of())));
} }
@Test @Test
@@ -1,6 +1,7 @@
package auth_service.modules.profile.api; package auth_service.modules.profile.api;
import auth_service.shared.test.AbstractIntegrationTest; import auth_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -13,8 +14,10 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.security.KeyFactory; import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec; import java.security.spec.X509EncodedKeySpec;
import java.util.Base64; import java.util.Base64;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; 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 key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)));
var claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload(); var claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(String.valueOf(publicUserId), claims.get("profileUserId", String.class)); 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 @Test
@@ -30,6 +30,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
@@ -347,6 +348,48 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.settings.appearance.showBanner").value(true)); .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 @Test
void updateAppearance_StoresTheValues() throws Exception { void updateAppearance_StoresTheValues() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
@@ -12,6 +12,7 @@ import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -253,6 +254,29 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.getSettings().getAppearance()); .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 @Test
void updateAppearance_UnknownUser() { void updateAppearance_UnknownUser() {
assertThrows(UserNotFoundException.class, () -> userRepository.updateAppearance(-1L, Appearance.DEFAULT)); assertThrows(UserNotFoundException.class, () -> userRepository.updateAppearance(-1L, Appearance.DEFAULT));
@@ -15,6 +15,7 @@ import org.mockito.InOrder;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -61,6 +62,13 @@ class UserServiceTest extends AbstractUnitTest {
assertThrows(UserNotFoundException.class, () -> userService.getUserByEmail(email)); 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 @Test
void updateUserSettings_NeverTouchesTheSteamIdAndIssuesNoToken() { void updateUserSettings_NeverTouchesTheSteamIdAndIssuesNoToken() {
var userSettings = UserSettings.builder().language("en").build(); var userSettings = UserSettings.builder().language("en").build();