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();