feat(library): add drill-down lists for stat numbers
Build, Test & Push Core Service / quality-check (push) Successful in 1m17s
Build, Test & Push Core Service / docker (push) Successful in 1m27s

The number and list share the same SQL query, so they cannot drift.
StatKey enum maps each statistic to its query, validation rules, and visibility.
StatGamesService reads games per key with 40-game pages; repositories share the
same set logic as the stat counts. Three endpoints open games: own library stats,
public profile stats (with ticket checks), and public landing-page stats (cached
10 min). Rewards track and bonus rows support drill-down too.
This commit is contained in:
lobo committed 2026-10-03 18:51:59 +02:00
1 parent 74cc8297ce
commit 065048415c
39 files changed
+1938 -109

No files matched your search

+10
View File
@@ -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 `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 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. 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` - **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 (old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
changes are not known. 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 | | `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 | | `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 | | `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 | | `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 | | `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 | | `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/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?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/achievements/summary` | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | 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` | 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/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 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/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`, `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 | | `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
@@ -17,8 +17,8 @@ public class CacheConfig {
@Bean @Bean
public CacheManager cacheManager() { public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "steamPlayerSummary", CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "publicStatGames",
"igdbSearch"); "steamPlayerSummary", "igdbSearch");
cacheManager.setCaffeine(Caffeine.newBuilder() cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) .expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000)); .maximumSize(1000));
@@ -33,6 +33,7 @@ public class SecurityConfig {
"/actuator/health", // Docker health check; details are off "/actuator/health", // Docker health check; details are off
"/error", "/error",
"/api/stats/public", "/api/stats/public",
"/api/stats/public/games",
"/api/public/**", // Public profiles; PublicProfileController checks the ticket "/api/public/**", // Public profiles; PublicProfileController checks the ticket
"/api/version" // Build of the running image, shown in the settings "/api/version" // Build of the running image, shown in the settings
}; };
@@ -34,7 +34,7 @@ public class AccountCleanupService {
} }
@Transactional @Transactional
@CacheEvict(value = "publicStats", allEntries = true) @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true)
public void markDeleted(List<Long> userIds) { public void markDeleted(List<Long> userIds) {
repository.addDeleted(userIds); repository.addDeleted(userIds);
log.info("Account cleanup: marked users {} as deleted", 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. * The whole flagged set of Auth. Also lifts the tombstone of a user whose delete was undone in Auth.
*/ */
@Transactional @Transactional
@CacheEvict(value = "publicStats", allEntries = true) @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true)
public void replaceDeleted(List<Long> userIds) { public void replaceDeleted(List<Long> userIds) {
repository.replaceDeleted(userIds); repository.replaceDeleted(userIds);
log.info("Account cleanup: {} users marked as deleted", userIds.size()); 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 * @param requestDryRun Auth's dry run; either side can ask for one
*/ */
@Transactional @Transactional
@CacheEvict(value = "publicStats", allEntries = true) @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true)
public CleanupResult purge(List<Long> userIds, boolean requestDryRun) { public CleanupResult purge(List<Long> userIds, boolean requestDryRun) {
List<Long> tombstoned = repository.findDeleted(userIds); List<Long> tombstoned = repository.findDeleted(userIds);
if (tombstoned.size() < userIds.size()) { if (tombstoned.size() < userIds.size()) {
@@ -69,7 +69,7 @@ public class AccountCleanupService {
* broken call can never wipe the library tables. * broken call can never wipe the library tables.
*/ */
@Transactional @Transactional
@CacheEvict(value = "publicStats", allEntries = true) @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true)
public CleanupResult prune(List<Long> knownUserIds, boolean requestDryRun) { public CleanupResult prune(List<Long> knownUserIds, boolean requestDryRun) {
if (knownUserIds.isEmpty()) { if (knownUserIds.isEmpty()) {
throw new IllegalArgumentException("knownUserIds must not be empty"); throw new IllegalArgumentException("knownUserIds must not be empty");
@@ -24,14 +24,14 @@ public class AccountStateService {
private final AccountStateRepository repository; private final AccountStateRepository repository;
@Transactional @Transactional
@CacheEvict(value = "publicStats", allEntries = true) @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true)
public void apply(List<AccountState> states) { public void apply(List<AccountState> states) {
states.forEach(repository::apply); states.forEach(repository::apply);
log.info("Account state: {} users updated", states.size()); log.info("Account state: {} users updated", states.size());
} }
@Transactional @Transactional
@CacheEvict(value = "publicStats", allEntries = true) @CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true)
public void replaceAll(List<AccountState> states) { public void replaceAll(List<AccountState> states) {
repository.replaceAll(states); repository.replaceAll(states);
log.info("Account state: {} users replaced", states.size()); log.info("Account state: {} users replaced", states.size());
@@ -35,6 +35,7 @@ import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.StatGroup; 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.AuditLogService;
import core_service.modules.library.service.BacklogService; import core_service.modules.library.service.BacklogService;
import core_service.modules.library.service.GameCorrectionService; 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.InvalidCorrectionException;
import core_service.modules.library.service.LibraryQueryService; import core_service.modules.library.service.LibraryQueryService;
import core_service.modules.library.service.LibrarySyncService; 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.SteamProfileService;
import core_service.modules.library.service.SyncCooldownException; import core_service.modules.library.service.SyncCooldownException;
import core_service.modules.library.service.UserStatsService; import core_service.modules.library.service.UserStatsService;
@@ -97,6 +99,7 @@ public class LibraryController {
private final SteamProfileService steamProfileService; private final SteamProfileService steamProfileService;
private final ImageRepairService imageRepairService; private final ImageRepairService imageRepairService;
private final UserStatsService userStatsService; private final UserStatsService userStatsService;
private final StatGamesService statGamesService;
private final GameCorrectionService gameCorrectionService; private final GameCorrectionService gameCorrectionService;
private final GameMergeService gameMergeService; private final GameMergeService gameMergeService;
private final GameLinkService gameLinkService; private final GameLinkService gameLinkService;
@@ -461,6 +464,17 @@ public class LibraryController {
return ResponseEntity.ok(userStatsService.getOwnStats(userId, asked)); 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)
.<ResponseEntity<?>>map(parsed -> ResponseEntity.ok(
PageDto.of(statGamesService.ownGames(principal.getIdAsLong(), key, parsed, page))))
.orElseGet(() -> ResponseEntity.badRequest().body(StatGamesErrors.BAD_ARG));
}
@GetMapping("/sync-status") @GetMapping("/sync-status")
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
return queryService.getSyncStatus(principal.getIdAsLong()) return queryService.getSyncStatus(principal.getIdAsLong())
@@ -1,7 +1,10 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.shared.api.ErrorResponse; 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.LibraryQueryService;
import core_service.modules.library.service.StatGamesService;
import core_service.shared.security.ProfileTicket; import core_service.shared.security.ProfileTicket;
import core_service.shared.security.ProfileTicketVerifier; import core_service.shared.security.ProfileTicketVerifier;
import lombok.RequiredArgsConstructor; 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.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Optional; import java.util.Optional;
@@ -24,13 +28,41 @@ import java.util.Optional;
public class PublicProfileController { public class PublicProfileController {
private final LibraryQueryService queryService; private final LibraryQueryService queryService;
private final StatGamesService statGamesService;
private final ProfileTicketVerifier profileTicketVerifier; private final ProfileTicketVerifier profileTicketVerifier;
@GetMapping("/library") @GetMapping("/library")
public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) { public ResponseEntity<?> getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) {
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket)) return verify(ticket)
.<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections()))) .<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) .orElseGet(PublicProfileController::invalidTicket);
.body(new ErrorResponse("INVALID_TICKET", "Missing, invalid or expired profile ticket"))); }
@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<ProfileTicket> verified = verify(ticket);
if (verified.isEmpty()) {
return invalidTicket();
}
ProfileTicket t = verified.get();
return key.parseArg(arg)
.<ResponseEntity<?>>map(parsed -> statGamesService
.profileGames(t.userId(), t.profileStats(), t.sections(), key, parsed, page)
.<ResponseEntity<?>>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<ProfileTicket> 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"));
} }
} }
@@ -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() {
}
}
@@ -1,5 +1,7 @@
package core_service.modules.library.domain; package core_service.modules.library.domain;
import java.util.List;
/** /**
* Achievement state of one owned game. * 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) { public static int capped(int unlocked, int total) {
return Math.min(unlocked, total); return Math.min(unlocked, total);
} }
public static List<AchievementProgress> started(List<AchievementProgress> games) {
return games.stream().filter(g -> g.hasAchievements() && g.visible() && g.unlockedCapped() > 0).toList();
}
public static List<Long> perfectAppids(List<AchievementProgress> started) {
return started.stream().filter(g -> g.unlockedCapped() == g.total()).map(AchievementProgress::appid).toList();
}
} }
@@ -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<Object> 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<ProfileSection> sections, Set<StatGroup> shared) {
return (section != null && sections.contains(section))
|| (group != null && sections.contains(ProfileSection.STATS) && shared.contains(group));
}
private static Optional<Object> parseStatus(String raw) {
try {
return raw == null ? Optional.empty() : Optional.of(GameStatus.valueOf(raw));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
private static Optional<Object> 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();
}
}
}
@@ -38,4 +38,15 @@ public final class UserStats {
public record PileTimes(long normalMinutes, long completionistMinutes, long gamesWithTimeToBeat) {} public record PileTimes(long normalMinutes, long completionistMinutes, long gamesWithTimeToBeat) {}
public record PlayedCounts(long started, long underOneHour) {} 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) {}
} }
@@ -1,6 +1,8 @@
package core_service.modules.library.repo; package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes; 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 core_service.modules.library.domain.UserStats;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
@@ -13,7 +15,9 @@ import java.sql.Timestamp;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -41,6 +45,94 @@ public class UserStatsRepository {
and ug.appid = p.appid and ug.hidden_at is not null) 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<StatKey, String> 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<StatKey, String> 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<UserStats.TagCount> TAG_COUNT = private static final RowMapper<UserStats.TagCount> TAG_COUNT =
(rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes")); (rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"));
@@ -50,9 +142,7 @@ public class UserStatsRepository {
return jdbc.queryForObject(""" return jdbc.queryForObject("""
select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete, select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete,
count(t.normally_minutes) as known count(t.normally_minutes) as known
from counted_user_game ug join game_time_to_beat t on t.appid = ug.appid %s""".formatted(PILE_TTB_FROM), user(userId),
where %s and ug.status = 'BACKLOG'
""".formatted(OF_USER), user(userId),
(rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known"))); (rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known")));
} }
@@ -78,10 +168,8 @@ public class UserStatsRepository {
public List<UserStats.GameMinutes> mostPlayed(long userId, int limit) { public List<UserStats.GameMinutes> mostPlayed(long userId, int limit) {
return jdbc.query(""" return jdbc.query("""
select ug.appid, sg.name, sg.image_url, ug.playtime_minutes 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 %s order by ug.playtime_minutes desc, ug.appid limit :limit
where %s and ug.playtime_minutes > 0 """.formatted(MOST_PLAYED_FROM), user(userId).addValue("limit", limit),
order by ug.playtime_minutes desc, ug.appid limit :limit
""".formatted(OF_USER), user(userId).addValue("limit", limit),
(rs, i) -> new UserStats.GameMinutes(rs.getLong("appid"), rs.getString("name"), (rs, i) -> new UserStats.GameMinutes(rs.getLong("appid"), rs.getString("name"),
rs.getString("image_url"), rs.getLong("playtime_minutes"))); rs.getString("image_url"), rs.getLong("playtime_minutes")));
} }
@@ -111,13 +199,18 @@ public class UserStatsRepository {
return tagCounts(userId, "moods", Integer.MAX_VALUE); 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<UserStats.TagCount> tagCounts(long userId, String column, int limit) { private List<UserStats.TagCount> tagCounts(long userId, String column, int limit) {
return jdbc.query(""" return jdbc.query("""
select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes 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) %s
where %s
group by tag.name order by minutes desc, games desc, tag.name limit :limit 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(""" return jdbc.query("""
select mode() within group (order by g.name) as name, count(*) as games, select mode() within group (order by g.name) as name, count(*) as games,
coalesce(sum(g.playtime_minutes), 0) as minutes coalesce(sum(g.playtime_minutes), 0) as minutes
from (select case when ug.source = 'STEAM' then 'Steam' else trim(ug.platform) end as name, %s
ug.playtime_minutes
from listed_user_game ug where %s) g
where g.name <> ''
group by lower(g.name) order by games desc, minutes desc, name 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<DayMinutes> longestDay(long userId) { public Optional<DayMinutes> longestDay(long userId) {
@@ -147,9 +237,9 @@ public class UserStatsRepository {
public UserStats.PlayedCounts playedCounts(long userId) { public UserStats.PlayedCounts playedCounts(long userId) {
return jdbc.queryForObject(""" return jdbc.queryForObject("""
select count(*) filter (where ug.playtime_minutes > 0) as started, 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 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"))); (rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour")));
} }
@@ -159,22 +249,17 @@ public class UserStatsRepository {
*/ */
public List<UserStats.YearCount> finishedPerYear(long userId, ZoneId zone) { public List<UserStats.YearCount> finishedPerYear(long userId, ZoneId zone) {
return jdbc.query(""" return jdbc.query("""
select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone) select %s as year, count(*) as games
as integer) as year, count(*) as games %s
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
group by year order by year 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"))); (rs, i) -> new UserStats.YearCount(rs.getInt("year"), rs.getLong("games")));
} }
public long finishedBeforeTracking(long userId) { public long finishedBeforeTracking(long userId) {
Long games = jdbc.queryForObject(""" Long games = jdbc.queryForObject("""
select count(*) from counted_user_game ug select count(*) from counted_user_game ug where %s and %s
where %s and ug.status in %s and ug.finished_at_estimate is null """.formatted(OF_USER, UNTRACKED), user(userId), Long.class);
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);
return games != null ? games : 0L; return games != null ? games : 0L;
} }
@@ -186,17 +271,76 @@ public class UserStatsRepository {
*/ */
public UserStats.MonthMoves pileMoves(long userId, Instant monthStart) { public UserStats.MonthMoves pileMoves(long userId, Instant monthStart) {
return jdbc.queryForObject(""" return jdbc.queryForObject("""
select count(distinct sc.appid) filter (where sc.to_status = 'BACKLOG' and (sc.by_user select count(distinct sc.appid) filter (where %s) as added,
or sc.from_status is not null or sc.changed_at > uss.history_since)) as added, count(distinct sc.appid) filter (where %s) as done
count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG') %s""".formatted(ADDED, DONE, MOVES_FROM), user(userId).addValue("arg", Timestamp.from(monthStart)),
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)),
(rs, i) -> new UserStats.MonthMoves(rs.getLong("added"), rs.getLong("done"))); (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<UserStats.StatGame> 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) { private static MapSqlParameterSource user(long userId) {
return new MapSqlParameterSource("userId", userId); return new MapSqlParameterSource("userId", userId);
} }
@@ -132,7 +132,7 @@ public class LibraryQueryService {
public AchievementSummaryDto getAchievementSummary(Long userId) { public AchievementSummaryDto getAchievementSummary(Long userId) {
List<AchievementProgress> games = userGameRepository.findAchievementProgress(userId); List<AchievementProgress> games = userGameRepository.findAchievementProgress(userId);
List<AchievementProgress> started = startedGames(games); List<AchievementProgress> started = AchievementProgress.started(games);
List<AchievementProgress> closest = started.stream() List<AchievementProgress> closest = started.stream()
.filter(g -> g.unlockedCapped() < g.total()) .filter(g -> g.unlockedCapped() < g.total())
.sorted(Comparator.comparingDouble((AchievementProgress g) -> (double) g.unlockedCapped() / g.total()) .sorted(Comparator.comparingDouble((AchievementProgress g) -> (double) g.unlockedCapped() / g.total())
@@ -146,7 +146,7 @@ public class LibraryQueryService {
return new AchievementSummaryDto( return new AchievementSummaryDto(
started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(), started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(),
started.size(), started.size(),
perfectGames(started), AchievementProgress.perfectAppids(started).size(),
averageCompletion(started), averageCompletion(started),
(int) games.stream().filter(g -> !g.known()).count(), (int) games.stream().filter(g -> !g.known()).count(),
(int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(), (int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(),
@@ -158,14 +158,6 @@ public class LibraryQueryService {
.toList()); .toList());
} }
private static List<AchievementProgress> startedGames(List<AchievementProgress> games) {
return games.stream().filter(g -> g.hasAchievements() && g.visible() && g.unlockedCapped() > 0).toList();
}
private static int perfectGames(List<AchievementProgress> started) {
return (int) started.stream().filter(g -> g.unlockedCapped() == g.total()).count();
}
private static Double averageCompletion(List<AchievementProgress> started) { private static Double averageCompletion(List<AchievementProgress> started) {
if (started.isEmpty()) return null; if (started.isEmpty()) return null;
double mean = started.stream().mapToDouble(g -> g.unlockedCapped() * 100.0 / g.total()).average().orElse(0); double mean = started.stream().mapToDouble(g -> g.unlockedCapped() * 100.0 / g.total()).average().orElse(0);
@@ -234,7 +226,7 @@ public class LibraryQueryService {
Map<Long, SteamGame> meta = steamGameRepository.findAllById(Stream.of(playing, finished) Map<Long, SteamGame> meta = steamGameRepository.findAllById(Stream.of(playing, finished)
.filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList()) .filter(Objects::nonNull).flatMap(List::stream).map(UserGame::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
List<AchievementProgress> started = summary ? startedGames(userGameRepository.findAchievementProgress(userId)) : null; List<AchievementProgress> started = summary ? AchievementProgress.started(userGameRepository.findAchievementProgress(userId)) : null;
Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of(); Set<StatGroup> shared = sections.contains(ProfileSection.STATS) ? StatGroup.shared(profileStats) : Set.of();
return new PublicLibraryDto( return new PublicLibraryDto(
summary ? byStatus.values().stream().mapToLong(Long::longValue).sum() : null, 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, summary ? byStatus.get(GameStatus.FINISHED) + byStatus.get(GameStatus.COMPLETED) : null,
sections.contains(ProfileSection.STATUS) ? byStatus : null, sections.contains(ProfileSection.STATUS) ? byStatus : null,
toPublicGames(playing, meta), toPublicGames(finished, meta), toPublicGames(playing, meta), toPublicGames(finished, meta),
summary ? perfectGames(started) : null, summary ? AchievementProgress.perfectAppids(started).size() : null,
summary ? averageCompletion(started) : null, summary ? averageCompletion(started) : null,
shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
} }
@@ -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<UserStats.StatGame> 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<Page<UserStats.StatGame>> profileGames(long userId, Collection<String> profileStats,
Collection<String> sectionNames, StatKey key, Object arg,
int page) {
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
Set<StatGroup> 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<UserStats.StatGame> 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;
};
}
}
@@ -30,7 +30,7 @@ public class UserStatsService {
private static final int MOST_PLAYED = 5; private static final int MOST_PLAYED = 5;
private static final int TOP_GENRES = 8; 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 UserGameRepository userGameRepository;
private final UserStatsRepository userStatsRepository; private final UserStatsRepository userStatsRepository;
@@ -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.EquipRequest;
import core_service.modules.rewards.api.dto.PurchaseRequest; 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.api.dto.WalletDto;
import core_service.modules.rewards.domain.LookSlot; 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.modules.rewards.service.RewardService;
import core_service.shared.api.ErrorResponse; import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal; 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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/rewards") @RequestMapping("/api/rewards")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -35,6 +41,26 @@ public class RewardController {
return ResponseEntity.ok(WalletDto.of(rewardService.wallet(principal.getIdAsLong(), principal.role()))); 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<RewardService.RewardGamesPage> games) {
return games.<ResponseEntity<?>>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") @PostMapping("/purchases")
public ResponseEntity<?> buy(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody PurchaseRequest request) { public ResponseEntity<?> buy(@AuthenticationPrincipal UserPrincipal principal, @Valid @RequestBody PurchaseRequest request) {
RewardService.BuyResult result = rewardService.buy(principal.getIdAsLong(), principal.role(), request.item()); RewardService.BuyResult result = rewardService.buy(principal.getIdAsLong(), principal.role(), request.item());
@@ -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<RewardGame> 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());
}
}
@@ -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) {}
@@ -1,7 +1,9 @@
package core_service.modules.rewards.domain; package core_service.modules.rewards.domain;
import java.time.DateTimeException;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.YearMonth; import java.time.YearMonth;
import java.time.temporal.ChronoField;
import java.time.temporal.IsoFields; import java.time.temporal.IsoFields;
import java.util.Optional; import java.util.Optional;
@@ -89,4 +91,33 @@ public final class RewardRules {
public static String weekRef(LocalDate day) { 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)); 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);
}
}
} }
@@ -2,6 +2,7 @@ package core_service.modules.rewards.domain;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.YearMonth; import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
@@ -24,21 +25,35 @@ public final class Unlocks {
* @return the longest run of consecutive weeks * @return the longest run of consecutive weeks
*/ */
public static int longestWeekRun(Collection<LocalDate> mondays) { public static int longestWeekRun(Collection<LocalDate> mondays) {
return longestRun(mondays, day -> day.plusWeeks(1)); return longestWeekRunWeeks(mondays).size();
} }
public static int longestMonthRun(Collection<YearMonth> months) { public static int longestMonthRun(Collection<YearMonth> months) {
return longestMonthRunMonths(months).size();
}
/**
* @return the Mondays of the longest run, oldest first; the latest run wins a tie
*/
public static List<LocalDate> longestWeekRunWeeks(Collection<LocalDate> mondays) {
return longestRun(mondays, day -> day.plusWeeks(1));
}
public static List<YearMonth> longestMonthRunMonths(Collection<YearMonth> months) {
return longestRun(months, month -> month.plusMonths(1)); return longestRun(months, month -> month.plusMonths(1));
} }
private static <T extends Comparable<? super T>> int longestRun(Collection<T> values, UnaryOperator<T> next) { private static <T extends Comparable<? super T>> List<T> longestRun(Collection<T> values, UnaryOperator<T> next) {
int best = 0; List<T> best = List.of();
int run = 0; List<T> run = new ArrayList<>();
T previous = null;
for (T value : new TreeSet<>(values)) { for (T value : new TreeSet<>(values)) {
run = previous != null && next.apply(previous).equals(value) ? run + 1 : 1; if (!run.isEmpty() && !next.apply(run.getLast()).equals(value)) {
best = Math.max(best, run); run = new ArrayList<>();
previous = value; }
run.add(value);
if (run.size() >= best.size()) {
best = List.copyOf(run);
}
} }
return best; return best;
} }
@@ -3,6 +3,7 @@ package core_service.modules.rewards.repo;
import core_service.modules.rewards.domain.HistoryEntry; import core_service.modules.rewards.domain.HistoryEntry;
import core_service.modules.rewards.domain.LookSlot; import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.RewardEntry; 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.RewardKind;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowCallbackHandler; import org.springframework.jdbc.core.RowCallbackHandler;
@@ -77,6 +78,51 @@ public class RewardLedgerRepository {
rs.getString("name"), rs.getInt("points"), rs.getTimestamp("event_at").toInstant())); 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<RewardGame> games(long userId, Collection<RewardKind> 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<RewardKind> 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<RewardKind> 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. * The codes of the items the user bought or unlocked.
*/ */
@@ -5,6 +5,7 @@ import core_service.modules.rewards.domain.HistoryEntry;
import core_service.modules.rewards.domain.LookSlot; import core_service.modules.rewards.domain.LookSlot;
import core_service.modules.rewards.domain.Looks; import core_service.modules.rewards.domain.Looks;
import core_service.modules.rewards.domain.RewardEntry; 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.RewardKind;
import core_service.modules.rewards.domain.RewardRules; import core_service.modules.rewards.domain.RewardRules;
import core_service.modules.rewards.domain.ShopItem; 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.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
import java.time.DateTimeException;
import java.time.DayOfWeek; import java.time.DayOfWeek;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
@@ -32,6 +34,7 @@ import java.time.temporal.TemporalAdjusters;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -46,6 +49,9 @@ import java.util.stream.Collectors;
public class RewardService { public class RewardService {
private static final int HISTORY_SIZE = 50; 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 RewardLedgerRepository ledger;
private final RewardFactsRepository facts; private final RewardFactsRepository facts;
@@ -113,6 +119,73 @@ public class RewardService {
return EquipResult.DONE; return EquipResult.DONE;
} }
/**
* @param untracked finished games from before the ledger, which have no row; GAMES_FINISHED only
*/
public record RewardGamesPage(List<RewardGame> 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<RewardGamesPage> 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<LocalDate> 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<YearMonth> 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<RewardGamesPage> 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<RewardKind> 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. * The looks a visitor of the public profile sees; the role comes from the profile ticket.
*/ */
@@ -1,11 +1,17 @@
package core_service.modules.stats.api; package core_service.modules.stats.api;
import core_service.modules.stats.api.dto.PublicStatsDto; 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 core_service.modules.stats.service.PublicStatsService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Optional;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@@ -19,4 +25,47 @@ public class PublicStatsController {
public ResponseEntity<PublicStatsDto> getPublicStats() { public ResponseEntity<PublicStatsDto> getPublicStats() {
return ResponseEntity.ok(publicStatsService.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))
.<ResponseEntity<?>>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> 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<Listed> named(List<PublicStatsDto.NameCount> 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));
}
} }
@@ -0,0 +1,17 @@
package core_service.modules.stats.api.dto;
import java.util.List;
public record CommunityGamesDto(
List<Game> 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) {}
}
@@ -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<Object> 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<Object> 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();
}
}
}
@@ -1,9 +1,11 @@
package core_service.modules.stats.repo; package core_service.modules.stats.repo;
import core_service.modules.stats.domain.CommunityKey;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -13,6 +15,53 @@ public class StatsRepository {
private static final String NOT_DELETED_OR_BLOCKED = 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)"; "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<CommunityKey, String> 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; private final JdbcTemplate jdbc;
@Autowired @Autowired
@@ -21,20 +70,17 @@ public class StatsRepository {
} }
public long countDistinctGames() { public long countDistinctGames() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject("SELECT COUNT(DISTINCT appid) FROM (" + OWNED_SET + ") s", Long.class);
"SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countLibraryEntries() { public long countLibraryEntries() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject("SELECT COUNT(*) FROM (" + ENTRIES_SET + ") s", Long.class);
"SELECT COUNT(*) FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED, Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public long countGamesPlayed() { public long countGamesPlayed() {
Long v = jdbc.queryForObject( Long v = jdbc.queryForObject("SELECT COUNT(*) FROM (" + PLAYED_SET + ") s", Long.class);
"SELECT COUNT(*) FROM counted_user_game WHERE " + NOT_DELETED_OR_BLOCKED + " AND playtime_minutes > 0", Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
@@ -72,26 +118,24 @@ public class StatsRepository {
public List<Map<String, Object>> topOwnedGames(int limit) { public List<Map<String, Object>> topOwnedGames(int limit) {
return jdbc.queryForList(""" return jdbc.queryForList("""
SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value SELECT s.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value
FROM listed_user_game ug FROM (%s) s
LEFT JOIN steam_game sg ON sg.appid = ug.appid LEFT JOIN steam_game sg ON sg.appid = s.appid
WHERE %s GROUP BY s.appid, sg.name, sg.img_icon_url, sg.image_url
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC ORDER BY value DESC
LIMIT ? LIMIT ?
""".formatted(NOT_DELETED_OR_BLOCKED), limit); """.formatted(OWNED_SET), limit);
} }
public List<Map<String, Object>> topPlayedGames(int limit) { public List<Map<String, Object>> topPlayedGames(int limit) {
return jdbc.queryForList(""" return jdbc.queryForList("""
SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(ug.playtime_minutes) AS value SELECT s.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(s.playtime_minutes) AS value
FROM listed_user_game ug FROM (%s) s
LEFT JOIN steam_game sg ON sg.appid = ug.appid LEFT JOIN steam_game sg ON sg.appid = s.appid
WHERE %s GROUP BY s.appid, sg.name, sg.img_icon_url, sg.image_url
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC ORDER BY value DESC
LIMIT ? LIMIT ?
""".formatted(NOT_DELETED_OR_BLOCKED), limit); """.formatted(OWNED_SET), limit);
} }
/** /**
@@ -102,11 +146,9 @@ public class StatsRepository {
return jdbc.queryForList(""" return jdbc.queryForList("""
SELECT MODE() WITHIN GROUP (ORDER BY g.name) AS name, COUNT(*) AS games, SELECT MODE() WITHIN GROUP (ORDER BY g.name) AS name, COUNT(*) AS games,
COALESCE(SUM(g.playtime_minutes), 0) AS minutes 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 (%s) g
FROM listed_user_game WHERE %s) g
WHERE g.name <> ''
GROUP BY LOWER(g.name) ORDER BY games DESC, minutes DESC, name 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<Map<String, Object>> topGenres(int limit) { public List<Map<String, Object>> topGenres(int limit) {
return jdbc.queryForList(""" return jdbc.queryForList("""
SELECT tag.name, COUNT(*) AS games, COALESCE(SUM(ug.playtime_minutes), 0) AS minutes SELECT s.name, COUNT(*) AS games, COALESCE(SUM(s.playtime_minutes), 0) AS minutes
FROM listed_user_game ug JOIN game_mood m ON m.appid = ug.appid FROM (%s) s
CROSS JOIN UNNEST(m.genres) AS tag(name) GROUP BY s.name ORDER BY minutes DESC, games DESC, s.name
WHERE %s
GROUP BY tag.name ORDER BY minutes DESC, games DESC, tag.name
LIMIT ? LIMIT ?
""".formatted(NOT_DELETED_OR_BLOCKED), limit); """.formatted(GENRE_SET), limit);
} }
/** /**
@@ -129,17 +169,44 @@ public class StatsRepository {
*/ */
public List<Map<String, Object>> finishedPerYear(String zone) { public List<Map<String, Object>> finishedPerYear(String zone) {
return jdbc.queryForList(""" return jdbc.queryForList("""
SELECT CAST(EXTRACT(YEAR FROM COALESCE(ug.finished_at_estimate, f.finished_at) AT TIME ZONE ?) SELECT s.year, COUNT(*) AS games FROM (%s) s
AS INTEGER) AS year, COUNT(*) AS games GROUP BY s.year ORDER BY s.year
FROM counted_user_game ug """.formatted(FINISHED_SET), zone);
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') * The games behind one number, once per game with the libraries that count it and their playtime together. No
GROUP BY sc.user_id, sc.appid) f ON f.uid = ug.user_id AND f.appid = ug.appid * column says whose library it is. Columns: appid, name, image_url, libraries, minutes.
WHERE %s AND ug.status IN ('FINISHED', 'COMPLETED') *
AND COALESCE(ug.finished_at_estimate, f.finished_at) IS NOT NULL * @param arg the checked argument of the key
GROUP BY year ORDER BY year * @param zone the time zone of FINISHED_YEAR
""".formatted(NOT_DELETED_OR_BLOCKED), zone); */
public List<Map<String, Object>> communityGames(CommunityKey key, Object arg, String zone, int offset, int limit) {
List<Object> 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<Object> 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();
} }
} }
@@ -1,10 +1,13 @@
package core_service.modules.stats.service; 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.api.dto.PublicStatsDto;
import core_service.modules.stats.domain.CommunityKey;
import core_service.modules.stats.repo.StatsRepository; import core_service.modules.stats.repo.StatsRepository;
import core_service.shared.security.Public; import core_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -19,14 +22,19 @@ public class PublicStatsService {
private static final int TOP_LIMIT = 5; private static final int TOP_LIMIT = 5;
private static final int TOP_GENRES = 8; 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 StatsRepository statsRepository;
private final CacheManager cacheManager;
@Value("${app.library.zone:Europe/Berlin}") @Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone; private ZoneId zone;
@Cacheable(value = "publicStats", key = "'global'") @Cacheable(value = "publicStats", key = "'global'")
public PublicStatsDto getPublicStats() { 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 distinctGames = statsRepository.countDistinctGames();
long libraryEntries = statsRepository.countLibraryEntries(); long libraryEntries = statsRepository.countLibraryEntries();
long gamesPlayed = statsRepository.countGamesPlayed(); 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<CommunityGamesDto.Game> 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<PublicStatsDto.TopGame> mapTop(List<Map<String, Object>> rows) { private List<PublicStatsDto.TopGame> mapTop(List<Map<String, Object>> rows) {
return rows.stream() return rows.stream()
.map(r -> new PublicStatsDto.TopGame( .map(r -> new PublicStatsDto.TopGame(
@@ -69,7 +69,9 @@ class PublicEndpointsTest {
} }
@ParameterizedTest @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 { void endpointIsNotPublic(String path) throws Exception {
int status = mockMvc.perform(get(path)).andReturn().getResponse().getStatus(); int status = mockMvc.perform(get(path)).andReturn().getResponse().getStatus();
assertTrue(Set.of(401, 403).contains(status), path + " answered " + status); 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()); 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 @Test
void versionIsPublic() throws Exception { void versionIsPublic() throws Exception {
mockMvc.perform(get("/api/version")) mockMvc.perform(get("/api/version"))
@@ -17,6 +17,8 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc; 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.time.Instant;
import java.util.List; import java.util.List;
@@ -193,4 +195,106 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.contentType("application/json").content("{\"status\":\"DROPPED\"}")) .contentType("application/json").content("{\"status\":\"DROPPED\"}"))
.andExpect(status().isNotFound()); .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"));
}
} }
@@ -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<JsonNode, Long> count, String minutesField,
Function<JsonNode, Long> 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<Case> 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<JsonNode> allRows(String key, String arg) throws Exception {
List<JsonNode> 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<JsonNode> 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<Long> top = new ArrayList<>();
stats().at("/topGames/mostPlayed").forEach(g -> top.add(g.get("appid").asLong()));
List<Long> 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<Long> 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<JsonNode> 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();
}
}
@@ -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<StatGroup> 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<ProfileSection> all = Set.of(ProfileSection.values());
Set<StatGroup> 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();
}
}
@@ -4,6 +4,7 @@ import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; 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.UserGame;
import core_service.modules.library.domain.UserStats; import core_service.modules.library.domain.UserStats;
import core_service.shared.test.AbstractPersistenceTest; import core_service.shared.test.AbstractPersistenceTest;
@@ -359,4 +360,14 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
private void setStatus(long appid, String status) { private void setStatus(long appid, String status) {
jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER_ID, appid); 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);
}
} }
@@ -331,4 +331,36 @@ class RewardControllerTest extends AbstractIntegrationTest {
jdbc.update("delete from blocked_user where user_id = ?", USER); 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());
}
} }
@@ -3,12 +3,14 @@ package core_service.modules.rewards.domain;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.time.DateTimeException;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.YearMonth; import java.time.YearMonth;
import java.util.Optional; import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RewardRulesTest extends AbstractUnitTest { 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(2026, 10, 8))).isEqualTo("2026-W41");
assertThat(RewardRules.weekRef(LocalDate.of(2027, 1, 1))).isEqualTo("2026-W53"); 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));
}
} }
@@ -66,4 +66,21 @@ class UnlocksTest extends AbstractUnitTest {
assertThat(unlock.stream().map(item -> item.track() + ":" + item.target()).collect(Collectors.toSet())) assertThat(unlock.stream().map(item -> item.track() + ":" + item.target()).collect(Collectors.toSet()))
.hasSameSizeAs(unlock); .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));
}
} }
@@ -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);
}
}
@@ -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<String> 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));
}
}
@@ -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();
}
}
@@ -7,6 +7,7 @@ import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.stats.api.dto.PublicStatsDto; 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.modules.stats.service.PublicStatsService;
import core_service.shared.test.AbstractPersistenceTest; import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -23,6 +24,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@Import(StatsRepository.class) @Import(StatsRepository.class)
class StatsRepositoryTest extends AbstractPersistenceTest { 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()); assertEquals(List.of(1L, 1L), rows.stream().map(row -> ((Number) row.get("games")).longValue()).toList());
} }
private long sum(List<Map<String, Object>> rows, String column) {
return rows.stream().mapToLong(row -> ((Number) row.get(column)).longValue()).sum();
}
private List<Map<String, Object>> 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<Map<String, Object>> entries = community(CommunityKey.ENTRIES, "");
assertEquals(statsRepository.countLibraryEntries(), sum(entries, "libraries"));
assertEquals(entries.size(), statsRepository.countCommunityGames(CommunityKey.ENTRIES, "", "Europe/Berlin"));
List<Map<String, Object>> played = community(CommunityKey.PLAYED, "");
assertEquals(statsRepository.countGamesPlayed(), sum(played, "libraries"));
List<Map<String, Object>> 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<Map<String, Object>> platform = community(CommunityKey.PLATFORM, " PS5 ");
assertEquals(1, sum(platform, "libraries"));
assertEquals(10, sum(platform, "minutes"));
List<Map<String, Object>> 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<Long> appids(List<Map<String, Object>> rows) { private static List<Long> appids(List<Map<String, Object>> rows) {
return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList(); 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")); estimated.setFinishedAtEstimate(Instant.parse("2025-06-01T00:00:00Z"));
userGameRepository.saveAndFlush(estimated); userGameRepository.saveAndFlush(estimated);
jdbc.update("UPDATE user_game SET status = 'FINISHED' WHERE user_id = 1 AND appid = 620"); 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")); ReflectionTestUtils.setField(service, "zone", ZoneId.of("Europe/Berlin"));
PublicStatsDto dto = service.getPublicStats(); PublicStatsDto dto = service.getPublicStats();