diff --git a/readme.md b/readme.md index b080faf..5ff2d72 100644 --- a/readme.md +++ b/readme.md @@ -200,7 +200,7 @@ The token comes from Auth's `tbp_access` cookie or a `Bearer` header, whichever (`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` is refused. The claims are pinned in `src/test/resources/contract/auth-core-contract.json`, the same file as in Auth; the tests build tokens only from it. `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the -frontend origin. +frontend origin; preflight answers carry `Access-Control-Max-Age: 3600` (pinned by `ClientHeaderFilterTest`). A token for another audience (for example a profile ticket) is rejected on normal endpoints; the profile ticket is accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header. Every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with @@ -236,6 +236,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT | | `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT | | `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT | +| `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT | | `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | | `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR | @@ -263,7 +264,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut | `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt, reviewImportId}`: the PSN import waiting for review, else null), unlink (`204`, deletes the stored token) | JWT | | `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT | | `POST` | `/api/library/sync` | JWT | -| `GET` | `/api/library/games/{appid}` (detail; slice 3: `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | JWT | +| `GET` | `/api/library/games/{appid}` (detail; `refresh=false` answers from stored data and sets `achievementsStale` when a due Steam refresh was skipped (default `refresh=true` refreshes, as before); slice 3: `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | JWT | | `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT | | `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT | | `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT | @@ -391,7 +392,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` ### Sync flow -1. `GET /api/library/games`, `/games-count` and `/picker` call `LibrarySyncService.syncIfStale`. It starts a sync +1. `GET /api/library/games`, `/dashboard-games`, `/games-count` and `/picker` call `LibrarySyncService.syncIfStale`. It starts a sync when the last one is older than 24 hours, older than 15 minutes after a failure, or was for another Steam ID. `POST /api/library/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`). 2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. A diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index f3e7237..9850471 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -14,6 +14,7 @@ import core_service.modules.library.api.dto.CatalogHitDto; import core_service.modules.library.api.dto.CorrectionMarkDto; import core_service.modules.library.api.dto.GameDetailDto; import core_service.modules.library.api.dto.CorrectionDto; +import core_service.modules.library.api.dto.DashboardGamesDto; import core_service.modules.library.api.dto.FinishedHintDismissal; import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.HiddenUpdateDto; @@ -99,6 +100,7 @@ public class LibraryController { private static final int MAX_ACTIVITY_DAYS = 90; private static final int MAX_PICKER_EXCLUDES = 100; private static final int MAX_BACKLOG_GAMES = 20; + private static final int MAX_DASHBOARD_GAMES = 20; private static final Set SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private final LibrarySyncService syncService; @@ -148,6 +150,16 @@ public class LibraryController { return ResponseEntity.ok(PageDto.of(dtoPage)); } + @GetMapping("/dashboard-games") + public ResponseEntity getDashboardGames(@AuthenticationPrincipal UserPrincipal principal, + @RequestParam(defaultValue = "10") int top, + @RequestParam(defaultValue = "8") int playing) { + Long userId = principal.getIdAsLong(); + syncService.syncIfStale(userId, principal.steamIdText().orElse(null)); + return ResponseEntity.ok(queryService.listDashboardGames(userId, Math.clamp(top, 1, MAX_DASHBOARD_GAMES), + Math.clamp(playing, 1, MAX_DASHBOARD_GAMES))); + } + private Sort parseSort(String sort) { Sort fallback = Sort.by(Sort.Direction.DESC, "playtimeMinutes"); if (sort == null || sort.isBlank()) return fallback; @@ -179,9 +191,10 @@ public class LibraryController { @GetMapping("/games/{appid}") public ResponseEntity getGameDetail(@AuthenticationPrincipal UserPrincipal principal, - @PathVariable("appid") Long appid) { + @PathVariable("appid") Long appid, + @RequestParam(defaultValue = "true") boolean refresh) { Long userId = principal.getIdAsLong(); - return gameDetailService.getGameDetail(userId, principal.steamIdText().orElse(null), appid) + return gameDetailService.getGameDetail(userId, principal.steamIdText().orElse(null), appid, refresh) .>map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .body(GAME_NOT_FOUND)); diff --git a/src/main/java/core_service/modules/library/api/dto/DashboardGamesDto.java b/src/main/java/core_service/modules/library/api/dto/DashboardGamesDto.java new file mode 100644 index 0000000..90553c8 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/DashboardGamesDto.java @@ -0,0 +1,8 @@ +package core_service.modules.library.api.dto; + +import java.util.List; + +/** + * The MOST_PLAYED and PLAYING tiles: listed entries by set playtime, most first; a game can be in both. + */ +public record DashboardGamesDto(List top, List playing) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index 3686d80..3b5ec6a 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -61,7 +61,9 @@ public record GameDetailDto( // one line per platform of the entry, Steam first; empty for a hidden game List platforms, // the sum of the platform lines; the counts above and playtimeMinutes stay this edition's own - int totalPlaytimeMinutes + int totalPlaytimeMinutes, + // true when this answer skipped a due Steam refresh (refresh=false); the same call without it refreshes + boolean achievementsStale ) { /** diff --git a/src/main/java/core_service/modules/library/repo/GameLinkRepository.java b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java index eef5d9e..c18f88d 100644 --- a/src/main/java/core_service/modules/library/repo/GameLinkRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameLinkRepository.java @@ -309,7 +309,8 @@ public class GameLinkRepository { /** * @return per given entry key, the platforms of its editions (see V43): Steam, PlayStation, then manual, by - * platform name; a PlayStation platform without a name is left out when the entry has a named PlayStation one, or its own member row has a manual platform + * platform name; a PlayStation platform without a name is left out when the entry has a named PlayStation one, or its own member row has a manual platform. + * The view is read once: each reference costs ~16 ms of planning (nested views), more than the query runs. */ public Map> entryPlatforms(long userId, Collection appids) { Map> platforms = new HashMap<>(); @@ -317,13 +318,15 @@ public class GameLinkRepository { return platforms; } jdbc.query(""" + with p as materialized ( + select appid, member_appid, source, platform from user_game_entry_platform + where user_id = :user and appid in (:appids)) select appid, source, platform - from user_game_entry_platform p - where user_id = :user and appid in (:appids) - and not (source = 'PSN' and platform = '' and exists ( - select 1 from user_game_entry_platform n - where n.user_id = p.user_id and n.appid = p.appid and n.platform <> '' - and (n.source = 'PSN' or (n.source = 'MANUAL' and n.member_appid = p.member_appid)))) + from p + where not (source = 'PSN' and platform = '' and exists ( + select 1 from p n + where n.appid = p.appid and n.platform <> '' + and (n.source = 'PSN' or (n.source = 'MANUAL' and n.member_appid = p.member_appid)))) group by appid, source, platform order by appid, case source when 'STEAM' then 0 when 'PSN' then 1 else 2 end, platform """, new MapSqlParameterSource("user", userId).addValue("appids", appids), diff --git a/src/main/java/core_service/modules/library/service/AchievementSyncService.java b/src/main/java/core_service/modules/library/service/AchievementSyncService.java index 97bbebd..d029f5d 100644 --- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java +++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java @@ -134,12 +134,26 @@ public class AchievementSyncService { } public void refreshSchemaIfStale(Long appid) { - Optional existing = schemaMetaRepository.findById(appid); - if (existing.isEmpty() || isStale(existing.get())) { + if (schemaStale(appid)) { refreshSchema(appid, false, false); } } + /** + * @return true when the schema meta of the game is missing or older than the schema TTL + */ + public boolean schemaStale(Long appid) { + return schemaMetaRepository.findById(appid).map(this::isStale).orElse(true); + } + + /** + * @return true when the user's achievement meta of the game is missing or older than ttl + */ + public boolean userStale(Long userId, Long appid, Duration ttl) { + return userAchievementMetaRepository.findByUserIdAndAppid(userId, appid) + .map(meta -> meta.getRefreshedAt().isBefore(Instant.now().minus(ttl))).orElse(true); + } + /** * Sets FINISHED when the user unlocked the ending achievement (see {@link EndingAchievement}) of a Steam game whose * status they never chose. Uses stored rows only. @@ -168,8 +182,7 @@ public class AchievementSyncService { } public void refreshUserIfStale(Long userId, String steamId, Long appid, int playtimeMinutes, Duration ttl) { - Optional existing = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid); - if (existing.isEmpty() || existing.get().getRefreshedAt().isBefore(Instant.now().minus(ttl))) { + if (userStale(userId, appid, ttl)) { refreshUser(userId, steamId, appid, playtimeMinutes); } } diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 895c5b8..46ce14f 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -98,6 +98,15 @@ public class GameDetailService { * @param steamId null when the user has none; then the stored achievements are shown without a refresh */ public Optional getGameDetail(Long userId, String steamId, Long askedAppid) { + return getGameDetail(userId, steamId, askedAppid, true); + } + + /** + * @param refreshAchievements false answers from stored data and only says in {@code achievementsStale} that the + * Steam refresh is due + */ + public Optional getGameDetail(Long userId, String steamId, Long askedAppid, + boolean refreshAchievements) { Long appid = askedAppid; Optional userGameOpt = userGameRepository.findById(new UserGameId(userId, appid)); if (userGameOpt.isEmpty()) { @@ -112,17 +121,18 @@ public class GameDetailService { return Optional.empty(); } SteamGame meta = steamGameRepository.findById(appid).orElse(null); - Refreshed refreshed = refresh(userGameOpt.get(), steamId); - return Optional.of(assemble(refreshed.userGame(), meta, refreshed.hasSchema())); + Refreshed refreshed = refresh(userGameOpt.get(), steamId, refreshAchievements); + return Optional.of(assemble(refreshed.userGame(), meta, refreshed.hasSchema(), refreshed.achievementsStale())); } /** * @param userGame the game after the refresh, re-read when the finish rule changed its status + * @param achievementsStale a due Steam refresh was skipped */ - private record Refreshed(UserGame userGame, boolean hasSchema) {} + private record Refreshed(UserGame userGame, boolean hasSchema, boolean achievementsStale) {} // May call Steam, IGDB and speedrun.com and write. - private Refreshed refresh(UserGame userGame, String steamId) { + private Refreshed refresh(UserGame userGame, String steamId, boolean refreshAchievements) { Long userId = userGame.getUserId(); Long appid = userGame.getAppid(); // A hidden game gets no calls to Steam, IGDB or speedrun.com; it shows what is stored. @@ -138,42 +148,64 @@ public class GameDetailService { timeToBeatService.refreshInBackground(List.of(appid)); moodService.refreshInBackground(List.of(appid)); releaseDateService.refreshInBackground(List.of(appid)); - return new Refreshed(userGame, false); + return new Refreshed(userGame, false, false); } - if (!hidden) { - try { - achievementSyncService.refreshSchemaIfStale(appid); - } catch (Exception e) { - log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage()); - } - } + boolean stale = !hidden && refreshSchemaIfAsked(appid, refreshAchievements); boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false); if (!hasSchema) { - return new Refreshed(userGame, false); + return new Refreshed(userGame, false, stale); } if (steamId != null && !hidden) { - try { - achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(), - Duration.ofHours(achievementTtlHours)); - } catch (Exception e) { - log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage()); - } + stale |= refreshUserIfAsked(userGame, steamId, refreshAchievements); } if (!hidden && !userGame.isStatusSetByUser() && achievementSyncService.finishIfEndingUnlocked(userId, appid)) { userGame = userGameRepository.findById(new UserGameId(userId, appid)).orElse(userGame); } - return new Refreshed(userGame, true); + return new Refreshed(userGame, true, stale); + } + + /** + * @return true when the refresh was not asked and is due + */ + private boolean refreshSchemaIfAsked(Long appid, boolean refreshAchievements) { + if (!refreshAchievements) { + return achievementSyncService.schemaStale(appid); + } + try { + achievementSyncService.refreshSchemaIfStale(appid); + } catch (Exception e) { + log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage()); + } + return false; + } + + /** + * @return true when the refresh was not asked and is due + */ + private boolean refreshUserIfAsked(UserGame userGame, String steamId, boolean refreshAchievements) { + Long userId = userGame.getUserId(); + Long appid = userGame.getAppid(); + Duration ttl = Duration.ofHours(achievementTtlHours); + if (!refreshAchievements) { + return achievementSyncService.userStale(userId, appid, ttl); + } + try { + achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(), ttl); + } catch (Exception e) { + log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage()); + } + return false; } // Reads only. - private GameDetailDto assemble(UserGame userGame, SteamGame meta, boolean hasSchema) { + private GameDetailDto assemble(UserGame userGame, SteamGame meta, boolean hasSchema, boolean achievementsStale) { List psnSets = psnSets(userGame); List psnTrophies = psnSets.isEmpty() ? List.of() : psnTrophies(userGame); if (!hasSchema) { - return buildDto(userGame, meta, true, 0, 0, List.of(), List.of(), psnSets, psnTrophies); + return buildDto(userGame, meta, true, 0, 0, List.of(), List.of(), psnSets, psnTrophies, achievementsStale); } Long userId = userGame.getUserId(); Long appid = userGame.getAppid(); @@ -206,7 +238,8 @@ public class GameDetailService { .toList(); return buildDto(userGame, meta, statsVisible, schemaList.size(), - AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos, schemaList, psnSets, psnTrophies); + AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos, schemaList, psnSets, psnTrophies, + achievementsStale); } private List psnSets(UserGame userGame) { @@ -233,7 +266,8 @@ public class GameDetailService { private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible, int total, int unlocked, List achievements, List schema, - List psnSets, List psnTrophies) { + List psnSets, List psnTrophies, + boolean achievementsStale) { Optional timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid()); Set unlockedNames = achievements.stream().filter(AchievementDto::unlocked).map(AchievementDto::apiName) .collect(Collectors.toSet()); @@ -299,7 +333,8 @@ public class GameDetailService { coming.map(ComingGame::getReleaseDate).orElse(null), sets, plays.stream().map(line -> toDto(line, userGame, total, unlocked, statsVisible)).toList(), - PlatformPlay.total(plays) + PlatformPlay.total(plays), + achievementsStale ); } diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 8a1d002..86d7af3 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -3,6 +3,7 @@ package core_service.modules.library.service; import core_service.modules.library.api.dto.AchievementSummaryDto; import core_service.modules.library.api.dto.ActivityDto; import core_service.modules.library.api.dto.BacklogOrderDto; +import core_service.modules.library.api.dto.DashboardGamesDto; import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.PickReasonDto; @@ -134,6 +135,20 @@ public class LibraryQueryService { return new PageImpl<>(games, page.getPageable(), page.getTotalElements()); } + /** + * The same rows and order as {@link #listOwnedGames} without a filter, sorted {@code playtimeMinutes,desc} (top) + * and with {@code status=PLAYING} (playing), but without the count query; one enrichment for both lists. + */ + public DashboardGamesDto listDashboardGames(Long userId, int topLimit, int playingLimit) { + Sort byPlaytime = UserGameRepository.querySort(Sort.by(Sort.Direction.DESC, "playtimeMinutes")); + List top = userGameRepository.findListedByStatusIn(userId, EnumSet.allOf(GameStatus.class), + PageRequest.of(0, topLimit, byPlaytime)); + List playing = userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.PLAYING), + PageRequest.of(0, playingLimit, byPlaytime)); + List dtos = toDtos(Stream.concat(top.stream(), playing.stream()).toList()); + return new DashboardGamesDto(dtos.subList(0, top.size()), dtos.subList(top.size(), dtos.size())); + } + /** * One row per {@link PickSource} (next up, next in series, because you played, same studio, friends played, * longest waiting, mood), in the order of its constants, each with up to three backlog games. Budget and exclude diff --git a/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java b/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java index a261c1f..67e3619 100644 --- a/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java +++ b/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java @@ -46,7 +46,8 @@ class ClientHeaderFilterTest extends AbstractMockedAdaptersTest { .header("Access-Control-Request-Headers", "x-tbp-client")) .andExpect(status().isOk()) .andExpect(header().string("Access-Control-Allow-Origin", FRONTEND)) - .andExpect(header().string("Access-Control-Allow-Credentials", "true")); + .andExpect(header().string("Access-Control-Allow-Credentials", "true")) + .andExpect(header().string("Access-Control-Max-Age", "3600")); mockMvc.perform(options("/api/library/games/220/backlog") .header("Origin", FRONTEND) diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerDashboardGamesTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerDashboardGamesTest.java new file mode 100644 index 0000000..112112f --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerDashboardGamesTest.java @@ -0,0 +1,130 @@ +package core_service.modules.library.api; + +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 core_service.shared.test.ContractTokens; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.time.Instant; +import java.util.List; +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; + +@AutoConfigureMockMvc +@ActiveProfiles(value = "prod", inheritProfiles = false) +class LibraryControllerDashboardGamesTest extends AbstractIntegrationTest { + + private static final long USER = 931_101L; + private static final long OTHER = 931_102L; + private static final JsonMapper MAPPER = JsonMapper.builder().build(); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + userGameRepository.deleteAll(); + // 25 games, playtime falls with the appid; 1, 3, 5 and 20 play (20 is outside the top 10) + for (long appid = 1; appid <= 25; appid++) { + steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now())); + userGameRepository.save(new UserGame(USER, appid, (int) (1000 - appid * 10), Instant.now(), Instant.now())); + } + jdbc.update("update user_game set status = 'BACKLOG' where user_id = ?", USER); + jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid in (1, 3, 5, 20)", USER); + steamGameRepository.save(new SteamGame(900L, "Other", null, Instant.now())); + userGameRepository.save(new UserGame(OTHER, 900L, 99_999, Instant.now(), Instant.now())); + jdbc.update("update user_game set status = 'PLAYING' where user_id = ?", OTHER); + } + + private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) { + return request.header("Authorization", "Bearer " + ContractTokens.loginToken(userId)); + } + + private JsonNode json(MockHttpServletRequestBuilder request) throws Exception { + return MAPPER.readTree(mockMvc.perform(as(request, USER)).andExpect(status().isOk()).andReturn() + .getResponse().getContentAsString()); + } + + private static List appids(JsonNode games) { + return Stream.iterate(0, i -> i + 1).limit(games.size()).map(i -> games.get(i).get("appid").asLong()).toList(); + } + + @Test + void getDashboardGames_ReturnsBothTilesInTheOrderOfGames() throws Exception { + JsonNode dashboard = json(get("/api/library/dashboard-games").param("top", "3").param("playing", "3")); + JsonNode topGames = json(get("/api/library/games").param("size", "20").param("sort", "playtimeMinutes,desc")); + JsonNode playingGames = json(get("/api/library/games").param("size", "20").param("status", "PLAYING")); + + assertThat(appids(dashboard.get("top"))).containsExactly(1L, 2L, 3L) + .isEqualTo(appids(topGames.get("content")).subList(0, 3)); + assertThat(appids(dashboard.get("playing"))).containsExactly(1L, 3L, 5L) + .isEqualTo(appids(playingGames.get("content")).subList(0, 3)); + assertThat(dashboard.get("top").get(0)).isEqualTo(topGames.get("content").get(0)); + assertThat(dashboard.get("playing").get(2)).isEqualTo(playingGames.get("content").get(2)); + } + + @Test + void getDashboardGames_ClampsTheLimits() throws Exception { + JsonNode low = json(get("/api/library/dashboard-games").param("top", "0").param("playing", "-4")); + JsonNode high = json(get("/api/library/dashboard-games").param("top", "500").param("playing", "500")); + + assertThat(low.get("top")).hasSize(1); + assertThat(low.get("playing")).hasSize(1); + assertThat(high.get("top")).hasSize(20); + assertThat(high.get("playing")).hasSize(4); + } + + @Test + void getDashboardGames_DefaultsAreTenAndEight() throws Exception { + JsonNode dashboard = json(get("/api/library/dashboard-games")); + + assertThat(dashboard.get("top")).hasSize(10); + assertThat(dashboard.get("playing")).hasSize(4); + } + + @Test + void getDashboardGames_OnlyTheCallersGames() throws Exception { + JsonNode dashboard = json(get("/api/library/dashboard-games")); + + assertThat(appids(dashboard.get("top"))).doesNotContain(900L); + assertThat(appids(dashboard.get("playing"))).doesNotContain(900L); + } + + @Test + void getDashboardGames_WithoutLogin_401() throws Exception { + mockMvc.perform(get("/api/library/dashboard-games")).andExpect(status().isUnauthorized()); + } + + @Test + void getGameDetail_RefreshFalse_HasTheStaleFlag() throws Exception { + mockMvc.perform(as(get("/api/library/games/1").param("refresh", "false"), USER)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.appid").value(1)) + .andExpect(jsonPath("$.achievementsStale").value(true)); + } +} diff --git a/src/test/java/core_service/modules/library/repo/DashboardGamesQueryGuardTest.java b/src/test/java/core_service/modules/library/repo/DashboardGamesQueryGuardTest.java new file mode 100644 index 0000000..960307d --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/DashboardGamesQueryGuardTest.java @@ -0,0 +1,104 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.GameSource; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.shared.test.AbstractPersistenceTest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The Dashboard tiles read {@code findListedByStatusIn}; they must show the rows and the order of the Library list + * ({@code findOwnedGames}, no filter, playtime descending) for the same user. + */ +class DashboardGamesQueryGuardTest extends AbstractPersistenceTest { + + private static final long USER_ID = 1L; + private static final long OTHER_USER_ID = 2L; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private EntityManager entityManager; + + @BeforeEach + void seed() { + List catalog = new ArrayList<>(); + for (long appid = 100; appid < 112; appid++) catalog.add(new SteamGame(appid, "Game " + appid, null, Instant.now())); + steamGameRepository.saveAll(catalog); + + Instant now = Instant.now(); + int[] playtime = {300, 120, 500, 500, 60, 800, 90, 400, 700, 10, 250, 250}; + for (long user : new long[]{USER_ID, OTHER_USER_ID}) { + for (int i = 0; i < playtime.length; i++) { + userGameRepository.save(new UserGame(user, 100L + i, playtime[i], now, now)); + } + } + // 100 and 101 are one set; 101 plays 120 but the set total (420) beats 102's single row for the user + jdbc.update("delete from game_link"); + jdbc.update("insert into game_link (appid, set_id) values (100, 1), (101, 1), (108, 2), (109, 2)"); + for (long user : new long[]{USER_ID, OTHER_USER_ID}) { + update(user, 100, "status = 'PLAYING'"); + update(user, 102, "status = 'PLAYING'"); + update(user, 103, "status = 'PLAYING'"); + update(user, 105, "source = 'MANUAL', status = 'PLAYING'"); + update(user, 106, "source = 'PSN', status = 'PLAYING'"); + update(user, 107, "hidden_at = now(), status = 'PLAYING'"); + update(user, 104, "removed_at = now(), status = 'PLAYING'"); + update(user, 110, "status = 'FINISHED'"); + update(user, 111, "status = 'PLAYING'"); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) " + + "values (?, 'c1', 'STEAM_OWNED', 102, 'PS5', 700)", user); + } + // The users differ, so a query without the user filter returns other rows + update(OTHER_USER_ID, 100, "status = 'BACKLOG'"); + update(OTHER_USER_ID, 108, "removed_at = now()"); + update(OTHER_USER_ID, 109, "status = 'PLAYING'"); + entityManager.flush(); + entityManager.clear(); + } + + private void update(long user, long appid, String set) { + jdbc.update("update user_game set " + set + " where user_id = ? and appid = ?", user, appid); + } + + @Test + void findListedByStatusIn_ReturnsTheRowsAndOrderOfTheLibraryList() { + Sort sort = UserGameRepository.querySort(Sort.by(Sort.Direction.DESC, "playtimeMinutes")); + for (long user : new long[]{USER_ID, OTHER_USER_ID}) { + for (int size : new int[]{8, 10, 20}) { + for (GameStatus status : new GameStatus[]{null, GameStatus.PLAYING}) { + PageRequest page = PageRequest.of(0, size, sort); + List statuses = status == null ? List.copyOf(EnumSet.allOf(GameStatus.class)) : List.of(status); + + List tile = userGameRepository.findListedByStatusIn(user, statuses, page).stream() + .map(UserGame::getAppid).toList(); + List library = userGameRepository.findOwnedGames(user, false, status, null, null, + EnumSet.allOf(GameSource.class), page).stream().map(UserGame::getAppid).toList(); + + assertThat(tile).as("user=" + user + " size=" + size + " status=" + status).isEqualTo(library); + } + } + } + } +} diff --git a/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java index 85c94b5..1283a40 100644 --- a/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/GameLinkRepositoryTest.java @@ -376,6 +376,22 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest { .extracting(p -> p.source() + ":" + p.platform()).containsExactly("MANUAL:PS5"); } + @Test + void entryPlatforms_AnotherUsersPlayStationRowsDoNotChangeMine() { + repository.link(100, -7); + ownOn(100, "STEAM", null); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', 100)", USER); + jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, 100, 'BACKLOG', 'STEAM', null)", OTHER_USER); + jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, -7, 'BACKLOG', 'MANUAL', 'PS5')", OTHER_USER); + jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c2', 'LINKED', -7)", OTHER_USER); + jdbc.update("update user_game set playtime_minutes = 500 where appid = 100"); + + assertThat(repository.entryPlatforms(USER, List.of(100L)).get(100L)) + .extracting(p -> p.source() + ":" + p.platform()).containsExactly("STEAM:null", "PSN:null"); + assertThat(repository.entryPlatforms(OTHER_USER, List.of(100L)).get(100L)) + .extracting(p -> p.source() + ":" + p.platform()).containsExactly("STEAM:null", "MANUAL:PS5"); + } + @Test void editionCounts_GivesTwoForBothKeysAndNothingForASingleOwnedKey() { repository.link(100, 200); diff --git a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java index 7024ed0..a3d6f85 100644 --- a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java @@ -116,6 +116,33 @@ class AchievementSyncServiceTest extends AbstractUnitTest { return saved.getValue(); } + @Test + void schemaStale_MissingOldAndFresh() { + when(schemaMetaRepository.findById(1L)).thenReturn(Optional.empty()); + when(schemaMetaRepository.findById(2L)).thenReturn(Optional.of( + new GameAchievementSchemaMeta(2L, 5, true, Instant.now().minus(Duration.ofDays(31))))); + when(schemaMetaRepository.findById(3L)).thenReturn(Optional.of(schema(3L, 5))); + + assertTrue(service.schemaStale(1L)); + assertTrue(service.schemaStale(2L)); + assertFalse(service.schemaStale(3L)); + verifyNoInteractions(steamApiService); + } + + @Test + void userStale_MissingOldAndFresh() { + Duration ttl = Duration.ofHours(1); + when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 1L)).thenReturn(Optional.empty()); + when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 2L)).thenReturn(Optional.of( + new UserGameAchievementMeta(USER_ID, 2L, Instant.now().minus(Duration.ofHours(2)), true, 3, 60))); + when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 3L)).thenReturn(Optional.of(unlocks(3L, 3, 60))); + + assertTrue(service.userStale(USER_ID, 1L, ttl)); + assertTrue(service.userStale(USER_ID, 2L, ttl)); + assertFalse(service.userStale(USER_ID, 3L, ttl)); + verifyNoInteractions(steamApiService); + } + @Test void syncLibrary_UnchangedPlaytime_AsksSteamNothing() { library(List.of(game(620L, 120)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120))); diff --git a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java index cbf86c2..e4a807f 100644 --- a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java @@ -45,7 +45,13 @@ import java.util.Set; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; /** @@ -194,6 +200,57 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest { assertEquals(5, userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L).orElseThrow().getAchievementsUnlocked()); assertEquals(5, detail.achievementsUnlocked()); + assertFalse(detail.achievementsStale()); + } + + @Test + void getGameDetail_NoRefresh_StaleUserMeta_AsksSteamNothingAndSaysStale() { + schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now())); + for (String name : List.of("A", "B", "C", "D", "E")) { + schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false)); + } + userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, + Instant.now().minus(Duration.ofHours(2)), true, 3, 60)); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L, false).orElseThrow(); + + assertEquals(3, detail.achievementsUnlocked()); + assertTrue(detail.achievementsStale()); + verify(steamApiService, never()).getPlayerAchievements(anyString(), anyLong()); + verify(steamApiService, never()).getSchemaForGame(anyLong()); + } + + @Test + void getGameDetail_NoRefresh_MissingSchema_AsksSteamNothingAndSaysStale() { + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L, false).orElseThrow(); + + assertFalse(detail.hasAchievements()); + assertTrue(detail.achievementsStale()); + verify(steamApiService, never()).getSchemaForGame(anyLong()); + verify(steamApiService, never()).getPlayerAchievements(anyString(), anyLong()); + } + + @Test + void getGameDetail_NoRefresh_FreshData_IsNotStale() { + schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now())); + userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 3, 60)); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L, false).orElseThrow(); + + assertFalse(detail.achievementsStale()); + verifyNoInteractions(steamApiService); + } + + @Test + void getGameDetail_NoRefresh_HiddenGame_IsNotStale() { + UserGame hidden = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow(); + hidden.setHiddenAt(Instant.now()); + userGameRepository.save(hidden); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L, false).orElseThrow(); + + assertFalse(detail.achievementsStale()); + verifyNoInteractions(steamApiService); } @Test