feat(settings): store chart form choice per stat group
Build, Test & Push Auth Service / quality-check (push) Successful in 1m0s
Build, Test & Push Auth Service / docker (push) Successful in 1m24s

This commit is contained in:
lobo committed 2026-10-04 10:43:02 +02:00
1 parent 19463a1a4d
commit 3410cbc598
19 files changed
+310 -8

No files matched your search

@@ -35,6 +35,7 @@ public class PublicProfileController {
private static PublicProfileResponse.Style style(PublicProfile profile) {
PublicProfile.Style style = profile.style();
return new PublicProfileResponse.Style(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid(),
profile.tiles().stream().map(tile -> new PublicProfileResponse.Tile(tile.id(), tile.size())).toList());
profile.tiles().stream().map(tile -> new PublicProfileResponse.Tile(tile.id(), tile.size())).toList(),
style.statCharts());
}
}
@@ -2,6 +2,7 @@ package auth_service.modules.profile.api.dto;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* @param ticket for Core's public profile endpoint; valid for a few minutes
@@ -11,8 +12,10 @@ public record PublicProfileResponse(String displayName, LocalDate memberSince, S
/**
* @param tiles the shown parts of the profile in their order
* @param statCharts the owner's chart form per stat group
*/
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<Tile> tiles) {}
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<Tile> tiles,
Map<String, String> statCharts) {}
public record Tile(String id, String size) {}
}
@@ -4,10 +4,11 @@ import auth_service.shared.security.Role;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
public record PublicProfile(long userId, Role role, String displayName, LocalDate memberSince, Style style, List<Tile> tiles) {
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid) {}
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, Map<String, String> statCharts) {}
/**
* @param id a part of the profile, or STATS_ and a stat group of Core
@@ -7,7 +7,9 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Repository
@@ -26,13 +28,25 @@ public class PublicProfileRepository {
: new PublicProfile.Tile(stored.substring(0, colon), stored.substring(colon + 1));
}
// Stored as "GROUP:CHART", like user.model.StatCharts.parse, which this module may not use.
private static Map<String, String> statCharts(String[] stored) {
Map<String, String> charts = new LinkedHashMap<>();
for (String entry : stored) {
int colon = entry.indexOf(':');
if (colon >= 0) {
charts.put(entry.substring(0, colon), entry.substring(colon + 1));
}
}
return charts;
}
/**
* Only enabled, not deleted users who switched the public profile on. The name ignores case, like its unique index.
*/
public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query("""
SELECT u.id, u.role, u.display_name, u.registration_date, s.profile_accent, s.profile_corners,
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles, s.stat_charts
FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND %s AND s.public_profile
""".formatted(ActiveUser.of("u")),
@@ -41,7 +55,8 @@ public class PublicProfileRepository {
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"),
rs.getObject("profile_banner_appid", Long.class),
rs.getObject("profile_background_appid", Long.class)),
rs.getObject("profile_background_appid", Long.class),
statCharts((String[]) rs.getArray("stat_charts").getArray())),
Arrays.stream((String[]) rs.getArray("profile_tiles").getArray()).map(PublicProfileRepository::tile).toList()),
displayName).stream().findFirst();
}
@@ -6,6 +6,7 @@ import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
import auth_service.modules.user.api.dto.request.FavoritePlatformRequest;
import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
import auth_service.modules.user.api.dto.request.StatChartRequest;
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;
@@ -57,6 +58,13 @@ public class UserController {
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/stat-chart")
public ResponseEntity<Void> updateStatChart(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody StatChartRequest request) {
this.userService.updateStatChart(principal.getIdAsLong(), request.group(), request.chart());
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/favorite-platform")
public ResponseEntity<Void> updateFavoritePlatform(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody FavoritePlatformRequest request) {
@@ -0,0 +1,21 @@
package auth_service.modules.user.api.dto.request;
import auth_service.modules.user.model.StatCharts;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
/**
* @param group a stat group of the Frontend
* @param chart the form for the group; null puts the group back to its default
*/
public record StatChartRequest(@NotNull @Pattern(regexp = "[A-Z_]{1,20}") String group,
@Pattern(regexp = "[A-Z]{1,10}") String chart) {
@AssertTrue(message = "Unknown stat group or chart")
@JsonIgnore
public boolean isKnownChoice() {
return group == null || (chart == null ? StatCharts.knows(group) : StatCharts.allows(group, chart));
}
}
@@ -4,10 +4,11 @@ import lombok.Builder;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
String favoritePlatform, Instant welcomeSeenAt) {
Map<String, String> statCharts, String favoritePlatform, Instant welcomeSeenAt) {
}
@@ -47,6 +47,7 @@ public abstract class ApiMapper {
.dashboardTiles(user.getSettings().getDashboardTiles() == null ? null
: toApi(user.getSettings().getDashboardTiles()))
.profileTiles(toApi(user.getSettings().getProfileTiles()))
.statCharts(user.getSettings().getStatCharts())
.favoritePlatform(user.getSettings().getFavoritePlatform())
.welcomeSeenAt(user.getSettings().getWelcomeSeenAt())
.build())
@@ -0,0 +1,51 @@
package auth_service.modules.user.model;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The chart form a user picked per stat group. Keep the allowed forms in line with the Frontend
* {@code utils/statCharts.js}.
*/
public final class StatCharts {
private static final Map<String, Set<String>> ALLOWED = Map.of(
"TREND", Set.of("LINE", "BARS"),
"FINISHED", Set.of("BARS", "LINE", "LIST"),
"TASTE", Set.of("BARS", "PIE", "DONUT", "LIST"),
"PLATFORMS", Set.of("BARS", "PIE", "DONUT", "LIST"));
private StatCharts() {
}
public static boolean knows(String group) {
return ALLOWED.containsKey(group);
}
public static boolean allows(String group, String chart) {
return ALLOWED.getOrDefault(group, Set.of()).contains(chart);
}
/**
* @param stored "GROUP:CHART" entries, as the column holds them; an entry without ":" is skipped
*/
public static Map<String, String> parse(List<String> stored) {
Map<String, String> charts = new LinkedHashMap<>();
for (String entry : stored) {
int colon = entry.indexOf(':');
if (colon >= 0) {
charts.put(entry.substring(0, colon), entry.substring(colon + 1));
}
}
return charts;
}
public static ArrayList<String> stored(Map<String, String> charts) {
ArrayList<String> entries = new ArrayList<>();
charts.forEach((group, chart) -> entries.add(group + ":" + chart));
return entries;
}
}
@@ -5,6 +5,7 @@ import lombok.Data;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@Data
@Builder
@@ -20,6 +21,9 @@ public class UserSettings {
private List<Tile> dashboardTiles;
@Builder.Default
private List<Tile> profileTiles = DEFAULT_PROFILE_TILES;
/** Stat group to chart form; a group not in it shows its default. */
@Builder.Default
private Map<String, String> statCharts = Map.of();
private String favoritePlatform;
/** Null: the welcome page was not seen yet. */
private Instant welcomeSeenAt;
@@ -77,6 +77,11 @@ public class UserSettingsEntity {
@Column(name = "profile_tiles", nullable = false)
private List<String> profileTiles;
/** "GROUP:CHART" entries, see {@link auth_service.modules.user.model.StatCharts}. */
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "stat_charts", nullable = false)
private List<String> statCharts;
@Column(name = "welcome_seen_at")
private Instant welcomeSeenAt;
}
@@ -2,6 +2,7 @@ package auth_service.modules.user.persistence.mapper;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.StatCharts;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
@@ -35,6 +36,7 @@ public abstract class EntityMapper {
.dashboardTiles(settings.getDashboardTiles() == null ? null
: settings.getDashboardTiles().stream().map(Tile::parse).toList())
.profileTiles(settings.getProfileTiles().stream().map(Tile::parse).toList())
.statCharts(StatCharts.parse(settings.getStatCharts()))
.favoritePlatform(settings.getFavoritePlatform())
.welcomeSeenAt(settings.getWelcomeSeenAt())
.build();
@@ -75,6 +77,7 @@ public abstract class EntityMapper {
target.setDashboardTiles(settings.getDashboardTiles() == null ? null
: Tile.storedAll(settings.getDashboardTiles()));
target.setProfileTiles(Tile.storedAll(settings.getProfileTiles()));
target.setStatCharts(StatCharts.stored(settings.getStatCharts()));
target.setFavoritePlatform(settings.getFavoritePlatform());
target.setWelcomeSeenAt(settings.getWelcomeSeenAt());
}
@@ -23,7 +23,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Slf4j
@@ -126,6 +128,24 @@ public class UserService {
userRepository.update(userId, user -> user.getSettings().setProfileTiles(Tile.distinct(tiles)));
}
/**
* @param chart null puts the group back to its default form
*/
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "users", key = "#userId")
public void updateStatChart(long userId, String group, String chart) {
userRepository.update(userId, user -> {
Map<String, String> charts = new LinkedHashMap<>(user.getSettings().getStatCharts());
if (chart == null) {
charts.remove(group);
} else {
charts.put(group, chart);
}
user.getSettings().setStatCharts(charts);
});
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "users", key = "#userId")
@@ -0,0 +1,2 @@
-- GROUP:CHART per changed stat group; a group not listed shows its default form
ALTER TABLE user_settings ADD COLUMN stat_charts VARCHAR(32)[] NOT NULL DEFAULT '{}';
@@ -102,6 +102,23 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.style.tiles[1].size").value("L"));
}
@Test
void publicProfile_AnswersWithTheOwnersStatCharts() throws Exception {
jdbc.update("update user_settings set stat_charts = '{PLATFORMS:DONUT,TREND:BARS}' where user_id = ?",
publicUserId);
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.style.statCharts.PLATFORMS").value("DONUT"))
.andExpect(jsonPath("$.style.statCharts.TREND").value("BARS"));
}
@Test
void publicProfile_NewRowHasNoStatCharts() throws Exception {
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(jsonPath("$.style.statCharts").isEmpty());
}
@Test
void publicProfile_NewRowShowsTheDefaultTiles() throws Exception {
mockMvc.perform(get("/api/public/profiles/Lobo"))
@@ -36,6 +36,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.ArgumentMatchers.*;
@@ -369,7 +370,8 @@ class UserControllerTest extends AbstractWebMvcTest {
String email = "test@example.com";
UserSettings settings = UserSettings.builder().language("en").theme("dark")
.dashboardTiles(List.of(new Tile("STATS_TREND", "L")))
.profileTiles(List.of(new Tile("PLAYING", "S"))).favoritePlatform("PS5").build();
.profileTiles(List.of(new Tile("PLAYING", "S"))).favoritePlatform("PS5")
.statCharts(Map.of("PLATFORMS", "PIE")).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
@@ -381,7 +383,8 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.settings.dashboardTiles[0].size").value("L"))
.andExpect(jsonPath("$.settings.profileTiles[0].id").value("PLAYING"))
.andExpect(jsonPath("$.settings.profileTiles[0].size").value("S"))
.andExpect(jsonPath("$.settings.favoritePlatform").value("PS5"));
.andExpect(jsonPath("$.settings.favoritePlatform").value("PS5"))
.andExpect(jsonPath("$.settings.statCharts.PLATFORMS").value("PIE"));
}
@Test
@@ -459,6 +462,47 @@ class UserControllerTest extends AbstractWebMvcTest {
verify(userService).updateDashboardTiles(1L, null);
}
@Test
void updateStatChart_StoresOrClearsTheChoice() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/stat-chart")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"group\":\"PLATFORMS\",\"chart\":\"PIE\"}"))
.andExpect(status().isNoContent());
mockMvc.perform(put("/api/users/settings/stat-chart")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"group\":\"FINISHED\",\"chart\":null}"))
.andExpect(status().isNoContent());
verify(userService).updateStatChart(1L, "PLATFORMS", "PIE");
verify(userService).updateStatChart(1L, "FINISHED", null);
}
@ParameterizedTest
@ValueSource(strings = {
"{\"group\":\"TASTE\",\"chart\":\"LINE\"}",
"{\"group\":\"TOP_GAMES\",\"chart\":\"LIST\"}",
"{\"group\":\"TOP_GAMES\",\"chart\":null}",
"{\"group\":\"UNKNOWN\",\"chart\":\"BARS\"}",
"{\"group\":\"platforms\",\"chart\":\"PIE\"}",
"{\"group\":\"PLATFORMS\",\"chart\":\"RADAR\"}",
"{\"chart\":\"PIE\"}",
"{}"
})
void updateStatChart_UnknownGroupOrChart_Is400(String body) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/stat-chart")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
verifyNoInteractions(userService);
}
@Test
void updateFavoritePlatform_StoresOrClearsIt() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
@@ -0,0 +1,54 @@
package auth_service.modules.user.model;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class StatChartsTest extends AbstractUnitTest {
@ParameterizedTest
@CsvSource({"TREND,LINE", "TREND,BARS", "FINISHED,BARS", "FINISHED,LINE", "FINISHED,LIST", "TASTE,BARS",
"TASTE,PIE", "TASTE,DONUT", "TASTE,LIST", "PLATFORMS,BARS", "PLATFORMS,PIE", "PLATFORMS,DONUT",
"PLATFORMS,LIST"})
void allows_EachAllowedPair(String group, String chart) {
assertTrue(StatCharts.allows(group, chart));
}
@ParameterizedTest
@CsvSource({"TASTE,LINE", "TOP_GAMES,LIST", "UNKNOWN,BARS", "platforms,PIE", "PLATFORMS,pie", "PLATFORMS,RADAR",
"TREND,PIE"})
void allows_OtherPairs_AreRefused(String group, String chart) {
assertFalse(StatCharts.allows(group, chart));
}
@Test
void knows_OnlyTheChartableGroups() {
assertTrue(StatCharts.knows("PLATFORMS"));
assertFalse(StatCharts.knows("TOP_GAMES"));
assertFalse(StatCharts.knows("platforms"));
}
@Test
void parseAndStored_RoundTripKeepsTheOrder() {
List<String> stored = List.of("TREND:BARS", "PLATFORMS:PIE", "FINISHED:LINE");
Map<String, String> parsed = StatCharts.parse(stored);
assertEquals(stored, List.copyOf(StatCharts.stored(parsed)));
assertEquals("PIE", parsed.get("PLATFORMS"));
assertEquals(List.of("TREND", "PLATFORMS", "FINISHED"), List.copyOf(parsed.keySet()));
}
@Test
void parse_SkipsAnEntryWithoutColon() {
assertEquals(Map.of("TREND", "BARS"), StatCharts.parse(List.of("BROKEN", "TREND:BARS")));
}
}
@@ -17,7 +17,9 @@ import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@@ -318,6 +320,29 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertEquals(profile, saved.getProfileTiles());
}
@Test
void updateStatCharts_StartEmptyAreReadBackAndKeptByUpdateSettings() {
UserEntity userEntity = persisted(UserEntity.builder()
.email("charts@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
assertEquals(Map.of(), findByEmail("charts@example.com").orElseThrow().getSettings().getStatCharts());
Map<String, String> charts = new LinkedHashMap<>();
charts.put("TREND", "BARS");
charts.put("PLATFORMS", "PIE");
userRepository.update(userEntity.getId(), user -> user.getSettings().setStatCharts(charts));
entityManager.flush();
updateSettings(userEntity.getId(), null,
UserSettings.builder().language("de").theme("dark").build());
entityManager.flush();
entityManager.clear();
assertEquals(charts, findByEmail("charts@example.com").orElseThrow().getSettings().getStatCharts());
}
@Test
void updateFavoritePlatform_StoresAndClears() {
UserEntity userEntity = persisted(UserEntity.builder()
@@ -22,6 +22,7 @@ import org.mockito.Mock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Consumer;
@@ -76,6 +77,31 @@ class UserServiceTest extends AbstractUnitTest {
assertEquals(List.of(new Tile("SUMMARY", "M")), changed.get(1).getSettings().getProfileTiles());
}
@Test
void updateStatChart_PutsTheFormOfTheGroup() {
userService.updateStatChart(1L, "PLATFORMS", "PIE");
userService.updateStatChart(1L, "TREND", "BARS");
List<User> changed = appliedChanges(2);
assertEquals(Map.of("PLATFORMS", "PIE"), changed.get(0).getSettings().getStatCharts());
assertEquals(Map.of("TREND", "BARS"), changed.get(1).getSettings().getStatCharts());
}
@Test
void updateStatChart_KeepsOtherGroupsAndNullRemovesTheEntry() {
UserSettings settings = UserSettings.builder().statCharts(Map.of("PLATFORMS", "PIE", "TREND", "BARS")).build();
User user = User.builder().id(1L).settings(settings).build();
userService.updateStatChart(1L, "PLATFORMS", null);
userService.updateStatChart(1L, "FINISHED", "LINE");
ArgumentCaptor<Consumer<User>> change = ArgumentCaptor.forClass(Consumer.class);
verify(userRepository, times(2)).update(eq(1L), change.capture());
change.getAllValues().get(0).accept(user);
assertEquals(Map.of("TREND", "BARS"), user.getSettings().getStatCharts());
change.getAllValues().get(1).accept(user);
assertEquals(Map.of("TREND", "BARS", "FINISHED", "LINE"), user.getSettings().getStatCharts());
}
@Test
void updateDashboardTiles_NullStaysNull() {
userService.updateDashboardTiles(1L, null);