Public Access
Compare commits
3
Commits
0a5186782f
...
762b7898d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
762b7898d3 | ||
|
|
9fd552a785 | ||
|
|
86db6f7167 |
No files matched your search
@@ -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.
|
||||
- **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.
|
||||
- **Picker**: up to 10 random backlog games for a time budget (IGDB normal time to beat), HIGH priority first, then
|
||||
MEDIUM, then the rest. Games already shown can be excluded for a reroll.
|
||||
- **Achievements**: after every library sync a background job fetches the achievement schema of each owned game
|
||||
and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half
|
||||
of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never
|
||||
overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5
|
||||
games closest to 100 %.
|
||||
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
|
||||
dashboard banner.
|
||||
- **Wishlist**: synced from Steam with store price and discount.
|
||||
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
||||
feature is off and the rest works.
|
||||
- **Public profile data**: totals, status counts, games in progress and finished games with rating. Readable only
|
||||
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
||||
and average completion. Readable only
|
||||
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID.
|
||||
|
||||
## Authentication
|
||||
|
||||
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with the public key only (`JWT_PUBLIC_KEY`) and
|
||||
reads `userId` and `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.
|
||||
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
|
||||
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
|
||||
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. Core reads `userId` and
|
||||
`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
|
||||
|
||||
@@ -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 |
|
||||
| `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 |
|
||||
| `GET` | `/api/library/picker?maxMinutes=&count=&exclude=` | JWT |
|
||||
| `GET` | `/api/library/achievements/summary` | JWT |
|
||||
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
|
||||
| `GET` | `/api/public/profiles/library` | profile ticket |
|
||||
| `GET` | `/api/stats/public` | public |
|
||||
@@ -87,7 +100,8 @@ Configuration comes from environment variables:
|
||||
|
||||
| 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 |
|
||||
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
|
||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
||||
@@ -102,11 +116,9 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
|
||||
|
||||
## Roadmap
|
||||
|
||||
- "What to play next" picker: time budget and mood on top of the current random suggestion.
|
||||
- Achievement summary per game and library-wide, "closest to 100 %".
|
||||
- Mood filter for the picker (needs genres or themes from IGDB).
|
||||
- Sale alerts for wishlist games.
|
||||
- Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit.
|
||||
- Fetch the public key from a JWKS endpoint in Auth instead of an environment variable.
|
||||
|
||||
## 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. */
|
||||
private String publicKey;
|
||||
|
||||
private String jwksUrl;
|
||||
}
|
||||
@@ -1,64 +1,43 @@
|
||||
package core_service.configuration.security;
|
||||
|
||||
import core_service.shared.security.ProfileTicketVerifier;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtParser;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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
|
||||
public class JwtService implements ProfileTicketVerifier {
|
||||
|
||||
private static final String ED25519 = "Ed25519";
|
||||
// Only non-login tokens carry this claim; isTokenValid rejects them.
|
||||
private static final String TYPE_CLAIM = "typ";
|
||||
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
||||
|
||||
private final JwtConfig jwtConfig;
|
||||
private PublicKey publicKey;
|
||||
private final JwtParser parser;
|
||||
|
||||
public JwtService(JwtConfig jwtConfig) {
|
||||
this.jwtConfig = jwtConfig;
|
||||
public JwtService(JwksKeyLocator keyLocator) {
|
||||
this.parser = Jwts.parser().keyLocator(keyLocator).build();
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
|
||||
private Claims claims(String token) {
|
||||
return parser.parseSignedClaims(token).getPayload();
|
||||
}
|
||||
|
||||
public String extractEmail(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload()
|
||||
.getSubject();
|
||||
return claims(token).getSubject();
|
||||
}
|
||||
|
||||
public String extractUserId(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload()
|
||||
.get("userId", String.class);
|
||||
return claims(token).get("userId", String.class);
|
||||
}
|
||||
|
||||
public Long extractSteamId(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload()
|
||||
.get("steamId", Long.class);
|
||||
return claims(token).get("steamId", Long.class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,11 +45,7 @@ public class JwtService implements ProfileTicketVerifier {
|
||||
*/
|
||||
public boolean isTokenValid(String token) {
|
||||
try {
|
||||
var claims = Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
Claims claims = claims(token);
|
||||
return claims.get(TYPE_CLAIM) == null;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
@@ -80,11 +55,7 @@ public class JwtService implements ProfileTicketVerifier {
|
||||
@Override
|
||||
public Optional<Long> verifyProfileTicket(String ticket) {
|
||||
try {
|
||||
var claims = Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.build()
|
||||
.parseSignedClaims(ticket)
|
||||
.getPayload();
|
||||
Claims claims = claims(ticket);
|
||||
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
|
||||
return Optional.empty();
|
||||
}
|
||||
@@ -93,13 +64,4 @@ public class JwtService implements ProfileTicketVerifier {
|
||||
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;
|
||||
|
||||
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||
import core_service.modules.library.api.dto.ActivityDto;
|
||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
||||
import core_service.modules.library.api.dto.GameDto;
|
||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||
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.SyncStatusDto;
|
||||
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.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@RestController
|
||||
@@ -45,6 +50,8 @@ public class LibraryController {
|
||||
private static final int DEFAULT_SIZE = 20;
|
||||
private static final int MIN_ACTIVITY_DAYS = 7;
|
||||
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 final LibrarySyncService syncService;
|
||||
@@ -142,6 +149,16 @@ public class LibraryController {
|
||||
.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")
|
||||
public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam(defaultValue = "30") int days) {
|
||||
@@ -160,6 +177,23 @@ public class LibraryController {
|
||||
return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude));
|
||||
}
|
||||
|
||||
@GetMapping("/achievements/summary")
|
||||
public ResponseEntity<AchievementSummaryDto> getAchievementSummary(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return ResponseEntity.ok(queryService.getAchievementSummary(principal.getIdAsLong()));
|
||||
}
|
||||
|
||||
@GetMapping("/picker")
|
||||
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam(required = false) Integer maxMinutes,
|
||||
@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")
|
||||
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
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 achievementsTotal null when the game has no achievements or they were not fetched yet
|
||||
* @param achievementsUnlocked null when not fetched yet or hidden by Steam
|
||||
*/
|
||||
public record GameDto(
|
||||
Long appid,
|
||||
@@ -14,5 +16,7 @@ public record GameDto(
|
||||
GameStatus status,
|
||||
Integer rating,
|
||||
GamePriority priority,
|
||||
Integer timeToBeatMinutes
|
||||
Integer timeToBeatMinutes,
|
||||
Integer achievementsUnlocked,
|
||||
Integer achievementsTotal
|
||||
) {}
|
||||
@@ -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 playing up to 8 games, most played first
|
||||
* @param finished up to 12 FINISHED or COMPLETED games, best rated first
|
||||
* @param averageCompletion see {@link AchievementSummaryDto#averageCompletion()}
|
||||
*/
|
||||
public record PublicLibraryDto(
|
||||
long totalGames,
|
||||
long totalPlaytimeMinutes,
|
||||
Map<GameStatus, Long> byStatus,
|
||||
List<Game> playing,
|
||||
List<Game> finished
|
||||
List<Game> finished,
|
||||
int perfectGames,
|
||||
Double averageCompletion
|
||||
) {
|
||||
|
||||
public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {}
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
private int achievementsUnlocked;
|
||||
|
||||
public UserGameAchievementMeta(Long userId, Long appid, Instant refreshedAt, boolean statsVisible, int achievementsUnlocked) {
|
||||
@Column(name = "playtime_at_refresh")
|
||||
private Integer playtimeAtRefresh;
|
||||
|
||||
public UserGameAchievementMeta(Long userId, Long appid, Instant refreshedAt, boolean statsVisible,
|
||||
int achievementsUnlocked, Integer playtimeAtRefresh) {
|
||||
this.userId = userId;
|
||||
this.appid = appid;
|
||||
this.refreshedAt = refreshedAt;
|
||||
this.statsVisible = statsVisible;
|
||||
this.achievementsUnlocked = achievementsUnlocked;
|
||||
this.playtimeAtRefresh = playtimeAtRefresh;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,15 @@ import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
import core_service.modules.library.domain.UserGameId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserGameAchievementMetaRepository extends JpaRepository<UserGameAchievementMeta, UserGameId> {
|
||||
|
||||
Optional<UserGameAchievementMeta> findByUserIdAndAppid(Long userId, Long appid);
|
||||
|
||||
List<UserGameAchievementMeta> findByUserId(Long userId);
|
||||
|
||||
List<UserGameAchievementMeta> findByUserIdAndAppidIn(Long userId, Collection<Long> appids);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.AchievementProgress;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.StatusCount;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
@@ -14,6 +15,7 @@ import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
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)
|
||||
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 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;
|
||||
|
||||
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.UserGameId;
|
||||
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) {
|
||||
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.GameDetailDto;
|
||||
import core_service.modules.library.domain.AchievementProgress;
|
||||
import core_service.modules.library.domain.GameAchievementSchema;
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
@@ -16,17 +17,12 @@ import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
|
||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -43,16 +39,16 @@ public class GameDetailService {
|
||||
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||
private final UserGameAchievementRepository userAchievementRepository;
|
||||
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||
private final SteamApiService steamApiService;
|
||||
private final AchievementSyncService achievementSyncService;
|
||||
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||
|
||||
@Value("${app.library.achievement-ttl-hours:1}")
|
||||
private long achievementTtlHours;
|
||||
|
||||
@Value("${app.library.schema-ttl-days:30}")
|
||||
private long schemaTtlDays;
|
||||
|
||||
@Transactional
|
||||
/**
|
||||
* Not transactional on purpose: the refreshes commit in their own transactions, and the reads after them must
|
||||
* see those rows, not a cached copy from a transaction that started before the refresh.
|
||||
*/
|
||||
public Optional<GameDetailDto> getGameDetail(Long userId, String steamId, Long appid) {
|
||||
Optional<UserGame> userGameOpt = userGameRepository.findById(new UserGameId(userId, appid));
|
||||
if (userGameOpt.isEmpty() || userGameOpt.get().getRemovedAt() != null) {
|
||||
@@ -61,14 +57,23 @@ public class GameDetailService {
|
||||
UserGame userGame = userGameOpt.get();
|
||||
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
|
||||
|
||||
refreshSchemaIfStale(appid);
|
||||
try {
|
||||
achievementSyncService.refreshSchemaIfStale(appid);
|
||||
} catch (Exception e) {
|
||||
log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage());
|
||||
}
|
||||
boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
|
||||
|
||||
if (!hasSchema) {
|
||||
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
|
||||
}
|
||||
|
||||
refreshUserAchievementsIfStale(userId, steamId, appid);
|
||||
try {
|
||||
achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(),
|
||||
Duration.ofHours(achievementTtlHours));
|
||||
} catch (Exception e) {
|
||||
log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage());
|
||||
}
|
||||
|
||||
UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
|
||||
boolean statsVisible = userMeta == null || userMeta.isStatsVisible();
|
||||
@@ -97,7 +102,8 @@ public class GameDetailService {
|
||||
})
|
||||
.toList();
|
||||
|
||||
return Optional.of(buildDto(userGame, meta, statsVisible, schemaList.size(), unlocked, achievementDtos));
|
||||
return Optional.of(buildDto(userGame, meta, statsVisible, schemaList.size(),
|
||||
AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos));
|
||||
}
|
||||
|
||||
private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible,
|
||||
@@ -129,68 +135,4 @@ public class GameDetailService {
|
||||
.orElse(null)
|
||||
);
|
||||
}
|
||||
|
||||
private void refreshSchemaIfStale(Long appid) {
|
||||
Optional<GameAchievementSchemaMeta> existing = schemaMetaRepository.findById(appid);
|
||||
Instant now = Instant.now();
|
||||
if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofDays(schemaTtlDays)))) {
|
||||
return;
|
||||
}
|
||||
Optional<SteamSchemaForGameResponse.Game> schema = steamApiService.getSchemaForGame(appid);
|
||||
boolean hasSchema = schema.isPresent()
|
||||
&& schema.get().availableGameStats() != null
|
||||
&& schema.get().availableGameStats().achievements() != null
|
||||
&& !schema.get().availableGameStats().achievements().isEmpty();
|
||||
|
||||
if (hasSchema) {
|
||||
schemaRepository.deleteByAppid(appid);
|
||||
List<GameAchievementSchema> rows = schema.get().availableGameStats().achievements().stream()
|
||||
.map(a -> new GameAchievementSchema(
|
||||
appid,
|
||||
a.name(),
|
||||
a.displayName(),
|
||||
a.description(),
|
||||
a.icon(),
|
||||
a.iconGray(),
|
||||
a.hidden() != null && a.hidden() == 1
|
||||
))
|
||||
.toList();
|
||||
schemaRepository.saveAll(rows);
|
||||
}
|
||||
int total = hasSchema ? schema.get().availableGameStats().achievements().size() : 0;
|
||||
schemaMetaRepository.save(new GameAchievementSchemaMeta(appid, total, hasSchema, now));
|
||||
}
|
||||
|
||||
private void refreshUserAchievementsIfStale(Long userId, String steamId, Long appid) {
|
||||
Optional<UserGameAchievementMeta> existing = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid);
|
||||
Instant now = Instant.now();
|
||||
if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofHours(achievementTtlHours)))) {
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<SteamPlayerAchievementsResponse.PlayerStats> response = steamApiService.getPlayerAchievements(steamId, appid);
|
||||
boolean visible = response.isPresent() && (response.get().success() == null || response.get().success());
|
||||
|
||||
if (!visible) {
|
||||
userAchievementMetaRepository.save(new UserGameAchievementMeta(userId, appid, now, false, 0));
|
||||
return;
|
||||
}
|
||||
|
||||
List<SteamPlayerAchievementsResponse.Achievement> list = response.get().achievements();
|
||||
userAchievementRepository.deleteByUserIdAndAppid(userId, appid);
|
||||
int unlockedCount = 0;
|
||||
if (list != null) {
|
||||
List<UserGameAchievement> rows = list.stream()
|
||||
.map(a -> {
|
||||
boolean unlocked = a.achieved() != null && a.achieved() == 1;
|
||||
Instant unlockedAt = unlocked && a.unlockTime() != null && a.unlockTime() > 0
|
||||
? Instant.ofEpochSecond(a.unlockTime()) : null;
|
||||
return new UserGameAchievement(userId, appid, a.apiName(), unlocked, unlockedAt);
|
||||
})
|
||||
.toList();
|
||||
userAchievementRepository.saveAll(rows);
|
||||
unlockedCount = (int) rows.stream().filter(UserGameAchievement::isUnlocked).count();
|
||||
}
|
||||
userAchievementMetaRepository.save(new UserGameAchievementMeta(userId, appid, now, true, unlockedCount));
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,34 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||
import core_service.modules.library.api.dto.ActivityDto;
|
||||
import core_service.modules.library.api.dto.GameDto;
|
||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||
import core_service.modules.library.api.dto.PickerDto;
|
||||
import core_service.modules.library.api.dto.PublicLibraryDto;
|
||||
import core_service.modules.library.api.dto.RecentGameDto;
|
||||
import core_service.modules.library.api.dto.WishlistDto;
|
||||
import core_service.modules.library.domain.AchievementProgress;
|
||||
import core_service.modules.library.domain.DayMinutes;
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.GameTimeToBeat;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||
import core_service.modules.library.repo.WishlistGameRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -29,6 +37,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -43,6 +52,7 @@ public class LibraryQueryService {
|
||||
|
||||
private static final int PUBLIC_PLAYING = 8;
|
||||
private static final int PUBLIC_FINISHED = 12;
|
||||
private static final int CLOSEST_GAMES = 5;
|
||||
|
||||
private final UserGameRepository userGameRepository;
|
||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||
@@ -50,6 +60,8 @@ public class LibraryQueryService {
|
||||
private final PlaytimeDayRepository playtimeDayRepository;
|
||||
private final WishlistGameRepository wishlistGameRepository;
|
||||
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||
|
||||
@Value("${app.library.zone:Europe/Berlin}")
|
||||
private ZoneId zone;
|
||||
@@ -64,12 +76,74 @@ public class LibraryQueryService {
|
||||
UserGameRepository.querySort(pageable.getSort()));
|
||||
Page<UserGame> page = userGameRepository.findOwnedGames(
|
||||
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()
|
||||
.collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||
Map<Long, GameTimeToBeat> times = gameTimeToBeatRepository.findAllById(appids).stream()
|
||||
.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 neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
|
||||
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1)
|
||||
.map(ug -> toDto(ug, steamGameRepository.findById(ug.getAppid()).orElse(null),
|
||||
gameTimeToBeatRepository.findById(ug.getAppid()).orElse(null)))
|
||||
.map(ug -> toDtos(List.of(ug)).getFirst())
|
||||
.orElse(null);
|
||||
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId));
|
||||
}
|
||||
@@ -126,8 +199,10 @@ public class LibraryQueryService {
|
||||
Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList())
|
||||
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
||||
List<AchievementProgress> started = startedGames(userGameRepository.findAchievementProgress(userId));
|
||||
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus,
|
||||
toPublicGames(playing, meta), toPublicGames(finished, meta));
|
||||
toPublicGames(playing, meta), toPublicGames(finished, meta),
|
||||
perfectGames(started), averageCompletion(started));
|
||||
}
|
||||
|
||||
// Every status, also those with 0 games.
|
||||
@@ -155,7 +230,12 @@ public class LibraryQueryService {
|
||||
.toList());
|
||||
}
|
||||
|
||||
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time) {
|
||||
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
|
||||
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks) {
|
||||
Integer achievementsTotal = schema != null && schema.isHasSchema() && schema.getAchievementsTotal() > 0
|
||||
? schema.getAchievementsTotal() : null;
|
||||
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
|
||||
? AchievementProgress.capped(unlocks.getAchievementsUnlocked(), achievementsTotal) : null;
|
||||
return new GameDto(
|
||||
ug.getAppid(),
|
||||
meta != null ? meta.getName() : null,
|
||||
@@ -164,7 +244,9 @@ public class LibraryQueryService {
|
||||
ug.getStatus(),
|
||||
ug.getRating(),
|
||||
ug.getPriority(),
|
||||
time != null ? time.getNormallyMinutes() : null);
|
||||
time != null ? time.getNormallyMinutes() : null,
|
||||
achievementsUnlocked,
|
||||
achievementsTotal);
|
||||
}
|
||||
|
||||
public Optional<UserSteamSync> getSyncStatus(Long userId) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -48,6 +49,7 @@ public class LibrarySyncWorker {
|
||||
private final PlaytimeDayRepository playtimeDayRepository;
|
||||
private final WishlistGameRepository wishlistGameRepository;
|
||||
private final TimeToBeatService timeToBeatService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Value("${app.library.running-stale-minutes:5}")
|
||||
private long runningStaleMinutes;
|
||||
@@ -115,6 +117,7 @@ public class LibrarySyncWorker {
|
||||
}
|
||||
userSteamSyncRepository.markHistoryBaseline(userId, steamId);
|
||||
timeToBeatService.refresh(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
|
||||
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
|
||||
finalStatus = Status.OK;
|
||||
} catch (Exception e) {
|
||||
log.error("Sync failed for user {}", userId, e);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
/**
|
||||
* Published inside the library sync transaction after a successful sync.
|
||||
*/
|
||||
public record LibrarySyncedEvent(Long userId, String steamId) {}
|
||||
@@ -30,6 +30,8 @@ import java.util.stream.Collectors;
|
||||
@Slf4j
|
||||
public class SteamApiService {
|
||||
|
||||
private static final String NO_STATS = "Requested app has no stats";
|
||||
|
||||
private final RestClient steamRestClient;
|
||||
|
||||
private static final int STORE_ITEMS_BATCH = 100;
|
||||
@@ -245,7 +247,8 @@ public class SteamApiService {
|
||||
|
||||
/**
|
||||
* Per-game achievement schema (global, language-aware).
|
||||
* Returns empty when game has no achievements or Steam returns no schema.
|
||||
* Returns empty when the game has no achievements or Steam answers 4xx, its final answer for delisted apps and
|
||||
* tools. A server or network failure throws, so the caller keeps what it stored before.
|
||||
*/
|
||||
@RateLimiter(name = "steamApi")
|
||||
@Retry(name = "steamApi")
|
||||
@@ -259,19 +262,20 @@ public class SteamApiService {
|
||||
.build())
|
||||
.retrieve()
|
||||
.body(SteamSchemaForGameResponse.class);
|
||||
|
||||
if (response != null && response.game() != null) {
|
||||
return Optional.of(response.game());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("GetSchemaForGame failed for appid {}: {}", appid, e.getMessage());
|
||||
} catch (HttpClientErrorException e) {
|
||||
log.info("No achievement schema for appid {}: {}", appid, e.getStatusCode());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user achievement state for a single game.
|
||||
* Returns empty when profile/stats are private (HTTP 403) or game has no achievements.
|
||||
* Returns empty when profile/stats are private (HTTP 403), and no achievements when Steam answers 400 "Requested
|
||||
* app has no stats". Any other failure throws, so the caller keeps what it stored before instead of storing
|
||||
* "hidden" or "nothing unlocked".
|
||||
*/
|
||||
@RateLimiter(name = "steamApi")
|
||||
@Retry(name = "steamApi")
|
||||
@@ -292,8 +296,11 @@ public class SteamApiService {
|
||||
}
|
||||
} catch (HttpClientErrorException.Forbidden e) {
|
||||
log.info("Achievements hidden by Steam privacy for steamId={} appid={}", steamId, appid);
|
||||
} catch (Exception e) {
|
||||
log.warn("GetPlayerAchievements failed for steamId={} appid={}: {}", steamId, appid, e.getMessage());
|
||||
} catch (HttpClientErrorException.BadRequest e) {
|
||||
if (!e.getResponseBodyAsString().contains(NO_STATS)) {
|
||||
throw e;
|
||||
}
|
||||
return Optional.of(new SteamPlayerAchievementsResponse.PlayerStats(steamId, null, List.of(), true, NO_STATS));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ management:
|
||||
show-details: never
|
||||
security:
|
||||
jwt:
|
||||
public-key: ${JWT_PUBLIC_KEY}
|
||||
public-key: ${JWT_PUBLIC_KEY:}
|
||||
jwks-url: ${JWT_JWKS_URL:}
|
||||
app:
|
||||
cors:
|
||||
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.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
@@ -37,8 +38,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
|
||||
lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64);
|
||||
|
||||
jwtService = new JwtService(jwtConfig);
|
||||
jwtService.init();
|
||||
jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create()));
|
||||
}
|
||||
|
||||
private String signToken(String userId, String email, Long steamId) {
|
||||
|
||||
+162
@@ -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;
|
||||
|
||||
import core_service.modules.library.domain.GamePriority;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
@@ -135,4 +136,53 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
|
||||
.content("{\"status\":\"PLAYING\"}"))
|
||||
.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;
|
||||
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import core_service.modules.library.domain.GamePriority;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.shared.test.AbstractIntegrationTest;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
@@ -37,6 +41,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||
|
||||
private static String ticket(long userId) {
|
||||
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
|
||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
||||
@@ -54,6 +64,8 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
userGameRepository.deleteAll();
|
||||
schemaMetaRepository.deleteAll();
|
||||
userAchievementMetaRepository.deleteAll();
|
||||
steamGameRepository.saveAll(List.of(
|
||||
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
|
||||
new SteamGame(620L, "Portal 2", null, Instant.now()),
|
||||
@@ -87,6 +99,21 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
.andExpect(jsonPath("$..priority").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicLibrary_ShowsPerfectGamesAndAverageCompletion() throws Exception {
|
||||
schemaMetaRepository.saveAll(List.of(
|
||||
new GameAchievementSchemaMeta(620L, 10, true, Instant.now()),
|
||||
new GameAchievementSchemaMeta(220L, 4, true, Instant.now())));
|
||||
userAchievementMetaRepository.saveAll(List.of(
|
||||
new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 10, 900),
|
||||
new UserGameAchievementMeta(USER_ID, 220L, Instant.now(), true, 1, 300)));
|
||||
|
||||
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.perfectGames").value(1))
|
||||
.andExpect(jsonPath("$.averageCompletion").value(62.5));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
|
||||
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementRepository;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import core_service.shared.test.AbstractIntegrationTest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* The game detail page refreshes inside its own @Transactional while the background sync refreshes the same
|
||||
* (user, appid) in a transaction of its own, e.g. right after the user pressed "sync" and opened the most played game.
|
||||
*/
|
||||
class AchievementSyncServiceConcurrencyTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
private static final String STEAM_ID = "76561198000000001";
|
||||
|
||||
@Autowired
|
||||
private AchievementSyncService achievementSyncService;
|
||||
|
||||
@Autowired
|
||||
private UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameAchievementRepository userAchievementRepository;
|
||||
|
||||
@Autowired
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@MockitoBean
|
||||
private SteamApiService steamApiService;
|
||||
|
||||
@BeforeEach
|
||||
@AfterEach
|
||||
void clean() {
|
||||
userAchievementMetaRepository.deleteAll();
|
||||
userAchievementRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshUser_DetailPageAndBackgroundSyncOnTheSameGame_BothCommit() {
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of(
|
||||
new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2",
|
||||
List.of(new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L)), true, null)));
|
||||
CountDownLatch firstWrote = new CountDownLatch(1);
|
||||
CountDownLatch secondStarted = new CountDownLatch(1);
|
||||
|
||||
CompletableFuture<Void> detailPage = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager)
|
||||
.executeWithoutResult(tx -> {
|
||||
achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1));
|
||||
firstWrote.countDown();
|
||||
await(secondStarted);
|
||||
// Keep the page's transaction open while the background run writes the same rows.
|
||||
sleep();
|
||||
}));
|
||||
CompletableFuture<Void> backgroundRun = CompletableFuture.runAsync(() -> {
|
||||
await(firstWrote);
|
||||
secondStarted.countDown();
|
||||
achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1));
|
||||
});
|
||||
|
||||
assertDoesNotThrow(() -> CompletableFuture.allOf(detailPage, backgroundRun).get(30, TimeUnit.SECONDS));
|
||||
assertEquals(1, userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L).orElseThrow().getAchievementsUnlocked());
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out");
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void sleep() {
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import core_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.LongStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AchievementSyncServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
private static final String STEAM_ID = "76561198000000001";
|
||||
|
||||
@Mock
|
||||
private UserGameRepository userGameRepository;
|
||||
@Mock
|
||||
private GameAchievementSchemaRepository schemaRepository;
|
||||
@Mock
|
||||
private GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||
@Mock
|
||||
private UserGameAchievementRepository userAchievementRepository;
|
||||
@Mock
|
||||
private UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||
@Mock
|
||||
private SteamApiService steamApiService;
|
||||
|
||||
private AchievementSyncService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
|
||||
userAchievementRepository, userAchievementMetaRepository, steamApiService,
|
||||
mock(PlatformTransactionManager.class), 30, 0);
|
||||
}
|
||||
|
||||
private static UserGame game(long appid, int playtime) {
|
||||
return new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
|
||||
}
|
||||
|
||||
private static GameAchievementSchemaMeta schema(long appid, int total) {
|
||||
return new GameAchievementSchemaMeta(appid, total, total > 0, Instant.now());
|
||||
}
|
||||
|
||||
private static UserGameAchievementMeta unlocks(long appid, int unlocked, Integer playtimeAtRefresh) {
|
||||
return new UserGameAchievementMeta(USER_ID, appid, Instant.now(), true, unlocked, playtimeAtRefresh);
|
||||
}
|
||||
|
||||
private static Optional<SteamPlayerAchievementsResponse.PlayerStats> visible(String... unlockedNames) {
|
||||
List<SteamPlayerAchievementsResponse.Achievement> list = java.util.Arrays.stream(unlockedNames)
|
||||
.map(name -> new SteamPlayerAchievementsResponse.Achievement(name, 1, 1_700_000_000L))
|
||||
.toList();
|
||||
return Optional.of(new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Game", list, true, null));
|
||||
}
|
||||
|
||||
private void library(List<UserGame> games, List<GameAchievementSchemaMeta> schemas, List<UserGameAchievementMeta> metas) {
|
||||
when(userGameRepository.findByUserIdAndRemovedAtIsNull(USER_ID)).thenReturn(games);
|
||||
when(schemaMetaRepository.findAllById(any())).thenReturn(schemas);
|
||||
when(userAchievementMetaRepository.findByUserId(USER_ID)).thenReturn(metas);
|
||||
}
|
||||
|
||||
private UserGameAchievementMeta savedMeta() {
|
||||
ArgumentCaptor<UserGameAchievementMeta> saved = ArgumentCaptor.forClass(UserGameAchievementMeta.class);
|
||||
verify(userAchievementMetaRepository).save(saved.capture());
|
||||
return saved.getValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_UnchangedPlaytime_AsksSteamNothing() {
|
||||
library(List.of(game(620L, 120)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
verifyNoInteractions(steamApiService);
|
||||
verify(userAchievementMetaRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() {
|
||||
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D"));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
UserGameAchievementMeta meta = savedMeta();
|
||||
assertEquals(4, meta.getAchievementsUnlocked());
|
||||
assertEquals(150, meta.getPlaytimeAtRefresh());
|
||||
assertTrue(meta.isStatsVisible());
|
||||
verify(userAchievementRepository).deleteByUserIdAndAppid(USER_ID, 620L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_NeverRefreshedWithPlaytime_AsksSteamOnce() {
|
||||
library(List.of(game(620L, 120)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, null)));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A"));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
assertEquals(120, savedMeta().getPlaytimeAtRefresh());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_UnplayedGameWithoutData_AsksSteamOnce() {
|
||||
library(List.of(game(620L, 0)), List.of(schema(620L, 10)), List.of());
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible());
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
UserGameAchievementMeta meta = savedMeta();
|
||||
assertEquals(0, meta.getAchievementsUnlocked());
|
||||
assertEquals(0, meta.getPlaytimeAtRefresh());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_MissingSchema_FetchesItAndSkipsGamesWithoutAchievements() {
|
||||
library(List.of(game(620L, 60)), List.of(), List.of());
|
||||
when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.empty());
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
ArgumentCaptor<GameAchievementSchemaMeta> saved = ArgumentCaptor.forClass(GameAchievementSchemaMeta.class);
|
||||
verify(schemaMetaRepository).save(saved.capture());
|
||||
assertFalse(saved.getValue().isHasSchema());
|
||||
verify(steamApiService, never()).getPlayerAchievements(anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_OldSchema_IsFetchedAgain() {
|
||||
GameAchievementSchemaMeta old = new GameAchievementSchemaMeta(620L, 10, true, Instant.now().minus(Duration.ofDays(31)));
|
||||
library(List.of(game(620L, 120)), List.of(old), List.of(unlocks(620L, 3, 120)));
|
||||
when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.empty());
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
verify(steamApiService).getSchemaForGame(620L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_StopsAfterThreeSteamErrorsInARow_AndKeepsStoredData() {
|
||||
List<UserGame> games = LongStream.rangeClosed(1, 6).mapToObj(appid -> game(appid, 60)).toList();
|
||||
List<GameAchievementSchemaMeta> schemas = LongStream.rangeClosed(1, 6).mapToObj(appid -> schema(appid, 10)).toList();
|
||||
library(games, schemas, List.of());
|
||||
when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenThrow(new ResourceAccessException("down"));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
verify(steamApiService, times(AchievementSyncService.MAX_ERRORS_IN_A_ROW)).getPlayerAchievements(eq(STEAM_ID), anyLong());
|
||||
verify(userAchievementMetaRepository, never()).save(any());
|
||||
verify(userAchievementRepository, never()).deleteByUserIdAndAppid(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_StopsWhenSteamHidesTheFirstGames() {
|
||||
List<UserGame> games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 60)).toList();
|
||||
List<GameAchievementSchemaMeta> schemas = LongStream.rangeClosed(1, 8).mapToObj(appid -> schema(appid, 10)).toList();
|
||||
library(games, schemas, List.of());
|
||||
when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenReturn(Optional.empty());
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
verify(steamApiService, times(AchievementSyncService.MAX_HIDDEN_IN_A_ROW)).getPlayerAchievements(eq(STEAM_ID), anyLong());
|
||||
verify(userAchievementMetaRepository, times(AchievementSyncService.MAX_HIDDEN_IN_A_ROW))
|
||||
.save(argThat(meta -> !meta.isStatsVisible() && meta.getPlaytimeAtRefresh() == null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_HiddenGame_IsAskedAgainOnTheNextSync() {
|
||||
UserGameAchievementMeta hidden = new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), false, 0, null);
|
||||
library(List.of(game(620L, 60)), List.of(schema(620L, 10)), List.of(hidden));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A"));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
assertTrue(savedMeta().isStatsVisible());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_HiddenGamesAfterAVisibleOne_DoNotStopTheRun() {
|
||||
List<UserGame> games = LongStream.rangeClosed(1, 8).mapToObj(appid -> game(appid, 100 - (int) appid)).toList();
|
||||
List<GameAchievementSchemaMeta> schemas = LongStream.rangeClosed(1, 8).mapToObj(appid -> schema(appid, 10)).toList();
|
||||
library(games, schemas, List.of());
|
||||
when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenReturn(Optional.empty());
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 1L)).thenReturn(visible("A"));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
verify(steamApiService, times(8)).getPlayerAchievements(eq(STEAM_ID), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_CallWhileRunning_RunsOnceMoreAfterwards() throws Exception {
|
||||
when(userGameRepository.findByUserIdAndRemovedAtIsNull(USER_ID)).thenReturn(List.of(game(620L, 60)));
|
||||
when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10)));
|
||||
// The second run sees the row the first run stored.
|
||||
when(userAchievementMetaRepository.findByUserId(USER_ID))
|
||||
.thenReturn(List.of(), List.of(unlocks(620L, 1, 60)));
|
||||
CountDownLatch inSteamCall = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenAnswer(invocation -> {
|
||||
inSteamCall.countDown();
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS));
|
||||
return visible("A");
|
||||
});
|
||||
Thread first = Thread.ofVirtual().start(() -> service.syncLibrary(USER_ID, STEAM_ID));
|
||||
assertTrue(inSteamCall.await(5, TimeUnit.SECONDS));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
release.countDown();
|
||||
first.join(5_000);
|
||||
|
||||
verify(steamApiService, times(1)).getPlayerAchievements(STEAM_ID, 620L);
|
||||
verify(userGameRepository, times(2)).findByUserIdAndRemovedAtIsNull(USER_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_UnplayedGameWithStoredUnlocks_KeepsTheUnlocks() {
|
||||
UserGameAchievementMeta stored = unlocks(620L, 10, null);
|
||||
library(List.of(game(620L, 0)), List.of(schema(620L, 10)), List.of(stored));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L))
|
||||
.thenReturn(visible("A", "B", "C", "D", "E", "F", "G", "H", "I", "J"));
|
||||
|
||||
service.syncLibrary(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userAchievementMetaRepository, never()).save(argThat(meta -> meta.getAchievementsUnlocked() < 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void syncLibrary_TwoUsersInParallel_ShareOneSteamBudget() throws Exception {
|
||||
long pauseMillis = 300;
|
||||
AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
|
||||
userAchievementRepository, userAchievementMetaRepository, steamApiService,
|
||||
mock(PlatformTransactionManager.class), 30, pauseMillis);
|
||||
when(userGameRepository.findByUserIdAndRemovedAtIsNull(1L)).thenReturn(List.of(new UserGame(1L, 620L, 60, Instant.now(), Instant.now())));
|
||||
when(userGameRepository.findByUserIdAndRemovedAtIsNull(2L)).thenReturn(List.of(new UserGame(2L, 620L, 60, Instant.now(), Instant.now())));
|
||||
when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10)));
|
||||
when(userAchievementMetaRepository.findByUserId(anyLong())).thenReturn(List.of());
|
||||
List<Long> steamCallsAtNanos = Collections.synchronizedList(new ArrayList<>());
|
||||
when(steamApiService.getPlayerAchievements(eq(STEAM_ID), anyLong())).thenAnswer(invocation -> {
|
||||
steamCallsAtNanos.add(System.nanoTime());
|
||||
return visible("A");
|
||||
});
|
||||
|
||||
Thread first = Thread.ofVirtual().start(() -> paced.syncLibrary(1L, STEAM_ID));
|
||||
Thread second = Thread.ofVirtual().start(() -> paced.syncLibrary(2L, STEAM_ID));
|
||||
first.join(5_000);
|
||||
second.join(5_000);
|
||||
|
||||
assertEquals(2, steamCallsAtNanos.size());
|
||||
long gapMillis = Math.abs(steamCallsAtNanos.get(1) - steamCallsAtNanos.get(0)) / 1_000_000;
|
||||
assertTrue(gapMillis >= pauseMillis - 50, "Steam calls of two runs were only " + gapMillis + " ms apart");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshUserIfStale_SteamError_ThrowsAndKeepsStoredData() {
|
||||
lenient().when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L)).thenReturn(Optional.empty());
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenThrow(new ResourceAccessException("down"));
|
||||
|
||||
assertThrows(ResourceAccessException.class,
|
||||
() -> service.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1)));
|
||||
verify(userAchievementMetaRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshUserIfStale_FreshData_AsksSteamNothing() {
|
||||
when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L)).thenReturn(Optional.of(unlocks(620L, 3, 60)));
|
||||
|
||||
service.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1));
|
||||
|
||||
verifyNoInteractions(steamApiService);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.api.dto.GameDetailDto;
|
||||
import core_service.modules.library.domain.GameAchievementSchema;
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import core_service.shared.test.AbstractIntegrationTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* One connection in the pool: a detail request must not need two at once.
|
||||
*/
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.hikari.maximum-pool-size=1",
|
||||
"spring.datasource.hikari.connection-timeout=1000"})
|
||||
class GameDetailServicePoolTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
private static final String STEAM_ID = "76561198000000001";
|
||||
|
||||
@Autowired
|
||||
private GameDetailService gameDetailService;
|
||||
|
||||
@Autowired
|
||||
private SteamGameRepository steamGameRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||
|
||||
@MockitoBean
|
||||
private SteamApiService steamApiService;
|
||||
|
||||
@Autowired
|
||||
private GameAchievementSchemaRepository schemaRepository;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
userGameRepository.deleteAll();
|
||||
schemaMetaRepository.deleteAll();
|
||||
userAchievementMetaRepository.deleteAll();
|
||||
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
|
||||
userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now()));
|
||||
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 1, true, Instant.now()));
|
||||
schemaRepository.save(new GameAchievementSchema(620L, "A", "First", null, null, null, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameDetail_NeedsOnlyOneConnectionAtATime() {
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of(
|
||||
new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2",
|
||||
List.of(new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L)), true, null)));
|
||||
|
||||
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
|
||||
|
||||
assertEquals(1, userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L).orElseThrow().getAchievementsUnlocked());
|
||||
assertEquals(1, detail.achievementsUnlocked());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.api.dto.GameDetailDto;
|
||||
import core_service.modules.library.domain.GameAchievementSchema;
|
||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||
import core_service.modules.library.domain.SteamGame;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||
import core_service.modules.library.repo.SteamGameRepository;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
|
||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||
import core_service.shared.test.AbstractIntegrationTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* The detail page refreshes stale rows in a transaction of its own, then reads them in its own read transaction.
|
||||
*/
|
||||
class GameDetailServiceTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final long USER_ID = 1L;
|
||||
private static final String STEAM_ID = "76561198000000001";
|
||||
|
||||
@Autowired
|
||||
private GameDetailService gameDetailService;
|
||||
|
||||
@Autowired
|
||||
private SteamGameRepository steamGameRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private GameAchievementSchemaRepository schemaRepository;
|
||||
|
||||
@Autowired
|
||||
private GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameAchievementRepository userAchievementRepository;
|
||||
|
||||
@Autowired
|
||||
private UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||
|
||||
@MockitoBean
|
||||
private SteamApiService steamApiService;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
userGameRepository.deleteAll();
|
||||
schemaRepository.deleteAll();
|
||||
schemaMetaRepository.deleteAll();
|
||||
userAchievementRepository.deleteAll();
|
||||
userAchievementMetaRepository.deleteAll();
|
||||
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
|
||||
userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now()));
|
||||
}
|
||||
|
||||
private static SteamPlayerAchievementsResponse.Achievement achieved(String name) {
|
||||
return new SteamPlayerAchievementsResponse.Achievement(name, 1, 1_700_000_000L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameDetail_StaleUserMeta_ShowsTheRefreshedCount() {
|
||||
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now()));
|
||||
for (String name : List.of("A", "B", "C", "D", "E")) {
|
||||
schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false));
|
||||
}
|
||||
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L,
|
||||
Instant.now().minus(Duration.ofHours(2)), true, 3, 60));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of(
|
||||
new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2",
|
||||
List.of(achieved("A"), achieved("B"), achieved("C"), achieved("D"), achieved("E")), true, null)));
|
||||
|
||||
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
|
||||
|
||||
assertEquals(5, userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L).orElseThrow().getAchievementsUnlocked());
|
||||
assertEquals(5, detail.achievementsUnlocked());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameDetail_StaleSchemaWithoutAchievements_ShowsTheRefreshedSchema() {
|
||||
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 0, false, Instant.now().minus(Duration.ofDays(31))));
|
||||
when(steamApiService.getSchemaForGame(620L)).thenReturn(Optional.of(new SteamSchemaForGameResponse.Game("Portal 2", "1",
|
||||
new SteamSchemaForGameResponse.AvailableGameStats(List.of(
|
||||
new SteamSchemaForGameResponse.Achievement("A", "A", null, 0, null, null))))));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(Optional.of(
|
||||
new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2", List.of(achieved("A")), true, null)));
|
||||
|
||||
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
|
||||
|
||||
assertTrue(schemaMetaRepository.findById(620L).orElseThrow().isHasSchema());
|
||||
assertTrue(detail.hasAchievements());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameDetail_SchemaLostAchievements_CapsTheUnlocks() {
|
||||
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 2, true, Instant.now()));
|
||||
for (String name : List.of("A", "B")) {
|
||||
schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false));
|
||||
}
|
||||
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 3, 60));
|
||||
|
||||
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
|
||||
|
||||
assertEquals(2, detail.achievementsUnlocked());
|
||||
assertEquals(100.0, detail.completionPercent());
|
||||
assertEquals(0, detail.achievementsRemaining());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameDetail_SteamDown_ShowsTheStoredData() {
|
||||
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 2, true, Instant.now()));
|
||||
for (String name : List.of("A", "B")) {
|
||||
schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false));
|
||||
}
|
||||
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L,
|
||||
Instant.now().minus(Duration.ofHours(2)), true, 1, 60));
|
||||
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenThrow(new ResourceAccessException("down"));
|
||||
|
||||
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
|
||||
|
||||
assertEquals(1, detail.achievementsUnlocked());
|
||||
assertTrue(detail.statsVisible());
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Instant;
|
||||
@@ -39,6 +40,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@@ -99,6 +101,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Mock
|
||||
private TimeToBeatService timeToBeatService;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@InjectMocks
|
||||
private LibrarySyncWorker worker;
|
||||
|
||||
@@ -116,6 +121,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||
verifyNoInteractions(eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -154,6 +160,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
|
||||
verify(eventPublisher).publishEvent(new LibrarySyncedEvent(USER_ID, STEAM_ID));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package core_service.modules.steam_adapter.service;
|
||||
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -20,9 +23,11 @@ import static org.hamcrest.CoreMatchers.allOf;
|
||||
import static org.hamcrest.CoreMatchers.containsString;
|
||||
import static org.hamcrest.CoreMatchers.not;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
class SteamApiServiceTest {
|
||||
@@ -258,4 +263,67 @@ class SteamApiServiceTest {
|
||||
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPlayerAchievements_Forbidden_IsEmpty() {
|
||||
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
|
||||
.andRespond(withStatus(HttpStatus.FORBIDDEN));
|
||||
|
||||
assertThat(steamApiService.getPlayerAchievements("76561197960435530", 620)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPlayerAchievements_ServerError_Throws() {
|
||||
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
|
||||
.andRespond(withServerError());
|
||||
|
||||
assertThatThrownBy(() -> steamApiService.getPlayerAchievements("76561197960435530", 620))
|
||||
.isInstanceOf(RestClientException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSchemaForGame_ServerError_Throws() {
|
||||
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
|
||||
.andRespond(withServerError());
|
||||
|
||||
assertThatThrownBy(() -> steamApiService.getSchemaForGame(620)).isInstanceOf(RestClientException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSchemaForGame_NoGame_IsEmpty() {
|
||||
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
|
||||
.andRespond(withSuccess("{\"game\":null}", MediaType.APPLICATION_JSON));
|
||||
|
||||
assertThat(steamApiService.getSchemaForGame(620)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPlayerAchievements_NoStatsForThisPlayer_IsVisibleWithNothingUnlocked() {
|
||||
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
|
||||
.andRespond(withStatus(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON)
|
||||
.body("{\"playerstats\":{\"error\":\"Requested app has no stats\",\"success\":false}}"));
|
||||
|
||||
Optional<SteamPlayerAchievementsResponse.PlayerStats> stats = steamApiService.getPlayerAchievements("76561197960435530", 620);
|
||||
assertThat(stats).isPresent();
|
||||
assertThat(stats.get().success()).isTrue();
|
||||
assertThat(stats.get().achievements()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSchemaForGame_ClientError_IsEmpty() {
|
||||
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
|
||||
.andRespond(withStatus(HttpStatus.BAD_REQUEST));
|
||||
|
||||
assertThat(steamApiService.getSchemaForGame(620)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPlayerAchievements_OtherBadRequest_Throws() {
|
||||
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
|
||||
.andRespond(withStatus(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON)
|
||||
.body("{\"playerstats\":{\"error\":\"Invalid SteamID\",\"success\":false}}"));
|
||||
|
||||
assertThatThrownBy(() -> steamApiService.getPlayerAchievements("76561197960435530", 620))
|
||||
.isInstanceOf(RestClientException.class);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ public abstract class AbstractIntegrationTest {
|
||||
static void secrets(DynamicPropertyRegistry registry) {
|
||||
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded()));
|
||||
registry.add("app.steam.api-key", () -> "test-key");
|
||||
registry.add("app.library.achievement-sync-pause-ms", () -> "0");
|
||||
}
|
||||
|
||||
private static KeyPair generateKeys() {
|
||||
|
||||
Reference in new issue
Block a user