Public Access
feat(library): add drill-down lists for stat numbers
The number and list share the same SQL query, so they cannot drift. StatKey enum maps each statistic to its query, validation rules, and visibility. StatGamesService reads games per key with 40-game pages; repositories share the same set logic as the stat counts. Three endpoints open games: own library stats, public profile stats (with ticket checks), and public landing-page stats (cached 10 min). Rewards track and bonus rows support drill-down too.
This commit is contained in:
1 parent
74cc8297ce
commit
065048415c
39 files changed
+1938
-109
No files matched your search
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user