diff --git a/readme.md b/readme.md index ec225df..70a8196 100644 --- a/readme.md +++ b/readme.md @@ -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/`. 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_`; `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 | diff --git a/src/main/java/auth_service/modules/profile/api/PublicProfileController.java b/src/main/java/auth_service/modules/profile/api/PublicProfileController.java index efa02e5..93fdc40 100644 --- a/src/main/java/auth_service/modules/profile/api/PublicProfileController.java +++ b/src/main/java/auth_service/modules/profile/api/PublicProfileController.java @@ -27,13 +27,14 @@ public class PublicProfileController { public ResponseEntity getPublicProfile(@PathVariable("displayName") String displayName) { return publicProfileService.findPublicProfile(displayName) .>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()); } } diff --git a/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java b/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java index 5ef0ece..6893c9d 100644 --- a/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java +++ b/src/main/java/auth_service/modules/profile/api/dto/PublicProfileResponse.java @@ -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 sections) {} + public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List tiles) {} + + public record Tile(String id, String size) {} } diff --git a/src/main/java/auth_service/modules/profile/model/PublicProfile.java b/src/main/java/auth_service/modules/profile/model/PublicProfile.java index 97c759c..3151fe6 100644 --- a/src/main/java/auth_service/modules/profile/model/PublicProfile.java +++ b/src/main/java/auth_service/modules/profile/model/PublicProfile.java @@ -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 profileStats, - Style style) { +public record PublicProfile(long userId, String displayName, LocalDate memberSince, Style style, List tiles) { - public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List 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) {} } diff --git a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java index 2eaeb7d..3c24715 100644 --- a/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java +++ b/src/main/java/auth_service/modules/profile/repo/PublicProfileRepository.java @@ -4,6 +4,7 @@ import auth_service.modules.profile.model.PublicProfile; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; +import java.util.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 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(); } } diff --git a/src/main/java/auth_service/modules/profile/service/PublicProfileService.java b/src/main/java/auth_service/modules/profile/service/PublicProfileService.java index 7c5edff..e96b862 100644 --- a/src/main/java/auth_service/modules/profile/service/PublicProfileService.java +++ b/src/main/java/auth_service/modules/profile/service/PublicProfileService.java @@ -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 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> byStats = profile.tiles().stream().map(PublicProfile.Tile::id) + .collect(Collectors.partitioningBy(id -> id.startsWith(STATS_PREFIX))); + List statGroups = byStats.get(true).stream().map(id -> id.substring(STATS_PREFIX.length())).toList(); + List sections = new ArrayList<>(byStats.get(false)); + if (!statGroups.isEmpty()) { + sections.add("STATS"); + } + return profileTicketIssuer.generateProfileTicket(profile.userId(), statGroups, sections); } } diff --git a/src/main/java/auth_service/modules/user/api/UserController.java b/src/main/java/auth_service/modules/user/api/UserController.java index dd22f97..6644225 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -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 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 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 updateProfileTiles(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody ProfileTilesRequest request) { + this.userService.updateProfileTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles())); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/auth_service/modules/user/api/dto/request/DashboardTilesRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/DashboardTilesRequest.java new file mode 100644 index 0000000..31e5f8f --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/request/DashboardTilesRequest.java @@ -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); + } +} diff --git a/src/main/java/auth_service/modules/user/api/dto/request/ProfileStatsRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/ProfileStatsRequest.java deleted file mode 100644 index 025a255..0000000 --- a/src/main/java/auth_service/modules/user/api/dto/request/ProfileStatsRequest.java +++ /dev/null @@ -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) { -} diff --git a/src/main/java/auth_service/modules/user/api/dto/request/ProfileStyleRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/ProfileStyleRequest.java index 103660f..b4ce319 100644 --- a/src/main/java/auth_service/modules/user/api/dto/request/ProfileStyleRequest.java +++ b/src/main/java/auth_service/modules/user/api/dto/request/ProfileStyleRequest.java @@ -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) { } diff --git a/src/main/java/auth_service/modules/user/api/dto/request/ProfileTilesRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/ProfileTilesRequest.java new file mode 100644 index 0000000..eed9bbd --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/request/ProfileTilesRequest.java @@ -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); + } +} diff --git a/src/main/java/auth_service/modules/user/api/dto/request/TileRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/TileRequest.java new file mode 100644 index 0000000..436fd44 --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/request/TileRequest.java @@ -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 tiles, String ids) { + return tiles == null || tiles.stream().allMatch(tile -> tile == null || tile.id() == null || tile.id().matches(ids)); + } +} diff --git a/src/main/java/auth_service/modules/user/api/dto/response/ProfileStyleResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/ProfileStyleResponse.java index 720d746..92e2c29 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/ProfileStyleResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/ProfileStyleResponse.java @@ -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 sections) { +public record ProfileStyleResponse(String accent, String corners, Long bannerAppid, Long backgroundAppid) { } diff --git a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java index 0fc870e..5779e7e 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java @@ -6,6 +6,6 @@ import java.util.List; @Builder public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, - AppearanceResponse appearance, List profileStats, - ProfileStyleResponse profileStyle) { + AppearanceResponse appearance, ProfileStyleResponse profileStyle, + List dashboardTiles, List profileTiles) { } diff --git a/src/main/java/auth_service/modules/user/api/dto/response/TileResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/TileResponse.java new file mode 100644 index 0000000..8e855d8 --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/response/TileResponse.java @@ -0,0 +1,4 @@ +package auth_service.modules.user.api.dto.response; + +public record TileResponse(String id, String size) { +} diff --git a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java index a649271..df388af 100644 --- a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java @@ -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 toApi(List 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 toDomain(List tiles) { + return tiles == null ? null : tiles.stream().map(tile -> new Tile(tile.id(), tile.size())).toList(); } public static Appearance toDomain(AppearanceRequest request) { diff --git a/src/main/java/auth_service/modules/user/model/ProfileStyle.java b/src/main/java/auth_service/modules/user/model/ProfileStyle.java index 3d8af28..c0cc6a2 100644 --- a/src/main/java/auth_service/modules/user/model/ProfileStyle.java +++ b/src/main/java/auth_service/modules/user/model/ProfileStyle.java @@ -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 sections) { - - public static final List 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); } diff --git a/src/main/java/auth_service/modules/user/model/Tile.java b/src/main/java/auth_service/modules/user/model/Tile.java new file mode 100644 index 0000000..7dcbc6a --- /dev/null +++ b/src/main/java/auth_service/modules/user/model/Tile.java @@ -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 storedAll(List tiles) { + return tiles.stream().map(Tile::stored).collect(Collectors.toCollection(ArrayList::new)); + } + + /** + * Keeps each tile once, at its first place. + */ + public static List distinct(List tiles) { + Map byId = new LinkedHashMap<>(); + tiles.forEach(tile -> byId.putIfAbsent(tile.id(), tile)); + return new ArrayList<>(byId.values()); + } +} diff --git a/src/main/java/auth_service/modules/user/model/UserSettings.java b/src/main/java/auth_service/modules/user/model/UserSettings.java index 4869e18..f7b86f4 100644 --- a/src/main/java/auth_service/modules/user/model/UserSettings.java +++ b/src/main/java/auth_service/modules/user/model/UserSettings.java @@ -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 dashboardTiles; @Builder.Default - private List profileStats = List.of(); + private List profileTiles = DEFAULT_PROFILE_TILES; + + /** Also the column default of user_settings.profile_tiles. */ + public static final List DEFAULT_PROFILE_TILES = List.of(new Tile("SUMMARY", "M"), new Tile("STATUS", "M"), + new Tile("PLAYING", "M"), new Tile("FINISHED", "M")); } diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepository.java b/src/main/java/auth_service/modules/user/persistence/UserRepository.java index e52981a..c3c7507 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepository.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepository.java @@ -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 groups); + void updateDashboardTiles(long userId, List tiles); + + void updateProfileTiles(long userId, List tiles); void updateProfileStyle(long userId, ProfileStyle style); diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java index fb623f8..fc07daf 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java @@ -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 groups) { - settingsOf(userId).setProfileStats(new ArrayList<>(groups)); + public void updateDashboardTiles(long userId, List tiles) { + settingsOf(userId).setDashboardTiles(tiles == null ? null : Tile.storedAll(tiles)); + } + + @Transactional + @Override + public void updateProfileTiles(long userId, List 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())); } /** diff --git a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java index c1e166e..d22611f 100644 --- a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java +++ b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java @@ -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 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 profileSections = new ArrayList<>(ProfileStyle.ALL_SECTIONS); + @Column(name = "dashboard_tiles") + private List dashboardTiles; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "profile_tiles", nullable = false) + private List profileTiles = Tile.storedAll(UserSettings.DEFAULT_PROFILE_TILES); } diff --git a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java index 798aaab..bce9b44 100644 --- a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java @@ -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 diff --git a/src/main/java/auth_service/modules/user/service/UserService.java b/src/main/java/auth_service/modules/user/service/UserService.java index 05acb16..071b480 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -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 groups) { - userRepository.updateProfileStats(userId, groups.stream().distinct().toList()); + public void updateDashboardTiles(long userId, List 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 tiles) { + userRepository.updateProfileTiles(userId, Tile.distinct(tiles)); } @RequiresRole(Role.USER) diff --git a/src/main/resources/db/migration/V17__Tile_Layouts.sql b/src/main/resources/db/migration/V17__Tile_Layouts.sql new file mode 100644 index 0000000..64f9ee5 --- /dev/null +++ b/src/main/resources/db/migration/V17__Tile_Layouts.sql @@ -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_ 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; diff --git a/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java b/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java index cfc5e54..6719437 100644 --- a/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java +++ b/src/test/java/auth_service/modules/profile/api/PublicProfileControllerTest.java @@ -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 diff --git a/src/test/java/auth_service/modules/user/api/UserControllerTest.java b/src/test/java/auth_service/modules/user/api/UserControllerTest.java index 173afc9..6d200f4 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -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( diff --git a/src/test/java/auth_service/modules/user/model/ProfileStyleTest.java b/src/test/java/auth_service/modules/user/model/ProfileStyleTest.java deleted file mode 100644 index a3afe9d..0000000 --- a/src/test/java/auth_service/modules/user/model/ProfileStyleTest.java +++ /dev/null @@ -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()); - } -} diff --git a/src/test/java/auth_service/modules/user/model/TileTest.java b/src/test/java/auth_service/modules/user/model/TileTest.java new file mode 100644 index 0000000..72e73c0 --- /dev/null +++ b/src/test/java/auth_service/modules/user/model/TileTest.java @@ -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")))); + } +} diff --git a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java index b665db7..ea64c67 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -1,6 +1,7 @@ package auth_service.modules.user.persistence; import auth_service.modules.user.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 dashboard = List.of(new Tile("STATS_TREND", "L"), new Tile("PLAYING", "S")); + List 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 diff --git a/src/test/java/auth_service/modules/user/service/UserServiceTest.java b/src/test/java/auth_service/modules/user/service/UserServiceTest.java index 45bdfaa..22f46ca 100644 --- a/src/test/java/auth_service/modules/user/service/UserServiceTest.java +++ b/src/test/java/auth_service/modules/user/service/UserServiceTest.java @@ -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