feat(tiles): store dashboard and profile tile layouts
Build, Test & Push Auth Service / quality-check (push) Successful in 48s
Build, Test & Push Auth Service / docker (push) Successful in 1m26s

This commit is contained in:
lobo committed 2026-09-28 15:03:16 +02:00
1 parent dd4fcd1546
commit 315e22c220
31 files changed
+464 -165

No files matched your search

+9 -6
View File
@@ -35,10 +35,12 @@ 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/<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. Its `profileStats` claim
names the stat groups the user chose to show there (none by default), its `sections` claim the parts of the
profile the user shows. The profile has its own look (accent, corners, banner and background from own game art,
sections in order), apart from the app appearance, and the public answer sends it as `style`. The ticket is not a login:
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as
`style`. The ticket's claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_`
tile is shown), `profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login:
both services reject every token with a `typ` claim on normal endpoints.
- **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by
SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login
@@ -91,8 +93,9 @@ To Be Played is split into three repositories:
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token |
| `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 |
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid, sections}`, sections from `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS` in their order; `204`) | access token |
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token |
| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`) | access token |
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`: `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
| `GET` | `/api/public/profiles/{displayName}` | public |
| `GET` | `/api/stats/public` | public |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
@@ -27,13 +27,14 @@ public class PublicProfileController {
public ResponseEntity<?> getPublicProfile(@PathVariable("displayName") String displayName) {
return publicProfileService.findPublicProfile(displayName)
.<ResponseEntity<?>>map(p -> ResponseEntity.ok(new PublicProfileResponse(
p.profile().displayName(), p.profile().memberSince(), p.ticket(), style(p.profile().style()))))
p.profile().displayName(), p.profile().memberSince(), p.ticket(), style(p.profile()))))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name")));
}
private static PublicProfileResponse.Style style(PublicProfile.Style style) {
return new PublicProfileResponse.Style(style.accent(), style.corners(), style.bannerAppid(),
style.backgroundAppid(), style.sections());
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());
}
}
@@ -10,7 +10,9 @@ import java.util.List;
public record PublicProfileResponse(String displayName, LocalDate memberSince, String ticket, Style style) {
/**
* @param sections the parts of the profile in their order; a part not listed is hidden
* @param tiles the shown parts of the profile in their order
*/
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<String> sections) {}
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<Tile> tiles) {}
public record Tile(String id, String size) {}
}
@@ -3,8 +3,12 @@ package auth_service.modules.profile.model;
import java.time.LocalDate;
import java.util.List;
public record PublicProfile(long userId, String displayName, LocalDate memberSince, List<String> profileStats,
Style style) {
public record PublicProfile(long userId, String displayName, LocalDate memberSince, Style style, List<Tile> tiles) {
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<String> sections) {}
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid) {}
/**
* @param id a part of the profile, or STATS_ and a stat group of Core
*/
public record Tile(String id, String size) {}
}
@@ -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.Arrays;
import java.util.List;
import java.util.Optional;
@@ -16,24 +17,30 @@ public class PublicProfileRepository {
this.jdbc = jdbc;
}
// Stored as "ID:SIZE", like user.model.Tile.parse, which this module may not use; a bare id (hand-written SQL) is M.
private static PublicProfile.Tile tile(String stored) {
int colon = stored.lastIndexOf(':');
return colon < 0 ? new PublicProfile.Tile(stored, "M")
: new PublicProfile.Tile(stored.substring(0, colon), stored.substring(colon + 1));
}
/**
* 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.display_name, u.registration_date, s.profile_stats, s.profile_accent,
s.profile_corners, s.profile_banner_appid, s.profile_background_appid, s.profile_sections
SELECT u.id, u.display_name, u.registration_date, s.profile_accent, s.profile_corners,
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles
FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL 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,
List.of((String[]) rs.getArray("profile_stats").getArray()),
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),
List.of((String[]) rs.getArray("profile_sections").getArray()))),
rs.getObject("profile_background_appid", Long.class)),
Arrays.stream((String[]) rs.getArray("profile_tiles").getArray()).map(PublicProfileRepository::tile).toList()),
displayName).stream().findFirst();
}
}
@@ -7,7 +7,11 @@ import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@Public
@@ -16,6 +20,8 @@ public class PublicProfileService {
public record TicketedProfile(PublicProfile profile, String ticket) {}
private static final String STATS_PREFIX = "STATS_";
private final PublicProfileRepository publicProfileRepository;
private final ProfileTicketIssuer profileTicketIssuer;
@@ -24,7 +30,18 @@ public class PublicProfileService {
*/
public Optional<TicketedProfile> findPublicProfile(String displayName) {
return publicProfileRepository.findPublicByDisplayName(displayName)
.map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(
profile.userId(), profile.profileStats(), profile.style().sections())));
.map(profile -> new TicketedProfile(profile, ticket(profile)));
}
// Core knows sections and stat groups, not tiles: every STATS_ tile is one shared group of the STATS section.
private String ticket(PublicProfile profile) {
Map<Boolean, List<String>> byStats = profile.tiles().stream().map(PublicProfile.Tile::id)
.collect(Collectors.partitioningBy(id -> id.startsWith(STATS_PREFIX)));
List<String> statGroups = byStats.get(true).stream().map(id -> id.substring(STATS_PREFIX.length())).toList();
List<String> sections = new ArrayList<>(byStats.get(false));
if (!statGroups.isEmpty()) {
sections.add("STATS");
}
return profileTicketIssuer.generateProfileTicket(profile.userId(), statGroups, sections);
}
}
@@ -2,7 +2,8 @@ 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.DashboardTilesRequest;
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.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.UserProfileResponse;
@@ -47,10 +48,17 @@ public class UserController {
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());
@PutMapping(value = "/settings/dashboard-tiles", version = "1.0")
public ResponseEntity<Void> updateDashboardTiles(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody DashboardTilesRequest request) {
this.userService.updateDashboardTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles()));
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/profile-tiles", version = "1.0")
public ResponseEntity<Void> updateProfileTiles(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody ProfileTilesRequest request) {
this.userService.updateProfileTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles()));
return ResponseEntity.noContent().build();
}
@@ -0,0 +1,22 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* @param tiles the shown tiles in their order; null resets to the default dashboard
*/
public record DashboardTilesRequest(@Size(max = 20) List<@NotNull @Valid TileRequest> tiles) {
public static final String IDS = "GAME_COUNT|MOST_PLAYED|PLAYING|PILE_OF_SHAME|ACTIVITY|ACHIEVEMENTS|STATS_OVERVIEW|"
+ ProfileTilesRequest.STAT_IDS;
@AssertTrue(message = "unknown tile id")
boolean isKnownIds() {
return TileRequest.knownIds(tiles, IDS);
}
}
@@ -1,13 +0,0 @@
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) {
}
@@ -4,15 +4,9 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import java.util.List;
/**
* @param sections the parts of the public profile in their order; a part not listed is hidden
*/
public record ProfileStyleRequest(
@NotNull @Pattern(regexp = AppearanceRequest.ACCENTS) String accent,
@NotNull @Pattern(regexp = AppearanceRequest.CORNERS) String corners,
@Positive Long bannerAppid,
@Positive Long backgroundAppid,
@NotNull List<@NotNull @Pattern(regexp = "SUMMARY|STATUS|PLAYING|FINISHED|STATS") String> sections) {
@Positive Long backgroundAppid) {
}
@@ -0,0 +1,24 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/**
* @param tiles the shown parts of the public profile in their order; the STATS_ tiles are the shared stat groups
*/
public record ProfileTilesRequest(@NotNull @Size(max = 20) List<@NotNull @Valid TileRequest> tiles) {
/** The stat groups of Core's stats page, as tiles. */
public static final String STAT_IDS = "STATS_PILE|STATS_TREND|STATS_TOP_GAMES|STATS_TASTE|STATS_HABITS|STATS_FINISHED";
public static final String IDS = "SUMMARY|STATUS|PLAYING|FINISHED|" + STAT_IDS;
@AssertTrue(message = "unknown tile id")
boolean isKnownIds() {
return TileRequest.knownIds(tiles, IDS);
}
}
@@ -0,0 +1,19 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.util.List;
/**
* The id is checked by the layout request that holds the tile, with {@link #knownIds}.
*/
public record TileRequest(@NotNull String id, @NotNull @Pattern(regexp = "S|M|L") String size) {
/**
* @return true when every id matches; a missing list, tile or id is left to its own constraint
*/
static boolean knownIds(List<TileRequest> tiles, String ids) {
return tiles == null || tiles.stream().allMatch(tile -> tile == null || tile.id() == null || tile.id().matches(ids));
}
}
@@ -1,7 +1,4 @@
package auth_service.modules.user.api.dto.response;
import java.util.List;
public record ProfileStyleResponse(String accent, String corners, Long bannerAppid, Long backgroundAppid,
List<String> sections) {
public record ProfileStyleResponse(String accent, String corners, Long bannerAppid, Long backgroundAppid) {
}
@@ -6,6 +6,6 @@ import java.util.List;
@Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
AppearanceResponse appearance, List<String> profileStats,
ProfileStyleResponse profileStyle) {
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles) {
}
@@ -0,0 +1,4 @@
package auth_service.modules.user.api.dto.response;
public record TileResponse(String id, String size) {
}
@@ -3,17 +3,22 @@ package auth_service.modules.user.api.mapper;
import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
import auth_service.modules.user.api.dto.request.TileRequest;
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.ProfileStyleResponse;
import auth_service.modules.user.api.dto.response.TileResponse;
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.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
import auth_service.shared.security.Role;
import java.util.List;
public abstract class ApiMapper {
private ApiMapper() {
}
@@ -38,8 +43,10 @@ public abstract class ApiMapper {
.language(user.getSettings().getLanguage())
.publicProfile(user.getSettings().isPublicProfile())
.appearance(toApi(user.getSettings().getAppearance()))
.profileStats(user.getSettings().getProfileStats())
.profileStyle(toApi(user.getSettings().getProfileStyle()))
.dashboardTiles(user.getSettings().getDashboardTiles() == null ? null
: toApi(user.getSettings().getDashboardTiles()))
.profileTiles(toApi(user.getSettings().getProfileTiles()))
.build())
.build();
}
@@ -50,13 +57,22 @@ public abstract class ApiMapper {
}
private static ProfileStyleResponse toApi(ProfileStyle style) {
return new ProfileStyleResponse(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid(),
style.sections());
return new ProfileStyleResponse(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid());
}
public static ProfileStyle toDomain(ProfileStyleRequest request) {
return new ProfileStyle(request.accent(), request.corners(), request.bannerAppid(), request.backgroundAppid(),
request.sections());
return new ProfileStyle(request.accent(), request.corners(), request.bannerAppid(), request.backgroundAppid());
}
private static List<TileResponse> toApi(List<Tile> tiles) {
return tiles.stream().map(tile -> new TileResponse(tile.id(), tile.size())).toList();
}
/**
* @return null for null, the reset of a layout
*/
public static List<Tile> toDomain(List<TileRequest> tiles) {
return tiles == null ? null : tiles.stream().map(tile -> new Tile(tile.id(), tile.size())).toList();
}
public static Appearance toDomain(AppearanceRequest request) {
@@ -1,26 +1,14 @@
package auth_service.modules.user.model;
import java.util.List;
/**
* The look of the public profile, apart from the {@link Appearance} of the app.
* The look of the public profile, apart from the {@link Appearance} of the app. Its layout is
* {@link UserSettings#getProfileTiles()}.
*
* @param bannerAppid the Steam app whose art is the banner of the profile; null for none
* @param backgroundAppid the Steam app whose art fills the background of the profile; null for none
* @param sections the parts of the profile in their order; a part not listed is hidden
*/
public record ProfileStyle(String accent, String corners, Long bannerAppid, Long backgroundAppid,
List<String> sections) {
public static final List<String> ALL_SECTIONS = List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", "STATS");
public record ProfileStyle(String accent, String corners, Long bannerAppid, Long backgroundAppid) {
public static final ProfileStyle DEFAULT = new ProfileStyle(Appearance.DEFAULT.accent(), Appearance.DEFAULT.corners(),
null, null, ALL_SECTIONS);
/**
* Keeps each section once, at its first place.
*/
public ProfileStyle {
sections = sections.stream().distinct().toList();
}
null, null);
}
@@ -0,0 +1,41 @@
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.stream.Collectors;
/**
* One tile of a layout (dashboard or public profile). The frontend knows the tiles and their sizes; a layout lists
* the shown tiles in their order.
*
* @param size S, M or L
*/
public record Tile(String id, String size) {
/**
* @param stored "ID:SIZE", as the tile columns hold it
*/
public static Tile parse(String stored) {
int colon = stored.lastIndexOf(':');
return colon < 0 ? new Tile(stored, "M") : new Tile(stored.substring(0, colon), stored.substring(colon + 1));
}
public String stored() {
return id + ":" + size;
}
public static ArrayList<String> storedAll(List<Tile> tiles) {
return tiles.stream().map(Tile::stored).collect(Collectors.toCollection(ArrayList::new));
}
/**
* Keeps each tile once, at its first place.
*/
public static List<Tile> distinct(List<Tile> tiles) {
Map<String, Tile> byId = new LinkedHashMap<>();
tiles.forEach(tile -> byId.putIfAbsent(tile.id(), tile));
return new ArrayList<>(byId.values());
}
}
@@ -15,7 +15,12 @@ public class UserSettings {
private Appearance appearance = Appearance.DEFAULT;
@Builder.Default
private ProfileStyle profileStyle = ProfileStyle.DEFAULT;
/** The stat groups of Core the public profile shows. */
/** Null: never changed, the frontend shows its default dashboard. */
private List<Tile> dashboardTiles;
@Builder.Default
private List<String> profileStats = List.of();
private List<Tile> profileTiles = DEFAULT_PROFILE_TILES;
/** Also the column default of user_settings.profile_tiles. */
public static final List<Tile> DEFAULT_PROFILE_TILES = List.of(new Tile("SUMMARY", "M"), new Tile("STATUS", "M"),
new Tile("PLAYING", "M"), new Tile("FINISHED", "M"));
}
@@ -3,6 +3,7 @@ package auth_service.modules.user.persistence;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
@@ -33,7 +34,9 @@ public interface UserRepository {
@Transactional
void updateAppearance(long userId, Appearance appearance);
void updateProfileStats(long userId, List<String> groups);
void updateDashboardTiles(long userId, List<Tile> tiles);
void updateProfileTiles(long userId, List<Tile> tiles);
void updateProfileStyle(long userId, ProfileStyle style);
@@ -3,6 +3,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.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
@@ -16,7 +17,6 @@ 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;
@@ -100,8 +100,14 @@ public class UserRepositoryImpl implements UserRepository {
@Transactional
@Override
public void updateProfileStats(long userId, List<String> groups) {
settingsOf(userId).setProfileStats(new ArrayList<>(groups));
public void updateDashboardTiles(long userId, List<Tile> tiles) {
settingsOf(userId).setDashboardTiles(tiles == null ? null : Tile.storedAll(tiles));
}
@Transactional
@Override
public void updateProfileTiles(long userId, List<Tile> tiles) {
settingsOf(userId).setProfileTiles(Tile.storedAll(tiles));
}
@Transactional
@@ -112,7 +118,6 @@ public class UserRepositoryImpl implements UserRepository {
settingsEntity.setProfileCorners(style.corners());
settingsEntity.setProfileBannerAppid(style.bannerAppid());
settingsEntity.setProfileBackgroundAppid(style.backgroundAppid());
settingsEntity.setProfileSections(new ArrayList<>(style.sections()));
}
/**
@@ -2,6 +2,8 @@ package auth_service.modules.user.persistence.entities;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.UserSettings;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
@@ -11,7 +13,6 @@ import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.util.ArrayList;
import java.util.List;
@Entity
@@ -53,10 +54,6 @@ 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<String> profileStats = new ArrayList<>();
@Column(name = "profile_accent", nullable = false)
private String profileAccent = ProfileStyle.DEFAULT.accent();
@@ -69,7 +66,12 @@ public class UserSettingsEntity {
@Column(name = "profile_background_appid")
private Long profileBackgroundAppid;
/** "ID:SIZE" entries, see {@link auth_service.modules.user.model.Tile}. */
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_sections", nullable = false)
private List<String> profileSections = new ArrayList<>(ProfileStyle.ALL_SECTIONS);
@Column(name = "dashboard_tiles")
private List<String> dashboardTiles;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_tiles", nullable = false)
private List<String> profileTiles = Tile.storedAll(UserSettings.DEFAULT_PROFILE_TILES);
}
@@ -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.Tile;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity;
@@ -30,11 +31,12 @@ 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()))
.profileStyle(new ProfileStyle(entity.getSettings().getProfileAccent(),
entity.getSettings().getProfileCorners(), entity.getSettings().getProfileBannerAppid(),
entity.getSettings().getProfileBackgroundAppid(),
List.copyOf(entity.getSettings().getProfileSections())))
entity.getSettings().getProfileBackgroundAppid()))
.dashboardTiles(entity.getSettings().getDashboardTiles() == null ? null
: entity.getSettings().getDashboardTiles().stream().map(Tile::parse).toList())
.profileTiles(entity.getSettings().getProfileTiles().stream().map(Tile::parse).toList())
.build();
} else {
// Fallback default settings for users without an entry in the database
@@ -4,6 +4,7 @@ import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.SteamLinkOutcome;
import auth_service.modules.user.model.User;
@@ -97,14 +98,18 @@ public class UserService {
userRepository.updateAppearance(userId, appearance);
}
/**
* Stores each group once; the request may repeat one.
*/
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
public void updateProfileStats(long userId, List<String> groups) {
userRepository.updateProfileStats(userId, groups.stream().distinct().toList());
public void updateDashboardTiles(long userId, List<Tile> tiles) {
userRepository.updateDashboardTiles(userId, tiles == null ? null : Tile.distinct(tiles));
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
public void updateProfileTiles(long userId, List<Tile> tiles) {
userRepository.updateProfileTiles(userId, Tile.distinct(tiles));
}
@RequiresRole(Role.USER)
@@ -0,0 +1,22 @@
-- Tile layouts of the dashboard and the public profile: tile ids in their order as 'ID:SIZE' (size S, M or L); a
-- tile not listed is hidden. dashboard_tiles NULL = never changed, the frontend shows its default dashboard.
ALTER TABLE user_settings
ADD COLUMN dashboard_tiles VARCHAR(32)[],
ADD COLUMN profile_tiles VARCHAR(32)[] NOT NULL DEFAULT '{SUMMARY:M,STATUS:M,PLAYING:M,FINISHED:M}';
-- The profile layout replaces the section list (V16) and the shared stat groups (V12): the sections keep their
-- order, and STATS becomes one STATS_<GROUP> tile per shared group at its place.
UPDATE user_settings s
SET profile_tiles = COALESCE((SELECT array_agg(tile ORDER BY section_no, group_no)
FROM (SELECT sec.no AS section_no, 0 AS group_no, sec.name || ':M' AS tile
FROM unnest(s.profile_sections) WITH ORDINALITY AS sec(name, no)
WHERE sec.name <> 'STATS'
UNION ALL
SELECT sec.no, grp.no, 'STATS_' || grp.name || ':M'
FROM unnest(s.profile_sections) WITH ORDINALITY AS sec(name, no)
CROSS JOIN unnest(s.profile_stats) WITH ORDINALITY AS grp(name, no)
WHERE sec.name = 'STATS') AS tiles), '{}');
ALTER TABLE user_settings
DROP COLUMN profile_sections,
DROP COLUMN profile_stats;
@@ -73,8 +73,9 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
}
@Test
void publicProfile_TicketCarriesTheSharedStatGroups() throws Exception {
jdbc.update("update user_settings set profile_stats = '{PILE,TREND}' where user_id = ?", publicUserId);
void publicProfile_TicketCarriesTheStatTilesAsSharedGroups() throws Exception {
jdbc.update("update user_settings set profile_tiles = '{STATS_PILE:M,PLAYING:S,STATS_TREND:L}' where user_id = ?",
publicUserId);
String body = mockMvc.perform(get("/api/public/profiles/Lobo")).andReturn().getResponse().getContentAsString();
@@ -82,12 +83,13 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
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));
assertEquals(List.of("PLAYING", "STATS"), claims.get("sections", List.class));
}
@Test
void publicProfile_AnswersWithTheOwnersStyle() throws Exception {
jdbc.update("update user_settings set profile_accent = 'red', profile_corners = 'round', profile_banner_appid = 620, "
+ "profile_sections = '{PLAYING,SUMMARY}' where user_id = ?", publicUserId);
+ "profile_tiles = '{PLAYING:S,STATS_PILE:L}' where user_id = ?", publicUserId);
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(status().isOk())
@@ -95,20 +97,23 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.style.corners").value("round"))
.andExpect(jsonPath("$.style.bannerAppid").value(620))
.andExpect(jsonPath("$.style.backgroundAppid").isEmpty())
.andExpect(jsonPath("$.style.sections[0]").value("PLAYING"))
.andExpect(jsonPath("$.style.sections[1]").value("SUMMARY"));
.andExpect(jsonPath("$.style.tiles[0].id").value("PLAYING"))
.andExpect(jsonPath("$.style.tiles[0].size").value("S"))
.andExpect(jsonPath("$.style.tiles[1].id").value("STATS_PILE"))
.andExpect(jsonPath("$.style.tiles[1].size").value("L"));
}
@Test
void publicProfile_NewRowShowsEverySection() throws Exception {
void publicProfile_NewRowShowsTheDefaultTiles() throws Exception {
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(jsonPath("$.style.accent").value("violet"))
.andExpect(jsonPath("$.style.sections.length()").value(5));
.andExpect(jsonPath("$.style.tiles.length()").value(4))
.andExpect(jsonPath("$.style.tiles[3].id").value("FINISHED"));
}
@Test
void publicProfile_TicketCarriesTheShownSections() throws Exception {
jdbc.update("update user_settings set profile_sections = '{PLAYING}' where user_id = ?", publicUserId);
void publicProfile_NoStatTiles_TicketSharesNoStats() throws Exception {
jdbc.update("update user_settings set profile_tiles = '{PLAYING:M}' where user_id = ?", publicUserId);
String body = mockMvc.perform(get("/api/public/profiles/Lobo")).andReturn().getResponse().getContentAsString();
@@ -116,6 +121,17 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
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("PLAYING"), claims.get("sections", List.class));
assertEquals(List.of(), claims.get("profileStats", List.class));
}
@Test
void publicProfile_TileWithoutSize_IsMedium() throws Exception {
jdbc.update("update user_settings set profile_tiles = '{PLAYING}' where user_id = ?", publicUserId);
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.style.tiles[0].id").value("PLAYING"))
.andExpect(jsonPath("$.style.tiles[0].size").value("M"));
}
@Test
@@ -1,6 +1,7 @@
package auth_service.modules.user.api;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.configuration.security.JwtService;
import auth_service.shared.security.UserPrincipal;
@@ -350,10 +351,11 @@ class UserControllerTest extends AbstractWebMvcTest {
}
@Test
void getCurrentUserV1_ShowsTheSharedStatGroups() throws Exception {
void getCurrentUserV1_ShowsTheTileLayouts() throws Exception {
String email = "test@example.com";
UserSettings settings = UserSettings.builder().language("en").theme("dark")
.profileStats(List.of("PILE", "TREND")).build();
.dashboardTiles(List.of(new Tile("STATS_TREND", "L")))
.profileTiles(List.of(new Tile("PLAYING", "S"))).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
@@ -361,33 +363,100 @@ class UserControllerTest extends AbstractWebMvcTest {
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"));
.andExpect(jsonPath("$.settings.dashboardTiles[0].id").value("STATS_TREND"))
.andExpect(jsonPath("$.settings.dashboardTiles[0].size").value("L"))
.andExpect(jsonPath("$.settings.profileTiles[0].id").value("PLAYING"))
.andExpect(jsonPath("$.settings.profileTiles[0].size").value("S"));
}
@Test
void updateProfileStats_StoresTheGroups() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
void getCurrentUserV1_NeverChangedDashboard_SendsNullTiles() throws Exception {
String email = "test@example.com";
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email)
.settings(UserSettings.builder().language("en").theme("dark").build()).build());
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"));
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.settings.dashboardTiles").isEmpty())
.andExpect(jsonPath("$.settings.profileTiles.length()").value(4));
}
// 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 {
@Test
void updateDashboardTiles_StoresTheTiles() 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")
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.content("{\"tiles\":[{\"id\":\"STATS_TREND\",\"size\":\"L\"},{\"id\":\"PLAYING\",\"size\":\"S\"}]}"))
.andExpect(status().isNoContent());
verify(userService).updateDashboardTiles(1L, List.of(new Tile("STATS_TREND", "L"), new Tile("PLAYING", "S")));
}
@Test
void updateDashboardTiles_NullTilesResets() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"tiles\":null}"))
.andExpect(status().isNoContent());
verify(userService).updateDashboardTiles(1L, null);
}
@Test
void updateProfileTiles_StoresTheTiles() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"tiles\":[{\"id\":\"STATS_PILE\",\"size\":\"M\"}]}"))
.andExpect(status().isNoContent());
verify(userService).updateProfileTiles(1L, List.of(new Tile("STATS_PILE", "M")));
}
// A dashboard tile is no profile tile and the other way round; activity and the game count stay private.
@ParameterizedTest
@ValueSource(strings = {
"dashboard-tiles|{\"tiles\":[{\"id\":\"SUMMARY\",\"size\":\"M\"}]}",
"dashboard-tiles|{\"tiles\":[{\"id\":\"PLAYING\",\"size\":\"XL\"}]}",
"dashboard-tiles|{\"tiles\":[{\"id\":\"PLAYING\"}]}",
"dashboard-tiles|{\"tiles\":[null]}",
"profile-tiles|{\"tiles\":[{\"id\":\"ACTIVITY\",\"size\":\"M\"}]}",
"profile-tiles|{\"tiles\":[{\"id\":\"STATS_OVERVIEW\",\"size\":\"M\"}]}",
"profile-tiles|{\"tiles\":null}",
"profile-tiles|{}"
})
void updateTiles_UnknownIdBadSizeOrMissing_IsRejected(String pathAndBody) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
String[] parts = pathAndBody.split("\\|", 2);
mockMvc.perform(put("/api/users/settings/" + parts[0])
.contentType(MediaType.APPLICATION_JSON)
.content(parts[1]))
.andExpect(status().isBadRequest());
verifyNoInteractions(userService);
}
@Test
void updateTiles_MoreThanTwentyTiles_IsRejected() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
String tiles = String.join(",", Collections.nCopies(21, "{\"id\":\"PLAYING\",\"size\":\"M\"}"));
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"tiles\":[" + tiles + "]}"))
.andExpect(status().isBadRequest());
}
@@ -395,7 +464,7 @@ class UserControllerTest extends AbstractWebMvcTest {
void getCurrentUserV1_ShowsTheProfileStyle() throws Exception {
String email = "test@example.com";
UserSettings settings = UserSettings.builder().language("en").theme("dark")
.profileStyle(new ProfileStyle("red", "sharp", 620L, null, List.of("PLAYING"))).build();
.profileStyle(new ProfileStyle("red", "sharp", 620L, null)).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
@@ -406,8 +475,7 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.settings.profileStyle.accent").value("red"))
.andExpect(jsonPath("$.settings.profileStyle.corners").value("sharp"))
.andExpect(jsonPath("$.settings.profileStyle.bannerAppid").value(620))
.andExpect(jsonPath("$.settings.profileStyle.backgroundAppid").isEmpty())
.andExpect(jsonPath("$.settings.profileStyle.sections[0]").value("PLAYING"));
.andExpect(jsonPath("$.settings.profileStyle.backgroundAppid").isEmpty());
}
@Test
@@ -417,23 +485,20 @@ class UserControllerTest extends AbstractWebMvcTest {
mockMvc.perform(put("/api/users/settings/profile-style")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"accent\":\"green\",\"corners\":\"round\",\"bannerAppid\":620,"
+ "\"sections\":[\"STATS\",\"SUMMARY\"]}"))
.content("{\"accent\":\"green\",\"corners\":\"round\",\"bannerAppid\":620}"))
.andExpect(status().isNoContent());
verify(userService).updateProfileStyle(1L, new ProfileStyle("green", "round", 620L, null, List.of("STATS", "SUMMARY")));
verify(userService).updateProfileStyle(1L, new ProfileStyle("green", "round", 620L, 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\",\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"pointy\",\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"bannerAppid\":0,\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":-620,\"sections\":[]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"sections\":[\"NOTES\"]}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"sections\":[null]}",
"{\"accent\":\"violet\",\"corners\":\"default\"}"
"{\"accent\":\"hotpink\",\"corners\":\"default\"}",
"{\"accent\":\"violet\",\"corners\":\"pointy\"}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"bannerAppid\":0}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":-620}",
"{\"corners\":\"default\"}"
})
void updateProfileStyle_UnknownOrMissingValue_IsRejected(String body) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
@@ -1,17 +0,0 @@
package auth_service.modules.user.model;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ProfileStyleTest extends AbstractUnitTest {
@Test
void keepsEachSectionOnceAtItsFirstPlace() {
assertEquals(List.of("STATS", "PLAYING"),
new ProfileStyle("red", "round", 620L, null, List.of("STATS", "PLAYING", "STATS")).sections());
}
}
@@ -0,0 +1,23 @@
package auth_service.modules.user.model;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TileTest extends AbstractUnitTest {
@Test
void parse_ReadsTheStoredForm() {
assertEquals(new Tile("STATS_TOP_GAMES", "L"), Tile.parse("STATS_TOP_GAMES:L"));
assertEquals("PLAYING:S", new Tile("PLAYING", "S").stored());
}
@Test
void distinct_KeepsEachTileOnceAtItsFirstPlace() {
assertEquals(List.of(new Tile("PLAYING", "S"), new Tile("SUMMARY", "M")),
Tile.distinct(List.of(new Tile("PLAYING", "S"), new Tile("SUMMARY", "M"), new Tile("PLAYING", "L"))));
}
}
@@ -1,6 +1,7 @@
package auth_service.modules.user.persistence;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.SteamIdHolder;
@@ -293,7 +294,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertEquals(ProfileStyle.DEFAULT, userRepository.findByEmail("style@example.com").orElseThrow()
.getSettings().getProfileStyle());
ProfileStyle style = new ProfileStyle("pink", "sharp", 620L, 1086940L, List.of("FINISHED", "SUMMARY"));
ProfileStyle style = new ProfileStyle("pink", "sharp", 620L, 1086940L);
userRepository.updateProfileStyle(userEntity.getId(), style);
entityManager.flush();
entityManager.clear();
@@ -302,26 +303,49 @@ class UserRepositoryTest extends AbstractPersistenceTest {
}
@Test
void updateProfileStats_IsReadBackAndKeptByUpdateSettings() {
void updateTiles_AreReadBackAndKeptByUpdateSettings() {
UserEntity userEntity = entityManager.persist(UserEntity.builder()
.email("stats@example.com")
.email("tiles@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
assertEquals(List.of(), userRepository.findByEmail("stats@example.com").orElseThrow()
.getSettings().getProfileStats());
UserSettings fresh = userRepository.findByEmail("tiles@example.com").orElseThrow().getSettings();
assertNull(fresh.getDashboardTiles());
assertEquals(UserSettings.DEFAULT_PROFILE_TILES, fresh.getProfileTiles());
userRepository.updateProfileStats(userEntity.getId(), List.of("PILE", "TREND"));
List<Tile> dashboard = List.of(new Tile("STATS_TREND", "L"), new Tile("PLAYING", "S"));
List<Tile> profile = List.of(new Tile("STATS_PILE", "M"), new Tile("SUMMARY", "S"));
userRepository.updateDashboardTiles(userEntity.getId(), dashboard);
userRepository.updateProfileTiles(userEntity.getId(), profile);
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());
UserSettings saved = userRepository.findByEmail("tiles@example.com").orElseThrow().getSettings();
assertEquals(dashboard, saved.getDashboardTiles());
assertEquals(profile, saved.getProfileTiles());
}
@Test
void updateDashboardTiles_NullResetsToTheDefault() {
UserEntity userEntity = entityManager.persist(UserEntity.builder()
.email("reset@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
userRepository.updateDashboardTiles(userEntity.getId(), List.of(new Tile("PLAYING", "S")));
entityManager.flush();
userRepository.updateDashboardTiles(userEntity.getId(), null);
entityManager.flush();
entityManager.clear();
assertNull(userRepository.findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles());
}
@Test
@@ -1,5 +1,6 @@
package auth_service.modules.user.service;
import auth_service.modules.user.model.Tile;
import auth_service.shared.security.Role;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException;
@@ -64,10 +65,19 @@ class UserServiceTest extends AbstractUnitTest {
}
@Test
void updateProfileStats_StoresEachGroupOnce() {
userService.updateProfileStats(1L, List.of("PILE", "TREND", "PILE"));
void updateTiles_StoreEachTileOnce() {
userService.updateDashboardTiles(1L, List.of(new Tile("PLAYING", "S"), new Tile("PLAYING", "L")));
userService.updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M"), new Tile("SUMMARY", "S")));
verify(userRepository).updateProfileStats(1L, List.of("PILE", "TREND"));
verify(userRepository).updateDashboardTiles(1L, List.of(new Tile("PLAYING", "S")));
verify(userRepository).updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M")));
}
@Test
void updateDashboardTiles_NullStaysNull() {
userService.updateDashboardTiles(1L, null);
verify(userRepository).updateDashboardTiles(1L, null);
}
@Test