diff --git a/readme.md b/readme.md index 3ab6110..5ddec34 100644 --- a/readme.md +++ b/readme.md @@ -19,12 +19,18 @@ To Be Played is split into three repositories: filters and sorting. "Pile of Shame" summary with a random suggestion of what to play. - **Picker**: up to 10 random backlog games for a time budget (IGDB normal time to beat), HIGH priority first, then MEDIUM, then the rest. Games already shown can be excluded for a reroll. +- **Achievements**: after every library sync a background job fetches the achievement schema of each owned game + and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half + of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never + overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5 + games closest to 100 %. - **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks" dashboard banner. - **Wishlist**: synced from Steam with store price and discount. - **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the feature is off and the rest works. -- **Public profile data**: totals, status counts, games in progress and finished games with rating. Readable only +- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games + and average completion. Readable only with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. ## Authentication @@ -43,6 +49,7 @@ on normal endpoints; the profile ticket is accepted only by `GET /api/public/pro | `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT | | `POST` | `/api/library/sync` | JWT | | `GET` | `/api/library/picker?maxMinutes=&count=&exclude=` | JWT | +| `GET` | `/api/library/achievements/summary` | JWT | | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT | | `GET` | `/api/public/profiles/library` | profile ticket | | `GET` | `/api/stats/public` | public | @@ -107,7 +114,6 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne ## Roadmap - Mood filter for the picker (needs genres or themes from IGDB). -- Achievement summary per game and library-wide, "closest to 100 %". - Sale alerts for wishlist games. - Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit. - Fetch the public key from a JWKS endpoint in Auth instead of an environment variable. 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 ff88789..ea3215d 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -1,5 +1,6 @@ package core_service.modules.library.api; +import core_service.modules.library.api.dto.AchievementSummaryDto; import core_service.modules.library.api.dto.ActivityDto; import core_service.modules.library.api.dto.BacklogEntryDto; import core_service.modules.library.api.dto.GameDto; @@ -176,6 +177,11 @@ public class LibraryController { return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude)); } + @GetMapping("/achievements/summary") + public ResponseEntity getAchievementSummary(@AuthenticationPrincipal UserPrincipal principal) { + return ResponseEntity.ok(queryService.getAchievementSummary(principal.getIdAsLong())); + } + @GetMapping("/picker") public ResponseEntity getPicker(@AuthenticationPrincipal UserPrincipal principal, @RequestParam(required = false) Integer maxMinutes, diff --git a/src/main/java/core_service/modules/library/api/dto/AchievementSummaryDto.java b/src/main/java/core_service/modules/library/api/dto/AchievementSummaryDto.java new file mode 100644 index 0000000..fb433ab --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/AchievementSummaryDto.java @@ -0,0 +1,26 @@ +package core_service.modules.library.api.dto; + +import java.util.List; + +/** + * Only games whose achievements Steam shows count. + * + * @param startedGames games with at least one unlocked achievement + * @param perfectGames games with every achievement unlocked + * @param averageCompletion mean completion in percent over the started games; null when none is started + * @param pendingGames owned games the achievement sync has not fetched yet + * @param hiddenGames games whose achievements Steam hides (private profile or stats) + * @param closest up to 5 started games that are not complete, closest to 100 % first + */ +public record AchievementSummaryDto( + int unlocked, + int startedGames, + int perfectGames, + Double averageCompletion, + int pendingGames, + int hiddenGames, + List closest +) { + + public record Game(Long appid, String name, int unlocked, int total) {} +} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java index fbdbcb8..ec12771 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java @@ -5,6 +5,8 @@ import core_service.modules.library.domain.GameStatus; /** * @param timeToBeatMinutes IGDB's normal time to beat, null when unknown + * @param achievementsTotal null when the game has no achievements or they were not fetched yet + * @param achievementsUnlocked null when not fetched yet or hidden by Steam */ public record GameDto( Long appid, @@ -14,5 +16,7 @@ public record GameDto( GameStatus status, Integer rating, GamePriority priority, - Integer timeToBeatMinutes + Integer timeToBeatMinutes, + Integer achievementsUnlocked, + Integer achievementsTotal ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java index 46fd071..7311604 100644 --- a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java @@ -11,13 +11,16 @@ import java.util.Map; * @param byStatus has every status, also those with 0 games * @param playing up to 8 games, most played first * @param finished up to 12 FINISHED or COMPLETED games, best rated first + * @param averageCompletion see {@link AchievementSummaryDto#averageCompletion()} */ public record PublicLibraryDto( long totalGames, long totalPlaytimeMinutes, Map byStatus, List playing, - List finished + List finished, + int perfectGames, + Double averageCompletion ) { public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {} diff --git a/src/main/java/core_service/modules/library/domain/AchievementProgress.java b/src/main/java/core_service/modules/library/domain/AchievementProgress.java new file mode 100644 index 0000000..9de25af --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/AchievementProgress.java @@ -0,0 +1,30 @@ +package core_service.modules.library.domain; + +/** + * Achievement state of one owned game. + * + * @param total 0 when the game has no achievements; null when the schema was never fetched + * @param unlocked null when the user's achievements were never fetched + * @param visible false when Steam hides the user's achievements + */ +public record AchievementProgress(Long appid, Integer total, Integer unlocked, boolean visible) { + + public boolean hasAchievements() { + return total != null && total > 0; + } + + public boolean known() { + return total != null && (total == 0 || unlocked != null); + } + + public int unlockedCapped() { + return unlocked == null || total == null ? 0 : capped(unlocked, total); + } + + /** + * A schema can lose achievements after the user unlocked them. + */ + public static int capped(int unlocked, int total) { + return Math.min(unlocked, total); + } +} diff --git a/src/main/java/core_service/modules/library/domain/UserGameAchievementMeta.java b/src/main/java/core_service/modules/library/domain/UserGameAchievementMeta.java index badfd38..1e875da 100644 --- a/src/main/java/core_service/modules/library/domain/UserGameAchievementMeta.java +++ b/src/main/java/core_service/modules/library/domain/UserGameAchievementMeta.java @@ -37,11 +37,16 @@ public class UserGameAchievementMeta { @Column(name = "achievements_unlocked", nullable = false) private int achievementsUnlocked; - public UserGameAchievementMeta(Long userId, Long appid, Instant refreshedAt, boolean statsVisible, int achievementsUnlocked) { + @Column(name = "playtime_at_refresh") + private Integer playtimeAtRefresh; + + public UserGameAchievementMeta(Long userId, Long appid, Instant refreshedAt, boolean statsVisible, + int achievementsUnlocked, Integer playtimeAtRefresh) { this.userId = userId; this.appid = appid; this.refreshedAt = refreshedAt; this.statsVisible = statsVisible; this.achievementsUnlocked = achievementsUnlocked; + this.playtimeAtRefresh = playtimeAtRefresh; } } diff --git a/src/main/java/core_service/modules/library/repo/UserGameAchievementMetaRepository.java b/src/main/java/core_service/modules/library/repo/UserGameAchievementMetaRepository.java index 4f6f88c..ae01440 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameAchievementMetaRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameAchievementMetaRepository.java @@ -4,9 +4,15 @@ import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameId; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; +import java.util.List; import java.util.Optional; public interface UserGameAchievementMetaRepository extends JpaRepository { Optional findByUserIdAndAppid(Long userId, Long appid); + + List findByUserId(Long userId); + + List findByUserIdAndAppidIn(Long userId, Collection appids); } diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index f7369a9..d991d53 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -1,5 +1,6 @@ package core_service.modules.library.repo; +import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.UserGame; @@ -47,6 +48,14 @@ public interface UserGameRepository extends JpaRepository "and appid <> :excludeAppid order by random() limit 1", nativeQuery = true) Optional findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid); + @Query("select new core_service.modules.library.domain.AchievementProgress(ug.appid, sm.achievementsTotal, " + + "um.achievementsUnlocked, coalesce(um.statsVisible, true)) " + + "from UserGame ug " + + "left join GameAchievementSchemaMeta sm on sm.appid = ug.appid " + + "left join UserGameAchievementMeta um on um.userId = ug.userId and um.appid = ug.appid " + + "where ug.userId = :userId and ug.removedAt is null") + List findAchievementProgress(@Param("userId") Long userId); + /** * Random backlog games, HIGH priority first, then MEDIUM, then the rest. * diff --git a/src/main/java/core_service/modules/library/service/AchievementSyncService.java b/src/main/java/core_service/modules/library/service/AchievementSyncService.java new file mode 100644 index 0000000..01d869e --- /dev/null +++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java @@ -0,0 +1,276 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.GameAchievementSchema; +import core_service.modules.library.domain.GameAchievementSchemaMeta; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserGameAchievement; +import core_service.modules.library.domain.UserGameAchievementMeta; +import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; +import core_service.modules.library.repo.GameAchievementSchemaRepository; +import core_service.modules.library.repo.UserGameAchievementMetaRepository; +import core_service.modules.library.repo.UserGameAchievementRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; +import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse; +import core_service.modules.steam_adapter.service.SteamApiService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Keeps the stored achievements in step with Steam: on demand for the game detail page, and for the whole library + * after each library sync. A Steam failure never overwrites stored data. + */ +@Service +@Slf4j +public class AchievementSyncService { + + static final int MAX_ERRORS_IN_A_ROW = 3; + private static final int LOCK_STRIPES = 64; + // Steam answers "hidden" for every game of a private profile; asking for the rest only costs calls. + static final int MAX_HIDDEN_IN_A_ROW = 5; + + private final UserGameRepository userGameRepository; + private final GameAchievementSchemaRepository schemaRepository; + private final GameAchievementSchemaMetaRepository schemaMetaRepository; + private final UserGameAchievementRepository userAchievementRepository; + private final UserGameAchievementMetaRepository userAchievementMetaRepository; + private final SteamApiService steamApiService; + private final TransactionTemplate transactionTemplate; + private final Duration schemaTtl; + private final long pauseNanos; + private final Set runningUsers = ConcurrentHashMap.newKeySet(); + private final Map requestedRuns = new ConcurrentHashMap<>(); + private final ReentrantLock[] locks = new ReentrantLock[LOCK_STRIPES]; + // One Steam budget for all runs: the nightly job starts one run per account. + private final Object paceLock = new Object(); + private long nextCallAtNanos; + + public AchievementSyncService(UserGameRepository userGameRepository, + GameAchievementSchemaRepository schemaRepository, + GameAchievementSchemaMetaRepository schemaMetaRepository, + UserGameAchievementRepository userAchievementRepository, + UserGameAchievementMetaRepository userAchievementMetaRepository, + SteamApiService steamApiService, + PlatformTransactionManager transactionManager, + @Value("${app.library.schema-ttl-days:30}") long schemaTtlDays, + // Half of the Steam rate limit, so requests of users still get through. + @Value("${app.library.achievement-sync-pause-ms:1000}") long pauseMillis) { + this.userGameRepository = userGameRepository; + this.schemaRepository = schemaRepository; + this.schemaMetaRepository = schemaMetaRepository; + this.userAchievementRepository = userAchievementRepository; + this.userAchievementMetaRepository = userAchievementMetaRepository; + this.steamApiService = steamApiService; + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + this.schemaTtl = Duration.ofDays(schemaTtlDays); + this.pauseNanos = TimeUnit.MILLISECONDS.toNanos(pauseMillis); + for (int i = 0; i < LOCK_STRIPES; i++) { + locks[i] = new ReentrantLock(); + } + } + + @Async + @TransactionalEventListener + public void onLibrarySynced(LibrarySyncedEvent event) { + syncLibrary(event.userId(), event.steamId()); + } + + /** + * Refreshes the schema of every owned game when it is missing or old, and the user's achievements of every game + * whose playtime changed since the last refresh. Runs at most once per user at a time; a request that arrives + * during a run starts one more run afterwards, with the newest Steam account. + */ + public void syncLibrary(Long userId, String steamId) { + requestedRuns.put(userId, steamId); + while (runningUsers.add(userId)) { + try { + String next; + while ((next = requestedRuns.remove(userId)) != null) { + runLibrarySync(userId, next); + } + } finally { + runningUsers.remove(userId); + } + // A request can arrive after the last remove() and before the user was released. + if (!requestedRuns.containsKey(userId)) return; + } + } + + public void refreshSchemaIfStale(Long appid) { + Optional existing = schemaMetaRepository.findById(appid); + if (existing.isEmpty() || isStale(existing.get())) { + refreshSchema(appid); + } + } + + 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))) { + refreshUser(userId, steamId, appid, playtimeMinutes); + } + } + + private void runLibrarySync(Long userId, String steamId) { + List games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream() + .sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed()) + .toList(); + Map schemas = schemaMetaRepository + .findAllById(games.stream().map(UserGame::getAppid).toList()).stream() + .collect(Collectors.toMap(GameAchievementSchemaMeta::getAppid, Function.identity())); + Map metas = userAchievementMetaRepository.findByUserId(userId).stream() + .collect(Collectors.toMap(UserGameAchievementMeta::getAppid, Function.identity())); + + int errorsInARow = 0; + int hiddenInARow = 0; + boolean anyVisible = false; + for (UserGame game : games) { + Outcome outcome; + try { + outcome = syncGame(userId, steamId, game, schemas.get(game.getAppid()), metas.get(game.getAppid())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (Exception e) { + log.warn("Achievement sync user={} appid={} failed: {}", userId, game.getAppid(), e.getMessage()); + if (++errorsInARow >= MAX_ERRORS_IN_A_ROW) { + log.warn("Achievement sync user={} stopped after {} Steam errors in a row", userId, errorsInARow); + return; + } + continue; + } + errorsInARow = 0; + if (outcome == Outcome.VISIBLE) { + anyVisible = true; + hiddenInARow = 0; + } else if (outcome == Outcome.HIDDEN && !anyVisible && ++hiddenInARow >= MAX_HIDDEN_IN_A_ROW) { + log.info("Achievement sync user={} stopped, Steam hides the achievements", userId); + return; + } + } + } + + private Outcome syncGame(Long userId, String steamId, UserGame game, GameAchievementSchemaMeta schema, + UserGameAchievementMeta meta) throws InterruptedException { + Long appid = game.getAppid(); + boolean hasSchema; + if (schema == null || isStale(schema)) { + pause(); + refreshSchema(appid); + hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false); + } else { + hasSchema = schema.isHasSchema(); + } + int playtime = game.getPlaytimeMinutes(); + if (!hasSchema || (meta != null && Objects.equals(meta.getPlaytimeAtRefresh(), playtime))) { + return Outcome.UNCHANGED; + } + pause(); + return refreshUser(userId, steamId, appid, playtime) ? Outcome.VISIBLE : Outcome.HIDDEN; + } + + private void refreshSchema(Long appid) { + Optional schema = steamApiService.getSchemaForGame(appid); + List achievements = schema + .map(SteamSchemaForGameResponse.Game::availableGameStats) + .map(SteamSchemaForGameResponse.AvailableGameStats::achievements) + .orElse(List.of()); + writeLocked(0L, appid, () -> { + if (!achievements.isEmpty()) { + schemaRepository.deleteByAppid(appid); + schemaRepository.saveAll(achievements.stream() + .map(a -> new GameAchievementSchema(appid, a.name(), a.displayName(), a.description(), + a.icon(), a.iconGray(), a.hidden() != null && a.hidden() == 1)) + .toList()); + } + schemaMetaRepository.save(new GameAchievementSchemaMeta(appid, achievements.size(), !achievements.isEmpty(), Instant.now())); + }); + } + + /** + * @return false when Steam hides the achievements of this user + */ + private boolean refreshUser(Long userId, String steamId, Long appid, int playtimeMinutes) { + Optional response = steamApiService.getPlayerAchievements(steamId, appid); + boolean visible = response.isPresent() && (response.get().success() == null || response.get().success()); + List rows = visible && response.get().achievements() != null + ? response.get().achievements().stream() + .map(a -> { + boolean unlocked = a.achieved() != null && a.achieved() == 1; + Instant unlockedAt = unlocked && a.unlockTime() != null && a.unlockTime() > 0 + ? Instant.ofEpochSecond(a.unlockTime()) : null; + return new UserGameAchievement(userId, appid, a.apiName(), unlocked, unlockedAt); + }) + .toList() + : List.of(); + int unlockedCount = (int) rows.stream().filter(UserGameAchievement::isUnlocked).count(); + // No playtime for a hidden answer: the next sync asks again, e.g. after the profile turned public. + Integer playtimeAtRefresh = visible ? playtimeMinutes : null; + writeLocked(userId, appid, () -> { + if (visible) { + userAchievementRepository.deleteByUserIdAndAppid(userId, appid); + userAchievementRepository.saveAll(rows); + } + userAchievementMetaRepository.save( + new UserGameAchievementMeta(userId, appid, Instant.now(), visible, unlockedCount, playtimeAtRefresh)); + }); + return visible; + } + + /** + * Commits before the lock is released, so two refreshes of the same rows (detail page and background run) + * never insert at the same time. + */ + private void writeLocked(Long userId, Long appid, Runnable write) { + ReentrantLock lock = locks[Math.floorMod(Objects.hash(userId, appid), LOCK_STRIPES)]; + lock.lock(); + try { + transactionTemplate.executeWithoutResult(status -> write.run()); + } finally { + lock.unlock(); + } + } + + private boolean isStale(GameAchievementSchemaMeta schema) { + return schema.getRefreshedAt().isBefore(Instant.now().minus(schemaTtl)); + } + + /** + * Waits for the next free Steam slot of all runs together. + */ + private void pause() throws InterruptedException { + if (pauseNanos <= 0) return; + long waitNanos; + synchronized (paceLock) { + long now = System.nanoTime(); + long slot = Math.max(now, nextCallAtNanos); + nextCallAtNanos = slot + pauseNanos; + waitNanos = slot - now; + } + if (waitNanos > 0) { + TimeUnit.NANOSECONDS.sleep(waitNanos); + } + } + + private enum Outcome { UNCHANGED, VISIBLE, HIDDEN } +} 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 a1efc4c..75bb733 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -2,6 +2,7 @@ package core_service.modules.library.service; import core_service.modules.library.api.dto.AchievementDto; import core_service.modules.library.api.dto.GameDetailDto; +import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.SteamGame; @@ -16,17 +17,12 @@ import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameAchievementRepository; import core_service.modules.library.repo.UserGameRepository; -import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; -import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse; -import core_service.modules.steam_adapter.service.SteamApiService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.Duration; -import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -43,16 +39,16 @@ public class GameDetailService { private final GameAchievementSchemaMetaRepository schemaMetaRepository; private final UserGameAchievementRepository userAchievementRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository; - private final SteamApiService steamApiService; + private final AchievementSyncService achievementSyncService; private final GameTimeToBeatRepository gameTimeToBeatRepository; @Value("${app.library.achievement-ttl-hours:1}") private long achievementTtlHours; - @Value("${app.library.schema-ttl-days:30}") - private long schemaTtlDays; - - @Transactional + /** + * Not transactional on purpose: the refreshes commit in their own transactions, and the reads after them must + * see those rows, not a cached copy from a transaction that started before the refresh. + */ public Optional getGameDetail(Long userId, String steamId, Long appid) { Optional userGameOpt = userGameRepository.findById(new UserGameId(userId, appid)); if (userGameOpt.isEmpty() || userGameOpt.get().getRemovedAt() != null) { @@ -61,14 +57,23 @@ public class GameDetailService { UserGame userGame = userGameOpt.get(); SteamGame meta = steamGameRepository.findById(appid).orElse(null); - refreshSchemaIfStale(appid); + try { + achievementSyncService.refreshSchemaIfStale(appid); + } catch (Exception e) { + log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage()); + } boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false); if (!hasSchema) { return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of())); } - refreshUserAchievementsIfStale(userId, steamId, appid); + 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()); + } UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null); boolean statsVisible = userMeta == null || userMeta.isStatsVisible(); @@ -97,7 +102,8 @@ public class GameDetailService { }) .toList(); - return Optional.of(buildDto(userGame, meta, statsVisible, schemaList.size(), unlocked, achievementDtos)); + return Optional.of(buildDto(userGame, meta, statsVisible, schemaList.size(), + AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos)); } private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible, @@ -129,68 +135,4 @@ public class GameDetailService { .orElse(null) ); } - - private void refreshSchemaIfStale(Long appid) { - Optional existing = schemaMetaRepository.findById(appid); - Instant now = Instant.now(); - if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofDays(schemaTtlDays)))) { - return; - } - Optional schema = steamApiService.getSchemaForGame(appid); - boolean hasSchema = schema.isPresent() - && schema.get().availableGameStats() != null - && schema.get().availableGameStats().achievements() != null - && !schema.get().availableGameStats().achievements().isEmpty(); - - if (hasSchema) { - schemaRepository.deleteByAppid(appid); - List rows = schema.get().availableGameStats().achievements().stream() - .map(a -> new GameAchievementSchema( - appid, - a.name(), - a.displayName(), - a.description(), - a.icon(), - a.iconGray(), - a.hidden() != null && a.hidden() == 1 - )) - .toList(); - schemaRepository.saveAll(rows); - } - int total = hasSchema ? schema.get().availableGameStats().achievements().size() : 0; - schemaMetaRepository.save(new GameAchievementSchemaMeta(appid, total, hasSchema, now)); - } - - private void refreshUserAchievementsIfStale(Long userId, String steamId, Long appid) { - Optional existing = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid); - Instant now = Instant.now(); - if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofHours(achievementTtlHours)))) { - return; - } - - Optional response = steamApiService.getPlayerAchievements(steamId, appid); - boolean visible = response.isPresent() && (response.get().success() == null || response.get().success()); - - if (!visible) { - userAchievementMetaRepository.save(new UserGameAchievementMeta(userId, appid, now, false, 0)); - return; - } - - List list = response.get().achievements(); - userAchievementRepository.deleteByUserIdAndAppid(userId, appid); - int unlockedCount = 0; - if (list != null) { - List rows = list.stream() - .map(a -> { - boolean unlocked = a.achieved() != null && a.achieved() == 1; - Instant unlockedAt = unlocked && a.unlockTime() != null && a.unlockTime() > 0 - ? Instant.ofEpochSecond(a.unlockTime()) : null; - return new UserGameAchievement(userId, appid, a.apiName(), unlocked, unlockedAt); - }) - .toList(); - userAchievementRepository.saveAll(rows); - unlockedCount = (int) rows.stream().filter(UserGameAchievement::isUnlocked).count(); - } - userAchievementMetaRepository.save(new UserGameAchievementMeta(userId, appid, now, true, unlockedCount)); - } } 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 dc83040..0dcd2d5 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -1,5 +1,6 @@ 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.GameDto; import core_service.modules.library.api.dto.LibrarySummaryDto; @@ -7,15 +8,20 @@ import core_service.modules.library.api.dto.PickerDto; import core_service.modules.library.api.dto.PublicLibraryDto; import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.WishlistDto; +import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.DayMinutes; +import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserSteamSync; +import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.WishlistGameRepository; @@ -31,6 +37,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.ZoneId; +import java.util.Comparator; import java.util.EnumMap; import java.util.List; import java.util.Map; @@ -45,6 +52,7 @@ public class LibraryQueryService { private static final int PUBLIC_PLAYING = 8; private static final int PUBLIC_FINISHED = 12; + private static final int CLOSEST_GAMES = 5; private final UserGameRepository userGameRepository; private final UserSteamSyncRepository userSteamSyncRepository; @@ -52,6 +60,8 @@ public class LibraryQueryService { private final PlaytimeDayRepository playtimeDayRepository; private final WishlistGameRepository wishlistGameRepository; private final GameTimeToBeatRepository gameTimeToBeatRepository; + private final GameAchievementSchemaMetaRepository schemaMetaRepository; + private final UserGameAchievementMetaRepository userAchievementMetaRepository; @Value("${app.library.zone:Europe/Berlin}") private ZoneId zone; @@ -74,13 +84,66 @@ public class LibraryQueryService { return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, excludeAppids, count))); } + /** + * @param games all of one user + */ private List toDtos(List games) { + if (games.isEmpty()) return List.of(); List appids = games.stream().map(UserGame::getAppid).toList(); Map meta = steamGameRepository.findAllById(appids).stream() .collect(Collectors.toMap(SteamGame::getAppid, g -> g)); Map times = gameTimeToBeatRepository.findAllById(appids).stream() .collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t)); - return games.stream().map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()))).toList(); + Map schemas = schemaMetaRepository.findAllById(appids).stream() + .collect(Collectors.toMap(GameAchievementSchemaMeta::getAppid, m -> m)); + Map unlocks = userAchievementMetaRepository + .findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream() + .collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m)); + return games.stream() + .map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()), + schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()))) + .toList(); + } + + public AchievementSummaryDto getAchievementSummary(Long userId) { + List games = userGameRepository.findAchievementProgress(userId); + List started = startedGames(games); + List closest = started.stream() + .filter(g -> g.unlockedCapped() < g.total()) + .sorted(Comparator.comparingDouble((AchievementProgress g) -> (double) g.unlockedCapped() / g.total()) + .reversed() + .thenComparingInt(g -> g.total() - g.unlockedCapped()) + .thenComparing(AchievementProgress::appid)) + .limit(CLOSEST_GAMES) + .toList(); + Map names = steamGameRepository.findAllById(closest.stream().map(AchievementProgress::appid).toList()) + .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); + return new AchievementSummaryDto( + started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(), + started.size(), + perfectGames(started), + averageCompletion(started), + (int) games.stream().filter(g -> !g.known()).count(), + (int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(), + closest.stream() + .map(g -> new AchievementSummaryDto.Game(g.appid(), + names.containsKey(g.appid()) ? names.get(g.appid()).getName() : null, + g.unlockedCapped(), g.total())) + .toList()); + } + + private static List startedGames(List games) { + return games.stream().filter(g -> g.hasAchievements() && g.visible() && g.unlockedCapped() > 0).toList(); + } + + private static int perfectGames(List started) { + return (int) started.stream().filter(g -> g.unlockedCapped() == g.total()).count(); + } + + private static Double averageCompletion(List started) { + if (started.isEmpty()) return null; + double mean = started.stream().mapToDouble(g -> g.unlockedCapped() * 100.0 / g.total()).average().orElse(0); + return Math.round(mean * 10.0) / 10.0; } /** @@ -91,8 +154,7 @@ public class LibraryQueryService { long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0); GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1) - .map(ug -> toDto(ug, steamGameRepository.findById(ug.getAppid()).orElse(null), - gameTimeToBeatRepository.findById(ug.getAppid()).orElse(null))) + .map(ug -> toDtos(List.of(ug)).getFirst()) .orElse(null); return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId)); } @@ -137,8 +199,10 @@ public class LibraryQueryService { Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList()) .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); + List started = startedGames(userGameRepository.findAchievementProgress(userId)); return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus, - toPublicGames(playing, meta), toPublicGames(finished, meta)); + toPublicGames(playing, meta), toPublicGames(finished, meta), + perfectGames(started), averageCompletion(started)); } // Every status, also those with 0 games. @@ -166,7 +230,12 @@ public class LibraryQueryService { .toList()); } - private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time) { + private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time, + GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks) { + Integer achievementsTotal = schema != null && schema.isHasSchema() && schema.getAchievementsTotal() > 0 + ? schema.getAchievementsTotal() : null; + Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible() + ? AchievementProgress.capped(unlocks.getAchievementsUnlocked(), achievementsTotal) : null; return new GameDto( ug.getAppid(), meta != null ? meta.getName() : null, @@ -175,7 +244,9 @@ public class LibraryQueryService { ug.getStatus(), ug.getRating(), ug.getPriority(), - time != null ? time.getNormallyMinutes() : null); + time != null ? time.getNormallyMinutes() : null, + achievementsUnlocked, + achievementsTotal); } public Optional getSyncStatus(Long userId) { diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 8d45e56..8288155 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -15,6 +15,7 @@ import core_service.modules.steam_adapter.service.SteamApiService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -48,6 +49,7 @@ public class LibrarySyncWorker { private final PlaytimeDayRepository playtimeDayRepository; private final WishlistGameRepository wishlistGameRepository; private final TimeToBeatService timeToBeatService; + private final ApplicationEventPublisher eventPublisher; @Value("${app.library.running-stale-minutes:5}") private long runningStaleMinutes; @@ -115,6 +117,7 @@ public class LibrarySyncWorker { } userSteamSyncRepository.markHistoryBaseline(userId, steamId); timeToBeatService.refresh(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList()); + eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId)); finalStatus = Status.OK; } catch (Exception e) { log.error("Sync failed for user {}", userId, e); diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncedEvent.java b/src/main/java/core_service/modules/library/service/LibrarySyncedEvent.java new file mode 100644 index 0000000..c3a40e8 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/LibrarySyncedEvent.java @@ -0,0 +1,6 @@ +package core_service.modules.library.service; + +/** + * Published inside the library sync transaction after a successful sync. + */ +public record LibrarySyncedEvent(Long userId, String steamId) {} diff --git a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java index 74ec784..9eaf921 100644 --- a/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java +++ b/src/main/java/core_service/modules/steam_adapter/service/SteamApiService.java @@ -30,6 +30,8 @@ import java.util.stream.Collectors; @Slf4j public class SteamApiService { + private static final String NO_STATS = "Requested app has no stats"; + private final RestClient steamRestClient; private static final int STORE_ITEMS_BATCH = 100; @@ -245,7 +247,8 @@ public class SteamApiService { /** * Per-game achievement schema (global, language-aware). - * Returns empty when game has no achievements or Steam returns no schema. + * Returns empty when the game has no achievements or Steam answers 4xx, its final answer for delisted apps and + * tools. A server or network failure throws, so the caller keeps what it stored before. */ @RateLimiter(name = "steamApi") @Retry(name = "steamApi") @@ -259,19 +262,20 @@ public class SteamApiService { .build()) .retrieve() .body(SteamSchemaForGameResponse.class); - if (response != null && response.game() != null) { return Optional.of(response.game()); } - } catch (Exception e) { - log.warn("GetSchemaForGame failed for appid {}: {}", appid, e.getMessage()); + } catch (HttpClientErrorException e) { + log.info("No achievement schema for appid {}: {}", appid, e.getStatusCode()); } return Optional.empty(); } /** * Per-user achievement state for a single game. - * Returns empty when profile/stats are private (HTTP 403) or game has no achievements. + * Returns empty when profile/stats are private (HTTP 403), and no achievements when Steam answers 400 "Requested + * app has no stats". Any other failure throws, so the caller keeps what it stored before instead of storing + * "hidden" or "nothing unlocked". */ @RateLimiter(name = "steamApi") @Retry(name = "steamApi") @@ -292,8 +296,11 @@ public class SteamApiService { } } catch (HttpClientErrorException.Forbidden e) { log.info("Achievements hidden by Steam privacy for steamId={} appid={}", steamId, appid); - } catch (Exception e) { - log.warn("GetPlayerAchievements failed for steamId={} appid={}: {}", steamId, appid, e.getMessage()); + } catch (HttpClientErrorException.BadRequest e) { + if (!e.getResponseBodyAsString().contains(NO_STATS)) { + throw e; + } + return Optional.of(new SteamPlayerAchievementsResponse.PlayerStats(steamId, null, List.of(), true, NO_STATS)); } return Optional.empty(); } diff --git a/src/main/resources/db/migration/V7__achievement_sync.sql b/src/main/resources/db/migration/V7__achievement_sync.sql new file mode 100644 index 0000000..4e3f51a --- /dev/null +++ b/src/main/resources/db/migration/V7__achievement_sync.sql @@ -0,0 +1,3 @@ +-- Playtime the last achievement refresh saw. The background sync asks Steam again only when the playtime changed. +-- NULL: never refreshed with this column, or Steam hid the achievements; the next sync asks again. +ALTER TABLE user_game_achievement_meta ADD COLUMN playtime_at_refresh INTEGER; diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java new file mode 100644 index 0000000..f71c324 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java @@ -0,0 +1,162 @@ +package core_service.modules.library.api; + +import core_service.modules.library.domain.GameAchievementSchemaMeta; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserGameAchievementMeta; +import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameAchievementMetaRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.library.service.LibrarySyncWorker; +import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse; +import core_service.modules.steam_adapter.service.SteamApiService; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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; + +/** + * The dev profile authenticates every request as user 1. + */ +@AutoConfigureMockMvc +class LibraryControllerAchievementsTest extends AbstractIntegrationTest { + + private static final long USER_ID = 1L; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private SteamApiService steamApiService; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private GameAchievementSchemaMetaRepository schemaMetaRepository; + + @Autowired + private UserGameAchievementMetaRepository userAchievementMetaRepository; + + @Autowired + private LibrarySyncWorker librarySyncWorker; + + @BeforeEach + void clean() { + userGameRepository.deleteAll(); + schemaMetaRepository.deleteAll(); + userAchievementMetaRepository.deleteAll(); + } + + /** + * @param total null for a schema that was never fetched, 0 for a game without achievements + * @param unlocked null for unlocks that were never fetched + */ + private void game(long appid, Integer total, Integer unlocked, boolean visible) { + steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now())); + userGameRepository.save(new UserGame(USER_ID, appid, 60, Instant.now(), Instant.now())); + if (total != null) { + schemaMetaRepository.save(new GameAchievementSchemaMeta(appid, total, total > 0, Instant.now())); + } + if (unlocked != null) { + userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, appid, Instant.now(), visible, unlocked, 60)); + } + } + + @Test + void summary_EmptyLibrary() throws Exception { + mockMvc.perform(get("/api/library/achievements/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unlocked").value(0)) + .andExpect(jsonPath("$.startedGames").value(0)) + .andExpect(jsonPath("$.averageCompletion").value(nullValue())) + .andExpect(jsonPath("$.closest", hasSize(0))); + } + + @Test + void summary_CountsOnlyVisibleStartedGamesAndCapsUnlocks() throws Exception { + game(10L, 10, 10, true); // perfect + game(20L, 4, 3, true); // 75 % + game(30L, 10, 1, true); // 10 % + game(40L, 5, 0, false); // hidden by Steam + game(50L, null, null, true); // schema never fetched + game(60L, 0, null, true); // no achievements + game(70L, 8, null, true); // unlocks never fetched + game(80L, 10, 12, true); // schema lost achievements, still perfect + game(90L, 6, 0, true); // not started + steamGameRepository.save(new SteamGame(95L, "Game 95", null, Instant.now())); + UserGame removed = new UserGame(USER_ID, 95L, 60, Instant.now(), Instant.now()); + removed.setRemovedAt(Instant.now()); + userGameRepository.save(removed); + schemaMetaRepository.save(new GameAchievementSchemaMeta(95L, 5, true, Instant.now())); + userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 95L, Instant.now(), true, 5, 60)); + userGameRepository.save(new UserGame(2L, 20L, 60, Instant.now(), Instant.now())); + userAchievementMetaRepository.save(new UserGameAchievementMeta(2L, 20L, Instant.now(), true, 4, 60)); + + mockMvc.perform(get("/api/library/achievements/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unlocked").value(24)) + .andExpect(jsonPath("$.startedGames").value(4)) + .andExpect(jsonPath("$.perfectGames").value(2)) + .andExpect(jsonPath("$.averageCompletion").value(71.3)) + .andExpect(jsonPath("$.pendingGames").value(2)) + .andExpect(jsonPath("$.hiddenGames").value(1)) + .andExpect(jsonPath("$.closest", hasSize(2))) + .andExpect(jsonPath("$.closest[0].appid").value(20)) + .andExpect(jsonPath("$.closest[0].name").value("Game 20")) + .andExpect(jsonPath("$.closest[0].unlocked").value(3)) + .andExpect(jsonPath("$.closest[0].total").value(4)) + .andExpect(jsonPath("$.closest[1].appid").value(30)); + } + + @Test + void games_CarryTheAchievementProgress() throws Exception { + game(20L, 4, 3, true); + game(40L, 5, 0, false); + game(60L, 0, null, true); + game(80L, 10, 12, true); + + mockMvc.perform(get("/api/library/games").param("sort", "appid,asc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].achievementsUnlocked").value(3)) + .andExpect(jsonPath("$.content[0].achievementsTotal").value(4)) + .andExpect(jsonPath("$.content[1].achievementsUnlocked").value(nullValue())) + .andExpect(jsonPath("$.content[1].achievementsTotal").value(5)) + .andExpect(jsonPath("$.content[2].achievementsTotal").value(nullValue())) + .andExpect(jsonPath("$.content[3].achievementsUnlocked").value(10)); + } + + @Test + void librarySync_StartsTheAchievementSyncAfterCommit() { + when(steamApiService.getOwnedGames("76561198000000001")) + .thenReturn(List.of(new SteamOwnedGamesResponse.Game(620, "Portal 2", 90, null, true, 0))); + when(steamApiService.getWishlist(anyString())).thenReturn(Optional.of(List.of())); + when(steamApiService.getSchemaForGame(anyLong())).thenReturn(Optional.empty()); + + librarySyncWorker.runSyncNow(USER_ID, "76561198000000001"); + + verify(steamApiService, timeout(5_000)).getSchemaForGame(620L); + } +} diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java index 1b87efc..f196005 100644 --- a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -1,10 +1,14 @@ package core_service.modules.library.api; +import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserGameAchievementMeta; +import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.shared.test.AbstractIntegrationTest; import io.jsonwebtoken.Jwts; @@ -37,6 +41,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { @Autowired private UserGameRepository userGameRepository; + @Autowired + private GameAchievementSchemaMetaRepository schemaMetaRepository; + + @Autowired + private UserGameAchievementMetaRepository userAchievementMetaRepository; + private static String ticket(long userId) { return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId)) .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact(); @@ -54,6 +64,8 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { @BeforeEach void seed() { userGameRepository.deleteAll(); + schemaMetaRepository.deleteAll(); + userAchievementMetaRepository.deleteAll(); steamGameRepository.saveAll(List.of( new SteamGame(220L, "Half-Life 2", null, Instant.now()), new SteamGame(620L, "Portal 2", null, Instant.now()), @@ -87,6 +99,21 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$..priority").isEmpty()); } + @Test + void publicLibrary_ShowsPerfectGamesAndAverageCompletion() 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))); + + mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.perfectGames").value(1)) + .andExpect(jsonPath("$.averageCompletion").value(62.5)); + } + @Test void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception { mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized()); diff --git a/src/test/java/core_service/modules/library/service/AchievementSyncServiceConcurrencyTest.java b/src/test/java/core_service/modules/library/service/AchievementSyncServiceConcurrencyTest.java new file mode 100644 index 0000000..ae9bb32 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceConcurrencyTest.java @@ -0,0 +1,100 @@ +package core_service.modules.library.service; + +import core_service.modules.library.repo.UserGameAchievementMetaRepository; +import core_service.modules.library.repo.UserGameAchievementRepository; +import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; +import core_service.modules.steam_adapter.service.SteamApiService; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * The game detail page refreshes inside its own @Transactional while the background sync refreshes the same + * (user, appid) in a transaction of its own, e.g. right after the user pressed "sync" and opened the most played game. + */ +class AchievementSyncServiceConcurrencyTest extends AbstractIntegrationTest { + + private static final long USER_ID = 1L; + private static final String STEAM_ID = "76561198000000001"; + + @Autowired + private AchievementSyncService achievementSyncService; + + @Autowired + private UserGameAchievementMetaRepository userAchievementMetaRepository; + + @Autowired + private UserGameAchievementRepository userAchievementRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @MockitoBean + private SteamApiService steamApiService; + + @BeforeEach + @AfterEach + void clean() { + userAchievementMetaRepository.deleteAll(); + userAchievementRepository.deleteAll(); + } + + @Test + void refreshUser_DetailPageAndBackgroundSyncOnTheSameGame_BothCommit() { + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of( + new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2", + List.of(new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L)), true, null))); + CountDownLatch firstWrote = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + + CompletableFuture detailPage = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager) + .executeWithoutResult(tx -> { + achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1)); + firstWrote.countDown(); + await(secondStarted); + // Keep the page's transaction open while the background run writes the same rows. + sleep(); + })); + CompletableFuture backgroundRun = CompletableFuture.runAsync(() -> { + await(firstWrote); + secondStarted.countDown(); + achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1)); + }); + + assertDoesNotThrow(() -> CompletableFuture.allOf(detailPage, backgroundRun).get(30, TimeUnit.SECONDS)); + assertEquals(1, userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L).orElseThrow().getAchievementsUnlocked()); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static void sleep() { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java new file mode 100644 index 0000000..fb819b3 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java @@ -0,0 +1,309 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.GameAchievementSchemaMeta; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserGameAchievementMeta; +import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; +import core_service.modules.library.repo.GameAchievementSchemaRepository; +import core_service.modules.library.repo.UserGameAchievementMetaRepository; +import core_service.modules.library.repo.UserGameAchievementRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; +import core_service.modules.steam_adapter.service.SteamApiService; +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.client.ResourceAccessException; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.stream.LongStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class AchievementSyncServiceTest extends AbstractUnitTest { + + private static final long USER_ID = 1L; + private static final String STEAM_ID = "76561198000000001"; + + @Mock + private UserGameRepository userGameRepository; + @Mock + private GameAchievementSchemaRepository schemaRepository; + @Mock + private GameAchievementSchemaMetaRepository schemaMetaRepository; + @Mock + private UserGameAchievementRepository userAchievementRepository; + @Mock + private UserGameAchievementMetaRepository userAchievementMetaRepository; + @Mock + private SteamApiService steamApiService; + + private AchievementSyncService service; + + @BeforeEach + void setUp() { + service = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository, + userAchievementRepository, userAchievementMetaRepository, steamApiService, + mock(PlatformTransactionManager.class), 30, 0); + } + + private static UserGame game(long appid, int playtime) { + return new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now()); + } + + private static GameAchievementSchemaMeta schema(long appid, int total) { + return new GameAchievementSchemaMeta(appid, total, total > 0, Instant.now()); + } + + private static UserGameAchievementMeta unlocks(long appid, int unlocked, Integer playtimeAtRefresh) { + return new UserGameAchievementMeta(USER_ID, appid, Instant.now(), true, unlocked, playtimeAtRefresh); + } + + private static Optional visible(String... unlockedNames) { + List list = java.util.Arrays.stream(unlockedNames) + .map(name -> new SteamPlayerAchievementsResponse.Achievement(name, 1, 1_700_000_000L)) + .toList(); + return Optional.of(new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Game", list, true, null)); + } + + private void library(List games, List schemas, List metas) { + when(userGameRepository.findByUserIdAndRemovedAtIsNull(USER_ID)).thenReturn(games); + when(schemaMetaRepository.findAllById(any())).thenReturn(schemas); + when(userAchievementMetaRepository.findByUserId(USER_ID)).thenReturn(metas); + } + + private UserGameAchievementMeta savedMeta() { + ArgumentCaptor saved = ArgumentCaptor.forClass(UserGameAchievementMeta.class); + verify(userAchievementMetaRepository).save(saved.capture()); + return saved.getValue(); + } + + @Test + void syncLibrary_UnchangedPlaytime_AsksSteamNothing() { + library(List.of(game(620L, 120)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120))); + + service.syncLibrary(USER_ID, STEAM_ID); + + verifyNoInteractions(steamApiService); + verify(userAchievementMetaRepository, never()).save(any()); + } + + @Test + void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() { + library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120))); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D")); + + service.syncLibrary(USER_ID, STEAM_ID); + + UserGameAchievementMeta meta = savedMeta(); + assertEquals(4, meta.getAchievementsUnlocked()); + assertEquals(150, meta.getPlaytimeAtRefresh()); + assertTrue(meta.isStatsVisible()); + verify(userAchievementRepository).deleteByUserIdAndAppid(USER_ID, 620L); + } + + @Test + void syncLibrary_NeverRefreshedWithPlaytime_AsksSteamOnce() { + library(List.of(game(620L, 120)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, null))); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A")); + + service.syncLibrary(USER_ID, STEAM_ID); + + assertEquals(120, savedMeta().getPlaytimeAtRefresh()); + } + + @Test + void syncLibrary_UnplayedGameWithoutData_AsksSteamOnce() { + library(List.of(game(620L, 0)), List.of(schema(620L, 10)), List.of()); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible()); + + service.syncLibrary(USER_ID, STEAM_ID); + + UserGameAchievementMeta meta = savedMeta(); + assertEquals(0, meta.getAchievementsUnlocked()); + assertEquals(0, meta.getPlaytimeAtRefresh()); + } + + @Test + void syncLibrary_MissingSchema_FetchesItAndSkipsGamesWithoutAchievements() { + library(List.of(game(620L, 60)), List.of(), List.of()); + when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.empty()); + + service.syncLibrary(USER_ID, STEAM_ID); + + ArgumentCaptor saved = ArgumentCaptor.forClass(GameAchievementSchemaMeta.class); + verify(schemaMetaRepository).save(saved.capture()); + assertFalse(saved.getValue().isHasSchema()); + verify(steamApiService, never()).getPlayerAchievements(anyString(), anyLong()); + } + + @Test + void syncLibrary_OldSchema_IsFetchedAgain() { + GameAchievementSchemaMeta old = new GameAchievementSchemaMeta(620L, 10, true, Instant.now().minus(Duration.ofDays(31))); + library(List.of(game(620L, 120)), List.of(old), List.of(unlocks(620L, 3, 120))); + when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.empty()); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(steamApiService).getSchemaForGame(620L); + } + + @Test + void syncLibrary_StopsAfterThreeSteamErrorsInARow_AndKeepsStoredData() { + List games = LongStream.rangeClosed(1, 6).mapToObj(appid -> game(appid, 60)).toList(); + List schemas = LongStream.rangeClosed(1, 6).mapToObj(appid -> schema(appid, 10)).toList(); + library(games, schemas, List.of()); + when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenThrow(new ResourceAccessException("down")); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(steamApiService, times(AchievementSyncService.MAX_ERRORS_IN_A_ROW)).getPlayerAchievements(eq(STEAM_ID), anyLong()); + verify(userAchievementMetaRepository, never()).save(any()); + verify(userAchievementRepository, never()).deleteByUserIdAndAppid(anyLong(), anyLong()); + } + + @Test + void syncLibrary_StopsWhenSteamHidesTheFirstGames() { + List games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 60)).toList(); + List schemas = LongStream.rangeClosed(1, 8).mapToObj(appid -> schema(appid, 10)).toList(); + library(games, schemas, List.of()); + when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenReturn(Optional.empty()); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(steamApiService, times(AchievementSyncService.MAX_HIDDEN_IN_A_ROW)).getPlayerAchievements(eq(STEAM_ID), anyLong()); + verify(userAchievementMetaRepository, times(AchievementSyncService.MAX_HIDDEN_IN_A_ROW)) + .save(argThat(meta -> !meta.isStatsVisible() && meta.getPlaytimeAtRefresh() == null)); + } + + @Test + void syncLibrary_HiddenGame_IsAskedAgainOnTheNextSync() { + UserGameAchievementMeta hidden = new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), false, 0, null); + library(List.of(game(620L, 60)), List.of(schema(620L, 10)), List.of(hidden)); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A")); + + service.syncLibrary(USER_ID, STEAM_ID); + + assertTrue(savedMeta().isStatsVisible()); + } + + @Test + void syncLibrary_HiddenGamesAfterAVisibleOne_DoNotStopTheRun() { + List games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 100 - (int) appid)).toList(); + List schemas = LongStream.rangeClosed(1, 8).mapToObj(appid -> schema(appid, 10)).toList(); + library(games, schemas, List.of()); + when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenReturn(Optional.empty()); + when(steamApiService.getPlayerAchievements(STEAM_ID, 1L)).thenReturn(visible("A")); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(steamApiService, times(8)).getPlayerAchievements(eq(STEAM_ID), anyLong()); + } + + @Test + void syncLibrary_CallWhileRunning_RunsOnceMoreAfterwards() throws Exception { + when(userGameRepository.findByUserIdAndRemovedAtIsNull(USER_ID)).thenReturn(List.of(game(620L, 60))); + when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10))); + // The second run sees the row the first run stored. + when(userAchievementMetaRepository.findByUserId(USER_ID)) + .thenReturn(List.of(), List.of(unlocks(620L, 1, 60))); + CountDownLatch inSteamCall = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenAnswer(invocation -> { + inSteamCall.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return visible("A"); + }); + Thread first = Thread.ofVirtual().start(() -> service.syncLibrary(USER_ID, STEAM_ID)); + assertTrue(inSteamCall.await(5, TimeUnit.SECONDS)); + + service.syncLibrary(USER_ID, STEAM_ID); + release.countDown(); + first.join(5_000); + + verify(steamApiService, times(1)).getPlayerAchievements(STEAM_ID, 620L); + verify(userGameRepository, times(2)).findByUserIdAndRemovedAtIsNull(USER_ID); + } + + @Test + void syncLibrary_UnplayedGameWithStoredUnlocks_KeepsTheUnlocks() { + UserGameAchievementMeta stored = unlocks(620L, 10, null); + library(List.of(game(620L, 0)), List.of(schema(620L, 10)), List.of(stored)); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)) + .thenReturn(visible("A", "B", "C", "D", "E", "F", "G", "H", "I", "J")); + + service.syncLibrary(USER_ID, STEAM_ID); + + verify(userAchievementMetaRepository, never()).save(argThat(meta -> meta.getAchievementsUnlocked() < 10)); + } + + @Test + void syncLibrary_TwoUsersInParallel_ShareOneSteamBudget() throws Exception { + long pauseMillis = 300; + AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository, + userAchievementRepository, userAchievementMetaRepository, steamApiService, + mock(PlatformTransactionManager.class), 30, pauseMillis); + when(userGameRepository.findByUserIdAndRemovedAtIsNull(1L)).thenReturn(List.of(new UserGame(1L, 620L, 60, Instant.now(), Instant.now()))); + when(userGameRepository.findByUserIdAndRemovedAtIsNull(2L)).thenReturn(List.of(new UserGame(2L, 620L, 60, Instant.now(), Instant.now()))); + when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10))); + when(userAchievementMetaRepository.findByUserId(anyLong())).thenReturn(List.of()); + List steamCallsAtNanos = Collections.synchronizedList(new ArrayList<>()); + when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenAnswer(invocation -> { + steamCallsAtNanos.add(System.nanoTime()); + return visible("A"); + }); + + Thread first = Thread.ofVirtual().start(() -> paced.syncLibrary(1L, STEAM_ID)); + Thread second = Thread.ofVirtual().start(() -> paced.syncLibrary(2L, STEAM_ID)); + first.join(5_000); + second.join(5_000); + + assertEquals(2, steamCallsAtNanos.size()); + long gapMillis = Math.abs(steamCallsAtNanos.get(1) - steamCallsAtNanos.get(0)) / 1_000_000; + assertTrue(gapMillis >= pauseMillis - 50, "Steam calls of two runs were only " + gapMillis + " ms apart"); + } + + @Test + void refreshUserIfStale_SteamError_ThrowsAndKeepsStoredData() { + lenient().when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L)).thenReturn(Optional.empty()); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenThrow(new ResourceAccessException("down")); + + assertThrows(ResourceAccessException.class, + () -> service.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1))); + verify(userAchievementMetaRepository, never()).save(any()); + } + + @Test + void refreshUserIfStale_FreshData_AsksSteamNothing() { + when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L)).thenReturn(Optional.of(unlocks(620L, 3, 60))); + + service.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1)); + + verifyNoInteractions(steamApiService); + } +} diff --git a/src/test/java/core_service/modules/library/service/GameDetailServicePoolTest.java b/src/test/java/core_service/modules/library/service/GameDetailServicePoolTest.java new file mode 100644 index 0000000..08d80a6 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/GameDetailServicePoolTest.java @@ -0,0 +1,83 @@ +package core_service.modules.library.service; + +import core_service.modules.library.api.dto.GameDetailDto; +import core_service.modules.library.domain.GameAchievementSchema; +import core_service.modules.library.domain.GameAchievementSchemaMeta; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; +import core_service.modules.library.repo.GameAchievementSchemaRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameAchievementMetaRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; +import core_service.modules.steam_adapter.service.SteamApiService; +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.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * One connection in the pool: a detail request must not need two at once. + */ +@TestPropertySource(properties = { + "spring.datasource.hikari.maximum-pool-size=1", + "spring.datasource.hikari.connection-timeout=1000"}) +class GameDetailServicePoolTest extends AbstractIntegrationTest { + + private static final long USER_ID = 1L; + private static final String STEAM_ID = "76561198000000001"; + + @Autowired + private GameDetailService gameDetailService; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private GameAchievementSchemaMetaRepository schemaMetaRepository; + + @Autowired + private UserGameAchievementMetaRepository userAchievementMetaRepository; + + @MockitoBean + private SteamApiService steamApiService; + + @Autowired + private GameAchievementSchemaRepository schemaRepository; + + @BeforeEach + void seed() { + userGameRepository.deleteAll(); + schemaMetaRepository.deleteAll(); + userAchievementMetaRepository.deleteAll(); + steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now())); + userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now())); + schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 1, true, Instant.now())); + schemaRepository.save(new GameAchievementSchema(620L, "A", "First", null, null, null, false)); + } + + @Test + void getGameDetail_NeedsOnlyOneConnectionAtATime() { + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of( + new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2", + List.of(new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L)), true, null))); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow(); + + assertEquals(1, userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L).orElseThrow().getAchievementsUnlocked()); + assertEquals(1, detail.achievementsUnlocked()); + } +} diff --git a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java new file mode 100644 index 0000000..e35ae81 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java @@ -0,0 +1,144 @@ +package core_service.modules.library.service; + +import core_service.modules.library.api.dto.GameDetailDto; +import core_service.modules.library.domain.GameAchievementSchema; +import core_service.modules.library.domain.GameAchievementSchemaMeta; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserGameAchievementMeta; +import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.GameAchievementSchemaRepository; +import core_service.modules.library.repo.UserGameAchievementMetaRepository; +import core_service.modules.library.repo.UserGameAchievementRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; +import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse; +import core_service.modules.steam_adapter.service.SteamApiService; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.client.ResourceAccessException; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * The detail page refreshes stale rows in a transaction of its own, then reads them in its own read transaction. + */ +class GameDetailServiceTest extends AbstractIntegrationTest { + + private static final long USER_ID = 1L; + private static final String STEAM_ID = "76561198000000001"; + + @Autowired + private GameDetailService gameDetailService; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private GameAchievementSchemaRepository schemaRepository; + + @Autowired + private GameAchievementSchemaMetaRepository schemaMetaRepository; + + @Autowired + private UserGameAchievementRepository userAchievementRepository; + + @Autowired + private UserGameAchievementMetaRepository userAchievementMetaRepository; + + @MockitoBean + private SteamApiService steamApiService; + + @BeforeEach + void seed() { + userGameRepository.deleteAll(); + schemaRepository.deleteAll(); + schemaMetaRepository.deleteAll(); + userAchievementRepository.deleteAll(); + userAchievementMetaRepository.deleteAll(); + steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now())); + userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now())); + } + + private static SteamPlayerAchievementsResponse.Achievement achieved(String name) { + return new SteamPlayerAchievementsResponse.Achievement(name, 1, 1_700_000_000L); + } + + @Test + void getGameDetail_StaleUserMeta_ShowsTheRefreshedCount() { + 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)); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of( + new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2", + List.of(achieved("A"), achieved("B"), achieved("C"), achieved("D"), achieved("E")), true, null))); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow(); + + assertEquals(5, userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L).orElseThrow().getAchievementsUnlocked()); + assertEquals(5, detail.achievementsUnlocked()); + } + + @Test + void getGameDetail_StaleSchemaWithoutAchievements_ShowsTheRefreshedSchema() { + schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 0, false, Instant.now().minus(Duration.ofDays(31)))); + when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.of(new SteamSchemaForGameResponse.Game("Portal 2", "1", + new SteamSchemaForGameResponse.AvailableGameStats(List.of( + new SteamSchemaForGameResponse.Achievement("A", "A", null, 0, null, null)))))); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of( + new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2", List.of(achieved("A")), true, null))); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow(); + + assertTrue(schemaMetaRepository.findById(620L).orElseThrow().isHasSchema()); + assertTrue(detail.hasAchievements()); + } + + @Test + void getGameDetail_SchemaLostAchievements_CapsTheUnlocks() { + schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 2, true, Instant.now())); + for (String name : List.of("A", "B")) { + schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false)); + } + userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 3, 60)); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow(); + + assertEquals(2, detail.achievementsUnlocked()); + assertEquals(100.0, detail.completionPercent()); + assertEquals(0, detail.achievementsRemaining()); + } + + @Test + void getGameDetail_SteamDown_ShowsTheStoredData() { + schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 2, true, Instant.now())); + for (String name : List.of("A", "B")) { + 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, 1, 60)); + when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenThrow(new ResourceAccessException("down")); + + GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow(); + + assertEquals(1, detail.achievementsUnlocked()); + assertTrue(detail.statsVisible()); + } +} diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 138e026..34213c6 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -21,6 +21,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; @@ -39,6 +40,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; class LibrarySyncWorkerTest extends AbstractUnitTest { @@ -99,6 +101,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { @Mock private TimeToBeatService timeToBeatService; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private LibrarySyncWorker worker; @@ -116,6 +121,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any()); verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any()); + verifyNoInteractions(eventPublisher); } @Test @@ -154,6 +160,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any()); verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any()); + verify(eventPublisher).publishEvent(new LibrarySyncedEvent(USER_ID, STEAM_ID)); } @ParameterizedTest diff --git a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java index dc07c28..495f19e 100644 --- a/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java +++ b/src/test/java/core_service/modules/steam_adapter/service/SteamApiServiceTest.java @@ -1,14 +1,17 @@ package core_service.modules.steam_adapter.service; import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse; +import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse; import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; import java.time.Instant; import java.util.List; @@ -20,9 +23,11 @@ import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.not; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; class SteamApiServiceTest { @@ -258,4 +263,67 @@ class SteamApiServiceTest { server.verify(); } + + @Test + void getPlayerAchievements_Forbidden_IsEmpty() { + server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/"))) + .andRespond(withStatus(HttpStatus.FORBIDDEN)); + + assertThat(steamApiService.getPlayerAchievements("76561197960435530", 620)).isEmpty(); + } + + @Test + void getPlayerAchievements_ServerError_Throws() { + server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/"))) + .andRespond(withServerError()); + + assertThatThrownBy(() -> steamApiService.getPlayerAchievements("76561197960435530", 620)) + .isInstanceOf(RestClientException.class); + } + + @Test + void getSchemaForGame_ServerError_Throws() { + server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/"))) + .andRespond(withServerError()); + + assertThatThrownBy(() -> steamApiService.getSchemaForGame(620)).isInstanceOf(RestClientException.class); + } + + @Test + void getSchemaForGame_NoGame_IsEmpty() { + server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/"))) + .andRespond(withSuccess("{\"game\":null}", MediaType.APPLICATION_JSON)); + + assertThat(steamApiService.getSchemaForGame(620)).isEmpty(); + } + + @Test + void getPlayerAchievements_NoStatsForThisPlayer_IsVisibleWithNothingUnlocked() { + server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/"))) + .andRespond(withStatus(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON) + .body("{\"playerstats\":{\"error\":\"Requested app has no stats\",\"success\":false}}")); + + Optional stats = steamApiService.getPlayerAchievements("76561197960435530", 620); + assertThat(stats).isPresent(); + assertThat(stats.get().success()).isTrue(); + assertThat(stats.get().achievements()).isEmpty(); + } + + @Test + void getSchemaForGame_ClientError_IsEmpty() { + server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/"))) + .andRespond(withStatus(HttpStatus.BAD_REQUEST)); + + assertThat(steamApiService.getSchemaForGame(620)).isEmpty(); + } + + @Test + void getPlayerAchievements_OtherBadRequest_Throws() { + server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/"))) + .andRespond(withStatus(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON) + .body("{\"playerstats\":{\"error\":\"Invalid SteamID\",\"success\":false}}")); + + assertThatThrownBy(() -> steamApiService.getPlayerAchievements("76561197960435530", 620)) + .isInstanceOf(RestClientException.class); + } } diff --git a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java index 6363401..c072b4e 100644 --- a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java @@ -39,6 +39,7 @@ public abstract class AbstractIntegrationTest { static void secrets(DynamicPropertyRegistry registry) { registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded())); registry.add("app.steam.api-key", () -> "test-key"); + registry.add("app.library.achievement-sync-pause-ms", () -> "0"); } private static KeyPair generateKeys() {