Compare commits

...
3 Commits
Author SHA1 Message Date
lobo 762b7898d3 feat(security): load verify keys from auth JWKS
Build, Test & Push Core Service / docker (push) Successful in 1m24s
Build, Test & Push Core Service / quality-check (push) Successful in 40s
Core finds the key by the token kid and loads the JWKS again for an
unknown kid, at most every 10 s. JWT_PUBLIC_KEY is used only until the
first JWKS load works, so a key removed from the JWKS cannot verify
later. While that key still works, requests do not wait for a slow
Auth.
2026-09-25 11:01:28 +02:00
lobo 9fd552a785 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).
2026-09-25 10:13:31 +02:00
lobo 86db6f7167 feat(library): picker for what to play next and a status-only update
GET /api/library/picker returns random backlog games for a time budget, HIGH priority first. PUT /games/{appid}/status changes only the status, so the picker does not need the game detail (and its Steam calls) to keep rating, notes and priority.
2026-09-25 09:29:15 +02:00
37 changed files with 2164 additions and 157 deletions

No files matched your search

+21 -9
View File
@@ -17,20 +17,30 @@ To Be Played is split into three repositories:
sync. Resilience4j retries and rate-limits every Steam call; Caffeine caches game counts and player summaries. sync. Resilience4j retries and rate-limits every Steam call; Caffeine caches game counts and player summaries.
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority, - **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play. 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" - **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
dashboard banner. dashboard banner.
- **Wishlist**: synced from Steam with store price and discount. - **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 - **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
feature is off and the rest works. 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. with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID.
## Authentication ## Authentication
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with the public key only (`JWT_PUBLIC_KEY`) and Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
reads `userId` and `steamId` from the claims. A token with a `typ` claim (for example a profile ticket) is rejected JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
on normal endpoints; the profile ticket is accepted only by `GET /api/public/profiles/library` in the until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. Core reads `userId` and
`X-Profile-Ticket` header. `steamId` from the claims.
A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header.
## API ## API
@@ -38,7 +48,10 @@ on normal endpoints; the profile ticket is accepted only by `GET /api/public/pro
|---|---|---| |---|---|---|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT | | `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
| `PUT` | `/api/library/games/{appid}/backlog` | JWT | | `PUT` | `/api/library/games/{appid}/backlog` | JWT |
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
| `POST` | `/api/library/sync` | 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/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket | | `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/stats/public` | public | | `GET` | `/api/stats/public` | public |
@@ -87,7 +100,8 @@ Configuration comes from environment variables:
| Variable | Purpose | | Variable | Purpose |
|---|---| |---|---|
| `JWT_PUBLIC_KEY` | Auth's Ed25519 public key, Base64 of the X.509 DER bytes | | `JWT_JWKS_URL` | Auth's `/.well-known/jwks.json` |
| `JWT_PUBLIC_KEY` | Ed25519 public key (Base64 of the X.509 DER bytes), used until the first JWKS load. Set this, `JWT_JWKS_URL`, or both |
| `STEAM_API_KEY` | Steam Web API key | | `STEAM_API_KEY` | Steam Web API key |
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) | | `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
| `DB_PASSWORD` | Local PostgreSQL password | | `DB_PASSWORD` | Local PostgreSQL password |
@@ -102,11 +116,9 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
## Roadmap ## Roadmap
- "What to play next" picker: time budget and mood on top of the current random suggestion. - 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. - Sale alerts for wishlist games.
- Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit. - 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.
## License ## License
@@ -0,0 +1,23 @@
package core_service.configuration.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import java.time.Duration;
@Configuration
public class JwksClientConfig {
@Bean
public RestClient jwksRestClient() {
// The call runs on a request thread, so a slow Auth must not hold it for long.
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(2));
requestFactory.setReadTimeout(Duration.ofSeconds(3));
return RestClient.builder()
.requestFactory(requestFactory)
.build();
}
}
@@ -0,0 +1,140 @@
package core_service.configuration.security;
import io.jsonwebtoken.JwsHeader;
import io.jsonwebtoken.LocatorAdapter;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Jwk;
import io.jsonwebtoken.security.JwkSet;
import io.jsonwebtoken.security.Jwks;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.security.Key;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
/**
* Finds the verify key for a token by its {@code kid} in Auth's JWKS. An unknown {@code kid} (Auth rotated its key)
* loads the JWKS again, at most once per {@link #MIN_REFRESH_INTERVAL}, so tokens with made-up ids cannot flood Auth.
* The static {@code JWT_PUBLIC_KEY} is used only until the first JWKS load works; after that a key that left the
* JWKS must not verify any more.
*/
@Slf4j
@Component
public class JwksKeyLocator extends LocatorAdapter<Key> {
static final Duration MIN_REFRESH_INTERVAL = Duration.ofSeconds(10);
private final RestClient jwksRestClient;
private final String jwksUrl;
private final PublicKey fallbackKey;
private final Clock clock;
private volatile Map<String, PublicKey> keysById = Map.of();
private volatile boolean jwksLoaded;
private final ReentrantLock refreshLock = new ReentrantLock();
private Instant lastRefresh = Instant.MIN;
@Autowired
public JwksKeyLocator(JwtConfig jwtConfig, @Qualifier("jwksRestClient") RestClient jwksRestClient) {
this(jwtConfig, jwksRestClient, Clock.systemUTC());
}
JwksKeyLocator(JwtConfig jwtConfig, RestClient jwksRestClient, Clock clock) {
this.jwksRestClient = jwksRestClient;
this.jwksUrl = blankToNull(jwtConfig.getJwksUrl());
String publicKey = blankToNull(jwtConfig.getPublicKey());
this.fallbackKey = publicKey == null ? null : decodePublicKey(publicKey);
this.clock = clock;
if (jwksUrl == null && fallbackKey == null) {
throw new IllegalStateException("Set JWT_JWKS_URL or JWT_PUBLIC_KEY, else no token can be verified");
}
if (jwksUrl != null) {
// Auth may start after Core. A failed first load is retried on the first token.
load();
}
}
@Override
protected Key locate(JwsHeader header) {
String kid = header.getKeyId();
if (kid != null) {
PublicKey key = keysById.get(kid);
if (key == null) {
// While the static key can still verify, a slow or down Auth must not make every request wait.
refreshIfAllowed(fallbackKey == null || jwksLoaded);
// Read again even when this thread did not load: another thread may have loaded while it waited.
key = keysById.get(kid);
}
if (key != null) {
return key;
}
}
if (fallbackKey != null && !jwksLoaded) {
return fallbackKey;
}
throw new UnsupportedJwtException("No verify key for kid " + kid);
}
private void refreshIfAllowed(boolean waitForRunningLoad) {
if (jwksUrl == null) {
return;
}
if (waitForRunningLoad) {
refreshLock.lock();
} else if (!refreshLock.tryLock()) {
return;
}
try {
Instant now = clock.instant();
if (lastRefresh.plus(MIN_REFRESH_INTERVAL).isAfter(now)) {
return;
}
lastRefresh = now;
load();
} finally {
refreshLock.unlock();
}
}
private void load() {
try {
String body = jwksRestClient.get().uri(jwksUrl).retrieve().body(String.class);
JwkSet jwkSet = Jwks.setParser().build().parse(body);
Map<String, PublicKey> keys = new HashMap<>();
for (Jwk<?> jwk : jwkSet.getKeys()) {
if (jwk.getId() != null && jwk.toKey() instanceof PublicKey publicKey) {
keys.put(jwk.getId(), publicKey);
}
}
keysById = Map.copyOf(keys);
jwksLoaded = true;
} catch (Exception e) {
log.warn("Loading JWKS from {} failed: {}", jwksUrl, e.getMessage());
}
}
private static String blankToNull(String value) {
return value == null || value.isBlank() ? null : value;
}
private static PublicKey decodePublicKey(String base64X509Der) {
try {
byte[] keyBytes = Base64.getDecoder().decode(base64X509Der);
return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(keyBytes));
} catch (Exception e) {
throw new IllegalStateException("Failed to decode Ed25519 public key", e);
}
}
}
@@ -13,4 +13,6 @@ public class JwtConfig {
/** Base64-encoded X.509 DER Ed25519 public key. Verify-only; private key lives in Auth. */ /** Base64-encoded X.509 DER Ed25519 public key. Verify-only; private key lives in Auth. */
private String publicKey; private String publicKey;
private String jwksUrl;
} }
@@ -1,64 +1,43 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.ProfileTicketVerifier; import core_service.shared.security.ProfileTicketVerifier;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Optional; import java.util.Optional;
/** /**
* Verifies JWTs issued by tbp-auth-service. Verify-only: holds the Ed25519 public key. * Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
*/ */
@Service @Service
public class JwtService implements ProfileTicketVerifier { public class JwtService implements ProfileTicketVerifier {
private static final String ED25519 = "Ed25519";
// Only non-login tokens carry this claim; isTokenValid rejects them. // Only non-login tokens carry this claim; isTokenValid rejects them.
private static final String TYPE_CLAIM = "typ"; private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile"; private static final String PROFILE_TICKET_TYPE = "public-profile";
private final JwtConfig jwtConfig; private final JwtParser parser;
private PublicKey publicKey;
public JwtService(JwtConfig jwtConfig) { public JwtService(JwksKeyLocator keyLocator) {
this.jwtConfig = jwtConfig; this.parser = Jwts.parser().keyLocator(keyLocator).build();
} }
@PostConstruct private Claims claims(String token) {
void init() { return parser.parseSignedClaims(token).getPayload();
this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
} }
public String extractEmail(String token) { public String extractEmail(String token) {
return Jwts.parser() return claims(token).getSubject();
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
} }
public String extractUserId(String token) { public String extractUserId(String token) {
return Jwts.parser() return claims(token).get("userId", String.class);
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("userId", String.class);
} }
public Long extractSteamId(String token) { public Long extractSteamId(String token) {
return Jwts.parser() return claims(token).get("steamId", Long.class);
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("steamId", Long.class);
} }
/** /**
@@ -66,11 +45,7 @@ public class JwtService implements ProfileTicketVerifier {
*/ */
public boolean isTokenValid(String token) { public boolean isTokenValid(String token) {
try { try {
var claims = Jwts.parser() Claims claims = claims(token);
.verifyWith(publicKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.get(TYPE_CLAIM) == null; return claims.get(TYPE_CLAIM) == null;
} catch (Exception e) { } catch (Exception e) {
return false; return false;
@@ -80,11 +55,7 @@ public class JwtService implements ProfileTicketVerifier {
@Override @Override
public Optional<Long> verifyProfileTicket(String ticket) { public Optional<Long> verifyProfileTicket(String ticket) {
try { try {
var claims = Jwts.parser() Claims claims = claims(ticket);
.verifyWith(publicKey)
.build()
.parseSignedClaims(ticket)
.getPayload();
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) { if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
return Optional.empty(); return Optional.empty();
} }
@@ -93,13 +64,4 @@ public class JwtService implements ProfileTicketVerifier {
return Optional.empty(); return Optional.empty();
} }
} }
private static PublicKey decodePublicKey(String base64X509Der) {
try {
byte[] keyBytes = Base64.getDecoder().decode(base64X509Der);
return KeyFactory.getInstance(ED25519).generatePublic(new X509EncodedKeySpec(keyBytes));
} catch (Exception e) {
throw new IllegalStateException("Failed to decode Ed25519 public key", e);
}
}
} }
@@ -1,10 +1,13 @@
package core_service.modules.library.api; 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.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto; import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PageDto; import core_service.modules.library.api.dto.PageDto;
import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.StatusUpdateDto;
import core_service.modules.library.api.dto.SteamProfileDto; import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.library.api.dto.SyncStatusDto; import core_service.modules.library.api.dto.SyncStatusDto;
import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.api.dto.WishlistDto;
@@ -33,7 +36,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
@RestController @RestController
@@ -45,6 +50,8 @@ public class LibraryController {
private static final int DEFAULT_SIZE = 20; private static final int DEFAULT_SIZE = 20;
private static final int MIN_ACTIVITY_DAYS = 7; private static final int MIN_ACTIVITY_DAYS = 7;
private static final int MAX_ACTIVITY_DAYS = 90; private static final int MAX_ACTIVITY_DAYS = 90;
private static final int MAX_PICKER_GAMES = 10;
private static final int MAX_PICKER_EXCLUDES = 100;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
private final LibrarySyncService syncService; private final LibrarySyncService syncService;
@@ -142,6 +149,16 @@ public class LibraryController {
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"))); .body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
} }
@PutMapping("/games/{appid}/status")
public ResponseEntity<?> updateStatus(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@Valid @RequestBody StatusUpdateDto update) {
return backlogService.updateStatus(principal.getIdAsLong(), appid, update.status())
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
}
@GetMapping("/activity") @GetMapping("/activity")
public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(defaultValue = "30") int days) { @RequestParam(defaultValue = "30") int days) {
@@ -160,6 +177,23 @@ public class LibraryController {
return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude)); 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,
@RequestParam(required = false) List<Long> exclude,
@RequestParam(defaultValue = "3") int count) {
List<Long> excluded = exclude == null ? List.of()
: exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList();
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
return ResponseEntity.ok(queryService.pickBacklogGames(principal.getIdAsLong(), budget, excluded, safeCount));
}
@GetMapping("/sync-status") @GetMapping("/sync-status")
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
return queryService.getSyncStatus(principal.getIdAsLong()) return queryService.getSyncStatus(principal.getIdAsLong())
@@ -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 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( public record GameDto(
Long appid, Long appid,
@@ -14,5 +16,7 @@ public record GameDto(
GameStatus status, GameStatus status,
Integer rating, Integer rating,
GamePriority priority, GamePriority priority,
Integer timeToBeatMinutes Integer timeToBeatMinutes,
Integer achievementsUnlocked,
Integer achievementsTotal
) {} ) {}
@@ -0,0 +1,5 @@
package core_service.modules.library.api.dto;
import java.util.List;
public record PickerDto(List<GameDto> games) {}
@@ -11,13 +11,16 @@ import java.util.Map;
* @param byStatus has every status, also those with 0 games * @param byStatus has every status, also those with 0 games
* @param playing up to 8 games, most played first * @param playing up to 8 games, most played first
* @param finished up to 12 FINISHED or COMPLETED games, best rated first * @param finished up to 12 FINISHED or COMPLETED games, best rated first
* @param averageCompletion see {@link AchievementSummaryDto#averageCompletion()}
*/ */
public record PublicLibraryDto( public record PublicLibraryDto(
long totalGames, long totalGames,
long totalPlaytimeMinutes, long totalPlaytimeMinutes,
Map<GameStatus, Long> byStatus, Map<GameStatus, Long> byStatus,
List<Game> playing, 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) {} public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {}
@@ -0,0 +1,6 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
import jakarta.validation.constraints.NotNull;
public record StatusUpdateDto(@NotNull GameStatus status) {}
@@ -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) @Column(name = "achievements_unlocked", nullable = false)
private int achievementsUnlocked; 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.userId = userId;
this.appid = appid; this.appid = appid;
this.refreshedAt = refreshedAt; this.refreshedAt = refreshedAt;
this.statsVisible = statsVisible; this.statsVisible = statsVisible;
this.achievementsUnlocked = achievementsUnlocked; 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 core_service.modules.library.domain.UserGameId;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface UserGameAchievementMetaRepository extends JpaRepository<UserGameAchievementMeta, UserGameId> { public interface UserGameAchievementMetaRepository extends JpaRepository<UserGameAchievementMeta, UserGameId> {
Optional<UserGameAchievementMeta> findByUserIdAndAppid(Long userId, Long appid); 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; 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.GameStatus;
import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
@@ -14,6 +15,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -46,6 +48,38 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"and appid <> :excludeAppid order by random() limit 1", nativeQuery = true) "and appid <> :excludeAppid order by random() limit 1", nativeQuery = true)
Optional<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid); 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.
*
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
* @param excludeAppids never picked; may be empty
*/
default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Collection<Long> excludeAppids, int count) {
List<Long> excluded = new ArrayList<>(excludeAppids);
// "not in ()" is invalid SQL, and no appid is -1
excluded.add(-1L);
return queryPickerGames(userId, maxMinutes, excluded, count);
}
@Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " +
"and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
"and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " +
" where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " +
"order by case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end, random() " +
"limit :count", nativeQuery = true)
List<UserGame> queryPickerGames(@Param("userId") Long userId,
@Param("maxMinutes") Integer maxMinutes,
@Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count);
/** /**
* @param status null for every status * @param status null for every status
* @param namePattern a {@link #containsPattern} result, null for every name * @param namePattern a {@link #containsPattern} result, null for every name
@@ -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 }
}
@@ -1,6 +1,7 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.api.dto.BacklogEntryDto; import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
@@ -32,6 +33,21 @@ public class BacklogService {
}); });
} }
/**
* Changes only the status and keeps rating, notes and priority.
*
* @return empty when the game is not in the user's active library
*/
@Transactional
public Optional<BacklogEntryDto> updateStatus(Long userId, Long appid, GameStatus status) {
return userGameRepository.findById(new UserGameId(userId, appid))
.filter(game -> game.getRemovedAt() == null)
.map(game -> {
game.setStatus(status);
return toDto(game);
});
}
private static BacklogEntryDto toDto(UserGame game) { private static BacklogEntryDto toDto(UserGame game) {
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority()); return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
} }
@@ -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.AchievementDto;
import core_service.modules.library.api.dto.GameDetailDto; 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.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta; import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SteamGame; 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.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository; import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository; 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.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration; import java.time.Duration;
import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -43,16 +39,16 @@ public class GameDetailService {
private final GameAchievementSchemaMetaRepository schemaMetaRepository; private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final UserGameAchievementRepository userAchievementRepository; private final UserGameAchievementRepository userAchievementRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final SteamApiService steamApiService; private final AchievementSyncService achievementSyncService;
private final GameTimeToBeatRepository gameTimeToBeatRepository; private final GameTimeToBeatRepository gameTimeToBeatRepository;
@Value("${app.library.achievement-ttl-hours:1}") @Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours; private long achievementTtlHours;
@Value("${app.library.schema-ttl-days:30}") /**
private long schemaTtlDays; * 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.
@Transactional */
public Optional<GameDetailDto> getGameDetail(Long userId, String steamId, Long appid) { public Optional<GameDetailDto> getGameDetail(Long userId, String steamId, Long appid) {
Optional<UserGame> userGameOpt = userGameRepository.findById(new UserGameId(userId, appid)); Optional<UserGame> userGameOpt = userGameRepository.findById(new UserGameId(userId, appid));
if (userGameOpt.isEmpty() || userGameOpt.get().getRemovedAt() != null) { if (userGameOpt.isEmpty() || userGameOpt.get().getRemovedAt() != null) {
@@ -61,14 +57,23 @@ public class GameDetailService {
UserGame userGame = userGameOpt.get(); UserGame userGame = userGameOpt.get();
SteamGame meta = steamGameRepository.findById(appid).orElse(null); 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); boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
if (!hasSchema) { if (!hasSchema) {
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of())); 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); UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
boolean statsVisible = userMeta == null || userMeta.isStatsVisible(); boolean statsVisible = userMeta == null || userMeta.isStatsVisible();
@@ -97,7 +102,8 @@ public class GameDetailService {
}) })
.toList(); .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, private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible,
@@ -129,68 +135,4 @@ public class GameDetailService {
.orElse(null) .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,26 +1,34 @@
package core_service.modules.library.service; 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.ActivityDto;
import core_service.modules.library.api.dto.GameDto; import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto; import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.PublicLibraryDto; import core_service.modules.library.api.dto.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto; 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.DayMinutes;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; 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.domain.UserSteamSync;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository; 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.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository; import core_service.modules.library.repo.WishlistGameRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -29,6 +37,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Comparator;
import java.util.EnumMap; import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -43,6 +52,7 @@ public class LibraryQueryService {
private static final int PUBLIC_PLAYING = 8; private static final int PUBLIC_PLAYING = 8;
private static final int PUBLIC_FINISHED = 12; private static final int PUBLIC_FINISHED = 12;
private static final int CLOSEST_GAMES = 5;
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository; private final UserSteamSyncRepository userSteamSyncRepository;
@@ -50,6 +60,8 @@ public class LibraryQueryService {
private final PlaytimeDayRepository playtimeDayRepository; private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository; private final WishlistGameRepository wishlistGameRepository;
private final GameTimeToBeatRepository gameTimeToBeatRepository; private final GameTimeToBeatRepository gameTimeToBeatRepository;
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
@Value("${app.library.zone:Europe/Berlin}") @Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone; private ZoneId zone;
@@ -64,12 +76,74 @@ public class LibraryQueryService {
UserGameRepository.querySort(pageable.getSort())); UserGameRepository.querySort(pageable.getSort()));
Page<UserGame> page = userGameRepository.findOwnedGames( Page<UserGame> page = userGameRepository.findOwnedGames(
userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable); userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList(); List<GameDto> games = toDtos(page.getContent());
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
}
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, List<Long> excludeAppids, int count) {
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() Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, g -> g)); .collect(Collectors.toMap(SteamGame::getAppid, g -> g));
Map<Long, GameTimeToBeat> times = gameTimeToBeatRepository.findAllById(appids).stream() Map<Long, GameTimeToBeat> times = gameTimeToBeatRepository.findAllById(appids).stream()
.collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t)); .collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t));
return page.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()))); 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;
} }
/** /**
@@ -80,8 +154,7 @@ public class LibraryQueryService {
long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0); long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1) GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1)
.map(ug -> toDto(ug, steamGameRepository.findById(ug.getAppid()).orElse(null), .map(ug -> toDtos(List.of(ug)).getFirst())
gameTimeToBeatRepository.findById(ug.getAppid()).orElse(null)))
.orElse(null); .orElse(null);
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId)); return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId));
} }
@@ -126,8 +199,10 @@ public class LibraryQueryService {
Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList()) Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
List<AchievementProgress> started = startedGames(userGameRepository.findAchievementProgress(userId));
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus, 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. // Every status, also those with 0 games.
@@ -155,7 +230,12 @@ public class LibraryQueryService {
.toList()); .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( return new GameDto(
ug.getAppid(), ug.getAppid(),
meta != null ? meta.getName() : null, meta != null ? meta.getName() : null,
@@ -164,7 +244,9 @@ public class LibraryQueryService {
ug.getStatus(), ug.getStatus(),
ug.getRating(), ug.getRating(),
ug.getPriority(), ug.getPriority(),
time != null ? time.getNormallyMinutes() : null); time != null ? time.getNormallyMinutes() : null,
achievementsUnlocked,
achievementsTotal);
} }
public Optional<UserSteamSync> getSyncStatus(Long userId) { public Optional<UserSteamSync> getSyncStatus(Long userId) {
@@ -15,6 +15,7 @@ import core_service.modules.steam_adapter.service.SteamApiService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -48,6 +49,7 @@ public class LibrarySyncWorker {
private final PlaytimeDayRepository playtimeDayRepository; private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository; private final WishlistGameRepository wishlistGameRepository;
private final TimeToBeatService timeToBeatService; private final TimeToBeatService timeToBeatService;
private final ApplicationEventPublisher eventPublisher;
@Value("${app.library.running-stale-minutes:5}") @Value("${app.library.running-stale-minutes:5}")
private long runningStaleMinutes; private long runningStaleMinutes;
@@ -115,6 +117,7 @@ public class LibrarySyncWorker {
} }
userSteamSyncRepository.markHistoryBaseline(userId, steamId); userSteamSyncRepository.markHistoryBaseline(userId, steamId);
timeToBeatService.refresh(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList()); timeToBeatService.refresh(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
finalStatus = Status.OK; finalStatus = Status.OK;
} catch (Exception e) { } catch (Exception e) {
log.error("Sync failed for user {}", userId, 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 @Slf4j
public class SteamApiService { public class SteamApiService {
private static final String NO_STATS = "Requested app has no stats";
private final RestClient steamRestClient; private final RestClient steamRestClient;
private static final int STORE_ITEMS_BATCH = 100; private static final int STORE_ITEMS_BATCH = 100;
@@ -245,7 +247,8 @@ public class SteamApiService {
/** /**
* Per-game achievement schema (global, language-aware). * 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") @RateLimiter(name = "steamApi")
@Retry(name = "steamApi") @Retry(name = "steamApi")
@@ -259,19 +262,20 @@ public class SteamApiService {
.build()) .build())
.retrieve() .retrieve()
.body(SteamSchemaForGameResponse.class); .body(SteamSchemaForGameResponse.class);
if (response != null && response.game() != null) { if (response != null && response.game() != null) {
return Optional.of(response.game()); return Optional.of(response.game());
} }
} catch (Exception e) { } catch (HttpClientErrorException e) {
log.warn("GetSchemaForGame failed for appid {}: {}", appid, e.getMessage()); log.info("No achievement schema for appid {}: {}", appid, e.getStatusCode());
} }
return Optional.empty(); return Optional.empty();
} }
/** /**
* Per-user achievement state for a single game. * 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") @RateLimiter(name = "steamApi")
@Retry(name = "steamApi") @Retry(name = "steamApi")
@@ -292,8 +296,11 @@ public class SteamApiService {
} }
} catch (HttpClientErrorException.Forbidden e) { } catch (HttpClientErrorException.Forbidden e) {
log.info("Achievements hidden by Steam privacy for steamId={} appid={}", steamId, appid); log.info("Achievements hidden by Steam privacy for steamId={} appid={}", steamId, appid);
} catch (Exception e) { } catch (HttpClientErrorException.BadRequest e) {
log.warn("GetPlayerAchievements failed for steamId={} appid={}: {}", steamId, appid, e.getMessage()); if (!e.getResponseBodyAsString().contains(NO_STATS)) {
throw e;
}
return Optional.of(new SteamPlayerAchievementsResponse.PlayerStats(steamId, null, List.of(), true, NO_STATS));
} }
return Optional.empty(); return Optional.empty();
} }
+2 -1
View File
@@ -35,7 +35,8 @@ management:
show-details: never show-details: never
security: security:
jwt: jwt:
public-key: ${JWT_PUBLIC_KEY} public-key: ${JWT_PUBLIC_KEY:}
jwks-url: ${JWT_JWKS_URL:}
app: app:
cors: cors:
allowed-origins: "https://tbp.andreas-dahm.eu" allowed-origins: "https://tbp.andreas-dahm.eu"
@@ -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,262 @@
package core_service.configuration.security;
import core_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PublicKey;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import static org.mockito.Mockito.lenient;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.client.ExpectedCount.once;
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.withSuccess;
class JwksKeyLocatorTest extends AbstractUnitTest {
private static final String JWKS_URL = "http://auth/.well-known/jwks.json";
@Mock
private JwtConfig jwtConfig;
@Mock
private Clock clock;
private Instant now = Instant.parse("2026-09-25T10:00:00Z");
private RestClient.Builder restClientBuilder;
private MockRestServiceServer authServer;
private KeyPair first;
private KeyPair second;
@BeforeEach
void setUp() throws Exception {
lenient().when(clock.instant()).thenAnswer(invocation -> now);
restClientBuilder = RestClient.builder();
authServer = MockRestServiceServer.bindTo(restClientBuilder).build();
first = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
second = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
}
private static String jwk(String kid, PublicKey key) {
byte[] der = key.getEncoded();
// The X.509 DER of an Ed25519 key is a 12-byte header, then the raw 32-byte key.
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(Arrays.copyOfRange(der, 12, der.length));
return """
{"kty":"OKP","crv":"Ed25519","x":"%s","kid":"%s","use":"sig","alg":"EdDSA"}""".formatted(x, kid);
}
private void authAnswers(String... jwks) {
String keys = Stream.of(jwks).collect(Collectors.joining(","));
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withSuccess("{\"keys\":[" + keys + "]}", APPLICATION_JSON));
}
private JwtParser parser(String jwksUrl, KeyPair fallback) {
lenient().when(jwtConfig.getJwksUrl()).thenReturn(jwksUrl);
lenient().when(jwtConfig.getPublicKey()).thenReturn(
fallback == null ? null : Base64.getEncoder().encodeToString(fallback.getPublic().getEncoded()));
JwksKeyLocator locator = new JwksKeyLocator(jwtConfig, restClientBuilder.build(), clock);
return Jwts.parser().keyLocator(locator).build();
}
private static String token(KeyPair keys, String kid) {
return Jwts.builder()
.header().keyId(kid).and()
.subject("a@b.de")
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(keys.getPrivate(), Jwts.SIG.EdDSA)
.compact();
}
private static String subject(JwtParser parser, String token) {
return parser.parseSignedClaims(token).getPayload().getSubject();
}
@Test
void knownKid_VerifiesWithTheJwksKey() {
authAnswers(jwk("k1", first.getPublic()));
JwtParser parser = parser(JWKS_URL, null);
assertEquals("a@b.de", subject(parser, token(first, "k1")));
authServer.verify();
}
@Test
void unknownKid_LoadsTheJwksAgain() {
authAnswers(jwk("k1", first.getPublic()));
authAnswers(jwk("k1", first.getPublic()), jwk("k2", second.getPublic()));
JwtParser parser = parser(JWKS_URL, null);
assertEquals("a@b.de", subject(parser, token(second, "k2")));
assertEquals("a@b.de", subject(parser, token(second, "k2")));
authServer.verify();
}
@Test
void unknownKid_LoadsAtMostOncePerInterval() {
authAnswers(jwk("k1", first.getPublic()));
authAnswers(jwk("k1", first.getPublic()));
authAnswers(jwk("k2", second.getPublic()));
JwtParser parser = parser(JWKS_URL, null);
assertThrows(JwtException.class, () -> subject(parser, token(second, "k2")));
assertThrows(JwtException.class, () -> subject(parser, token(second, "made-up")));
now = now.plus(JwksKeyLocator.MIN_REFRESH_INTERVAL).minus(Duration.ofSeconds(1));
assertThrows(JwtException.class, () -> subject(parser, token(second, "k2")));
now = now.plus(Duration.ofSeconds(1));
assertEquals("a@b.de", subject(parser, token(second, "k2")));
authServer.verify();
}
@Test
void authDownAtStartup_TheFirstTokenLoadsTheJwks() {
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
authAnswers(jwk("k1", first.getPublic()));
JwtParser parser = parser(JWKS_URL, null);
assertEquals("a@b.de", subject(parser, token(first, "k1")));
authServer.verify();
}
@Test
void authDown_VerifiesWithThePublicKey() {
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
JwtParser parser = parser(JWKS_URL, first);
assertEquals("a@b.de", subject(parser, token(first, "k1")));
authServer.verify();
}
@Test
void noJwksUrl_VerifiesWithThePublicKeyWithAndWithoutKid() {
JwtParser parser = parser(null, first);
assertEquals("a@b.de", subject(parser, token(first, "k1")));
assertEquals("a@b.de", subject(parser, token(first, null)));
assertThrows(JwtException.class, () -> subject(parser, token(second, "k1")));
}
@Test
void neitherJwksUrlNorPublicKey_FailsAtStartup() {
assertThrows(IllegalStateException.class, () -> parser(" ", null));
}
@Test
void unknownKid_ThreadWaitingOnTheLoadSeesTheFreshKeys() throws Exception {
authAnswers(jwk("k1", first.getPublic()));
CountDownLatch authMayAnswer = new CountDownLatch(1);
String rotated = "{\"keys\":[" + jwk("k1", first.getPublic()) + "," + jwk("k2", second.getPublic()) + "]}";
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(request -> {
try {
if (!authMayAnswer.await(5, TimeUnit.SECONDS)) {
throw new IllegalStateException("test did not release the JWKS answer");
}
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
return withSuccess(rotated, APPLICATION_JSON).createResponse(request);
});
JwtParser parser = parser(JWKS_URL, null);
String tokenK2 = token(second, "k2");
Map<String, Object> outcome = new ConcurrentHashMap<>();
Runnable parse = () -> {
try {
outcome.put(Thread.currentThread().getName(), subject(parser, tokenK2));
} catch (Exception e) {
outcome.put(Thread.currentThread().getName(), e);
}
};
Thread firstCaller = new Thread(parse, "first");
Thread secondCaller = new Thread(parse, "second");
firstCaller.start();
awaitState(firstCaller, Thread.State.TIMED_WAITING); // inside load(), waiting for Auth
secondCaller.start();
awaitState(secondCaller, Thread.State.WAITING); // waiting on the refresh lock
authMayAnswer.countDown();
firstCaller.join(5_000);
secondCaller.join(5_000);
assertEquals("a@b.de", outcome.get("first"));
assertEquals("a@b.de", outcome.get("second"), "second caller should see the keys the first caller loaded");
}
@Test
void authSlowBeforeTheFirstLoad_OtherRequestsVerifyWithThePublicKeyWithoutWaiting() throws Exception {
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
CountDownLatch authMayAnswer = new CountDownLatch(1);
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(request -> {
try {
authMayAnswer.await(5, TimeUnit.SECONDS);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
return withServerError().createResponse(request);
});
JwtParser parser = parser(JWKS_URL, first);
String token = token(first, "k1");
Thread loader = new Thread(() -> subject(parser, token), "loader");
loader.start();
awaitState(loader, Thread.State.TIMED_WAITING); // inside load(), waiting for Auth
try {
assertEquals("a@b.de", assertTimeout(Duration.ofSeconds(1), () -> subject(parser, token)));
} finally {
authMayAnswer.countDown();
loader.join(5_000);
}
authServer.verify();
}
private static void awaitState(Thread thread, Thread.State state) throws InterruptedException {
long deadline = System.currentTimeMillis() + 5_000;
while (thread.getState() != state) {
if (System.currentTimeMillis() > deadline) {
throw new AssertionError(thread.getName() + " never reached " + state + ", is " + thread.getState());
}
Thread.sleep(5);
}
}
@Test
void afterTheFirstJwksLoad_ThePublicKeyNoLongerVerifies() {
authAnswers(jwk("k2", second.getPublic()));
authAnswers(jwk("k2", second.getPublic()));
JwtParser parser = parser(JWKS_URL, first);
assertEquals("a@b.de", subject(parser, token(second, "k2")));
assertThrows(JwtException.class, () -> subject(parser, token(first, "tbp-auth-1")),
"kid tbp-auth-1 is not in the JWKS any more, the old key must not verify");
}
}
@@ -5,6 +5,7 @@ import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.Mock; import org.mockito.Mock;
import org.springframework.web.client.RestClient;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
@@ -37,8 +38,7 @@ class JwtServiceTest extends AbstractUnitTest {
lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64); lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64);
jwtService = new JwtService(jwtConfig); jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create()));
jwtService.init();
} }
private String signToken(String userId, String email, Long steamId) { private String signToken(String userId, String email, Long steamId) {
@@ -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,5 +1,6 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
@@ -135,4 +136,53 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
.content("{\"status\":\"PLAYING\"}")) .content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
@Test
void updateStatus_KeepsRatingNotesAndPriority() throws Exception {
UserGame game = stored();
game.setRating(4);
game.setNotes("co-op with Tom");
game.setPriority(GamePriority.HIGH);
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PLAYING"))
.andExpect(jsonPath("$.notes").value("co-op with Tom"));
UserGame updated = stored();
assertEquals(GameStatus.PLAYING, updated.getStatus());
assertEquals(4, updated.getRating());
assertEquals("co-op with Tom", updated.getNotes());
assertEquals(GamePriority.HIGH, updated.getPriority());
}
@Test
void updateStatus_MissingStatus_Is400() throws Exception {
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
assertEquals(GameStatus.BACKLOG, stored().getStatus());
}
@Test
void updateStatus_RemovedGame_Is404() throws Exception {
UserGame game = stored();
game.setRemovedAt(Instant.now());
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void updateStatus_GameNotOwned_Is404() throws Exception {
mockMvc.perform(put("/api/library/games/999/status").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound());
}
} }
@@ -0,0 +1,166 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GamePriority;
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.UserGameId;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class LibraryControllerPickerTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
}
private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
UserGame game = new UserGame(USER_ID, appid, 0, Instant.now(), Instant.now());
game.setStatus(status);
game.setPriority(priority);
userGameRepository.save(game);
if (normallyMinutes != null) {
gameTimeToBeatRepository.save(new GameTimeToBeat(appid, null, normallyMinutes, null, Instant.now()));
}
}
@Test
void picker_EmptyBacklog_ReturnsNoGames() throws Exception {
game(10L, GameStatus.PLAYING, null, 60);
mockMvc.perform(get("/api/library/picker"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games", hasSize(0)));
}
@Test
void picker_WithoutBudget_PicksOnlyBacklogGamesIncludingUnknownLength() throws Exception {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.FINISHED, null, 60);
game(40L, GameStatus.DROPPED, null, 60);
mockMvc.perform(get("/api/library/picker"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)))
.andExpect(jsonPath("$.games[?(@.appid == 10)].timeToBeatMinutes").value(60));
}
@Test
void picker_WithBudget_SkipsLongerAndUnknownGames() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, 301);
game(30L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "300"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
}
@Test
void picker_ZeroBudgetMeansAnyLength() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "0"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
}
@Test
void picker_SkipsExcludedGames() throws Exception {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, null, 60);
game(30L, GameStatus.BACKLOG, null, 60);
mockMvc.perform(get("/api/library/picker").param("exclude", "10", "30"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(20)));
}
@Test
void picker_FillsSlotsByPriorityFirst() throws Exception {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, GamePriority.LOW, 60);
game(30L, GameStatus.BACKLOG, GamePriority.HIGH, 60);
game(40L, GameStatus.BACKLOG, GamePriority.MEDIUM, 60);
game(50L, GameStatus.BACKLOG, null, 60);
mockMvc.perform(get("/api/library/picker").param("count", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(30, 40)))
.andExpect(jsonPath("$.games[0].appid").value(30));
}
@Test
void picker_DefaultsToThreeAndCapsTheCount() throws Exception {
for (long appid = 1; appid <= 12; appid++) {
game(appid, GameStatus.BACKLOG, null, 60);
}
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games", hasSize(3)));
mockMvc.perform(get("/api/library/picker").param("count", "500"))
.andExpect(jsonPath("$.games", hasSize(10)));
mockMvc.perform(get("/api/library/picker").param("count", "0"))
.andExpect(jsonPath("$.games", hasSize(1)));
}
@Test
void picker_SkipsRemovedGames() throws Exception {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, null, 60);
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 20L)).orElseThrow();
removed.setRemovedAt(Instant.now());
userGameRepository.save(removed);
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
}
@Test
void picker_OnlyShowsTheCallersGames() throws Exception {
steamGameRepository.save(new SteamGame(99L, "Other", null, Instant.now()));
userGameRepository.save(new UserGame(2L, 99L, 0, Instant.now(), Instant.now()));
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games", hasSize(0)));
}
}
@@ -1,10 +1,14 @@
package core_service.modules.library.api; 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.GamePriority;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; 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.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
@@ -37,6 +41,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@Autowired @Autowired
private UserGameRepository userGameRepository; private UserGameRepository userGameRepository;
@Autowired
private GameAchievementSchemaMetaRepository schemaMetaRepository;
@Autowired
private UserGameAchievementMetaRepository userAchievementMetaRepository;
private static String ticket(long userId) { private static String ticket(long userId) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(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(); .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
@@ -54,6 +64,8 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
@BeforeEach @BeforeEach
void seed() { void seed() {
userGameRepository.deleteAll(); userGameRepository.deleteAll();
schemaMetaRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
steamGameRepository.saveAll(List.of( steamGameRepository.saveAll(List.of(
new SteamGame(220L, "Half-Life 2", null, Instant.now()), new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now()), new SteamGame(620L, "Portal 2", null, Instant.now()),
@@ -87,6 +99,21 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$..priority").isEmpty()); .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 @Test
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception { void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized()); 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.InOrder;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant; 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.inOrder;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class LibrarySyncWorkerTest extends AbstractUnitTest { class LibrarySyncWorkerTest extends AbstractUnitTest {
@@ -99,6 +101,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock @Mock
private TimeToBeatService timeToBeatService; private TimeToBeatService timeToBeatService;
@Mock
private ApplicationEventPublisher eventPublisher;
@InjectMocks @InjectMocks
private LibrarySyncWorker worker; private LibrarySyncWorker worker;
@@ -116,6 +121,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any()); verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any()); verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
verifyNoInteractions(eventPublisher);
} }
@Test @Test
@@ -154,6 +160,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any()); verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any()); verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
verify(eventPublisher).publishEvent(new LibrarySyncedEvent(USER_ID, STEAM_ID));
} }
@ParameterizedTest @ParameterizedTest
@@ -1,14 +1,17 @@
package core_service.modules.steam_adapter.service; 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.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.SteamPlayerSummaryResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse; import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Instant; import java.time.Instant;
import java.util.List; 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.containsString;
import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.not;
import static org.assertj.core.api.Assertions.assertThat; 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.queryParam;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; 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.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class SteamApiServiceTest { class SteamApiServiceTest {
@@ -258,4 +263,67 @@ class SteamApiServiceTest {
server.verify(); 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) { static void secrets(DynamicPropertyRegistry registry) {
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded())); registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded()));
registry.add("app.steam.api-key", () -> "test-key"); registry.add("app.steam.api-key", () -> "test-key");
registry.add("app.library.achievement-sync-pause-ms", () -> "0");
} }
private static KeyPair generateKeys() { private static KeyPair generateKeys() {