feat(library): library-wide achievement sync and summary

Achievements were fetched only when a game detail page was opened, and a Steam error was stored as "no achievements" or "hidden". A background run after each library sync now fetches schemas and the unlocks of games with new playtime, one Steam call per second for all runs together, and never overwrites stored data on a Steam error. Adds GET /api/library/achievements/summary, achievement counts on GameDto and the public profile, and migration V7 (playtime_at_refresh).
This commit is contained in:
lobo committed 2026-09-25 10:13:31 +02:00
1 parent 86db6f7167
commit 9fd552a785
25 files changed
+1399 -95

No files matched your search

+8 -2
View File
@@ -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.
@@ -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<AchievementSummaryDto> getAchievementSummary(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(queryService.getAchievementSummary(principal.getIdAsLong()));
}
@GetMapping("/picker")
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Integer maxMinutes,
@@ -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<Game> closest
) {
public record Game(Long appid, String name, int unlocked, int total) {}
}
@@ -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
) {}
@@ -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<GameStatus, Long> byStatus,
List<Game> playing,
List<Game> finished
List<Game> finished,
int perfectGames,
Double averageCompletion
) {
public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<UserGameAchievementMeta, UserGameId> {
Optional<UserGameAchievementMeta> findByUserIdAndAppid(Long userId, Long appid);
List<UserGameAchievementMeta> findByUserId(Long userId);
List<UserGameAchievementMeta> findByUserIdAndAppidIn(Long userId, Collection<Long> appids);
}
@@ -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<UserGame, UserGameId>
"and appid <> :excludeAppid order by random() limit 1", nativeQuery = true)
Optional<UserGame> 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<AchievementProgress> findAchievementProgress(@Param("userId") Long userId);
/**
* Random backlog games, HIGH priority first, then MEDIUM, then the rest.
*
@@ -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<Long> runningUsers = ConcurrentHashMap.newKeySet();
private final Map<Long, String> 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<GameAchievementSchemaMeta> 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<UserGameAchievementMeta> 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<UserGame> games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream()
.sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed())
.toList();
Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository
.findAllById(games.stream().map(UserGame::getAppid).toList()).stream()
.collect(Collectors.toMap(GameAchievementSchemaMeta::getAppid, Function.identity()));
Map<Long, UserGameAchievementMeta> 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<SteamSchemaForGameResponse.Game> schema = steamApiService.getSchemaForGame(appid);
List<SteamSchemaForGameResponse.Achievement> 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<SteamPlayerAchievementsResponse.PlayerStats> response = steamApiService.getPlayerAchievements(steamId, appid);
boolean visible = response.isPresent() && (response.get().success() == null || response.get().success());
List<UserGameAchievement> 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 }
}
@@ -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<GameDetailDto> getGameDetail(Long userId, String steamId, Long appid) {
Optional<UserGame> 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<GameAchievementSchemaMeta> existing = schemaMetaRepository.findById(appid);
Instant now = Instant.now();
if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofDays(schemaTtlDays)))) {
return;
}
Optional<SteamSchemaForGameResponse.Game> 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<GameAchievementSchema> 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<UserGameAchievementMeta> existing = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid);
Instant now = Instant.now();
if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofHours(achievementTtlHours)))) {
return;
}
Optional<SteamPlayerAchievementsResponse.PlayerStats> 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<SteamPlayerAchievementsResponse.Achievement> list = response.get().achievements();
userAchievementRepository.deleteByUserIdAndAppid(userId, appid);
int unlockedCount = 0;
if (list != null) {
List<UserGameAchievement> 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));
}
}
@@ -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<GameDto> toDtos(List<UserGame> games) {
if (games.isEmpty()) return List.of();
List<Long> appids = games.stream().map(UserGame::getAppid).toList();
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, g -> g));
Map<Long, GameTimeToBeat> 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<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository.findAllById(appids).stream()
.collect(Collectors.toMap(GameAchievementSchemaMeta::getAppid, m -> m));
Map<Long, UserGameAchievementMeta> 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<AchievementProgress> games = userGameRepository.findAchievementProgress(userId);
List<AchievementProgress> started = startedGames(games);
List<AchievementProgress> 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<Long, SteamGame> 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<AchievementProgress> startedGames(List<AchievementProgress> games) {
return games.stream().filter(g -> g.hasAchievements() && g.visible() && g.unlockedCapped() > 0).toList();
}
private static int perfectGames(List<AchievementProgress> started) {
return (int) started.stream().filter(g -> g.unlockedCapped() == g.total()).count();
}
private static Double averageCompletion(List<AchievementProgress> started) {
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<AchievementProgress> 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<UserSteamSync> getSyncStatus(Long userId) {
@@ -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);
@@ -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) {}
@@ -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();
}
@@ -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;
@@ -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);
}
}
@@ -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());
@@ -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<Void> 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<Void> 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();
}
}
}
@@ -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<SteamPlayerAchievementsResponse.PlayerStats> visible(String... unlockedNames) {
List<SteamPlayerAchievementsResponse.Achievement> 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<UserGame> games, List<GameAchievementSchemaMeta> schemas, List<UserGameAchievementMeta> 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<UserGameAchievementMeta> 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<GameAchievementSchemaMeta> 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<UserGame> games = LongStream.rangeClosed(1, 6).mapToObj(appid -> game(appid, 60)).toList();
List<GameAchievementSchemaMeta> 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<UserGame> games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 60)).toList();
List<GameAchievementSchemaMeta> 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<UserGame> games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 100 - (int) appid)).toList();
List<GameAchievementSchemaMeta> 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<Long> 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);
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -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
@@ -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<SteamPlayerAchievementsResponse.PlayerStats> 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);
}
}
@@ -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() {