From 065048415c1d3b5d48bbb203d4356a94e13ee796 Mon Sep 17 00:00:00 2001 From: lobo Date: Sat, 3 Oct 2026 18:51:59 +0200 Subject: [PATCH] feat(library): add drill-down lists for stat numbers The number and list share the same SQL query, so they cannot drift. StatKey enum maps each statistic to its query, validation rules, and visibility. StatGamesService reads games per key with 40-game pages; repositories share the same set logic as the stat counts. Three endpoints open games: own library stats, public profile stats (with ticket checks), and public landing-page stats (cached 10 min). Rewards track and bonus rows support drill-down too. --- readme.md | 10 + .../configuration/CacheConfig.java | 4 +- .../security/SecurityConfig.java | 1 + .../service/AccountCleanupService.java | 8 +- .../account/service/AccountStateService.java | 4 +- .../library/api/LibraryController.java | 14 + .../library/api/PublicProfileController.java | 38 ++- .../modules/library/api/StatGamesErrors.java | 11 + .../library/domain/AchievementProgress.java | 10 + .../modules/library/domain/StatKey.java | 84 +++++ .../modules/library/domain/UserStats.java | 11 + .../library/repo/UserStatsRepository.java | 214 +++++++++++-- .../library/service/LibraryQueryService.java | 16 +- .../library/service/StatGamesService.java | 89 ++++++ .../library/service/UserStatsService.java | 2 +- .../modules/rewards/api/RewardController.java | 26 ++ .../rewards/api/dto/RewardGamesDto.java | 19 ++ .../modules/rewards/domain/RewardGame.java | 11 + .../modules/rewards/domain/RewardRules.java | 31 ++ .../modules/rewards/domain/Unlocks.java | 31 +- .../rewards/repo/RewardLedgerRepository.java | 46 +++ .../rewards/service/RewardService.java | 73 +++++ .../stats/api/PublicStatsController.java | 49 +++ .../stats/api/dto/CommunityGamesDto.java | 17 + .../modules/stats/domain/CommunityKey.java | 49 +++ .../modules/stats/repo/StatsRepository.java | 147 ++++++--- .../stats/service/PublicStatsService.java | 27 ++ .../security/PublicEndpointsTest.java | 9 +- .../api/PublicProfileControllerTest.java | 104 ++++++ .../library/api/StatGamesFlowTest.java | 298 ++++++++++++++++++ .../modules/library/domain/StatKeyTest.java | 72 +++++ .../library/repo/UserStatsRepositoryTest.java | 11 + .../rewards/api/RewardControllerTest.java | 32 ++ .../rewards/domain/RewardRulesTest.java | 22 ++ .../modules/rewards/domain/UnlocksTest.java | 17 + .../rewards/service/RewardGamesTest.java | 208 ++++++++++++ .../stats/api/PublicStatsControllerTest.java | 156 +++++++++ .../stats/domain/CommunityKeyTest.java | 29 ++ .../stats/repo/StatsRepositoryTest.java | 47 ++- 39 files changed, 1938 insertions(+), 109 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/StatGamesErrors.java create mode 100644 src/main/java/core_service/modules/library/domain/StatKey.java create mode 100644 src/main/java/core_service/modules/library/service/StatGamesService.java create mode 100644 src/main/java/core_service/modules/rewards/api/dto/RewardGamesDto.java create mode 100644 src/main/java/core_service/modules/rewards/domain/RewardGame.java create mode 100644 src/main/java/core_service/modules/stats/api/dto/CommunityGamesDto.java create mode 100644 src/main/java/core_service/modules/stats/domain/CommunityKey.java create mode 100644 src/test/java/core_service/modules/library/api/StatGamesFlowTest.java create mode 100644 src/test/java/core_service/modules/library/domain/StatKeyTest.java create mode 100644 src/test/java/core_service/modules/rewards/service/RewardGamesTest.java create mode 100644 src/test/java/core_service/modules/stats/api/PublicStatsControllerTest.java create mode 100644 src/test/java/core_service/modules/stats/domain/CommunityKeyTest.java diff --git a/readme.md b/readme.md index 106e323..591508e 100644 --- a/readme.md +++ b/readme.md @@ -56,6 +56,12 @@ To Be Played is split into three repositories: `listed_user_game`, V24). A linked set (see "Editions of one game") counts once: its most advanced edition (status, then playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Playtime sums, most played, genres, moods and platforms still use every listed game. +- **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page) + or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the + list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor + gets a list only when the ticket shares that section, or the `STATS` section and the key's group (`403 NOT_SHARED`; + `OVERVIEW` keys are for the owner only). The community list has one row per game with `libraries` and `minutes`, + never a user, and is cached 10 minutes like the numbers. A bad argument is `400 BAD_ARG`. - **Status history**: a database trigger logs every status change of a library game in `user_game_status_change` (old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older changes are not known. @@ -138,6 +144,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut |---|---|---| | `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track) | JWT | | `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT | +| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | | `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/api/library/games/{appid}` | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | @@ -163,11 +170,14 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT | | `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT | | `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT | +| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT | | `GET` | `/api/library/achievements/summary` | JWT | | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT | | `GET` | `/api/public/profiles/library` | profile ticket | +| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket | | `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket | | `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public | +| `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked}]}`, `displayName` optional, at most 100 characters, kept in `account_name` for the audit log; POST updates, PUT replaces the whole set; `204`) | service token | | `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token | diff --git a/src/main/java/core_service/configuration/CacheConfig.java b/src/main/java/core_service/configuration/CacheConfig.java index 98472f9..b95c082 100644 --- a/src/main/java/core_service/configuration/CacheConfig.java +++ b/src/main/java/core_service/configuration/CacheConfig.java @@ -17,8 +17,8 @@ public class CacheConfig { @Bean public CacheManager cacheManager() { - CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "steamPlayerSummary", - "igdbSearch"); + CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "publicStatGames", + "steamPlayerSummary", "igdbSearch"); cacheManager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) .maximumSize(1000)); diff --git a/src/main/java/core_service/configuration/security/SecurityConfig.java b/src/main/java/core_service/configuration/security/SecurityConfig.java index 50c5cd1..60bf28a 100644 --- a/src/main/java/core_service/configuration/security/SecurityConfig.java +++ b/src/main/java/core_service/configuration/security/SecurityConfig.java @@ -33,6 +33,7 @@ public class SecurityConfig { "/actuator/health", // Docker health check; details are off "/error", "/api/stats/public", + "/api/stats/public/games", "/api/public/**", // Public profiles; PublicProfileController checks the ticket "/api/version" // Build of the running image, shown in the settings }; diff --git a/src/main/java/core_service/modules/account/service/AccountCleanupService.java b/src/main/java/core_service/modules/account/service/AccountCleanupService.java index 580e163..452bb1e 100644 --- a/src/main/java/core_service/modules/account/service/AccountCleanupService.java +++ b/src/main/java/core_service/modules/account/service/AccountCleanupService.java @@ -34,7 +34,7 @@ public class AccountCleanupService { } @Transactional - @CacheEvict(value = "publicStats", allEntries = true) + @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) public void markDeleted(List userIds) { repository.addDeleted(userIds); log.info("Account cleanup: marked users {} as deleted", userIds); @@ -44,7 +44,7 @@ public class AccountCleanupService { * The whole flagged set of Auth. Also lifts the tombstone of a user whose delete was undone in Auth. */ @Transactional - @CacheEvict(value = "publicStats", allEntries = true) + @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) public void replaceDeleted(List userIds) { repository.replaceDeleted(userIds); log.info("Account cleanup: {} users marked as deleted", userIds.size()); @@ -54,7 +54,7 @@ public class AccountCleanupService { * @param requestDryRun Auth's dry run; either side can ask for one */ @Transactional - @CacheEvict(value = "publicStats", allEntries = true) + @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) public CleanupResult purge(List userIds, boolean requestDryRun) { List tombstoned = repository.findDeleted(userIds); if (tombstoned.size() < userIds.size()) { @@ -69,7 +69,7 @@ public class AccountCleanupService { * broken call can never wipe the library tables. */ @Transactional - @CacheEvict(value = "publicStats", allEntries = true) + @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) public CleanupResult prune(List knownUserIds, boolean requestDryRun) { if (knownUserIds.isEmpty()) { throw new IllegalArgumentException("knownUserIds must not be empty"); diff --git a/src/main/java/core_service/modules/account/service/AccountStateService.java b/src/main/java/core_service/modules/account/service/AccountStateService.java index f340be4..87fe8ff 100644 --- a/src/main/java/core_service/modules/account/service/AccountStateService.java +++ b/src/main/java/core_service/modules/account/service/AccountStateService.java @@ -24,14 +24,14 @@ public class AccountStateService { private final AccountStateRepository repository; @Transactional - @CacheEvict(value = "publicStats", allEntries = true) + @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) public void apply(List states) { states.forEach(repository::apply); log.info("Account state: {} users updated", states.size()); } @Transactional - @CacheEvict(value = "publicStats", allEntries = true) + @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true) public void replaceAll(List states) { repository.replaceAll(states); log.info("Account state: {} users replaced", states.size()); diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 011edee..e0216bc 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -35,6 +35,7 @@ import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.StatGroup; +import core_service.modules.library.domain.StatKey; import core_service.modules.library.service.AuditLogService; import core_service.modules.library.service.BacklogService; import core_service.modules.library.service.GameCorrectionService; @@ -45,6 +46,7 @@ import core_service.modules.library.service.ImageRepairService; import core_service.modules.library.service.InvalidCorrectionException; import core_service.modules.library.service.LibraryQueryService; import core_service.modules.library.service.LibrarySyncService; +import core_service.modules.library.service.StatGamesService; import core_service.modules.library.service.SteamProfileService; import core_service.modules.library.service.SyncCooldownException; import core_service.modules.library.service.UserStatsService; @@ -97,6 +99,7 @@ public class LibraryController { private final SteamProfileService steamProfileService; private final ImageRepairService imageRepairService; private final UserStatsService userStatsService; + private final StatGamesService statGamesService; private final GameCorrectionService gameCorrectionService; private final GameMergeService gameMergeService; private final GameLinkService gameLinkService; @@ -461,6 +464,17 @@ public class LibraryController { return ResponseEntity.ok(userStatsService.getOwnStats(userId, asked)); } + @GetMapping("/stats/games") + public ResponseEntity getStatGames(@AuthenticationPrincipal UserPrincipal principal, + @RequestParam StatKey key, + @RequestParam(required = false) String arg, + @RequestParam(defaultValue = "0") int page) { + return key.parseArg(arg) + .>map(parsed -> ResponseEntity.ok( + PageDto.of(statGamesService.ownGames(principal.getIdAsLong(), key, parsed, page)))) + .orElseGet(() -> ResponseEntity.badRequest().body(StatGamesErrors.BAD_ARG)); + } + @GetMapping("/sync-status") public ResponseEntity getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) { return queryService.getSyncStatus(principal.getIdAsLong()) diff --git a/src/main/java/core_service/modules/library/api/PublicProfileController.java b/src/main/java/core_service/modules/library/api/PublicProfileController.java index 65bc496..d947c9f 100644 --- a/src/main/java/core_service/modules/library/api/PublicProfileController.java +++ b/src/main/java/core_service/modules/library/api/PublicProfileController.java @@ -1,7 +1,10 @@ package core_service.modules.library.api; import core_service.shared.api.ErrorResponse; +import core_service.modules.library.api.dto.PageDto; +import core_service.modules.library.domain.StatKey; import core_service.modules.library.service.LibraryQueryService; +import core_service.modules.library.service.StatGamesService; import core_service.shared.security.ProfileTicket; import core_service.shared.security.ProfileTicketVerifier; import lombok.RequiredArgsConstructor; @@ -10,6 +13,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.Optional; @@ -24,13 +28,41 @@ import java.util.Optional; public class PublicProfileController { private final LibraryQueryService queryService; + private final StatGamesService statGamesService; private final ProfileTicketVerifier profileTicketVerifier; @GetMapping("/library") public ResponseEntity getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) { - return (ticket == null ? Optional.empty() : profileTicketVerifier.verifyProfileTicket(ticket)) + return verify(ticket) .>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections()))) - .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(new ErrorResponse("INVALID_TICKET", "Missing, invalid or expired profile ticket"))); + .orElseGet(PublicProfileController::invalidTicket); + } + + @GetMapping("/library/stats/games") + public ResponseEntity getPublicStatGames(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket, + @RequestParam StatKey key, + @RequestParam(required = false) String arg, + @RequestParam(defaultValue = "0") int page) { + Optional verified = verify(ticket); + if (verified.isEmpty()) { + return invalidTicket(); + } + ProfileTicket t = verified.get(); + return key.parseArg(arg) + .>map(parsed -> statGamesService + .profileGames(t.userId(), t.profileStats(), t.sections(), key, parsed, page) + .>map(games -> ResponseEntity.ok(PageDto.of(games))) + .orElseGet(() -> ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse("NOT_SHARED", "This number is not shared")))) + .orElseGet(() -> ResponseEntity.badRequest().body(StatGamesErrors.BAD_ARG)); + } + + private Optional verify(String ticket) { + return ticket == null ? Optional.empty() : profileTicketVerifier.verifyProfileTicket(ticket); + } + + private static ResponseEntity invalidTicket() { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("INVALID_TICKET", "Missing, invalid or expired profile ticket")); } } diff --git a/src/main/java/core_service/modules/library/api/StatGamesErrors.java b/src/main/java/core_service/modules/library/api/StatGamesErrors.java new file mode 100644 index 0000000..12108af --- /dev/null +++ b/src/main/java/core_service/modules/library/api/StatGamesErrors.java @@ -0,0 +1,11 @@ +package core_service.modules.library.api; + +import core_service.shared.api.ErrorResponse; + +final class StatGamesErrors { + + static final ErrorResponse BAD_ARG = new ErrorResponse("BAD_ARG", "The argument does not fit the key"); + + private StatGamesErrors() { + } +} diff --git a/src/main/java/core_service/modules/library/domain/AchievementProgress.java b/src/main/java/core_service/modules/library/domain/AchievementProgress.java index 9de25af..85e7b50 100644 --- a/src/main/java/core_service/modules/library/domain/AchievementProgress.java +++ b/src/main/java/core_service/modules/library/domain/AchievementProgress.java @@ -1,5 +1,7 @@ package core_service.modules.library.domain; +import java.util.List; + /** * Achievement state of one owned game. * @@ -27,4 +29,12 @@ public record AchievementProgress(Long appid, Integer total, Integer unlocked, b public static int capped(int unlocked, int total) { return Math.min(unlocked, total); } + + public static List started(List games) { + return games.stream().filter(g -> g.hasAchievements() && g.visible() && g.unlockedCapped() > 0).toList(); + } + + public static List perfectAppids(List started) { + return started.stream().filter(g -> g.unlockedCapped() == g.total()).map(AchievementProgress::appid).toList(); + } } diff --git a/src/main/java/core_service/modules/library/domain/StatKey.java b/src/main/java/core_service/modules/library/domain/StatKey.java new file mode 100644 index 0000000..f7159ac --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/StatKey.java @@ -0,0 +1,84 @@ +package core_service.modules.library.domain; + +import java.util.Optional; +import java.util.Set; + +/** + * A stat number a client can open as a list of games. The list is the set of games the number counts. + */ +public enum StatKey { + TOTAL(StatGroup.OVERVIEW, ProfileSection.SUMMARY, Arg.NONE), + STATUS(StatGroup.OVERVIEW, ProfileSection.STATUS, Arg.STATUS), + PLAYED(StatGroup.OVERVIEW, null, Arg.NONE), + NEVER_STARTED(StatGroup.OVERVIEW, null, Arg.NONE), + PILE(StatGroup.PILE, null, Arg.NONE), + PILE_TIME_TO_BEAT(StatGroup.PILE, null, Arg.NONE), + MOST_PLAYED(StatGroup.TOP_GAMES, null, Arg.NONE), + GENRE(StatGroup.TASTE, null, Arg.TEXT), + MOOD(StatGroup.TASTE, null, Arg.TEXT), + LONGEST_DAY(StatGroup.HABITS, null, Arg.NONE), + UNDER_ONE_HOUR(StatGroup.HABITS, null, Arg.NONE), + FINISHED_YEAR(StatGroup.FINISHED, null, Arg.YEAR), + FINISHED_UNTRACKED(StatGroup.FINISHED, null, Arg.NONE), + FINISHED_ALL(null, ProfileSection.SUMMARY, Arg.NONE), + TREND_ADDED(StatGroup.TREND, null, Arg.NONE), + TREND_DONE(StatGroup.TREND, null, Arg.NONE), + TREND_MOVES(StatGroup.TREND, null, Arg.NONE), + PLATFORM(StatGroup.PLATFORMS, null, Arg.TEXT), + PERFECT(null, ProfileSection.SUMMARY, Arg.NONE); + + public enum Arg { NONE, STATUS, TEXT, YEAR } + + private static final int MAX_TEXT = 100; + private static final int MIN_YEAR = 1970; + private static final int MAX_YEAR = 2100; + + private final StatGroup group; + private final ProfileSection section; + private final Arg arg; + + StatKey(StatGroup group, ProfileSection section, Arg arg) { + this.group = group; + this.section = section; + this.arg = arg; + } + + /** + * @return the checked argument ({@code ""} for a key without one); empty when {@code raw} does not fit the key + */ + public Optional parseArg(String raw) { + return switch (arg) { + case NONE -> raw == null || raw.isBlank() ? Optional.of("") : Optional.empty(); + case STATUS -> parseStatus(raw); + case TEXT -> raw != null && !raw.isBlank() && raw.trim().length() <= MAX_TEXT + ? Optional.of(raw.trim()) : Optional.empty(); + case YEAR -> parseYear(raw); + }; + } + + /** + * Same rule as the public library: a section the user shows, or the STATS section with the key's group shared. + * OVERVIEW is never shared, so its keys without a section are for the owner only. + */ + public boolean visibleOnProfile(Set sections, Set shared) { + return (section != null && sections.contains(section)) + || (group != null && sections.contains(ProfileSection.STATS) && shared.contains(group)); + } + + private static Optional parseStatus(String raw) { + try { + return raw == null ? Optional.empty() : Optional.of(GameStatus.valueOf(raw)); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private static Optional parseYear(String raw) { + try { + int year = Integer.parseInt(raw == null ? "" : raw.trim()); + return year >= MIN_YEAR && year <= MAX_YEAR ? Optional.of(year) : Optional.empty(); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/core_service/modules/library/domain/UserStats.java b/src/main/java/core_service/modules/library/domain/UserStats.java index 256c47f..8dd7af1 100644 --- a/src/main/java/core_service/modules/library/domain/UserStats.java +++ b/src/main/java/core_service/modules/library/domain/UserStats.java @@ -38,4 +38,15 @@ public final class UserStats { public record PileTimes(long normalMinutes, long completionistMinutes, long gamesWithTimeToBeat) {} public record PlayedCounts(long started, long underOneHour) {} + + /** + * One game behind a stat number. + * + * @param minutes the minutes the stat counts for this game (e.g. on the longest day); null when the stat has none + * @param at the date the stat is about (finished, moved); null when it has none + * @param fromStatus only for the pile moves; null for a game that came in new + * @param toStatus only for the pile moves + */ + public record StatGame(long appid, String name, String imageUrl, GameStatus status, long playtimeMinutes, + Long minutes, Instant at, GameStatus fromStatus, GameStatus toStatus) {} } diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java index 5287faa..0f40486 100644 --- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java @@ -1,6 +1,8 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.DayMinutes; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.StatKey; import core_service.modules.library.domain.UserStats; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.RowMapper; @@ -13,7 +15,9 @@ import java.sql.Timestamp; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -41,6 +45,94 @@ public class UserStatsRepository { and ug.appid = p.appid and ug.hidden_at is not null) """; + /** The columns of a game set a set has no value for; the list query reads the same columns from every set. */ + private static final String NO_MINUTES = "cast(null as bigint) as minutes"; + private static final String NO_MOVE = "cast(null as varchar) as from_status, cast(null as varchar) as to_status"; + private static final String NO_EVENT = "cast(null as timestamp with time zone) as event_at"; + private static final String NO_STAT = NO_MINUTES + ", " + NO_EVENT + ", " + NO_MOVE; + + private static final String PILE_TTB_FROM = """ + from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid + where %s and ug.status = 'BACKLOG' + """.formatted(OF_USER); + private static final String MOST_PLAYED_FROM = """ + from listed_user_game ug join steam_game sg on sg.appid = ug.appid + where %s and ug.playtime_minutes > 0 + """.formatted(OF_USER); + private static final String PLATFORM_FROM = """ + from (select ug.appid, case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name, + ug.playtime_minutes + from listed_user_game ug where %s) g + where g.name <> '' + """.formatted(OF_USER); + private static final String FINISHED_AT = "coalesce(ug.finished_at_estimate, f.finished_at)"; + private static final String FINISHED_FROM = """ + from counted_user_game ug left join (%s) f on f.appid = ug.appid + where %s and ug.status in %s and %s is not null + """.formatted(FIRST_FINISH, OF_USER, FINISHED, FINISHED_AT); + private static final String FINISHED_YEAR = + "cast(extract(year from %s at time zone :zone) as integer)".formatted(FINISHED_AT); + private static final String UNDER_ONE_HOUR = "ug.playtime_minutes between 1 and 59"; + private static final String UNTRACKED = """ + ug.status in %s and ug.finished_at_estimate is null + and not exists (select 1 from user_game_status_change sc + where sc.user_id = ug.user_id and sc.appid = ug.appid and %s) + """.formatted(FINISHED, FINISH_CHANGE); + private static final String MOVES_FROM = """ + from user_game_status_change sc join counted_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid + left join user_steam_sync uss on uss.user_id = sc.user_id + where %s and sc.changed_at >= :arg + """.formatted(OF_USER); + private static final String ADDED = """ + sc.to_status = 'BACKLOG' and (sc.by_user or sc.from_status is not null or sc.changed_at > uss.history_since) + """; + private static final String DONE = "sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG'"; + + private static final String BY_PLAYTIME = "playtime desc"; + private static final String BY_NAME = "sg.name"; + private static final String BY_MINUTES = "s.minutes desc"; + private static final String BY_DATE = "s.event_at desc"; + + private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", " + + NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null"; + private static final String LONGEST_DAY_SET = "select p.appid, sum(p.minutes) as minutes, " + NO_EVENT + ", " + + NO_MOVE + " " + PLAYTIME_DAYS_NOT_HIDDEN + "and p.day = :arg group by p.appid"; + private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT + + " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg"; + private static final String MOVES_SET = "select sc.appid, " + NO_MINUTES + ", sc.changed_at as event_at, " + + "sc.from_status, sc.to_status " + MOVES_FROM + "and (sc.to_status = 'BACKLOG' or sc.from_status = 'BACKLOG')"; + + /** Every key's game set; the ones that are a plain filter of the counted games are built by {@link #counted}. */ + private static final Map SETS = Map.ofEntries( + Map.entry(StatKey.TOTAL, counted("true")), + Map.entry(StatKey.STATUS, counted("ug.status = :arg")), + Map.entry(StatKey.PLAYED, counted("ug.playtime_minutes > 0")), + Map.entry(StatKey.NEVER_STARTED, counted("ug.playtime_minutes = 0")), + Map.entry(StatKey.PILE, counted("ug.status = 'BACKLOG'")), + Map.entry(StatKey.FINISHED_ALL, counted("ug.status in " + FINISHED)), + Map.entry(StatKey.UNDER_ONE_HOUR, counted(UNDER_ONE_HOUR)), + Map.entry(StatKey.FINISHED_UNTRACKED, counted(UNTRACKED)), + Map.entry(StatKey.PILE_TIME_TO_BEAT, PILE_TTB_SET), + Map.entry(StatKey.MOST_PLAYED, "select ug.appid, " + NO_STAT + " " + MOST_PLAYED_FROM), + Map.entry(StatKey.GENRE, tagSet("genres")), + Map.entry(StatKey.MOOD, tagSet("moods")), + Map.entry(StatKey.PLATFORM, "select g.appid, " + NO_STAT + " " + PLATFORM_FROM + + "and lower(g.name) = lower(trim(:arg))"), + Map.entry(StatKey.PERFECT, "select ug.appid, " + NO_STAT + " from listed_user_game ug where " + OF_USER + + "and ug.appid in (:arg)"), + Map.entry(StatKey.LONGEST_DAY, LONGEST_DAY_SET), + Map.entry(StatKey.FINISHED_YEAR, FINISHED_YEAR_SET), + Map.entry(StatKey.TREND_ADDED, movedSet(ADDED)), + Map.entry(StatKey.TREND_DONE, movedSet(DONE)), + Map.entry(StatKey.TREND_MOVES, MOVES_SET)); + + /** The list order; a key not here goes by playtime. */ + private static final Map ORDERS = Map.of( + StatKey.NEVER_STARTED, BY_NAME, StatKey.PILE, BY_NAME, StatKey.FINISHED_UNTRACKED, BY_NAME, + StatKey.PILE_TIME_TO_BEAT, BY_MINUTES, StatKey.LONGEST_DAY, BY_MINUTES, + StatKey.FINISHED_YEAR, BY_DATE, StatKey.TREND_ADDED, BY_DATE, StatKey.TREND_DONE, BY_DATE, + StatKey.TREND_MOVES, BY_DATE); + private static final RowMapper TAG_COUNT = (rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes")); @@ -50,9 +142,7 @@ public class UserStatsRepository { return jdbc.queryForObject(""" select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete, count(t.normally_minutes) as known - from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid - where %s and ug.status = 'BACKLOG' - """.formatted(OF_USER), user(userId), + %s""".formatted(PILE_TTB_FROM), user(userId), (rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known"))); } @@ -78,10 +168,8 @@ public class UserStatsRepository { public List mostPlayed(long userId, int limit) { return jdbc.query(""" select ug.appid, sg.name, sg.image_url, ug.playtime_minutes - from listed_user_game ug join steam_game sg on sg.appid = ug.appid - where %s and ug.playtime_minutes > 0 - order by ug.playtime_minutes desc, ug.appid limit :limit - """.formatted(OF_USER), user(userId).addValue("limit", limit), + %s order by ug.playtime_minutes desc, ug.appid limit :limit + """.formatted(MOST_PLAYED_FROM), user(userId).addValue("limit", limit), (rs, i) -> new UserStats.GameMinutes(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"), rs.getLong("playtime_minutes"))); } @@ -111,13 +199,18 @@ public class UserStatsRepository { return tagCounts(userId, "moods", Integer.MAX_VALUE); } + private static String tagFrom(String column) { + return """ + from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name) + where %s""".formatted(column, OF_USER); + } + private List tagCounts(long userId, String column, int limit) { return jdbc.query(""" select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes - from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name) - where %s + %s group by tag.name order by minutes desc, games desc, tag.name limit :limit - """.formatted(column, OF_USER), user(userId).addValue("limit", limit), TAG_COUNT); + """.formatted(tagFrom(column)), user(userId).addValue("limit", limit), TAG_COUNT); } /** @@ -129,12 +222,9 @@ public class UserStatsRepository { return jdbc.query(""" select mode() within group (order by g.name) as name, count(*) as games, coalesce(sum(g.playtime_minutes), 0) as minutes - from (select case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name, - ug.playtime_minutes - from listed_user_game ug where %s) g - where g.name <> '' + %s group by lower(g.name) order by games desc, minutes desc, name - """.formatted(OF_USER), user(userId), TAG_COUNT); + """.formatted(PLATFORM_FROM), user(userId), TAG_COUNT); } public Optional longestDay(long userId) { @@ -147,9 +237,9 @@ public class UserStatsRepository { public UserStats.PlayedCounts playedCounts(long userId) { return jdbc.queryForObject(""" select count(*) filter (where ug.playtime_minutes > 0) as started, - count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour + count(*) filter (where %s) as under_one_hour from counted_user_game ug where %s - """.formatted(OF_USER), user(userId), + """.formatted(UNDER_ONE_HOUR, OF_USER), user(userId), (rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour"))); } @@ -159,22 +249,17 @@ public class UserStatsRepository { */ public List finishedPerYear(long userId, ZoneId zone) { return jdbc.query(""" - select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone) - as integer) as year, count(*) as games - from counted_user_game ug left join (%s) f on f.appid = ug.appid - where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null + select %s as year, count(*) as games + %s group by year order by year - """.formatted(FIRST_FINISH, OF_USER, FINISHED), user(userId).addValue("zone", zone.getId()), + """.formatted(FINISHED_YEAR, FINISHED_FROM), user(userId).addValue("zone", zone.getId()), (rs, i) -> new UserStats.YearCount(rs.getInt("year"), rs.getLong("games"))); } public long finishedBeforeTracking(long userId) { Long games = jdbc.queryForObject(""" - select count(*) from counted_user_game ug - where %s and ug.status in %s and ug.finished_at_estimate is null - and not exists (select 1 from user_game_status_change sc - where sc.user_id = ug.user_id and sc.appid = ug.appid and %s) - """.formatted(OF_USER, FINISHED, FINISH_CHANGE), user(userId), Long.class); + select count(*) from counted_user_game ug where %s and %s + """.formatted(OF_USER, UNTRACKED), user(userId), Long.class); return games != null ? games : 0L; } @@ -186,17 +271,76 @@ public class UserStatsRepository { */ public UserStats.MonthMoves pileMoves(long userId, Instant monthStart) { return jdbc.queryForObject(""" - select count(distinct sc.appid) filter (where sc.to_status = 'BACKLOG' and (sc.by_user - or sc.from_status is not null or sc.changed_at > uss.history_since)) as added, - count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG') - as done - from user_game_status_change sc join counted_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid - left join user_steam_sync uss on uss.user_id = sc.user_id - where %s and sc.changed_at >= :monthStart - """.formatted(OF_USER), user(userId).addValue("monthStart", Timestamp.from(monthStart)), + select count(distinct sc.appid) filter (where %s) as added, + count(distinct sc.appid) filter (where %s) as done + %s""".formatted(ADDED, DONE, MOVES_FROM), user(userId).addValue("arg", Timestamp.from(monthStart)), (rs, i) -> new UserStats.MonthMoves(rs.getLong("added"), rs.getLong("done"))); } + /** + * The games a stat number counts, from the same sets as the numbers above. + * + * @param arg the checked argument of the key, with the service's resolution: the day of LONGEST_DAY, the first + * instant of TREND_*, the appids of PERFECT; every value is bound, never formatted into the SQL + */ + public List games(long userId, StatKey key, Object arg, ZoneId zone, int offset, int limit) { + return jdbc.query(""" + select s.appid, sg.name, sg.image_url, ug.status, coalesce(ug.playtime_minutes, 0) as playtime, + s.minutes, s.event_at, s.from_status, s.to_status + from (%s) s left join steam_game sg on sg.appid = s.appid + left join user_game ug on ug.user_id = :userId and ug.appid = s.appid + order by %s, s.appid limit :limit offset :offset + """.formatted(SETS.get(key), ORDERS.getOrDefault(key, BY_PLAYTIME)), + gameParams(userId, arg, zone).addValue("limit", limit).addValue("offset", offset), + (rs, i) -> new UserStats.StatGame(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"), + status(rs.getString("status")), rs.getLong("playtime"), minutes(rs.getObject("minutes")), + instant(rs.getTimestamp("event_at")), status(rs.getString("from_status")), + status(rs.getString("to_status")))); + } + + public long countGames(long userId, StatKey key, Object arg, ZoneId zone) { + Long games = jdbc.queryForObject("select count(*) from (" + SETS.get(key) + ") s", + gameParams(userId, arg, zone), Long.class); + return games != null ? games : 0L; + } + + private static String counted(String where) { + return "select ug.appid, %s from counted_user_game ug where %s and %s".formatted(NO_STAT, OF_USER, where); + } + + private static String tagSet(String column) { + return "select ug.appid, " + NO_STAT + " " + tagFrom(column) + " and tag.name = :arg"; + } + + /** A game once, with its last change. */ + private static String movedSet(String move) { + return "select sc.appid, " + NO_MINUTES + ", max(sc.changed_at) as event_at, " + NO_MOVE + " " + MOVES_FROM + + "and " + move + " group by sc.appid"; + } + + private static MapSqlParameterSource gameParams(long userId, Object arg, ZoneId zone) { + Object bound = switch (arg) { + case GameStatus status -> status.name(); + case LocalDate day -> Date.valueOf(day); + case Instant since -> Timestamp.from(since); + case Collection appids -> appids; + default -> arg; + }; + return user(userId).addValue("arg", bound).addValue("zone", zone.getId()); + } + + private static GameStatus status(String name) { + return name == null ? null : GameStatus.valueOf(name); + } + + private static Long minutes(Object value) { + return value instanceof Number number ? number.longValue() : null; + } + + private static Instant instant(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toInstant(); + } + private static MapSqlParameterSource user(long userId) { return new MapSqlParameterSource("userId", userId); } diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index baf7e1c..a05ea3f 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -132,7 +132,7 @@ public class LibraryQueryService { public AchievementSummaryDto getAchievementSummary(Long userId) { List games = userGameRepository.findAchievementProgress(userId); - List started = startedGames(games); + List started = AchievementProgress.started(games); List closest = started.stream() .filter(g -> g.unlockedCapped() < g.total()) .sorted(Comparator.comparingDouble((AchievementProgress g) -> (double) g.unlockedCapped() / g.total()) @@ -146,7 +146,7 @@ public class LibraryQueryService { return new AchievementSummaryDto( started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(), started.size(), - perfectGames(started), + AchievementProgress.perfectAppids(started).size(), averageCompletion(started), (int) games.stream().filter(g -> !g.known()).count(), (int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(), @@ -158,14 +158,6 @@ public class LibraryQueryService { .toList()); } - private static List startedGames(List games) { - return games.stream().filter(g -> g.hasAchievements() && g.visible() && g.unlockedCapped() > 0).toList(); - } - - private static int perfectGames(List started) { - return (int) started.stream().filter(g -> g.unlockedCapped() == g.total()).count(); - } - private static Double averageCompletion(List started) { if (started.isEmpty()) return null; double mean = started.stream().mapToDouble(g -> g.unlockedCapped() * 100.0 / g.total()).average().orElse(0); @@ -234,7 +226,7 @@ public class LibraryQueryService { Map meta = steamGameRepository.findAllById(Stream.of(playing, finished) .filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList()) .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); - List started = summary ? startedGames(userGameRepository.findAchievementProgress(userId)) : null; + List started = summary ? AchievementProgress.started(userGameRepository.findAchievementProgress(userId)) : null; Set shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of(); return new PublicLibraryDto( summary ? byStatus.values().stream().mapToLong(Long::longValue).sum() : null, @@ -242,7 +234,7 @@ public class LibraryQueryService { summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null, sections.contains(ProfileSection.STATUS) ? byStatus : null, toPublicGames(playing, meta), toPublicGames(finished, meta), - summary ? perfectGames(started) : null, + summary ? AchievementProgress.perfectAppids(started).size() : null, summary ? averageCompletion(started) : null, shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); } diff --git a/src/main/java/core_service/modules/library/service/StatGamesService.java b/src/main/java/core_service/modules/library/service/StatGamesService.java new file mode 100644 index 0000000..6aff8a2 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/StatGamesService.java @@ -0,0 +1,89 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.AchievementProgress; +import core_service.modules.library.domain.ProfileSection; +import core_service.modules.library.domain.StatGroup; +import core_service.modules.library.domain.StatKey; +import core_service.modules.library.domain.UserStats; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.library.repo.UserStatsRepository; +import core_service.shared.security.Public; +import core_service.shared.security.RequiresRole; +import core_service.shared.security.Role; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * The games behind a stat number, see {@link StatKey}. Reads the same sets as the numbers of {@link UserStatsService}. + */ +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class StatGamesService { + + public static final int PAGE_SIZE = 40; + static final int MAX_PAGE = 250; + + private final UserStatsRepository userStatsRepository; + private final UserGameRepository userGameRepository; + + @Value("${app.library.zone:Europe/Berlin}") + private ZoneId zone; + + @RequiresRole(Role.USER) + public Page ownGames(long userId, StatKey key, Object arg, int page) { + return games(userId, key, arg, page); + } + + /** + * @param profileStats the stat group names of the profile ticket + * @param sectionNames see {@link ProfileSection#shown} + * @return empty when the profile does not show this number; no query runs then + */ + @Public + public Optional> profileGames(long userId, Collection profileStats, + Collection sectionNames, StatKey key, Object arg, + int page) { + Set sections = ProfileSection.shown(sectionNames); + Set shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of(); + return key.visibleOnProfile(sections, shared) ? Optional.of(games(userId, key, arg, page)) : Optional.empty(); + } + + private Page games(long userId, StatKey key, Object arg, int page) { + int safePage = Math.clamp(page, 0, MAX_PAGE); + PageRequest request = PageRequest.of(safePage, PAGE_SIZE); + Object resolved = resolve(userId, key, arg); + if (resolved == null || resolved instanceof Collection appids && appids.isEmpty()) { + return new PageImpl<>(List.of(), request, 0); + } + return new PageImpl<>(userStatsRepository.games(userId, key, resolved, zone, safePage * PAGE_SIZE, PAGE_SIZE), + request, userStatsRepository.countGames(userId, key, resolved, zone)); + } + + /** + * @return null when the key has nothing to list (no playtime day yet) + */ + private Object resolve(long userId, StatKey key, Object arg) { + LocalDate today = LocalDate.now(zone); + return switch (key) { + case LONGEST_DAY -> userStatsRepository.longestDay(userId).map(day -> (Object) day.day()).orElse(null); + case TREND_ADDED, TREND_DONE -> today.withDayOfMonth(1).atStartOfDay(zone).toInstant(); + case TREND_MOVES -> today.minusDays(UserStatsService.TREND_DAYS - 1L).atStartOfDay(zone).toInstant(); + case PERFECT -> AchievementProgress.perfectAppids( + AchievementProgress.started(userGameRepository.findAchievementProgress(userId))); + default -> arg; + }; + } +} diff --git a/src/main/java/core_service/modules/library/service/UserStatsService.java b/src/main/java/core_service/modules/library/service/UserStatsService.java index b6a2d72..8366b51 100644 --- a/src/main/java/core_service/modules/library/service/UserStatsService.java +++ b/src/main/java/core_service/modules/library/service/UserStatsService.java @@ -30,7 +30,7 @@ public class UserStatsService { private static final int MOST_PLAYED = 5; private static final int TOP_GENRES = 8; - private static final int TREND_DAYS = 90; + static final int TREND_DAYS = 90; private final UserGameRepository userGameRepository; private final UserStatsRepository userStatsRepository; diff --git a/src/main/java/core_service/modules/rewards/api/RewardController.java b/src/main/java/core_service/modules/rewards/api/RewardController.java index 4b29ba6..f383e18 100644 --- a/src/main/java/core_service/modules/rewards/api/RewardController.java +++ b/src/main/java/core_service/modules/rewards/api/RewardController.java @@ -2,8 +2,11 @@ package core_service.modules.rewards.api; import core_service.modules.rewards.api.dto.EquipRequest; import core_service.modules.rewards.api.dto.PurchaseRequest; +import core_service.modules.rewards.api.dto.RewardGamesDto; import core_service.modules.rewards.api.dto.WalletDto; import core_service.modules.rewards.domain.LookSlot; +import core_service.modules.rewards.domain.RewardKind; +import core_service.modules.rewards.domain.Track; import core_service.modules.rewards.service.RewardService; import core_service.shared.api.ErrorResponse; import core_service.shared.security.UserPrincipal; @@ -18,8 +21,11 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Optional; + @RestController @RequestMapping("/api/rewards") @RequiredArgsConstructor @@ -35,6 +41,26 @@ public class RewardController { return ResponseEntity.ok(WalletDto.of(rewardService.wallet(principal.getIdAsLong(), principal.role()))); } + @GetMapping("/progress/{track}/games") + public ResponseEntity getProgressGames(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable Track track, + @RequestParam(defaultValue = "0") int page) { + return gamesResponse(rewardService.progressGames(principal.getIdAsLong(), track, page)); + } + + @GetMapping("/history/{kind}/{ref}/games") + public ResponseEntity getHistoryGames(@AuthenticationPrincipal UserPrincipal principal, + @PathVariable RewardKind kind, @PathVariable String ref, + @RequestParam(defaultValue = "0") int page) { + return gamesResponse(rewardService.historyGames(principal.getIdAsLong(), kind, ref, page)); + } + + private static ResponseEntity gamesResponse(Optional games) { + return games.>map(g -> ResponseEntity.ok(RewardGamesDto.of(g))) + .orElseGet(() -> ResponseEntity.badRequest() + .body(new ErrorResponse("NOT_A_GAME_LIST", "There is no list of games behind this"))); + } + @PostMapping("/purchases") public ResponseEntity buy(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody PurchaseRequest request) { RewardService.BuyResult result = rewardService.buy(principal.getIdAsLong(), principal.role(), request.item()); diff --git a/src/main/java/core_service/modules/rewards/api/dto/RewardGamesDto.java b/src/main/java/core_service/modules/rewards/api/dto/RewardGamesDto.java new file mode 100644 index 0000000..5e3a6e7 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/api/dto/RewardGamesDto.java @@ -0,0 +1,19 @@ +package core_service.modules.rewards.api.dto; + +import core_service.modules.rewards.domain.RewardGame; +import core_service.modules.rewards.service.RewardService; + +import java.util.List; + +/** + * @param untracked finished games from before the ledger, which have no row to list; 0 for the other lists + */ +public record RewardGamesDto(List content, int page, int size, long totalElements, int totalPages, + long untracked) { + + public static RewardGamesDto of(RewardService.RewardGamesPage games) { + int size = RewardService.PAGE_SIZE; + return new RewardGamesDto(games.games(), games.page(), size, games.total(), + (int) ((games.total() + size - 1) / size), games.untracked()); + } +} diff --git a/src/main/java/core_service/modules/rewards/domain/RewardGame.java b/src/main/java/core_service/modules/rewards/domain/RewardGame.java new file mode 100644 index 0000000..be1b061 --- /dev/null +++ b/src/main/java/core_service/modules/rewards/domain/RewardGame.java @@ -0,0 +1,11 @@ +package core_service.modules.rewards.domain; + +import java.time.Instant; + +/** + * A game a ledger row is about. + * + * @param status null when the game left the library or is hidden + * @param at when the row happened + */ +public record RewardGame(long appid, String name, String imageUrl, String status, long playtimeMinutes, Instant at) {} diff --git a/src/main/java/core_service/modules/rewards/domain/RewardRules.java b/src/main/java/core_service/modules/rewards/domain/RewardRules.java index be3fc5d..7662963 100644 --- a/src/main/java/core_service/modules/rewards/domain/RewardRules.java +++ b/src/main/java/core_service/modules/rewards/domain/RewardRules.java @@ -1,7 +1,9 @@ package core_service.modules.rewards.domain; +import java.time.DateTimeException; import java.time.LocalDate; import java.time.YearMonth; +import java.time.temporal.ChronoField; import java.time.temporal.IsoFields; import java.util.Optional; @@ -89,4 +91,33 @@ public final class RewardRules { public static String weekRef(LocalDate day) { return "%d-W%02d".formatted(day.get(IsoFields.WEEK_BASED_YEAR), day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)); } + + /** + * @throws DateTimeException when the ref is no month + */ + public static YearMonth monthOf(String ref) { + return YearMonth.parse(ref); + } + + /** + * The Monday of a {@link #weekRef}. + * + * @throws DateTimeException when the ref is no week + */ + public static LocalDate weekOf(String ref) { + String[] parts = ref.split("-W", 2); + try { + int year = Integer.parseInt(parts[0]); + // January 4th is always in week 1 of its week-based year. + LocalDate monday = LocalDate.of(year, 1, 4) + .with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, Integer.parseInt(parts[1])) + .with(ChronoField.DAY_OF_WEEK, 1); + if (monday.get(IsoFields.WEEK_BASED_YEAR) != year) { + throw new DateTimeException("Not a week: " + ref); + } + return monday; + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + throw new DateTimeException("Not a week: " + ref, e); + } + } } diff --git a/src/main/java/core_service/modules/rewards/domain/Unlocks.java b/src/main/java/core_service/modules/rewards/domain/Unlocks.java index f064bda..a3f8e01 100644 --- a/src/main/java/core_service/modules/rewards/domain/Unlocks.java +++ b/src/main/java/core_service/modules/rewards/domain/Unlocks.java @@ -2,6 +2,7 @@ package core_service.modules.rewards.domain; import java.time.LocalDate; import java.time.YearMonth; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; @@ -24,21 +25,35 @@ public final class Unlocks { * @return the longest run of consecutive weeks */ public static int longestWeekRun(Collection mondays) { - return longestRun(mondays, day -> day.plusWeeks(1)); + return longestWeekRunWeeks(mondays).size(); } public static int longestMonthRun(Collection months) { + return longestMonthRunMonths(months).size(); + } + + /** + * @return the Mondays of the longest run, oldest first; the latest run wins a tie + */ + public static List longestWeekRunWeeks(Collection mondays) { + return longestRun(mondays, day -> day.plusWeeks(1)); + } + + public static List longestMonthRunMonths(Collection months) { return longestRun(months, month -> month.plusMonths(1)); } - private static > int longestRun(Collection values, UnaryOperator next) { - int best = 0; - int run = 0; - T previous = null; + private static > List longestRun(Collection values, UnaryOperator next) { + List best = List.of(); + List run = new ArrayList<>(); for (T value : new TreeSet<>(values)) { - run = previous != null && next.apply(previous).equals(value) ? run + 1 : 1; - best = Math.max(best, run); - previous = value; + if (!run.isEmpty() && !next.apply(run.getLast()).equals(value)) { + run = new ArrayList<>(); + } + run.add(value); + if (run.size() >= best.size()) { + best = List.copyOf(run); + } } return best; } diff --git a/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java b/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java index 6e586d6..d8eccaa 100644 --- a/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java +++ b/src/main/java/core_service/modules/rewards/repo/RewardLedgerRepository.java @@ -3,6 +3,7 @@ package core_service.modules.rewards.repo; import core_service.modules.rewards.domain.HistoryEntry; import core_service.modules.rewards.domain.LookSlot; import core_service.modules.rewards.domain.RewardEntry; +import core_service.modules.rewards.domain.RewardGame; import core_service.modules.rewards.domain.RewardKind; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.RowCallbackHandler; @@ -77,6 +78,51 @@ public class RewardLedgerRepository { rs.getString("name"), rs.getInt("points"), rs.getTimestamp("event_at").toInstant())); } + /** + * The games of the game rows of these kinds, newest first. A manual game is in steam_game too. + * + * @param from null for no lower bound; the rows have an event_at in [from, to) + * @param to null for no upper bound + */ + public List games(long userId, Collection kinds, Instant from, Instant to, int offset, + int limit) { + return jdbc.query(""" + select g.appid, sg.name, sg.image_url, ug.status, coalesce(ug.playtime_minutes, 0) as playtime, + g.event_at + from (select l.id, l.user_id, l.event_at, cast(l.ref as bigint) as appid + from reward_ledger l where %s) g + left join steam_game sg on sg.appid = g.appid + left join listed_user_game ug on ug.user_id = g.user_id and ug.appid = g.appid + order by g.event_at desc, g.id desc + limit :limit offset :offset + """.formatted(ledgerRows(from, to)), rangeParams(userId, kinds, from, to).addValue("limit", limit) + .addValue("offset", offset), + (rs, i) -> new RewardGame(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"), + rs.getString("status"), rs.getLong("playtime"), rs.getTimestamp("event_at").toInstant())); + } + + public int countGames(long userId, Collection kinds, Instant from, Instant to) { + Integer count = jdbc.queryForObject("select count(*) from reward_ledger l where " + ledgerRows(from, to), + rangeParams(userId, kinds, from, to), Integer.class); + return count != null ? count : 0; + } + + private static String ledgerRows(Instant from, Instant to) { + return "l.user_id = :userId and l.kind in (:kinds)" + (from != null ? " and l.event_at >= :from" : "") + + (to != null ? " and l.event_at < :to" : ""); + } + + private static MapSqlParameterSource rangeParams(long userId, Collection kinds, Instant from, Instant to) { + MapSqlParameterSource params = new MapSqlParameterSource("userId", userId).addValue("kinds", names(kinds)); + if (from != null) { + params.addValue("from", Timestamp.from(from)); + } + if (to != null) { + params.addValue("to", Timestamp.from(to)); + } + return params; + } + /** * The codes of the items the user bought or unlocked. */ diff --git a/src/main/java/core_service/modules/rewards/service/RewardService.java b/src/main/java/core_service/modules/rewards/service/RewardService.java index 4a43710..ef39687 100644 --- a/src/main/java/core_service/modules/rewards/service/RewardService.java +++ b/src/main/java/core_service/modules/rewards/service/RewardService.java @@ -5,6 +5,7 @@ import core_service.modules.rewards.domain.HistoryEntry; import core_service.modules.rewards.domain.LookSlot; import core_service.modules.rewards.domain.Looks; import core_service.modules.rewards.domain.RewardEntry; +import core_service.modules.rewards.domain.RewardGame; import core_service.modules.rewards.domain.RewardKind; import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.ShopItem; @@ -23,6 +24,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import java.time.DateTimeException; import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDate; @@ -32,6 +34,7 @@ import java.time.temporal.TemporalAdjusters; import java.util.EnumMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -46,6 +49,9 @@ import java.util.stream.Collectors; public class RewardService { private static final int HISTORY_SIZE = 50; + public static final int PAGE_SIZE = 40; + private static final int MAX_PAGE = 250; + private static final RewardGamesPage NO_GAMES = new RewardGamesPage(List.of(), 0, 0, 0); private final RewardLedgerRepository ledger; private final RewardFactsRepository facts; @@ -113,6 +119,73 @@ public class RewardService { return EquipResult.DONE; } + /** + * @param untracked finished games from before the ledger, which have no row; GAMES_FINISHED only + */ + public record RewardGamesPage(List games, int page, long total, long untracked) {} + + /** + * The games behind the progress of a track, read from the ledger without a settle. + * + * @return empty for a track that is no list of games + */ + @Transactional(readOnly = true) + public Optional progressGames(long userId, Track track, int page) { + return switch (track) { + case GAMES_FINISHED -> Optional.of(gamesPage(userId, RewardKind.FINISH_KINDS, null, null, page, + ledger.sumPoints(userId, RewardKind.STARTER) / RewardRules.STARTER_PER_GAME)); + case GAMES_COMPLETED -> Optional.of(gamesPage(userId, List.of(RewardKind.COMPLETED), null, null, page, 0)); + case FINISH_WEEKS -> { + List weeks = Unlocks.longestWeekRunWeeks(finishWeeks(userId, Instant.EPOCH)); + yield Optional.of(weeks.isEmpty() ? NO_GAMES + : finishedBetween(userId, weeks.getFirst(), weeks.getLast().plusWeeks(1), page)); + } + case PILE_DOWN_MONTHS -> { + List months = Unlocks.longestMonthRunMonths(ledger.refs(userId, RewardKind.MONTH).stream() + .map(YearMonth::parse).collect(Collectors.toSet())); + yield Optional.of(months.isEmpty() ? NO_GAMES + : finishedBetween(userId, months.getFirst().atDay(1), months.getLast().plusMonths(1).atDay(1), + page)); + } + case PLAY_MORE_MONTHS -> Optional.empty(); + }; + } + + /** + * The finished games behind a MONTH or STREAK bonus row. + * + * @return empty for another kind or a ref that is no month or week + */ + @Transactional(readOnly = true) + public Optional historyGames(long userId, RewardKind kind, String ref, int page) { + try { + return switch (kind) { + case MONTH -> { + YearMonth month = RewardRules.monthOf(ref); + yield Optional.of(finishedBetween(userId, month.atDay(1), month.plusMonths(1).atDay(1), page)); + } + case STREAK -> { + LocalDate monday = RewardRules.weekOf(ref); + yield Optional.of(finishedBetween(userId, monday.minusWeeks(1), monday.plusWeeks(1), page)); + } + default -> Optional.empty(); + }; + } catch (DateTimeException e) { + return Optional.empty(); + } + } + + private RewardGamesPage finishedBetween(long userId, LocalDate from, LocalDate to, int page) { + return gamesPage(userId, RewardKind.FINISH_KINDS, startOf(from), startOf(to), page, 0); + } + + private RewardGamesPage gamesPage(long userId, List kinds, Instant from, Instant to, int page, + long untracked) { + int safePage = Math.clamp(page, 0, MAX_PAGE); + return new RewardGamesPage(ledger.games(userId, kinds, from, to, safePage * PAGE_SIZE, PAGE_SIZE), safePage, + ledger.countGames(userId, kinds, from, to), untracked); + } + /** * The looks a visitor of the public profile sees; the role comes from the profile ticket. */ diff --git a/src/main/java/core_service/modules/stats/api/PublicStatsController.java b/src/main/java/core_service/modules/stats/api/PublicStatsController.java index b12fe49..5a103cd 100644 --- a/src/main/java/core_service/modules/stats/api/PublicStatsController.java +++ b/src/main/java/core_service/modules/stats/api/PublicStatsController.java @@ -1,11 +1,17 @@ package core_service.modules.stats.api; import core_service.modules.stats.api.dto.PublicStatsDto; +import core_service.modules.stats.domain.CommunityKey; +import core_service.shared.api.ErrorResponse; import core_service.modules.stats.service.PublicStatsService; import lombok.RequiredArgsConstructor; +import java.util.List; +import java.util.Optional; import org.springframework.http.ResponseEntity; + import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -19,4 +25,47 @@ public class PublicStatsController { public ResponseEntity getPublicStats() { return ResponseEntity.ok(publicStatsService.getPublicStats()); } + + @GetMapping("/public/games") + public ResponseEntity getPublicGames(@RequestParam CommunityKey key, + @RequestParam(required = false) String arg, + @RequestParam(defaultValue = "0") int page) { + return key.parseArg(arg) + .flatMap(parsed -> listed(key, parsed)) + .>map(l -> ResponseEntity.ok(publicStatsService.games(key, l.arg(), + Math.clamp(page, 0, l.maxPage())))) + .orElseGet(() -> ResponseEntity.badRequest() + .body(new ErrorResponse("BAD_ARG", "The argument does not fit the key"))); + } + + private record Listed(Object arg, int maxPage) {} + + /** + * Only what the cached landing aggregate lists can be opened, so made-up arguments run no query and fill no cache. + * The arg is returned in the spelling of the aggregate, one cache key per list. The aggregate counts rows, never + * fewer than the distinct games listed, so its page count is an upper bound. + */ + private Optional listed(CommunityKey key, Object arg) { + PublicStatsDto stats = publicStatsService.getPublicStats(); + return switch (key) { + case PLATFORM -> named(stats.gamesPerPlatform(), arg, true); + case GENRE -> named(stats.topGenres(), arg, false); + case FINISHED_YEAR -> stats.finishedPerYear().stream() + .filter(y -> arg.equals(y.year())).findFirst() + .map(y -> new Listed(arg, lastPage(y.games()))); + default -> Optional.of(new Listed(arg, PublicStatsService.MAX_PAGE)); + }; + } + + private static Optional named(List counts, Object arg, boolean ignoreCase) { + String wanted = String.valueOf(arg); + return counts.stream() + .filter(c -> ignoreCase ? c.name().equalsIgnoreCase(wanted) : c.name().equals(wanted)) + .findFirst() + .map(c -> new Listed(c.name(), lastPage(c.games()))); + } + + private static int lastPage(long games) { + return (int) Math.min(PublicStatsService.MAX_PAGE, Math.max(0, (games + PublicStatsService.PAGE_SIZE - 1) / PublicStatsService.PAGE_SIZE - 1)); + } } diff --git a/src/main/java/core_service/modules/stats/api/dto/CommunityGamesDto.java b/src/main/java/core_service/modules/stats/api/dto/CommunityGamesDto.java new file mode 100644 index 0000000..1089775 --- /dev/null +++ b/src/main/java/core_service/modules/stats/api/dto/CommunityGamesDto.java @@ -0,0 +1,17 @@ +package core_service.modules.stats.api.dto; + +import java.util.List; + +public record CommunityGamesDto( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + /** + * @param libraries in how many libraries the game is counted + * @param minutes the playtime of all those libraries together + */ + public record Game(long appid, String name, String imageUrl, long libraries, long minutes) {} +} diff --git a/src/main/java/core_service/modules/stats/domain/CommunityKey.java b/src/main/java/core_service/modules/stats/domain/CommunityKey.java new file mode 100644 index 0000000..2a03f3c --- /dev/null +++ b/src/main/java/core_service/modules/stats/domain/CommunityKey.java @@ -0,0 +1,49 @@ +package core_service.modules.stats.domain; + +import java.util.Optional; + +/** + * A number of the landing page a visitor can open as a list of games. + */ +public enum CommunityKey { + ENTRIES(Arg.NONE), + PLAYED(Arg.NONE), + OWNED(Arg.NONE), + MOST_PLAYED(Arg.NONE), + PLATFORM(Arg.TEXT), + GENRE(Arg.TEXT), + FINISHED_YEAR(Arg.YEAR); + + public enum Arg { NONE, TEXT, YEAR } + + private static final int MAX_TEXT = 100; + private static final int MIN_YEAR = 1970; + private static final int MAX_YEAR = 2100; + + private final Arg arg; + + CommunityKey(Arg arg) { + this.arg = arg; + } + + /** + * @return the checked argument ({@code ""} for a key without one); empty when {@code raw} does not fit the key + */ + public Optional parseArg(String raw) { + return switch (arg) { + case NONE -> raw == null || raw.isBlank() ? Optional.of("") : Optional.empty(); + case TEXT -> raw != null && !raw.isBlank() && raw.trim().length() <= MAX_TEXT + ? Optional.of(raw.trim()) : Optional.empty(); + case YEAR -> parseYear(raw); + }; + } + + private static Optional parseYear(String raw) { + try { + int year = Integer.parseInt(raw == null ? "" : raw.trim()); + return year >= MIN_YEAR && year <= MAX_YEAR ? Optional.of(year) : Optional.empty(); + } catch (NumberFormatException e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/core_service/modules/stats/repo/StatsRepository.java b/src/main/java/core_service/modules/stats/repo/StatsRepository.java index 4268e41..a12cca5 100644 --- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java @@ -1,9 +1,11 @@ package core_service.modules.stats.repo; +import core_service.modules.stats.domain.CommunityKey; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -13,6 +15,53 @@ public class StatsRepository { private static final String NOT_DELETED_OR_BLOCKED = "user_id NOT IN (SELECT user_id FROM deleted_user UNION SELECT user_id FROM blocked_user)"; + /** + * The games every number of the landing page counts, one row per game of a library, so a number and the list + * behind it read the same rows. + */ + private static final String ENTRIES_SET = + "SELECT appid, playtime_minutes FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED; + private static final String PLAYED_SET = ENTRIES_SET + " AND playtime_minutes > 0"; + private static final String OWNED_SET = + "SELECT appid, playtime_minutes FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED; + private static final String PLATFORM_SET = """ + SELECT appid, playtime_minutes, + CASE WHEN source = 'STEAM' THEN 'Steam' ELSE TRIM(platform) END AS name + FROM listed_user_game WHERE %s + """.formatted(NOT_DELETED_OR_BLOCKED); + private static final String GENRE_SET = """ + SELECT ug.appid, ug.playtime_minutes, tag.name AS name + FROM listed_user_game ug JOIN game_mood m ON m.appid = ug.appid + CROSS JOIN UNNEST(m.genres) AS tag(name) + WHERE %s + """.formatted(NOT_DELETED_OR_BLOCKED); + /** Has one {@code ?}: the time zone. */ + private static final String FINISHED_SET = """ + SELECT ug.appid, ug.playtime_minutes, + CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?) + AS INTEGER) AS year + FROM counted_user_game ug + LEFT JOIN (SELECT sc.user_id AS uid, sc.appid, MIN(sc.changed_at) AS finished_at + FROM user_game_status_change sc + WHERE sc.to_status IN ('FINISHED', 'COMPLETED') + AND sc.from_status NOT IN ('FINISHED', 'COMPLETED') + GROUP BY sc.user_id, sc.appid) f ON f.uid = ug.user_id AND f.appid = ug.appid + WHERE %s AND ug.status IN ('FINISHED', 'COMPLETED') + AND COALESCE(ug.finished_at_estimate, f.finished_at) IS NOT NULL + """.formatted(NOT_DELETED_OR_BLOCKED); + private static final String NAMED_PLATFORM = "SELECT * FROM (" + PLATFORM_SET + ") g WHERE g.name <> ''"; + + /** OWNED and MOST_PLAYED share a set; the ones with an argument have a {@code ?} for it. */ + private static final Map COMMUNITY_SETS = Map.of( + CommunityKey.ENTRIES, ENTRIES_SET, + CommunityKey.PLAYED, PLAYED_SET, + CommunityKey.OWNED, OWNED_SET, + CommunityKey.MOST_PLAYED, OWNED_SET, + CommunityKey.PLATFORM, "SELECT appid, playtime_minutes FROM (" + NAMED_PLATFORM + + ") g WHERE LOWER(g.name) = LOWER(TRIM(?))", + CommunityKey.GENRE, "SELECT appid, playtime_minutes FROM (" + GENRE_SET + ") g WHERE g.name = ?", + CommunityKey.FINISHED_YEAR, "SELECT appid, playtime_minutes FROM (" + FINISHED_SET + ") g WHERE g.year = ?"); + private final JdbcTemplate jdbc; @Autowired @@ -21,20 +70,17 @@ public class StatsRepository { } public long countDistinctGames() { - Long v = jdbc.queryForObject( - "SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class); + Long v = jdbc.queryForObject("SELECT COUNT(DISTINCT appid) FROM (" + OWNED_SET + ") s", Long.class); return v != null ? v : 0L; } public long countLibraryEntries() { - Long v = jdbc.queryForObject( - "SELECT COUNT(*) FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class); + Long v = jdbc.queryForObject("SELECT COUNT(*) FROM (" + ENTRIES_SET + ") s", Long.class); return v != null ? v : 0L; } public long countGamesPlayed() { - Long v = jdbc.queryForObject( - "SELECT COUNT(*) FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class); + Long v = jdbc.queryForObject("SELECT COUNT(*) FROM (" + PLAYED_SET + ") s", Long.class); return v != null ? v : 0L; } @@ -72,26 +118,24 @@ public class StatsRepository { public List> topOwnedGames(int limit) { return jdbc.queryForList(""" - SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value - FROM listed_user_game ug - LEFT JOIN steam_game sg ON sg.appid = ug.appid - WHERE %s - GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url + SELECT s.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value + FROM (%s) s + LEFT JOIN steam_game sg ON sg.appid = s.appid + GROUP BY s.appid, sg.name, sg.img_icon_url, sg.image_url ORDER BY value DESC LIMIT ? - """.formatted(NOT_DELETED_OR_BLOCKED), limit); + """.formatted(OWNED_SET), limit); } public List> topPlayedGames(int limit) { return jdbc.queryForList(""" - SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(ug.playtime_minutes) AS value - FROM listed_user_game ug - LEFT JOIN steam_game sg ON sg.appid = ug.appid - WHERE %s - GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url + SELECT s.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(s.playtime_minutes) AS value + FROM (%s) s + LEFT JOIN steam_game sg ON sg.appid = s.appid + GROUP BY s.appid, sg.name, sg.img_icon_url, sg.image_url ORDER BY value DESC LIMIT ? - """.formatted(NOT_DELETED_OR_BLOCKED), limit); + """.formatted(OWNED_SET), limit); } /** @@ -102,11 +146,9 @@ public class StatsRepository { return jdbc.queryForList(""" SELECT MODE() WITHIN GROUP (ORDER BY g.name) AS name, COUNT(*) AS games, COALESCE(SUM(g.playtime_minutes), 0) AS minutes - FROM (SELECT CASE WHEN source = 'STEAM' THEN 'Steam' ELSE TRIM(platform) END AS name, playtime_minutes - FROM listed_user_game WHERE %s) g - WHERE g.name <> '' + FROM (%s) g GROUP BY LOWER(g.name) ORDER BY games DESC, minutes DESC, name - """.formatted(NOT_DELETED_OR_BLOCKED)); + """.formatted(NAMED_PLATFORM)); } /** @@ -114,13 +156,11 @@ public class StatsRepository { */ public List> topGenres(int limit) { return jdbc.queryForList(""" - SELECT tag.name, COUNT(*) AS games, COALESCE(SUM(ug.playtime_minutes), 0) AS minutes - FROM listed_user_game ug JOIN game_mood m ON m.appid = ug.appid - CROSS JOIN UNNEST(m.genres) AS tag(name) - WHERE %s - GROUP BY tag.name ORDER BY minutes DESC, games DESC, tag.name + SELECT s.name, COUNT(*) AS games, COALESCE(SUM(s.playtime_minutes), 0) AS minutes + FROM (%s) s + GROUP BY s.name ORDER BY minutes DESC, games DESC, s.name LIMIT ? - """.formatted(NOT_DELETED_OR_BLOCKED), limit); + """.formatted(GENRE_SET), limit); } /** @@ -129,17 +169,44 @@ public class StatsRepository { */ public List> finishedPerYear(String zone) { return jdbc.queryForList(""" - SELECT CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?) - AS INTEGER) AS year, COUNT(*) AS games - FROM counted_user_game ug - LEFT JOIN (SELECT sc.user_id AS uid, sc.appid, MIN(sc.changed_at) AS finished_at - FROM user_game_status_change sc - WHERE sc.to_status IN ('FINISHED', 'COMPLETED') - AND sc.from_status NOT IN ('FINISHED', 'COMPLETED') - GROUP BY sc.user_id, sc.appid) f ON f.uid = ug.user_id AND f.appid = ug.appid - WHERE %s AND ug.status IN ('FINISHED', 'COMPLETED') - AND COALESCE(ug.finished_at_estimate, f.finished_at) IS NOT NULL - GROUP BY year ORDER BY year - """.formatted(NOT_DELETED_OR_BLOCKED), zone); + SELECT s.year, COUNT(*) AS games FROM (%s) s + GROUP BY s.year ORDER BY s.year + """.formatted(FINISHED_SET), zone); + } + + /** + * The games behind one number, once per game with the libraries that count it and their playtime together. No + * column says whose library it is. Columns: appid, name, image_url, libraries, minutes. + * + * @param arg the checked argument of the key + * @param zone the time zone of FINISHED_YEAR + */ + public List> communityGames(CommunityKey key, Object arg, String zone, int offset, int limit) { + List params = new ArrayList<>(communityParams(key, arg, zone)); + params.add(limit); + params.add(offset); + String order = key == CommunityKey.MOST_PLAYED ? "minutes DESC" : "libraries DESC"; + return jdbc.queryForList(""" + SELECT s.appid, sg.name, sg.image_url, COUNT(*) AS libraries, + COALESCE(SUM(s.playtime_minutes), 0) AS minutes + FROM (%s) s LEFT JOIN steam_game sg ON sg.appid = s.appid + GROUP BY s.appid, sg.name, sg.image_url + ORDER BY %s, s.appid + LIMIT ? OFFSET ? + """.formatted(COMMUNITY_SETS.get(key), order), params.toArray()); + } + + public long countCommunityGames(CommunityKey key, Object arg, String zone) { + Long v = jdbc.queryForObject("SELECT COUNT(DISTINCT appid) FROM (" + COMMUNITY_SETS.get(key) + ") s", + Long.class, communityParams(key, arg, zone).toArray()); + return v != null ? v : 0L; + } + + /** In the order of the {@code ?} of the set: FINISHED_SET has the time zone first. */ + private static List communityParams(CommunityKey key, Object arg, String zone) { + if (key == CommunityKey.FINISHED_YEAR) { + return List.of(zone, arg); + } + return key == CommunityKey.PLATFORM || key == CommunityKey.GENRE ? List.of(arg) : List.of(); } } diff --git a/src/main/java/core_service/modules/stats/service/PublicStatsService.java b/src/main/java/core_service/modules/stats/service/PublicStatsService.java index 6d8506d..83515f4 100644 --- a/src/main/java/core_service/modules/stats/service/PublicStatsService.java +++ b/src/main/java/core_service/modules/stats/service/PublicStatsService.java @@ -1,10 +1,13 @@ package core_service.modules.stats.service; +import core_service.modules.stats.api.dto.CommunityGamesDto; import core_service.modules.stats.api.dto.PublicStatsDto; +import core_service.modules.stats.domain.CommunityKey; import core_service.modules.stats.repo.StatsRepository; import core_service.shared.security.Public; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; @@ -19,14 +22,19 @@ public class PublicStatsService { private static final int TOP_LIMIT = 5; private static final int TOP_GENRES = 8; + public static final int PAGE_SIZE = 40; + public static final int MAX_PAGE = 250; private final StatsRepository statsRepository; + private final CacheManager cacheManager; @Value("${app.library.zone:Europe/Berlin}") private ZoneId zone; @Cacheable(value = "publicStats", key = "'global'") public PublicStatsDto getPublicStats() { + // The lists open from these numbers, so none of them may be older than the numbers. + cacheManager.getCache("publicStatGames").clear(); long distinctGames = statsRepository.countDistinctGames(); long libraryEntries = statsRepository.countLibraryEntries(); long gamesPlayed = statsRepository.countGamesPlayed(); @@ -58,6 +66,25 @@ public class PublicStatsService { ); } + /** + * The games behind a number of {@link #getPublicStats}, cached like it. The caller passes the checked argument and + * a page up to {@link #MAX_PAGE}, so the cache keys stay few. + */ + @Cacheable(value = "publicStatGames", key = "#key + ':' + #arg + ':' + #page") + public CommunityGamesDto games(CommunityKey key, Object arg, int page) { + List games = statsRepository + .communityGames(key, arg, zone.getId(), page * PAGE_SIZE, PAGE_SIZE).stream() + .map(r -> new CommunityGamesDto.Game( + ((Number) r.get("appid")).longValue(), + (String) r.get("name"), + (String) r.get("image_url"), + ((Number) r.get("libraries")).longValue(), + ((Number) r.get("minutes")).longValue())) + .toList(); + long total = statsRepository.countCommunityGames(key, arg, zone.getId()); + return new CommunityGamesDto(games, page, PAGE_SIZE, total, (int) ((total + PAGE_SIZE - 1) / PAGE_SIZE)); + } + private List mapTop(List> rows) { return rows.stream() .map(r -> new PublicStatsDto.TopGame( diff --git a/src/test/java/core_service/configuration/security/PublicEndpointsTest.java b/src/test/java/core_service/configuration/security/PublicEndpointsTest.java index 53cee36..f55c2c5 100644 --- a/src/test/java/core_service/configuration/security/PublicEndpointsTest.java +++ b/src/test/java/core_service/configuration/security/PublicEndpointsTest.java @@ -69,7 +69,9 @@ class PublicEndpointsTest { } @ParameterizedTest - @ValueSource(strings = {"/actuator/prometheus", "/api/steam/test/76561198000000001", "/internal/anything"}) + @ValueSource(strings = {"/actuator/prometheus", "/api/steam/test/76561198000000001", "/internal/anything", + "/api/library/stats/games", "/api/rewards/progress/GAMES_FINISHED/games", + "/api/rewards/history/MONTH/2026-09/games"}) void endpointIsNotPublic(String path) throws Exception { int status = mockMvc.perform(get(path)).andReturn().getResponse().getStatus(); assertTrue(Set.of(401, 403).contains(status), path + " answered " + status); @@ -115,6 +117,11 @@ class PublicEndpointsTest { mockMvc.perform(get("/api/stats/public")).andExpect(status().isOk()); } + @Test + void communityGamesArePublic() throws Exception { + mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED")).andExpect(status().isOk()); + } + @Test void versionIsPublic() throws Exception { mockMvc.perform(get("/api/version")) diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java index aaf5bb8..f5685af 100644 --- a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -17,6 +17,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import java.time.Instant; import java.util.List; @@ -193,4 +195,106 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .contentType("application/json").content("{\"status\":\"DROPPED\"}")) .andExpect(status().isNotFound()); } + + private ResultActions statGames(String ticket, String key, String arg) throws Exception { + MockHttpServletRequestBuilder request = get("/api/public/profiles/library/stats/games").param("key", key); + if (arg != null) { + request.param("arg", arg); + } + return mockMvc.perform(request.header("X-Profile-Ticket", ticket)); + } + + @Test + void statGames_SharedGroup_ListsTheGamesOfTheNumber() throws Exception { + statGames(ticket(USER_ID, List.of("TOP_GAMES")), "MOST_PLAYED", null) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(3)) + .andExpect(jsonPath("$.content[0].appid").value(620)) + .andExpect(jsonPath("$.content[0].name").value("Portal 2")) + .andExpect(jsonPath("$.content[0].playtimeMinutes").value(900)); + } + + @Test + void statGames_ListsNoRemovedGameNoOtherUserAndNoPrivateFields() throws Exception { + statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "TOTAL", null) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(3)) + .andExpect(jsonPath("$.content[?(@.appid == 800)]").isEmpty()) + .andExpect(jsonPath("$.content[1].appid").value(220)) + .andExpect(jsonPath("$.content[1].playtimeMinutes").value(300)) + .andExpect(jsonPath("$..notes").isEmpty()) + .andExpect(jsonPath("$..rating").isEmpty()) + .andExpect(jsonPath("$..priority").isEmpty()); + } + + @Test + void statGames_StatusSection_ListsTheStatusCount() throws Exception { + statGames(ticket(USER_ID, List.of(), List.of("STATUS")), "STATUS", "FINISHED") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content[0].appid").value(700)); + } + + @Test + void statGames_PerfectGames_EqualTheNumberOfTheProfile() throws Exception { + schemaMetaRepository.saveAll(List.of(new GameAchievementSchemaMeta(620L, 10, true, Instant.now()), + new GameAchievementSchemaMeta(220L, 4, true, Instant.now()))); + userAchievementMetaRepository.saveAll(List.of( + new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 10, 900), + new UserGameAchievementMeta(USER_ID, 220L, Instant.now(), true, 1, 300))); + + statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "PERFECT", null) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content[0].appid").value(620)); + } + + @Test + void statGames_PerfectGamesWithoutAny_IsAnEmptyPage() throws Exception { + statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "PERFECT", null) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(0)) + .andExpect(jsonPath("$.content", hasSize(0))); + } + + @Test + void statGames_GroupNotShared_Is403() throws Exception { + statGames(ticket(USER_ID, List.of("PILE")), "MOST_PLAYED", null) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("NOT_SHARED")); + } + + @Test + void statGames_StatsSectionHiddenButGroupInTheTicket_Is403() throws Exception { + statGames(ticket(USER_ID, List.of("TOP_GAMES"), List.of("SUMMARY")), "MOST_PLAYED", null) + .andExpect(status().isForbidden()); + } + + @Test + void statGames_OverviewKeys_AreNeverShared() throws Exception { + statGames(ticket(USER_ID, List.of("OVERVIEW")), "PLAYED", null).andExpect(status().isForbidden()); + statGames(ticket(USER_ID, List.of("OVERVIEW")), "NEVER_STARTED", null).andExpect(status().isForbidden()); + } + + @Test + void statGames_HiddenSection_Is403() throws Exception { + statGames(ticket(USER_ID, List.of(), List.of("SUMMARY")), "STATUS", "FINISHED").andExpect(status().isForbidden()); + statGames(ticket(USER_ID, List.of(), List.of("STATUS")), "TOTAL", null).andExpect(status().isForbidden()); + statGames(ticket(USER_ID, List.of(), List.of("STATUS")), "PERFECT", null).andExpect(status().isForbidden()); + statGames(ticket(USER_ID, List.of(), List.of("STATUS")), "FINISHED_ALL", null).andExpect(status().isForbidden()); + } + + @Test + void statGames_WithoutOrWithABadTicket_Is401() throws Exception { + mockMvc.perform(get("/api/public/profiles/library/stats/games").param("key", "TOTAL")) + .andExpect(status().isUnauthorized()); + statGames("garbage", "TOTAL", null).andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("INVALID_TICKET")); + } + + @Test + void statGames_BadArg_Is400() throws Exception { + statGames(ticket(USER_ID), "STATUS", "NOPE").andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BAD_ARG")); + } } diff --git a/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java b/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java new file mode 100644 index 0000000..61fb66f --- /dev/null +++ b/src/test/java/core_service/modules/library/api/StatGamesFlowTest.java @@ -0,0 +1,298 @@ +package core_service.modules.library.api; + +import core_service.modules.library.repo.GameMoodsRepository; +import core_service.shared.test.AbstractMockedLibraryServicesTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * For every key: the number on the stats page equals what its list holds. The dev profile authenticates every request + * as user 1. + */ +@AutoConfigureMockMvc +class StatGamesFlowTest extends AbstractMockedLibraryServicesTest { + + private static final long USER = 1L; + private static final long OTHER = 2L; + private static final long FINISHED_TRACKED = 5_000_001L; + private static final long PILE_WITH_TIME = 5_000_002L; + private static final long UNDER_HOUR = 5_000_003L; + private static final long COMPLETED_2011 = 5_000_004L; + private static final long PILE_NO_TIME = 5_000_005L; + private static final long HIDDEN = 5_000_006L; + private static final long REMOVED = 5_000_007L; + private static final long EDITION_MAIN = 5_000_008L; + private static final long EDITION_SECOND = 5_000_009L; + private static final long MANUAL_SWITCH = 5_000_010L; + private static final long MANUAL_SWITCH_LOWER = 5_000_011L; + private static final long BACK_TO_PILE = 5_000_012L; + private static final int YEAR = LocalDate.now(ZoneId.of("Europe/Berlin")).getYear(); + private static final JsonMapper JSON = JsonMapper.builder().build(); + + private record Case(String key, String arg, Function count, String minutesField, + Function minutes) { + @Override + public String toString() { + return key + (arg == null ? "" : " " + arg); + } + } + + private static Case count(String key, String arg, String path) { + return new Case(key, arg, s -> s.at(path).asLong(), null, null); + } + + private static Long tagValue(JsonNode tags, String name, String field) { + for (JsonNode tag : tags) { + if (tag.get("name").asString().equals(name)) { + return tag.get(field).asLong(); + } + } + return 0L; + } + + private static Long yearGames(JsonNode stats, int year) { + for (JsonNode row : stats.at("/finished/perYear")) { + if (row.get("year").asInt() == year) { + return row.get("games").asLong(); + } + } + return 0L; + } + + static Stream cases() { + return Stream.of( + count("TOTAL", null, "/overview/totalGames"), + count("STATUS", "BACKLOG", "/overview/byStatus/BACKLOG"), + count("STATUS", "PLAYING", "/overview/byStatus/PLAYING"), + count("STATUS", "FINISHED", "/overview/byStatus/FINISHED"), + count("STATUS", "COMPLETED", "/overview/byStatus/COMPLETED"), + count("PLAYED", null, "/overview/playedGames"), + count("NEVER_STARTED", null, "/overview/neverStarted"), + count("PILE", null, "/pile/backlogGames"), + new Case("PILE_TIME_TO_BEAT", null, s -> s.at("/pile/gamesWithTimeToBeat").asLong(), "minutes", + s -> s.at("/pile/normalMinutes").asLong()), + new Case("GENRE", "RPG", s -> tagValue(s.at("/taste/genres"), "RPG", "games"), "playtimeMinutes", + s -> tagValue(s.at("/taste/genres"), "RPG", "minutes")), + new Case("MOOD", "CHILL", s -> tagValue(s.at("/taste/moods"), "CHILL", "games"), "playtimeMinutes", + s -> tagValue(s.at("/taste/moods"), "CHILL", "minutes")), + new Case("LONGEST_DAY", null, null, "minutes", s -> s.at("/habits/longestDay/minutes").asLong()), + count("UNDER_ONE_HOUR", null, "/habits/underOneHourGames"), + new Case("FINISHED_YEAR", String.valueOf(YEAR), s -> yearGames(s, YEAR), null, null), + new Case("FINISHED_YEAR", "2011", s -> yearGames(s, 2011), null, null), + count("FINISHED_UNTRACKED", null, "/finished/finishedBeforeTracking"), + count("TREND_ADDED", null, "/trend/thisMonth/added"), + count("TREND_DONE", null, "/trend/thisMonth/done"), + new Case("PLATFORM", "Switch", s -> tagValue(s.at("/platforms/perPlatform"), "Switch", "games"), + "playtimeMinutes", s -> tagValue(s.at("/platforms/perPlatform"), "Switch", "minutes")), + new Case("PLATFORM", "Steam", s -> tagValue(s.at("/platforms/perPlatform"), "Steam", "games"), + "playtimeMinutes", s -> tagValue(s.at("/platforms/perPlatform"), "Steam", "minutes")), + new Case("FINISHED_ALL", null, + s -> s.at("/overview/byStatus/FINISHED").asLong() + s.at("/overview/byStatus/COMPLETED").asLong(), + null, null)); + } + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private GameMoodsRepository gameMoodsRepository; + + @BeforeEach + void seed() { + jdbc.update("delete from user_game where user_id in (?, ?)", USER, OTHER); + jdbc.update("delete from user_game_playtime_day where user_id in (?, ?)", USER, OTHER); + jdbc.update("delete from game_link where appid in (?, ?)", EDITION_MAIN, EDITION_SECOND); + + game(USER, FINISHED_TRACKED, 600, "BACKLOG", "STEAM", null); + jdbc.update("update user_game set status = 'FINISHED' where user_id = ? and appid = ?", USER, FINISHED_TRACKED); + game(USER, PILE_WITH_TIME, 0, "BACKLOG", "STEAM", null); + game(USER, UNDER_HOUR, 30, "PLAYING", "STEAM", null); + game(USER, COMPLETED_2011, 1200, "COMPLETED", "STEAM", null); + jdbc.update("update user_game set finished_at_estimate = '2011-06-15T10:00:00Z' where user_id = ? and appid = ?", + USER, COMPLETED_2011); + game(USER, PILE_NO_TIME, 0, "BACKLOG", "STEAM", null); + game(USER, HIDDEN, 9999, "BACKLOG", "STEAM", null); + jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER, HIDDEN); + game(USER, REMOVED, 8888, "BACKLOG", "STEAM", null); + jdbc.update("update user_game set removed_at = now() where user_id = ? and appid = ?", USER, REMOVED); + game(USER, EDITION_MAIN, 100, "FINISHED", "STEAM", null); + game(USER, EDITION_SECOND, 0, "BACKLOG", "STEAM", null); + jdbc.update("insert into game_link (appid, set_id) values (?, 900001), (?, 900001) on conflict do nothing", + EDITION_MAIN, EDITION_SECOND); + game(USER, MANUAL_SWITCH, 50, "BACKLOG", "MANUAL", " Switch "); + game(USER, MANUAL_SWITCH_LOWER, 70, "BACKLOG", "MANUAL", "switch"); + game(USER, BACK_TO_PILE, 10, "PLAYING", "STEAM", null); + jdbc.update("update user_game set status = 'BACKLOG' where user_id = ? and appid = ?", USER, BACK_TO_PILE); + game(OTHER, FINISHED_TRACKED, 4000, "FINISHED", "STEAM", null); + + jdbc.update(""" + insert into game_time_to_beat (appid, normally_minutes, fetched_at) values (?, 500, now()) + on conflict (appid) do update set normally_minutes = 500 + """, PILE_WITH_TIME); + gameMoodsRepository.upsert(FINISHED_TRACKED, new String[]{"CHILL"}, new String[]{"RPG"}, true, Instant.now()); + gameMoodsRepository.upsert(UNDER_HOUR, new String[]{"CHILL"}, new String[]{"RPG", "Indie"}, true, Instant.now()); + gameMoodsRepository.upsert(HIDDEN, new String[]{"CHILL"}, new String[]{"RPG"}, true, Instant.now()); + day(FINISHED_TRACKED, 3, 120); + day(UNDER_HOUR, 3, 40); + day(FINISHED_TRACKED, 9, 50); + day(HIDDEN, 3, 5000); + } + + private void game(long user, long appid, int minutes, String status, String source, String platform) { + jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid); + jdbc.update(""" + insert into user_game (user_id, appid, status, playtime_minutes, source, platform) + values (?, ?, ?, ?, ?, ?) + """, user, appid, status, minutes, source, platform); + } + + private void day(long appid, int daysAgo, int minutes) { + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?, ?, ?)", + USER, java.sql.Date.valueOf(LocalDate.now().minusDays(daysAgo)), appid, minutes); + } + + private JsonNode read(String url, String key, String arg, int page) throws Exception { + var request = get(url).param("key", key).param("page", String.valueOf(page)); + if (arg != null) { + request.param("arg", arg); + } + return JSON.readTree(mockMvc.perform(request).andExpect(status().isOk()).andReturn().getResponse() + .getContentAsString()); + } + + private List allRows(String key, String arg) throws Exception { + List rows = new ArrayList<>(); + int page = 0; + JsonNode body; + do { + body = read("/api/library/stats/games", key, arg, page++); + body.get("content").forEach(rows::add); + } while (page < body.get("totalPages").asInt()); + return rows; + } + + private JsonNode stats() throws Exception { + return JSON.readTree(mockMvc.perform(get("/api/library/stats")).andExpect(status().isOk()).andReturn() + .getResponse().getContentAsString()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("cases") + void number_EqualsTheGamesBehindIt(Case c) throws Exception { + JsonNode stats = stats(); + List rows = allRows(c.key(), c.arg()); + + assertThat(rows).as("the seed has games for every case").isNotEmpty(); + if (c.count() != null) { + assertThat((long) rows.size()).isEqualTo(c.count().apply(stats)); + assertThat(read("/api/library/stats/games", c.key(), c.arg(), 0).get("totalElements").asLong()) + .isEqualTo(c.count().apply(stats)); + } + if (c.minutes() != null) { + assertThat(rows.stream().mapToLong(r -> r.get(c.minutesField()).asLong()).sum()) + .isEqualTo(c.minutes().apply(stats)); + } + assertThat(rows).extracting(r -> r.get("appid").asLong()).doesNotContain(HIDDEN, REMOVED); + assertThat(rows).extracting(r -> r.get("appid").asLong()).doesNotHaveDuplicates(); + } + + @Test + void mostPlayed_FirstFiveAreTheStatsTopGames() throws Exception { + List top = new ArrayList<>(); + stats().at("/topGames/mostPlayed").forEach(g -> top.add(g.get("appid").asLong())); + + List rows = allRows("MOST_PLAYED", null).stream().map(r -> r.get("appid").asLong()).toList(); + + assertThat(rows.subList(0, top.size())).isEqualTo(top); + assertThat(rows).doesNotContain(HIDDEN, REMOVED); + } + + @Test + void linkedSet_ListsTheCountedEditionOnce() throws Exception { + List rows = allRows("TOTAL", null).stream().map(r -> r.get("appid").asLong()).toList(); + + assertThat(rows).contains(EDITION_MAIN).doesNotContain(EDITION_SECOND); + } + + @Test + void trendMoves_ListsEveryMoveOntoAndOffThePile_NewestFirst() throws Exception { + List rows = allRows("TREND_MOVES", null); + + assertThat(rows).extracting(r -> r.get("appid").asLong()).contains(FINISHED_TRACKED, BACK_TO_PILE); + assertThat(rows).extracting(r -> r.get("at").asString()).isSortedAccordingTo(java.util.Comparator.reverseOrder()); + assertThat(rows).filteredOn(r -> r.get("appid").asLong() == BACK_TO_PILE) + .extracting(r -> r.get("fromStatus").asString(), r -> r.get("toStatus").asString()) + .contains(org.assertj.core.groups.Tuple.tuple("PLAYING", "BACKLOG")); + } + + @Test + void moreThanOnePage_Has40OnTheFirstAndTheRestOnTheNext_ByPlaytime() throws Exception { + for (int i = 0; i < 45; i++) { + game(USER, 5_100_000L + i, 2000 + i, "PLAYING", "STEAM", null); + } + + JsonNode first = read("/api/library/stats/games", "TOTAL", null, 0); + JsonNode second = read("/api/library/stats/games", "TOTAL", null, 1); + + assertThat(first.get("content")).hasSize(40); + assertThat(first.get("size").asInt()).isEqualTo(40); + assertThat(second.get("content")).hasSize(first.get("totalElements").asInt() - 40); + assertThat(first.get("content").get(0).get("playtimeMinutes").asLong()).isEqualTo(2044); + assertThat(first.get("content").get(39).get("playtimeMinutes").asLong()) + .isGreaterThan(second.get("content").get(0).get("playtimeMinutes").asLong()); + } + + @Test + void aPageFarBeyondTheEnd_IsEmpty_AndClamped() throws Exception { + JsonNode body = read("/api/library/stats/games", "TOTAL", null, 100_000); + + assertThat(body.get("content")).isEmpty(); + assertThat(body.get("page").asInt()).isEqualTo(250); + } + + @Test + void anArgThatDoesNotFitTheKey_Is400WithBadArg() throws Exception { + mockMvc.perform(get("/api/library/stats/games").param("key", "STATUS").param("arg", "NOPE")) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("BAD_ARG")); + mockMvc.perform(get("/api/library/stats/games").param("key", "TOTAL").param("arg", "x")) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("BAD_ARG")); + mockMvc.perform(get("/api/library/stats/games").param("key", "FINISHED_YEAR").param("arg", "1900")) + .andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("BAD_ARG")); + } + + @Test + void anUnknownKey_Is400() throws Exception { + mockMvc.perform(get("/api/library/stats/games").param("key", "NOPE")).andExpect(status().isBadRequest()); + } + + @Test + void aTextArgIsNeverSql() throws Exception { + JsonNode body = read("/api/library/stats/games", "GENRE", "x' or '1'='1", 0); + + assertThat(body.get("content")).isEmpty(); + } +} diff --git a/src/test/java/core_service/modules/library/domain/StatKeyTest.java b/src/test/java/core_service/modules/library/domain/StatKeyTest.java new file mode 100644 index 0000000..0da49c0 --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/StatKeyTest.java @@ -0,0 +1,72 @@ +package core_service.modules.library.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class StatKeyTest extends AbstractUnitTest { + + @Test + void parseArg_KeyWithoutArg_AcceptsOnlyNothing() { + assertThat(StatKey.TOTAL.parseArg(null)).contains(""); + assertThat(StatKey.TOTAL.parseArg(" ")).contains(""); + assertThat(StatKey.TOTAL.parseArg("1")).isEmpty(); + } + + @Test + void parseArg_Status_IsTheEnum() { + assertThat(StatKey.STATUS.parseArg("BACKLOG")).contains(GameStatus.BACKLOG); + assertThat(StatKey.STATUS.parseArg("NOPE")).isEmpty(); + assertThat(StatKey.STATUS.parseArg(null)).isEmpty(); + } + + @Test + void parseArg_Text_IsTrimmedAndAtMost100Chars() { + assertThat(StatKey.GENRE.parseArg(" RPG ")).contains("RPG"); + assertThat(StatKey.GENRE.parseArg("x".repeat(100))).isPresent(); + assertThat(StatKey.GENRE.parseArg("x".repeat(101))).isEmpty(); + assertThat(StatKey.GENRE.parseArg(" ")).isEmpty(); + assertThat(StatKey.GENRE.parseArg(null)).isEmpty(); + } + + @Test + void parseArg_Year_IsAnIntegerFrom1970To2100() { + assertThat(StatKey.FINISHED_YEAR.parseArg("2011")).contains(2011); + assertThat(StatKey.FINISHED_YEAR.parseArg("1969")).isEmpty(); + assertThat(StatKey.FINISHED_YEAR.parseArg("2101")).isEmpty(); + assertThat(StatKey.FINISHED_YEAR.parseArg("abc")).isEmpty(); + assertThat(StatKey.FINISHED_YEAR.parseArg(null)).isEmpty(); + } + + @Test + void visibleOnProfile_AGroupNeedsTheStatsSectionAndTheSharedGroup() { + Set shared = Set.of(StatGroup.TASTE); + + assertThat(StatKey.GENRE.visibleOnProfile(Set.of(ProfileSection.STATS), shared)).isTrue(); + assertThat(StatKey.GENRE.visibleOnProfile(Set.of(ProfileSection.SUMMARY), shared)).isFalse(); + assertThat(StatKey.GENRE.visibleOnProfile(Set.of(ProfileSection.STATS), Set.of(StatGroup.PILE))).isFalse(); + } + + @Test + void visibleOnProfile_OverviewOnlyKeysAreNeverShown() { + Set all = Set.of(ProfileSection.values()); + Set everyShared = StatGroup.shared(Arrays.stream(StatGroup.values()).map(Enum::name).toList()); + + assertThat(StatKey.PLAYED.visibleOnProfile(all, everyShared)).isFalse(); + assertThat(StatKey.NEVER_STARTED.visibleOnProfile(all, everyShared)).isFalse(); + } + + @Test + void visibleOnProfile_SectionKeysFollowTheirSection() { + assertThat(StatKey.TOTAL.visibleOnProfile(Set.of(ProfileSection.SUMMARY), Set.of())).isTrue(); + assertThat(StatKey.TOTAL.visibleOnProfile(Set.of(ProfileSection.STATUS), Set.of())).isFalse(); + assertThat(StatKey.STATUS.visibleOnProfile(Set.of(ProfileSection.STATUS), Set.of())).isTrue(); + assertThat(StatKey.STATUS.visibleOnProfile(Set.of(ProfileSection.SUMMARY), Set.of())).isFalse(); + assertThat(StatKey.PERFECT.visibleOnProfile(Set.of(ProfileSection.SUMMARY), Set.of())).isTrue(); + assertThat(StatKey.FINISHED_ALL.visibleOnProfile(Set.of(ProfileSection.STATS), Set.of())).isFalse(); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java index bb9d94d..57fe45b 100644 --- a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java @@ -4,6 +4,7 @@ import core_service.modules.library.domain.DayMinutes; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.StatKey; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserStats; import core_service.shared.test.AbstractPersistenceTest; @@ -359,4 +360,14 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest { private void setStatus(long appid, String status) { jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER_ID, appid); } + + @Test + void games_LinkedSetCountsOnce() { + userGameRepository.saveAllAndFlush(List.of(game(220L, 0, GameStatus.BACKLOG, Instant.now()), + game(620L, 0, GameStatus.BACKLOG, Instant.now()))); + jdbc.update("insert into game_link (appid, set_id) values (220, 1), (620, 1)"); + + assertThat(repository.countGames(USER_ID, StatKey.TOTAL, "", BERLIN)).isEqualTo(1); + assertThat(repository.games(USER_ID, StatKey.TOTAL, "", BERLIN, 0, 10)).hasSize(1); + } } diff --git a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java index c453215..6d0c88a 100644 --- a/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java +++ b/src/test/java/core_service/modules/rewards/api/RewardControllerTest.java @@ -331,4 +331,36 @@ class RewardControllerTest extends AbstractIntegrationTest { jdbc.update("delete from blocked_user where user_id = ?", USER); } } + + @Test + void progressGames_ListsTheGamesBehindTheTrack() throws Exception { + game(GAME, "STEAM", "COMPLETED", 100); + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'COMPLETED', ?, 40, now())", + USER, String.valueOf(GAME)); + + mockMvc.perform(get("/api/rewards/progress/GAMES_COMPLETED/games")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.untracked").value(0)) + .andExpect(jsonPath("$.content[0].appid").value(GAME)) + .andExpect(jsonPath("$.content[0].name").value("Game " + GAME)) + .andExpect(jsonPath("$.content[0].status").value("COMPLETED")); + } + + @Test + void progressGames_PlayMoreMonths_Is400() throws Exception { + mockMvc.perform(get("/api/rewards/progress/PLAY_MORE_MONTHS/games")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("NOT_A_GAME_LIST")); + } + + @Test + void historyGames_MonthAndStreak_AreLists_OtherKindsAre400() throws Exception { + mockMvc.perform(get("/api/rewards/history/MONTH/2025-03/games")).andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(0)); + mockMvc.perform(get("/api/rewards/history/STREAK/2025-W10/games")).andExpect(status().isOk()); + mockMvc.perform(get("/api/rewards/history/BUY/BADGE/games")).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("NOT_A_GAME_LIST")); + mockMvc.perform(get("/api/rewards/history/MONTH/garbage/games")).andExpect(status().isBadRequest()); + } } diff --git a/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java b/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java index f0c6eea..a1409f6 100644 --- a/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java +++ b/src/test/java/core_service/modules/rewards/domain/RewardRulesTest.java @@ -3,12 +3,14 @@ package core_service.modules.rewards.domain; import core_service.shared.test.AbstractUnitTest; import org.junit.jupiter.api.Test; +import java.time.DateTimeException; import java.time.Instant; import java.time.LocalDate; import java.time.YearMonth; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class RewardRulesTest extends AbstractUnitTest { @@ -89,4 +91,24 @@ class RewardRulesTest extends AbstractUnitTest { assertThat(RewardRules.weekRef(LocalDate.of(2026, 10, 8))).isEqualTo("2026-W41"); assertThat(RewardRules.weekRef(LocalDate.of(2027, 1, 1))).isEqualTo("2026-W53"); } + + @Test + void weekOf_IsTheInverseOfWeekRef_AlsoAtTheIsoYearEnd() { + for (LocalDate monday : new LocalDate[]{LocalDate.of(2026, 10, 5), LocalDate.of(2026, 12, 28), + LocalDate.of(2027, 1, 4), LocalDate.of(2024, 12, 30)}) { + assertThat(RewardRules.weekOf(RewardRules.weekRef(monday))).isEqualTo(monday); + } + } + + @Test + void weekOf_NoWeek_Throws() { + for (String ref : new String[]{"2026-W54", "2027-W53", "x", "2026-W", "2026"}) { + assertThatThrownBy(() -> RewardRules.weekOf(ref)).isInstanceOf(DateTimeException.class); + } + } + + @Test + void monthOf_IsTheInverseOfMonthRef() { + assertThat(RewardRules.monthOf(RewardRules.monthRef(YearMonth.of(2026, 9)))).isEqualTo(YearMonth.of(2026, 9)); + } } diff --git a/src/test/java/core_service/modules/rewards/domain/UnlocksTest.java b/src/test/java/core_service/modules/rewards/domain/UnlocksTest.java index 29ee44f..5a412fc 100644 --- a/src/test/java/core_service/modules/rewards/domain/UnlocksTest.java +++ b/src/test/java/core_service/modules/rewards/domain/UnlocksTest.java @@ -66,4 +66,21 @@ class UnlocksTest extends AbstractUnitTest { assertThat(unlock.stream().map(item -> item.track() + ":" + item.target()).collect(Collectors.toSet())) .hasSameSizeAs(unlock); } + + @Test + void longestWeekRunWeeks_TheLatestRunWinsATie() { + LocalDate monday = LocalDate.of(2026, 9, 7); + + assertThat(Unlocks.longestWeekRunWeeks(List.of(monday, monday.plusWeeks(1), monday.plusWeeks(5), + monday.plusWeeks(6), monday.plusWeeks(9)))) + .containsExactly(monday.plusWeeks(5), monday.plusWeeks(6)); + assertThat(Unlocks.longestWeekRunWeeks(List.of())).isEmpty(); + } + + @Test + void longestMonthRunMonths_AreTheMonthsOfTheLongestRun() { + assertThat(Unlocks.longestMonthRunMonths(List.of(YearMonth.of(2026, 12), YearMonth.of(2027, 1), + YearMonth.of(2027, 2), YearMonth.of(2027, 5)))) + .containsExactly(YearMonth.of(2026, 12), YearMonth.of(2027, 1), YearMonth.of(2027, 2)); + } } diff --git a/src/test/java/core_service/modules/rewards/service/RewardGamesTest.java b/src/test/java/core_service/modules/rewards/service/RewardGamesTest.java new file mode 100644 index 0000000..0cb0bc4 --- /dev/null +++ b/src/test/java/core_service/modules/rewards/service/RewardGamesTest.java @@ -0,0 +1,208 @@ +package core_service.modules.rewards.service; + +import core_service.modules.rewards.domain.RewardGame; +import core_service.modules.rewards.domain.RewardKind; +import core_service.modules.rewards.domain.Track; +import core_service.shared.security.Role; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Timestamp; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.IsoFields; +import java.time.temporal.TemporalAdjusters; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class RewardGamesTest extends AbstractIntegrationTest { + + private static final long USER = 15L; + private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); + private static final LocalDate MONDAY = + LocalDate.now(BERLIN).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).minusWeeks(30); + + @Autowired + private RewardService rewardService; + + @Autowired + private JdbcTemplate jdbc; + + private int games = 0; + + @BeforeEach + @AfterEach + void clean() { + jdbc.update("delete from reward_ledger where user_id = ?", USER); + } + + private long row(RewardKind kind, LocalDate day) { + long appid = 6_000_000L + games++; + jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid); + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 0, ?)", + USER, kind.name(), String.valueOf(appid), Timestamp.from(day.atTime(12, 0).atZone(BERLIN).toInstant())); + return appid; + } + + private void bonus(RewardKind kind, String ref) { + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, ?, ?, 0, now())", + USER, kind.name(), ref); + } + + private RewardService.RewardGamesPage progress(Track track) { + return rewardService.progressGames(USER, track, 0).orElseThrow(); + } + + @Test + void gamesFinished_TotalPlusUntrackedIsTheProgress() { + row(RewardKind.FINISHED, MONDAY); + row(RewardKind.FINISHED, MONDAY.plusDays(2)); + row(RewardKind.FINISHED_MANUAL, MONDAY.plusDays(3)); + row(RewardKind.COMPLETED, MONDAY.plusDays(4)); + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'STARTER', 'starter', 40, now())", + USER); + + int progress = rewardService.wallet(USER, Role.USER).progress().get(Track.GAMES_FINISHED); + RewardService.RewardGamesPage page = progress(Track.GAMES_FINISHED); + + assertThat(page.total() + page.untracked()).isEqualTo(progress); + assertThat(page.untracked()).isEqualTo(2); + assertThat(page.games()).hasSize(3); + } + + @Test + void gamesCompleted_TotalIsTheProgress() { + row(RewardKind.COMPLETED, MONDAY); + row(RewardKind.COMPLETED, MONDAY.plusDays(1)); + row(RewardKind.FINISHED, MONDAY.plusDays(1)); + + int progress = rewardService.wallet(USER, Role.USER).progress().get(Track.GAMES_COMPLETED); + RewardService.RewardGamesPage page = progress(Track.GAMES_COMPLETED); + + assertThat(page.total()).isEqualTo(progress).isEqualTo(2); + assertThat(page.untracked()).isZero(); + } + + @Test + void finishWeeks_ListsTheGamesOfTheLatestLongestRun_NewestFirst() { + row(RewardKind.FINISHED, MONDAY.minusWeeks(4)); + row(RewardKind.FINISHED, MONDAY); + row(RewardKind.FINISHED, MONDAY.plusDays(3)); + row(RewardKind.FINISHED, MONDAY.plusWeeks(1)); + row(RewardKind.FINISHED_MANUAL, MONDAY.plusWeeks(2).plusDays(6)); + row(RewardKind.FINISHED, MONDAY.plusWeeks(4)); + + int progress = rewardService.wallet(USER, Role.USER).progress().get(Track.FINISH_WEEKS); + RewardService.RewardGamesPage page = progress(Track.FINISH_WEEKS); + + assertThat(progress).isEqualTo(3); + assertThat(page.games()).hasSize(4); + assertThat(page.games().stream().map(g -> g.at().atZone(BERLIN).toLocalDate() + .get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)).distinct()).hasSize(progress); + assertThat(page.games()).extracting(RewardGame::at).isSortedAccordingTo(java.util.Comparator.reverseOrder()); + } + + @Test + void pileDownMonths_ListsTheFinishedGamesOfTheMonthsOfTheRun() { + LocalDate may = LocalDate.now(BERLIN).minusMonths(8).withDayOfMonth(10); + row(RewardKind.FINISHED, may.minusMonths(1)); + row(RewardKind.FINISHED, may); + row(RewardKind.FINISHED, may.plusMonths(1)); + row(RewardKind.FINISHED, may.plusMonths(3)); + bonus(RewardKind.MONTH, java.time.YearMonth.from(may).toString()); + bonus(RewardKind.MONTH, java.time.YearMonth.from(may).plusMonths(1).toString()); + + int progress = rewardService.wallet(USER, Role.USER).progress().get(Track.PILE_DOWN_MONTHS); + RewardService.RewardGamesPage page = progress(Track.PILE_DOWN_MONTHS); + + assertThat(progress).isEqualTo(2); + assertThat(page.games()).hasSize(2); + assertThat(page.games().stream().map(g -> java.time.YearMonth.from(g.at().atZone(BERLIN))).distinct()) + .hasSize(progress); + } + + @Test + void noRunYet_IsAnEmptyPage() { + assertThat(progress(Track.FINISH_WEEKS).games()).isEmpty(); + assertThat(progress(Track.PILE_DOWN_MONTHS).total()).isZero(); + } + + @Test + void playMoreMonths_IsNoListOfGames() { + assertThat(rewardService.progressGames(USER, Track.PLAY_MORE_MONTHS, 0)).isEmpty(); + } + + @Test + void aGameThatLeftTheLibrary_StaysInTheListWithoutAStatus() { + row(RewardKind.COMPLETED, MONDAY); + + RewardGame game = progress(Track.GAMES_COMPLETED).games().getFirst(); + + assertThat(game.status()).isNull(); + assertThat(game.name()).startsWith("Game "); + } + + @Test + void historyMonth_ListsTheFinishedGamesOfThatMonth() { + LocalDate day = LocalDate.of(2025, 3, 10); + row(RewardKind.FINISHED, day); + row(RewardKind.FINISHED_MANUAL, day.plusDays(10)); + row(RewardKind.FINISHED, day.plusMonths(1)); + + assertThat(rewardService.historyGames(USER, RewardKind.MONTH, "2025-03", 0).orElseThrow().games()).hasSize(2); + } + + @Test + void historyStreak_ListsTheWeekAndTheWeekBefore() { + LocalDate monday = LocalDate.of(2025, 3, 10); + row(RewardKind.FINISHED, monday.minusWeeks(2)); + row(RewardKind.FINISHED, monday.minusDays(1)); + row(RewardKind.FINISHED, monday.plusDays(4)); + row(RewardKind.FINISHED, monday.plusWeeks(1)); + + String ref = "%d-W%02d".formatted(monday.get(IsoFields.WEEK_BASED_YEAR), + monday.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)); + + assertThat(rewardService.historyGames(USER, RewardKind.STREAK, ref, 0).orElseThrow().games()).hasSize(2); + } + + @Test + void history_OtherKindsAndBadRefs_AreNoList() { + assertThat(rewardService.historyGames(USER, RewardKind.BUY, "BADGE", 0)).isEmpty(); + assertThat(rewardService.historyGames(USER, RewardKind.FINISHED, "220", 0)).isEmpty(); + assertThat(rewardService.historyGames(USER, RewardKind.MONTH, "not-a-month", 0)).isEmpty(); + assertThat(rewardService.historyGames(USER, RewardKind.STREAK, "2026-W99", 0)).isEmpty(); + assertThat(rewardService.historyGames(USER, RewardKind.STREAK, "x", 0)).isEmpty(); + } + + @Test + void anotherUsersRows_AreNeverListed() { + row(RewardKind.COMPLETED, MONDAY); + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'COMPLETED', '6999999', 0, now())", + USER + 1); + + try { + assertThat(progress(Track.GAMES_COMPLETED).games()).extracting(RewardGame::appid).doesNotContain(6_999_999L); + } finally { + jdbc.update("delete from reward_ledger where user_id = ?", USER + 1); + } + } + + @Test + void list_Has40PerPage() { + for (int i = 0; i < 45; i++) { + row(RewardKind.COMPLETED, MONDAY.plusDays(i % 7)); + } + + assertThat(rewardService.progressGames(USER, Track.GAMES_COMPLETED, 0).orElseThrow().games()).hasSize(40); + assertThat(rewardService.progressGames(USER, Track.GAMES_COMPLETED, 1).orElseThrow().games()).hasSize(5); + assertThat(List.of(rewardService.progressGames(USER, Track.GAMES_COMPLETED, 999).orElseThrow().page())) + .containsExactly(250); + } +} diff --git a/src/test/java/core_service/modules/stats/api/PublicStatsControllerTest.java b/src/test/java/core_service/modules/stats/api/PublicStatsControllerTest.java new file mode 100644 index 0000000..9d1e6f1 --- /dev/null +++ b/src/test/java/core_service/modules/stats/api/PublicStatsControllerTest.java @@ -0,0 +1,156 @@ +package core_service.modules.stats.api; + +import com.github.benmanes.caffeine.cache.Cache; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.cache.CacheManager; +import org.springframework.test.web.servlet.MockMvc; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +class PublicStatsControllerTest extends AbstractIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private CacheManager cacheManager; + + @BeforeEach + void seed() { + userGameRepository.deleteAll(); + steamGameRepository.saveAll(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now()))); + userGameRepository.saveAll(List.of(new UserGame(1L, 220L, 300, Instant.now(), Instant.now()), + new UserGame(2L, 220L, 100, Instant.now(), Instant.now()))); + cacheManager.getCache("publicStatGames").clear(); + cacheManager.getCache("publicStats").clear(); + } + + @Test + void games_WithoutLogin_ListsTheGamesWithTheirLibraries() throws Exception { + mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.content[0].appid").value(220)) + .andExpect(jsonPath("$.content[0].libraries").value(2)) + .andExpect(jsonPath("$.content[0].minutes").value(400)); + } + + @Test + void games_EveryRowHasExactlyTheseKeys_SoNoUserDataCanLeakLater() throws Exception { + JsonNode body = JsonMapper.builder().build().readTree(mockMvc.perform(get("/api/stats/public/games") + .param("key", "ENTRIES")).andReturn().getResponse().getContentAsString()); + + Set keys = new HashSet<>(); + body.get("content").get(0).propertyNames().forEach(keys::add); + + assertThat(keys).containsExactlyInAnyOrder("appid", "name", "imageUrl", "libraries", "minutes"); + } + + @Test + void games_BadArg_Is400() throws Exception { + mockMvc.perform(get("/api/stats/public/games").param("key", "GENRE")).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("BAD_ARG")); + mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED").param("arg", "x")) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/api/stats/public/games").param("key", "NOPE")).andExpect(status().isBadRequest()); + } + + @Test + void games_APageBeyondTheEnd_IsEmptyAndClamped() throws Exception { + mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED").param("page", "100000")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content", hasSize(0))) + .andExpect(jsonPath("$.page").value(250)); + } + + @Test + void games_AreCached() throws Exception { + mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED")).andExpect(status().isOk()); + userGameRepository.deleteAll(); + + mockMvc.perform(get("/api/stats/public/games").param("key", "OWNED")) + .andExpect(jsonPath("$.totalElements").value(1)); + } + + @Test + void games_UnknownTextArg_RunsNoQueryAndFillsNoCache() throws Exception { + for (int i = 0; i < 3; i++) { + mockMvc.perform(get("/api/stats/public/games").param("key", "PLATFORM").param("arg", "no-such-platform-" + i)); + mockMvc.perform(get("/api/stats/public/games").param("key", "GENRE").param("arg", "no-such-genre-" + i)); + } + + Cache cache = (Cache) cacheManager.getCache("publicStatGames").getNativeCache(); + cache.cleanUp(); + assertThat(cache.estimatedSize()).as("cache entries made by made-up args").isZero(); + } + + @Test + void games_TextArgCheck_UsesTheCachedAggregate() throws Exception { + mockMvc.perform(get("/api/stats/public/games").param("key", "PLATFORM").param("arg", "no-such-platform")); + + assertThat(cacheManager.getCache("publicStats").get("global")) + .as("the check must read the cached aggregate, not run it again").isNotNull(); + } + + @Test + void games_CaseVariantsOfAListedPlatform_ShareOneCacheEntry() throws Exception { + for (String arg : List.of("Steam", "STEAM", "steam", "sTeAm")) { + mockMvc.perform(get("/api/stats/public/games").param("key", "PLATFORM").param("arg", arg)) + .andExpect(status().isOk()); + } + + Cache cache = (Cache) cacheManager.getCache("publicStatGames").getNativeCache(); + cache.cleanUp(); + assertThat(cache.estimatedSize()).as("cache entries for one platform, page 0").isEqualTo(1); + } + + @Test + void games_YearWithoutFinishedGames_RunsNoQueryAndFillsNoCache() throws Exception { + for (int year = 1971; year < 1974; year++) { + mockMvc.perform(get("/api/stats/public/games").param("key", "FINISHED_YEAR").param("arg", "" + year)); + } + + Cache cache = (Cache) cacheManager.getCache("publicStatGames").getNativeCache(); + cache.cleanUp(); + assertThat(cache.estimatedSize()).as("cache entries made by years nobody finished a game in").isZero(); + } + + @Test + void publicStats_Recomputed_DropsTheCachedLists() throws Exception { + mockMvc.perform(get("/api/stats/public/games").param("key", "ENTRIES")) + .andExpect(jsonPath("$.content[0].libraries").value(2)); + userGameRepository.save(new UserGame(3L, 220L, 50, Instant.now(), Instant.now())); + cacheManager.getCache("publicStats").clear(); + + mockMvc.perform(get("/api/stats/public")).andExpect(status().isOk()); + + mockMvc.perform(get("/api/stats/public/games").param("key", "ENTRIES")) + .andExpect(jsonPath("$.content[0].libraries").value(3)); + } +} diff --git a/src/test/java/core_service/modules/stats/domain/CommunityKeyTest.java b/src/test/java/core_service/modules/stats/domain/CommunityKeyTest.java new file mode 100644 index 0000000..b47e706 --- /dev/null +++ b/src/test/java/core_service/modules/stats/domain/CommunityKeyTest.java @@ -0,0 +1,29 @@ +package core_service.modules.stats.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommunityKeyTest extends AbstractUnitTest { + + @Test + void parseArg_KeyWithoutArg_AcceptsOnlyNothing() { + assertThat(CommunityKey.ENTRIES.parseArg(null)).contains(""); + assertThat(CommunityKey.ENTRIES.parseArg("x")).isEmpty(); + } + + @Test + void parseArg_Text_IsTrimmedAndAtMost100Chars() { + assertThat(CommunityKey.GENRE.parseArg(" RPG ")).contains("RPG"); + assertThat(CommunityKey.PLATFORM.parseArg("x".repeat(101))).isEmpty(); + assertThat(CommunityKey.PLATFORM.parseArg(null)).isEmpty(); + } + + @Test + void parseArg_Year_IsAnIntegerFrom1970To2100() { + assertThat(CommunityKey.FINISHED_YEAR.parseArg("2011")).contains(2011); + assertThat(CommunityKey.FINISHED_YEAR.parseArg("1969")).isEmpty(); + assertThat(CommunityKey.FINISHED_YEAR.parseArg("x")).isEmpty(); + } +} diff --git a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java index 9d14d7c..34d6a8d 100644 --- a/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java +++ b/src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java @@ -7,6 +7,7 @@ import core_service.modules.library.repo.GameMoodsRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.stats.api.dto.PublicStatsDto; +import core_service.modules.stats.domain.CommunityKey; import core_service.modules.stats.service.PublicStatsService; import core_service.shared.test.AbstractPersistenceTest; import org.junit.jupiter.api.BeforeEach; @@ -23,6 +24,7 @@ import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; @Import(StatsRepository.class) class StatsRepositoryTest extends AbstractPersistenceTest { @@ -136,6 +138,49 @@ class StatsRepositoryTest extends AbstractPersistenceTest { assertEquals(List.of(1L, 1L), rows.stream().map(row -> ((Number) row.get("games")).longValue()).toList()); } + private long sum(List> rows, String column) { + return rows.stream().mapToLong(row -> ((Number) row.get(column)).longValue()).sum(); + } + + private List> community(CommunityKey key, Object arg) { + return statsRepository.communityGames(key, arg, "Europe/Berlin", 0, 100); + } + + @Test + void communityGames_ListEqualsTheNumberOfEveryKey_NeverDeletedUsers_NeverAUserColumn() { + steamGameRepository.saveAllAndFlush(List.of(new SteamGame(8001L, "A", null, Instant.now()), + new SteamGame(8003L, "C", null, Instant.now()))); + userGameRepository.saveAllAndFlush(List.of( + new UserGame(5L, 220L, 100, Instant.now(), Instant.now()), + UserGame.manual(5L, 8001L, "ps5", GameStatus.BACKLOG, 10, Instant.now()), + UserGame.manual(3L, 8003L, "ps5", GameStatus.BACKLOG, 20, Instant.now()))); + jdbc.update("INSERT INTO deleted_user (user_id) VALUES (3)"); + jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 5 AND appid = 220"); + gameMoodsRepository.upsert(220L, new String[]{}, new String[]{"RPG"}, false, Instant.now()); + + List> entries = community(CommunityKey.ENTRIES, ""); + assertEquals(statsRepository.countLibraryEntries(), sum(entries, "libraries")); + assertEquals(entries.size(), statsRepository.countCommunityGames(CommunityKey.ENTRIES, "", "Europe/Berlin")); + List> played = community(CommunityKey.PLAYED, ""); + assertEquals(statsRepository.countGamesPlayed(), sum(played, "libraries")); + List> owned = community(CommunityKey.OWNED, ""); + assertEquals(statsRepository.countDistinctGames(), owned.size()); + assertEquals(statsRepository.countDistinctGames(), + statsRepository.countCommunityGames(CommunityKey.OWNED, "", "Europe/Berlin")); + assertEquals(appids(statsRepository.topPlayedGames(1)).getFirst(), + appids(community(CommunityKey.MOST_PLAYED, "")).getFirst()); + assertEquals(400L, ((Number) community(CommunityKey.MOST_PLAYED, "").getFirst().get("minutes")).longValue()); + assertFalse(appids(owned).contains(8003L)); + List> platform = community(CommunityKey.PLATFORM, " PS5 "); + assertEquals(1, sum(platform, "libraries")); + assertEquals(10, sum(platform, "minutes")); + List> genre = community(CommunityKey.GENRE, "RPG"); + assertEquals(2, sum(genre, "libraries")); + assertEquals(400, sum(genre, "minutes")); + assertEquals(1, sum(community(CommunityKey.FINISHED_YEAR, LocalDate.now().getYear()), "libraries")); + assertEquals(java.util.Set.of("appid", "name", "image_url", "libraries", "minutes"), owned.getFirst().keySet()); + } + private static List appids(List> rows) { return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList(); } @@ -158,7 +203,7 @@ class StatsRepositoryTest extends AbstractPersistenceTest { estimated.setFinishedAtEstimate(Instant.parse("2025-06-01T00:00:00Z")); userGameRepository.saveAndFlush(estimated); jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 620"); - PublicStatsService service = new PublicStatsService(statsRepository); + PublicStatsService service = new PublicStatsService(statsRepository, new org.springframework.cache.concurrent.ConcurrentMapCacheManager("publicStatGames")); ReflectionTestUtils.setField(service, "zone", ZoneId.of("Europe/Berlin")); PublicStatsDto dto = service.getPublicStats();