Public Access
Compare commits
3
Commits
| 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.
|
sync. Resilience4j retries and rate-limits every Steam call; Caffeine caches game counts and player summaries.
|
||||||
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
||||||
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
||||||
|
- **Picker**: up to 10 random backlog games for a time budget (IGDB normal time to beat), HIGH priority first, then
|
||||||
|
MEDIUM, then the rest. Games already shown can be excluded for a reroll.
|
||||||
|
- **Achievements**: after every library sync a background job fetches the achievement schema of each owned game
|
||||||
|
and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half
|
||||||
|
of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never
|
||||||
|
overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5
|
||||||
|
games closest to 100 %.
|
||||||
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
|
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
|
||||||
dashboard banner.
|
dashboard banner.
|
||||||
- **Wishlist**: synced from Steam with store price and discount.
|
- **Wishlist**: synced from Steam with store price and discount.
|
||||||
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
||||||
feature is off and the rest works.
|
feature is off and the rest works.
|
||||||
- **Public profile data**: totals, status counts, games in progress and finished games with rating. Readable only
|
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
||||||
|
and average completion. Readable only
|
||||||
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID.
|
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with the public key only (`JWT_PUBLIC_KEY`) and
|
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
|
||||||
reads `userId` and `steamId` from the claims. A token with a `typ` claim (for example a profile ticket) is rejected
|
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
|
||||||
on normal endpoints; the profile ticket is accepted only by `GET /api/public/profiles/library` in the
|
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. Core reads `userId` and
|
||||||
`X-Profile-Ticket` header.
|
`steamId` from the claims.
|
||||||
|
A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
|
||||||
|
accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -38,7 +48,10 @@ on normal endpoints; the profile ticket is accepted only by `GET /api/public/pro
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
|
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
|
||||||
|
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
|
||||||
| `POST` | `/api/library/sync` | JWT |
|
| `POST` | `/api/library/sync` | JWT |
|
||||||
|
| `GET` | `/api/library/picker?maxMinutes=&count=&exclude=` | JWT |
|
||||||
|
| `GET` | `/api/library/achievements/summary` | JWT |
|
||||||
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
|
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
|
||||||
| `GET` | `/api/public/profiles/library` | profile ticket |
|
| `GET` | `/api/public/profiles/library` | profile ticket |
|
||||||
| `GET` | `/api/stats/public` | public |
|
| `GET` | `/api/stats/public` | public |
|
||||||
@@ -87,7 +100,8 @@ Configuration comes from environment variables:
|
|||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `JWT_PUBLIC_KEY` | Auth's Ed25519 public key, Base64 of the X.509 DER bytes |
|
| `JWT_JWKS_URL` | Auth's `/.well-known/jwks.json` |
|
||||||
|
| `JWT_PUBLIC_KEY` | Ed25519 public key (Base64 of the X.509 DER bytes), used until the first JWKS load. Set this, `JWT_JWKS_URL`, or both |
|
||||||
| `STEAM_API_KEY` | Steam Web API key |
|
| `STEAM_API_KEY` | Steam Web API key |
|
||||||
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
|
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
|
||||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
| `DB_PASSWORD` | Local PostgreSQL password |
|
||||||
@@ -102,11 +116,9 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- "What to play next" picker: time budget and mood on top of the current random suggestion.
|
- Mood filter for the picker (needs genres or themes from IGDB).
|
||||||
- Achievement summary per game and library-wide, "closest to 100 %".
|
|
||||||
- Sale alerts for wishlist games.
|
- Sale alerts for wishlist games.
|
||||||
- Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit.
|
- Move `steam_adapter` DTOs out of `api`, then turn on strict module independence in ArchUnit.
|
||||||
- Fetch the public key from a JWKS endpoint in Auth instead of an environment variable.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package core_service.configuration.security;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class JwksClientConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestClient jwksRestClient() {
|
||||||
|
// The call runs on a request thread, so a slow Auth must not hold it for long.
|
||||||
|
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
||||||
|
requestFactory.setConnectTimeout(Duration.ofSeconds(2));
|
||||||
|
requestFactory.setReadTimeout(Duration.ofSeconds(3));
|
||||||
|
return RestClient.builder()
|
||||||
|
.requestFactory(requestFactory)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package core_service.configuration.security;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.JwsHeader;
|
||||||
|
import io.jsonwebtoken.LocatorAdapter;
|
||||||
|
import io.jsonwebtoken.UnsupportedJwtException;
|
||||||
|
import io.jsonwebtoken.security.Jwk;
|
||||||
|
import io.jsonwebtoken.security.JwkSet;
|
||||||
|
import io.jsonwebtoken.security.Jwks;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.security.Key;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.spec.X509EncodedKeySpec;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the verify key for a token by its {@code kid} in Auth's JWKS. An unknown {@code kid} (Auth rotated its key)
|
||||||
|
* loads the JWKS again, at most once per {@link #MIN_REFRESH_INTERVAL}, so tokens with made-up ids cannot flood Auth.
|
||||||
|
* The static {@code JWT_PUBLIC_KEY} is used only until the first JWKS load works; after that a key that left the
|
||||||
|
* JWKS must not verify any more.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class JwksKeyLocator extends LocatorAdapter<Key> {
|
||||||
|
|
||||||
|
static final Duration MIN_REFRESH_INTERVAL = Duration.ofSeconds(10);
|
||||||
|
|
||||||
|
private final RestClient jwksRestClient;
|
||||||
|
private final String jwksUrl;
|
||||||
|
private final PublicKey fallbackKey;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
private volatile Map<String, PublicKey> keysById = Map.of();
|
||||||
|
private volatile boolean jwksLoaded;
|
||||||
|
private final ReentrantLock refreshLock = new ReentrantLock();
|
||||||
|
private Instant lastRefresh = Instant.MIN;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public JwksKeyLocator(JwtConfig jwtConfig, @Qualifier("jwksRestClient") RestClient jwksRestClient) {
|
||||||
|
this(jwtConfig, jwksRestClient, Clock.systemUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
JwksKeyLocator(JwtConfig jwtConfig, RestClient jwksRestClient, Clock clock) {
|
||||||
|
this.jwksRestClient = jwksRestClient;
|
||||||
|
this.jwksUrl = blankToNull(jwtConfig.getJwksUrl());
|
||||||
|
String publicKey = blankToNull(jwtConfig.getPublicKey());
|
||||||
|
this.fallbackKey = publicKey == null ? null : decodePublicKey(publicKey);
|
||||||
|
this.clock = clock;
|
||||||
|
if (jwksUrl == null && fallbackKey == null) {
|
||||||
|
throw new IllegalStateException("Set JWT_JWKS_URL or JWT_PUBLIC_KEY, else no token can be verified");
|
||||||
|
}
|
||||||
|
if (jwksUrl != null) {
|
||||||
|
// Auth may start after Core. A failed first load is retried on the first token.
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Key locate(JwsHeader header) {
|
||||||
|
String kid = header.getKeyId();
|
||||||
|
if (kid != null) {
|
||||||
|
PublicKey key = keysById.get(kid);
|
||||||
|
if (key == null) {
|
||||||
|
// While the static key can still verify, a slow or down Auth must not make every request wait.
|
||||||
|
refreshIfAllowed(fallbackKey == null || jwksLoaded);
|
||||||
|
// Read again even when this thread did not load: another thread may have loaded while it waited.
|
||||||
|
key = keysById.get(kid);
|
||||||
|
}
|
||||||
|
if (key != null) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fallbackKey != null && !jwksLoaded) {
|
||||||
|
return fallbackKey;
|
||||||
|
}
|
||||||
|
throw new UnsupportedJwtException("No verify key for kid " + kid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshIfAllowed(boolean waitForRunningLoad) {
|
||||||
|
if (jwksUrl == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (waitForRunningLoad) {
|
||||||
|
refreshLock.lock();
|
||||||
|
} else if (!refreshLock.tryLock()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Instant now = clock.instant();
|
||||||
|
if (lastRefresh.plus(MIN_REFRESH_INTERVAL).isAfter(now)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastRefresh = now;
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
refreshLock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void load() {
|
||||||
|
try {
|
||||||
|
String body = jwksRestClient.get().uri(jwksUrl).retrieve().body(String.class);
|
||||||
|
JwkSet jwkSet = Jwks.setParser().build().parse(body);
|
||||||
|
Map<String, PublicKey> keys = new HashMap<>();
|
||||||
|
for (Jwk<?> jwk : jwkSet.getKeys()) {
|
||||||
|
if (jwk.getId() != null && jwk.toKey() instanceof PublicKey publicKey) {
|
||||||
|
keys.put(jwk.getId(), publicKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysById = Map.copyOf(keys);
|
||||||
|
jwksLoaded = true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Loading JWKS from {} failed: {}", jwksUrl, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String blankToNull(String value) {
|
||||||
|
return value == null || value.isBlank() ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PublicKey decodePublicKey(String base64X509Der) {
|
||||||
|
try {
|
||||||
|
byte[] keyBytes = Base64.getDecoder().decode(base64X509Der);
|
||||||
|
return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(keyBytes));
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to decode Ed25519 public key", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,6 @@ public class JwtConfig {
|
|||||||
|
|
||||||
/** Base64-encoded X.509 DER Ed25519 public key. Verify-only; private key lives in Auth. */
|
/** Base64-encoded X.509 DER Ed25519 public key. Verify-only; private key lives in Auth. */
|
||||||
private String publicKey;
|
private String publicKey;
|
||||||
|
|
||||||
|
private String jwksUrl;
|
||||||
}
|
}
|
||||||
@@ -1,64 +1,43 @@
|
|||||||
package core_service.configuration.security;
|
package core_service.configuration.security;
|
||||||
|
|
||||||
import core_service.shared.security.ProfileTicketVerifier;
|
import core_service.shared.security.ProfileTicketVerifier;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.JwtParser;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.security.KeyFactory;
|
|
||||||
import java.security.PublicKey;
|
|
||||||
import java.security.spec.X509EncodedKeySpec;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies JWTs issued by tbp-auth-service. Verify-only: holds the Ed25519 public key.
|
* Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class JwtService implements ProfileTicketVerifier {
|
public class JwtService implements ProfileTicketVerifier {
|
||||||
|
|
||||||
private static final String ED25519 = "Ed25519";
|
|
||||||
// Only non-login tokens carry this claim; isTokenValid rejects them.
|
// Only non-login tokens carry this claim; isTokenValid rejects them.
|
||||||
private static final String TYPE_CLAIM = "typ";
|
private static final String TYPE_CLAIM = "typ";
|
||||||
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
private static final String PROFILE_TICKET_TYPE = "public-profile";
|
||||||
|
|
||||||
private final JwtConfig jwtConfig;
|
private final JwtParser parser;
|
||||||
private PublicKey publicKey;
|
|
||||||
|
|
||||||
public JwtService(JwtConfig jwtConfig) {
|
public JwtService(JwksKeyLocator keyLocator) {
|
||||||
this.jwtConfig = jwtConfig;
|
this.parser = Jwts.parser().keyLocator(keyLocator).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostConstruct
|
private Claims claims(String token) {
|
||||||
void init() {
|
return parser.parseSignedClaims(token).getPayload();
|
||||||
this.publicKey = decodePublicKey(jwtConfig.getPublicKey());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String extractEmail(String token) {
|
public String extractEmail(String token) {
|
||||||
return Jwts.parser()
|
return claims(token).getSubject();
|
||||||
.verifyWith(publicKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token)
|
|
||||||
.getPayload()
|
|
||||||
.getSubject();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String extractUserId(String token) {
|
public String extractUserId(String token) {
|
||||||
return Jwts.parser()
|
return claims(token).get("userId", String.class);
|
||||||
.verifyWith(publicKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token)
|
|
||||||
.getPayload()
|
|
||||||
.get("userId", String.class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long extractSteamId(String token) {
|
public Long extractSteamId(String token) {
|
||||||
return Jwts.parser()
|
return claims(token).get("steamId", Long.class);
|
||||||
.verifyWith(publicKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token)
|
|
||||||
.getPayload()
|
|
||||||
.get("steamId", Long.class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,11 +45,7 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
*/
|
*/
|
||||||
public boolean isTokenValid(String token) {
|
public boolean isTokenValid(String token) {
|
||||||
try {
|
try {
|
||||||
var claims = Jwts.parser()
|
Claims claims = claims(token);
|
||||||
.verifyWith(publicKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(token)
|
|
||||||
.getPayload();
|
|
||||||
return claims.get(TYPE_CLAIM) == null;
|
return claims.get(TYPE_CLAIM) == null;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return false;
|
return false;
|
||||||
@@ -80,11 +55,7 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
@Override
|
@Override
|
||||||
public Optional<Long> verifyProfileTicket(String ticket) {
|
public Optional<Long> verifyProfileTicket(String ticket) {
|
||||||
try {
|
try {
|
||||||
var claims = Jwts.parser()
|
Claims claims = claims(ticket);
|
||||||
.verifyWith(publicKey)
|
|
||||||
.build()
|
|
||||||
.parseSignedClaims(ticket)
|
|
||||||
.getPayload();
|
|
||||||
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
|
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
@@ -93,13 +64,4 @@ public class JwtService implements ProfileTicketVerifier {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PublicKey decodePublicKey(String base64X509Der) {
|
|
||||||
try {
|
|
||||||
byte[] keyBytes = Base64.getDecoder().decode(base64X509Der);
|
|
||||||
return KeyFactory.getInstance(ED25519).generatePublic(new X509EncodedKeySpec(keyBytes));
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException("Failed to decode Ed25519 public key", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package core_service.modules.library.api;
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||||
import core_service.modules.library.api.dto.ActivityDto;
|
import core_service.modules.library.api.dto.ActivityDto;
|
||||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
import core_service.modules.library.api.dto.BacklogEntryDto;
|
||||||
import core_service.modules.library.api.dto.GameDto;
|
import core_service.modules.library.api.dto.GameDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
import core_service.modules.library.api.dto.PageDto;
|
import core_service.modules.library.api.dto.PageDto;
|
||||||
|
import core_service.modules.library.api.dto.PickerDto;
|
||||||
|
import core_service.modules.library.api.dto.StatusUpdateDto;
|
||||||
import core_service.modules.library.api.dto.SteamProfileDto;
|
import core_service.modules.library.api.dto.SteamProfileDto;
|
||||||
import core_service.modules.library.api.dto.SyncStatusDto;
|
import core_service.modules.library.api.dto.SyncStatusDto;
|
||||||
import core_service.modules.library.api.dto.WishlistDto;
|
import core_service.modules.library.api.dto.WishlistDto;
|
||||||
@@ -33,7 +36,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -45,6 +50,8 @@ public class LibraryController {
|
|||||||
private static final int DEFAULT_SIZE = 20;
|
private static final int DEFAULT_SIZE = 20;
|
||||||
private static final int MIN_ACTIVITY_DAYS = 7;
|
private static final int MIN_ACTIVITY_DAYS = 7;
|
||||||
private static final int MAX_ACTIVITY_DAYS = 90;
|
private static final int MAX_ACTIVITY_DAYS = 90;
|
||||||
|
private static final int MAX_PICKER_GAMES = 10;
|
||||||
|
private static final int MAX_PICKER_EXCLUDES = 100;
|
||||||
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
|
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
|
||||||
|
|
||||||
private final LibrarySyncService syncService;
|
private final LibrarySyncService syncService;
|
||||||
@@ -142,6 +149,16 @@ public class LibraryController {
|
|||||||
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
|
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/games/{appid}/status")
|
||||||
|
public ResponseEntity<?> updateStatus(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("appid") Long appid,
|
||||||
|
@Valid @RequestBody StatusUpdateDto update) {
|
||||||
|
return backlogService.updateStatus(principal.getIdAsLong(), appid, update.status())
|
||||||
|
.<ResponseEntity<?>>map(ResponseEntity::ok)
|
||||||
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/activity")
|
@GetMapping("/activity")
|
||||||
public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@RequestParam(defaultValue = "30") int days) {
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
@@ -160,6 +177,23 @@ public class LibraryController {
|
|||||||
return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude));
|
return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/achievements/summary")
|
||||||
|
public ResponseEntity<AchievementSummaryDto> getAchievementSummary(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
return ResponseEntity.ok(queryService.getAchievementSummary(principal.getIdAsLong()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/picker")
|
||||||
|
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@RequestParam(required = false) Integer maxMinutes,
|
||||||
|
@RequestParam(required = false) List<Long> exclude,
|
||||||
|
@RequestParam(defaultValue = "3") int count) {
|
||||||
|
List<Long> excluded = exclude == null ? List.of()
|
||||||
|
: exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList();
|
||||||
|
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
|
||||||
|
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
|
||||||
|
return ResponseEntity.ok(queryService.pickBacklogGames(principal.getIdAsLong(), budget, excluded, safeCount));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/sync-status")
|
@GetMapping("/sync-status")
|
||||||
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
|
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
return queryService.getSyncStatus(principal.getIdAsLong())
|
return queryService.getSyncStatus(principal.getIdAsLong())
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only games whose achievements Steam shows count.
|
||||||
|
*
|
||||||
|
* @param startedGames games with at least one unlocked achievement
|
||||||
|
* @param perfectGames games with every achievement unlocked
|
||||||
|
* @param averageCompletion mean completion in percent over the started games; null when none is started
|
||||||
|
* @param pendingGames owned games the achievement sync has not fetched yet
|
||||||
|
* @param hiddenGames games whose achievements Steam hides (private profile or stats)
|
||||||
|
* @param closest up to 5 started games that are not complete, closest to 100 % first
|
||||||
|
*/
|
||||||
|
public record AchievementSummaryDto(
|
||||||
|
int unlocked,
|
||||||
|
int startedGames,
|
||||||
|
int perfectGames,
|
||||||
|
Double averageCompletion,
|
||||||
|
int pendingGames,
|
||||||
|
int hiddenGames,
|
||||||
|
List<Game> closest
|
||||||
|
) {
|
||||||
|
|
||||||
|
public record Game(Long appid, String name, int unlocked, int total) {}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import core_service.modules.library.domain.GameStatus;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
|
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
|
||||||
|
* @param achievementsTotal null when the game has no achievements or they were not fetched yet
|
||||||
|
* @param achievementsUnlocked null when not fetched yet or hidden by Steam
|
||||||
*/
|
*/
|
||||||
public record GameDto(
|
public record GameDto(
|
||||||
Long appid,
|
Long appid,
|
||||||
@@ -14,5 +16,7 @@ public record GameDto(
|
|||||||
GameStatus status,
|
GameStatus status,
|
||||||
Integer rating,
|
Integer rating,
|
||||||
GamePriority priority,
|
GamePriority priority,
|
||||||
Integer timeToBeatMinutes
|
Integer timeToBeatMinutes,
|
||||||
|
Integer achievementsUnlocked,
|
||||||
|
Integer achievementsTotal
|
||||||
) {}
|
) {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record PickerDto(List<GameDto> games) {}
|
||||||
@@ -11,13 +11,16 @@ import java.util.Map;
|
|||||||
* @param byStatus has every status, also those with 0 games
|
* @param byStatus has every status, also those with 0 games
|
||||||
* @param playing up to 8 games, most played first
|
* @param playing up to 8 games, most played first
|
||||||
* @param finished up to 12 FINISHED or COMPLETED games, best rated first
|
* @param finished up to 12 FINISHED or COMPLETED games, best rated first
|
||||||
|
* @param averageCompletion see {@link AchievementSummaryDto#averageCompletion()}
|
||||||
*/
|
*/
|
||||||
public record PublicLibraryDto(
|
public record PublicLibraryDto(
|
||||||
long totalGames,
|
long totalGames,
|
||||||
long totalPlaytimeMinutes,
|
long totalPlaytimeMinutes,
|
||||||
Map<GameStatus, Long> byStatus,
|
Map<GameStatus, Long> byStatus,
|
||||||
List<Game> playing,
|
List<Game> playing,
|
||||||
List<Game> finished
|
List<Game> finished,
|
||||||
|
int perfectGames,
|
||||||
|
Double averageCompletion
|
||||||
) {
|
) {
|
||||||
|
|
||||||
public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {}
|
public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record StatusUpdateDto(@NotNull GameStatus status) {}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Achievement state of one owned game.
|
||||||
|
*
|
||||||
|
* @param total 0 when the game has no achievements; null when the schema was never fetched
|
||||||
|
* @param unlocked null when the user's achievements were never fetched
|
||||||
|
* @param visible false when Steam hides the user's achievements
|
||||||
|
*/
|
||||||
|
public record AchievementProgress(Long appid, Integer total, Integer unlocked, boolean visible) {
|
||||||
|
|
||||||
|
public boolean hasAchievements() {
|
||||||
|
return total != null && total > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean known() {
|
||||||
|
return total != null && (total == 0 || unlocked != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int unlockedCapped() {
|
||||||
|
return unlocked == null || total == null ? 0 : capped(unlocked, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A schema can lose achievements after the user unlocked them.
|
||||||
|
*/
|
||||||
|
public static int capped(int unlocked, int total) {
|
||||||
|
return Math.min(unlocked, total);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,11 +37,16 @@ public class UserGameAchievementMeta {
|
|||||||
@Column(name = "achievements_unlocked", nullable = false)
|
@Column(name = "achievements_unlocked", nullable = false)
|
||||||
private int achievementsUnlocked;
|
private int achievementsUnlocked;
|
||||||
|
|
||||||
public UserGameAchievementMeta(Long userId, Long appid, Instant refreshedAt, boolean statsVisible, int achievementsUnlocked) {
|
@Column(name = "playtime_at_refresh")
|
||||||
|
private Integer playtimeAtRefresh;
|
||||||
|
|
||||||
|
public UserGameAchievementMeta(Long userId, Long appid, Instant refreshedAt, boolean statsVisible,
|
||||||
|
int achievementsUnlocked, Integer playtimeAtRefresh) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
this.appid = appid;
|
this.appid = appid;
|
||||||
this.refreshedAt = refreshedAt;
|
this.refreshedAt = refreshedAt;
|
||||||
this.statsVisible = statsVisible;
|
this.statsVisible = statsVisible;
|
||||||
this.achievementsUnlocked = achievementsUnlocked;
|
this.achievementsUnlocked = achievementsUnlocked;
|
||||||
|
this.playtimeAtRefresh = playtimeAtRefresh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,15 @@ import core_service.modules.library.domain.UserGameAchievementMeta;
|
|||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface UserGameAchievementMetaRepository extends JpaRepository<UserGameAchievementMeta, UserGameId> {
|
public interface UserGameAchievementMetaRepository extends JpaRepository<UserGameAchievementMeta, UserGameId> {
|
||||||
|
|
||||||
Optional<UserGameAchievementMeta> findByUserIdAndAppid(Long userId, Long appid);
|
Optional<UserGameAchievementMeta> findByUserIdAndAppid(Long userId, Long appid);
|
||||||
|
|
||||||
|
List<UserGameAchievementMeta> findByUserId(Long userId);
|
||||||
|
|
||||||
|
List<UserGameAchievementMeta> findByUserIdAndAppidIn(Long userId, Collection<Long> appids);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.modules.library.repo;
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.AchievementProgress;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.StatusCount;
|
import core_service.modules.library.domain.StatusCount;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
@@ -14,6 +15,7 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -46,6 +48,38 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
"and appid <> :excludeAppid order by random() limit 1", nativeQuery = true)
|
"and appid <> :excludeAppid order by random() limit 1", nativeQuery = true)
|
||||||
Optional<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid);
|
Optional<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid);
|
||||||
|
|
||||||
|
@Query("select new core_service.modules.library.domain.AchievementProgress(ug.appid, sm.achievementsTotal, " +
|
||||||
|
"um.achievementsUnlocked, coalesce(um.statsVisible, true)) " +
|
||||||
|
"from UserGame ug " +
|
||||||
|
"left join GameAchievementSchemaMeta sm on sm.appid = ug.appid " +
|
||||||
|
"left join UserGameAchievementMeta um on um.userId = ug.userId and um.appid = ug.appid " +
|
||||||
|
"where ug.userId = :userId and ug.removedAt is null")
|
||||||
|
List<AchievementProgress> findAchievementProgress(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random backlog games, HIGH priority first, then MEDIUM, then the rest.
|
||||||
|
*
|
||||||
|
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
|
||||||
|
* @param excludeAppids never picked; may be empty
|
||||||
|
*/
|
||||||
|
default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Collection<Long> excludeAppids, int count) {
|
||||||
|
List<Long> excluded = new ArrayList<>(excludeAppids);
|
||||||
|
// "not in ()" is invalid SQL, and no appid is -1
|
||||||
|
excluded.add(-1L);
|
||||||
|
return queryPickerGames(userId, maxMinutes, excluded, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " +
|
||||||
|
"and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
|
||||||
|
"and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " +
|
||||||
|
" where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " +
|
||||||
|
"order by case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end, random() " +
|
||||||
|
"limit :count", nativeQuery = true)
|
||||||
|
List<UserGame> queryPickerGames(@Param("userId") Long userId,
|
||||||
|
@Param("maxMinutes") Integer maxMinutes,
|
||||||
|
@Param("excludeAppids") Collection<Long> excludeAppids,
|
||||||
|
@Param("count") int count);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param status null for every status
|
* @param status null for every status
|
||||||
* @param namePattern a {@link #containsPattern} result, null for every name
|
* @param namePattern a {@link #containsPattern} result, null for every name
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameAchievementSchema;
|
||||||
|
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.domain.UserGameAchievement;
|
||||||
|
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||||
|
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||||
|
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameAchievementRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||||
|
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
|
||||||
|
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
|
import org.springframework.transaction.event.TransactionalEventListener;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the stored achievements in step with Steam: on demand for the game detail page, and for the whole library
|
||||||
|
* after each library sync. A Steam failure never overwrites stored data.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class AchievementSyncService {
|
||||||
|
|
||||||
|
static final int MAX_ERRORS_IN_A_ROW = 3;
|
||||||
|
private static final int LOCK_STRIPES = 64;
|
||||||
|
// Steam answers "hidden" for every game of a private profile; asking for the rest only costs calls.
|
||||||
|
static final int MAX_HIDDEN_IN_A_ROW = 5;
|
||||||
|
|
||||||
|
private final UserGameRepository userGameRepository;
|
||||||
|
private final GameAchievementSchemaRepository schemaRepository;
|
||||||
|
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||||
|
private final UserGameAchievementRepository userAchievementRepository;
|
||||||
|
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||||
|
private final SteamApiService steamApiService;
|
||||||
|
private final TransactionTemplate transactionTemplate;
|
||||||
|
private final Duration schemaTtl;
|
||||||
|
private final long pauseNanos;
|
||||||
|
private final Set<Long> runningUsers = ConcurrentHashMap.newKeySet();
|
||||||
|
private final Map<Long, String> requestedRuns = new ConcurrentHashMap<>();
|
||||||
|
private final ReentrantLock[] locks = new ReentrantLock[LOCK_STRIPES];
|
||||||
|
// One Steam budget for all runs: the nightly job starts one run per account.
|
||||||
|
private final Object paceLock = new Object();
|
||||||
|
private long nextCallAtNanos;
|
||||||
|
|
||||||
|
public AchievementSyncService(UserGameRepository userGameRepository,
|
||||||
|
GameAchievementSchemaRepository schemaRepository,
|
||||||
|
GameAchievementSchemaMetaRepository schemaMetaRepository,
|
||||||
|
UserGameAchievementRepository userAchievementRepository,
|
||||||
|
UserGameAchievementMetaRepository userAchievementMetaRepository,
|
||||||
|
SteamApiService steamApiService,
|
||||||
|
PlatformTransactionManager transactionManager,
|
||||||
|
@Value("${app.library.schema-ttl-days:30}") long schemaTtlDays,
|
||||||
|
// Half of the Steam rate limit, so requests of users still get through.
|
||||||
|
@Value("${app.library.achievement-sync-pause-ms:1000}") long pauseMillis) {
|
||||||
|
this.userGameRepository = userGameRepository;
|
||||||
|
this.schemaRepository = schemaRepository;
|
||||||
|
this.schemaMetaRepository = schemaMetaRepository;
|
||||||
|
this.userAchievementRepository = userAchievementRepository;
|
||||||
|
this.userAchievementMetaRepository = userAchievementMetaRepository;
|
||||||
|
this.steamApiService = steamApiService;
|
||||||
|
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||||
|
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||||
|
this.schemaTtl = Duration.ofDays(schemaTtlDays);
|
||||||
|
this.pauseNanos = TimeUnit.MILLISECONDS.toNanos(pauseMillis);
|
||||||
|
for (int i = 0; i < LOCK_STRIPES; i++) {
|
||||||
|
locks[i] = new ReentrantLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
@TransactionalEventListener
|
||||||
|
public void onLibrarySynced(LibrarySyncedEvent event) {
|
||||||
|
syncLibrary(event.userId(), event.steamId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the schema of every owned game when it is missing or old, and the user's achievements of every game
|
||||||
|
* whose playtime changed since the last refresh. Runs at most once per user at a time; a request that arrives
|
||||||
|
* during a run starts one more run afterwards, with the newest Steam account.
|
||||||
|
*/
|
||||||
|
public void syncLibrary(Long userId, String steamId) {
|
||||||
|
requestedRuns.put(userId, steamId);
|
||||||
|
while (runningUsers.add(userId)) {
|
||||||
|
try {
|
||||||
|
String next;
|
||||||
|
while ((next = requestedRuns.remove(userId)) != null) {
|
||||||
|
runLibrarySync(userId, next);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
runningUsers.remove(userId);
|
||||||
|
}
|
||||||
|
// A request can arrive after the last remove() and before the user was released.
|
||||||
|
if (!requestedRuns.containsKey(userId)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshSchemaIfStale(Long appid) {
|
||||||
|
Optional<GameAchievementSchemaMeta> existing = schemaMetaRepository.findById(appid);
|
||||||
|
if (existing.isEmpty() || isStale(existing.get())) {
|
||||||
|
refreshSchema(appid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void refreshUserIfStale(Long userId, String steamId, Long appid, int playtimeMinutes, Duration ttl) {
|
||||||
|
Optional<UserGameAchievementMeta> existing = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid);
|
||||||
|
if (existing.isEmpty() || existing.get().getRefreshedAt().isBefore(Instant.now().minus(ttl))) {
|
||||||
|
refreshUser(userId, steamId, appid, playtimeMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runLibrarySync(Long userId, String steamId) {
|
||||||
|
List<UserGame> games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream()
|
||||||
|
.sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed())
|
||||||
|
.toList();
|
||||||
|
Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository
|
||||||
|
.findAllById(games.stream().map(UserGame::getAppid).toList()).stream()
|
||||||
|
.collect(Collectors.toMap(GameAchievementSchemaMeta::getAppid, Function.identity()));
|
||||||
|
Map<Long, UserGameAchievementMeta> metas = userAchievementMetaRepository.findByUserId(userId).stream()
|
||||||
|
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, Function.identity()));
|
||||||
|
|
||||||
|
int errorsInARow = 0;
|
||||||
|
int hiddenInARow = 0;
|
||||||
|
boolean anyVisible = false;
|
||||||
|
for (UserGame game : games) {
|
||||||
|
Outcome outcome;
|
||||||
|
try {
|
||||||
|
outcome = syncGame(userId, steamId, game, schemas.get(game.getAppid()), metas.get(game.getAppid()));
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Achievement sync user={} appid={} failed: {}", userId, game.getAppid(), e.getMessage());
|
||||||
|
if (++errorsInARow >= MAX_ERRORS_IN_A_ROW) {
|
||||||
|
log.warn("Achievement sync user={} stopped after {} Steam errors in a row", userId, errorsInARow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
errorsInARow = 0;
|
||||||
|
if (outcome == Outcome.VISIBLE) {
|
||||||
|
anyVisible = true;
|
||||||
|
hiddenInARow = 0;
|
||||||
|
} else if (outcome == Outcome.HIDDEN && !anyVisible && ++hiddenInARow >= MAX_HIDDEN_IN_A_ROW) {
|
||||||
|
log.info("Achievement sync user={} stopped, Steam hides the achievements", userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Outcome syncGame(Long userId, String steamId, UserGame game, GameAchievementSchemaMeta schema,
|
||||||
|
UserGameAchievementMeta meta) throws InterruptedException {
|
||||||
|
Long appid = game.getAppid();
|
||||||
|
boolean hasSchema;
|
||||||
|
if (schema == null || isStale(schema)) {
|
||||||
|
pause();
|
||||||
|
refreshSchema(appid);
|
||||||
|
hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
|
||||||
|
} else {
|
||||||
|
hasSchema = schema.isHasSchema();
|
||||||
|
}
|
||||||
|
int playtime = game.getPlaytimeMinutes();
|
||||||
|
if (!hasSchema || (meta != null && Objects.equals(meta.getPlaytimeAtRefresh(), playtime))) {
|
||||||
|
return Outcome.UNCHANGED;
|
||||||
|
}
|
||||||
|
pause();
|
||||||
|
return refreshUser(userId, steamId, appid, playtime) ? Outcome.VISIBLE : Outcome.HIDDEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshSchema(Long appid) {
|
||||||
|
Optional<SteamSchemaForGameResponse.Game> schema = steamApiService.getSchemaForGame(appid);
|
||||||
|
List<SteamSchemaForGameResponse.Achievement> achievements = schema
|
||||||
|
.map(SteamSchemaForGameResponse.Game::availableGameStats)
|
||||||
|
.map(SteamSchemaForGameResponse.AvailableGameStats::achievements)
|
||||||
|
.orElse(List.of());
|
||||||
|
writeLocked(0L, appid, () -> {
|
||||||
|
if (!achievements.isEmpty()) {
|
||||||
|
schemaRepository.deleteByAppid(appid);
|
||||||
|
schemaRepository.saveAll(achievements.stream()
|
||||||
|
.map(a -> new GameAchievementSchema(appid, a.name(), a.displayName(), a.description(),
|
||||||
|
a.icon(), a.iconGray(), a.hidden() != null && a.hidden() == 1))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
schemaMetaRepository.save(new GameAchievementSchemaMeta(appid, achievements.size(), !achievements.isEmpty(), Instant.now()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return false when Steam hides the achievements of this user
|
||||||
|
*/
|
||||||
|
private boolean refreshUser(Long userId, String steamId, Long appid, int playtimeMinutes) {
|
||||||
|
Optional<SteamPlayerAchievementsResponse.PlayerStats> response = steamApiService.getPlayerAchievements(steamId, appid);
|
||||||
|
boolean visible = response.isPresent() && (response.get().success() == null || response.get().success());
|
||||||
|
List<UserGameAchievement> rows = visible && response.get().achievements() != null
|
||||||
|
? response.get().achievements().stream()
|
||||||
|
.map(a -> {
|
||||||
|
boolean unlocked = a.achieved() != null && a.achieved() == 1;
|
||||||
|
Instant unlockedAt = unlocked && a.unlockTime() != null && a.unlockTime() > 0
|
||||||
|
? Instant.ofEpochSecond(a.unlockTime()) : null;
|
||||||
|
return new UserGameAchievement(userId, appid, a.apiName(), unlocked, unlockedAt);
|
||||||
|
})
|
||||||
|
.toList()
|
||||||
|
: List.of();
|
||||||
|
int unlockedCount = (int) rows.stream().filter(UserGameAchievement::isUnlocked).count();
|
||||||
|
// No playtime for a hidden answer: the next sync asks again, e.g. after the profile turned public.
|
||||||
|
Integer playtimeAtRefresh = visible ? playtimeMinutes : null;
|
||||||
|
writeLocked(userId, appid, () -> {
|
||||||
|
if (visible) {
|
||||||
|
userAchievementRepository.deleteByUserIdAndAppid(userId, appid);
|
||||||
|
userAchievementRepository.saveAll(rows);
|
||||||
|
}
|
||||||
|
userAchievementMetaRepository.save(
|
||||||
|
new UserGameAchievementMeta(userId, appid, Instant.now(), visible, unlockedCount, playtimeAtRefresh));
|
||||||
|
});
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commits before the lock is released, so two refreshes of the same rows (detail page and background run)
|
||||||
|
* never insert at the same time.
|
||||||
|
*/
|
||||||
|
private void writeLocked(Long userId, Long appid, Runnable write) {
|
||||||
|
ReentrantLock lock = locks[Math.floorMod(Objects.hash(userId, appid), LOCK_STRIPES)];
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
transactionTemplate.executeWithoutResult(status -> write.run());
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isStale(GameAchievementSchemaMeta schema) {
|
||||||
|
return schema.getRefreshedAt().isBefore(Instant.now().minus(schemaTtl));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for the next free Steam slot of all runs together.
|
||||||
|
*/
|
||||||
|
private void pause() throws InterruptedException {
|
||||||
|
if (pauseNanos <= 0) return;
|
||||||
|
long waitNanos;
|
||||||
|
synchronized (paceLock) {
|
||||||
|
long now = System.nanoTime();
|
||||||
|
long slot = Math.max(now, nextCallAtNanos);
|
||||||
|
nextCallAtNanos = slot + pauseNanos;
|
||||||
|
waitNanos = slot - now;
|
||||||
|
}
|
||||||
|
if (waitNanos > 0) {
|
||||||
|
TimeUnit.NANOSECONDS.sleep(waitNanos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Outcome { UNCHANGED, VISIBLE, HIDDEN }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
import core_service.modules.library.api.dto.BacklogEntryDto;
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
@@ -32,6 +33,21 @@ public class BacklogService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes only the status and keeps rating, notes and priority.
|
||||||
|
*
|
||||||
|
* @return empty when the game is not in the user's active library
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Optional<BacklogEntryDto> updateStatus(Long userId, Long appid, GameStatus status) {
|
||||||
|
return userGameRepository.findById(new UserGameId(userId, appid))
|
||||||
|
.filter(game -> game.getRemovedAt() == null)
|
||||||
|
.map(game -> {
|
||||||
|
game.setStatus(status);
|
||||||
|
return toDto(game);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static BacklogEntryDto toDto(UserGame game) {
|
private static BacklogEntryDto toDto(UserGame game) {
|
||||||
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
|
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
|
|||||||
|
|
||||||
import core_service.modules.library.api.dto.AchievementDto;
|
import core_service.modules.library.api.dto.AchievementDto;
|
||||||
import core_service.modules.library.api.dto.GameDetailDto;
|
import core_service.modules.library.api.dto.GameDetailDto;
|
||||||
|
import core_service.modules.library.domain.AchievementProgress;
|
||||||
import core_service.modules.library.domain.GameAchievementSchema;
|
import core_service.modules.library.domain.GameAchievementSchema;
|
||||||
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
@@ -16,17 +17,12 @@ import core_service.modules.library.repo.SteamGameRepository;
|
|||||||
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||||
import core_service.modules.library.repo.UserGameAchievementRepository;
|
import core_service.modules.library.repo.UserGameAchievementRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
|
||||||
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
|
|
||||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -43,16 +39,16 @@ public class GameDetailService {
|
|||||||
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
|
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||||
private final UserGameAchievementRepository userAchievementRepository;
|
private final UserGameAchievementRepository userAchievementRepository;
|
||||||
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
|
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||||
private final SteamApiService steamApiService;
|
private final AchievementSyncService achievementSyncService;
|
||||||
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||||
|
|
||||||
@Value("${app.library.achievement-ttl-hours:1}")
|
@Value("${app.library.achievement-ttl-hours:1}")
|
||||||
private long achievementTtlHours;
|
private long achievementTtlHours;
|
||||||
|
|
||||||
@Value("${app.library.schema-ttl-days:30}")
|
/**
|
||||||
private long schemaTtlDays;
|
* Not transactional on purpose: the refreshes commit in their own transactions, and the reads after them must
|
||||||
|
* see those rows, not a cached copy from a transaction that started before the refresh.
|
||||||
@Transactional
|
*/
|
||||||
public Optional<GameDetailDto> getGameDetail(Long userId, String steamId, Long appid) {
|
public Optional<GameDetailDto> getGameDetail(Long userId, String steamId, Long appid) {
|
||||||
Optional<UserGame> userGameOpt = userGameRepository.findById(new UserGameId(userId, appid));
|
Optional<UserGame> userGameOpt = userGameRepository.findById(new UserGameId(userId, appid));
|
||||||
if (userGameOpt.isEmpty() || userGameOpt.get().getRemovedAt() != null) {
|
if (userGameOpt.isEmpty() || userGameOpt.get().getRemovedAt() != null) {
|
||||||
@@ -61,14 +57,23 @@ public class GameDetailService {
|
|||||||
UserGame userGame = userGameOpt.get();
|
UserGame userGame = userGameOpt.get();
|
||||||
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
|
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
|
||||||
|
|
||||||
refreshSchemaIfStale(appid);
|
try {
|
||||||
|
achievementSyncService.refreshSchemaIfStale(appid);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage());
|
||||||
|
}
|
||||||
boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
|
boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
|
||||||
|
|
||||||
if (!hasSchema) {
|
if (!hasSchema) {
|
||||||
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
|
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshUserAchievementsIfStale(userId, steamId, appid);
|
try {
|
||||||
|
achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(),
|
||||||
|
Duration.ofHours(achievementTtlHours));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
|
UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
|
||||||
boolean statsVisible = userMeta == null || userMeta.isStatsVisible();
|
boolean statsVisible = userMeta == null || userMeta.isStatsVisible();
|
||||||
@@ -97,7 +102,8 @@ public class GameDetailService {
|
|||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return Optional.of(buildDto(userGame, meta, statsVisible, schemaList.size(), unlocked, achievementDtos));
|
return Optional.of(buildDto(userGame, meta, statsVisible, schemaList.size(),
|
||||||
|
AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos));
|
||||||
}
|
}
|
||||||
|
|
||||||
private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible,
|
private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible,
|
||||||
@@ -129,68 +135,4 @@ public class GameDetailService {
|
|||||||
.orElse(null)
|
.orElse(null)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void refreshSchemaIfStale(Long appid) {
|
|
||||||
Optional<GameAchievementSchemaMeta> existing = schemaMetaRepository.findById(appid);
|
|
||||||
Instant now = Instant.now();
|
|
||||||
if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofDays(schemaTtlDays)))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Optional<SteamSchemaForGameResponse.Game> schema = steamApiService.getSchemaForGame(appid);
|
|
||||||
boolean hasSchema = schema.isPresent()
|
|
||||||
&& schema.get().availableGameStats() != null
|
|
||||||
&& schema.get().availableGameStats().achievements() != null
|
|
||||||
&& !schema.get().availableGameStats().achievements().isEmpty();
|
|
||||||
|
|
||||||
if (hasSchema) {
|
|
||||||
schemaRepository.deleteByAppid(appid);
|
|
||||||
List<GameAchievementSchema> rows = schema.get().availableGameStats().achievements().stream()
|
|
||||||
.map(a -> new GameAchievementSchema(
|
|
||||||
appid,
|
|
||||||
a.name(),
|
|
||||||
a.displayName(),
|
|
||||||
a.description(),
|
|
||||||
a.icon(),
|
|
||||||
a.iconGray(),
|
|
||||||
a.hidden() != null && a.hidden() == 1
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
schemaRepository.saveAll(rows);
|
|
||||||
}
|
|
||||||
int total = hasSchema ? schema.get().availableGameStats().achievements().size() : 0;
|
|
||||||
schemaMetaRepository.save(new GameAchievementSchemaMeta(appid, total, hasSchema, now));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void refreshUserAchievementsIfStale(Long userId, String steamId, Long appid) {
|
|
||||||
Optional<UserGameAchievementMeta> existing = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid);
|
|
||||||
Instant now = Instant.now();
|
|
||||||
if (existing.isPresent() && existing.get().getRefreshedAt().isAfter(now.minus(Duration.ofHours(achievementTtlHours)))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<SteamPlayerAchievementsResponse.PlayerStats> response = steamApiService.getPlayerAchievements(steamId, appid);
|
|
||||||
boolean visible = response.isPresent() && (response.get().success() == null || response.get().success());
|
|
||||||
|
|
||||||
if (!visible) {
|
|
||||||
userAchievementMetaRepository.save(new UserGameAchievementMeta(userId, appid, now, false, 0));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<SteamPlayerAchievementsResponse.Achievement> list = response.get().achievements();
|
|
||||||
userAchievementRepository.deleteByUserIdAndAppid(userId, appid);
|
|
||||||
int unlockedCount = 0;
|
|
||||||
if (list != null) {
|
|
||||||
List<UserGameAchievement> rows = list.stream()
|
|
||||||
.map(a -> {
|
|
||||||
boolean unlocked = a.achieved() != null && a.achieved() == 1;
|
|
||||||
Instant unlockedAt = unlocked && a.unlockTime() != null && a.unlockTime() > 0
|
|
||||||
? Instant.ofEpochSecond(a.unlockTime()) : null;
|
|
||||||
return new UserGameAchievement(userId, appid, a.apiName(), unlocked, unlockedAt);
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
userAchievementRepository.saveAll(rows);
|
|
||||||
unlockedCount = (int) rows.stream().filter(UserGameAchievement::isUnlocked).count();
|
|
||||||
}
|
|
||||||
userAchievementMetaRepository.save(new UserGameAchievementMeta(userId, appid, now, true, unlockedCount));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,26 +1,34 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
import core_service.modules.library.api.dto.AchievementSummaryDto;
|
||||||
import core_service.modules.library.api.dto.ActivityDto;
|
import core_service.modules.library.api.dto.ActivityDto;
|
||||||
import core_service.modules.library.api.dto.GameDto;
|
import core_service.modules.library.api.dto.GameDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
|
import core_service.modules.library.api.dto.PickerDto;
|
||||||
import core_service.modules.library.api.dto.PublicLibraryDto;
|
import core_service.modules.library.api.dto.PublicLibraryDto;
|
||||||
import core_service.modules.library.api.dto.RecentGameDto;
|
import core_service.modules.library.api.dto.RecentGameDto;
|
||||||
import core_service.modules.library.api.dto.WishlistDto;
|
import core_service.modules.library.api.dto.WishlistDto;
|
||||||
|
import core_service.modules.library.domain.AchievementProgress;
|
||||||
import core_service.modules.library.domain.DayMinutes;
|
import core_service.modules.library.domain.DayMinutes;
|
||||||
|
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.GameTimeToBeat;
|
import core_service.modules.library.domain.GameTimeToBeat;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||||
import core_service.modules.library.domain.UserSteamSync;
|
import core_service.modules.library.domain.UserSteamSync;
|
||||||
|
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||||
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||||
import core_service.modules.library.repo.PlaytimeDayRepository;
|
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||||
import core_service.modules.library.repo.WishlistGameRepository;
|
import core_service.modules.library.repo.WishlistGameRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -29,6 +37,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.EnumMap;
|
import java.util.EnumMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -43,6 +52,7 @@ public class LibraryQueryService {
|
|||||||
|
|
||||||
private static final int PUBLIC_PLAYING = 8;
|
private static final int PUBLIC_PLAYING = 8;
|
||||||
private static final int PUBLIC_FINISHED = 12;
|
private static final int PUBLIC_FINISHED = 12;
|
||||||
|
private static final int CLOSEST_GAMES = 5;
|
||||||
|
|
||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||||
@@ -50,6 +60,8 @@ public class LibraryQueryService {
|
|||||||
private final PlaytimeDayRepository playtimeDayRepository;
|
private final PlaytimeDayRepository playtimeDayRepository;
|
||||||
private final WishlistGameRepository wishlistGameRepository;
|
private final WishlistGameRepository wishlistGameRepository;
|
||||||
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||||
|
private final GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||||
|
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||||
|
|
||||||
@Value("${app.library.zone:Europe/Berlin}")
|
@Value("${app.library.zone:Europe/Berlin}")
|
||||||
private ZoneId zone;
|
private ZoneId zone;
|
||||||
@@ -64,12 +76,74 @@ public class LibraryQueryService {
|
|||||||
UserGameRepository.querySort(pageable.getSort()));
|
UserGameRepository.querySort(pageable.getSort()));
|
||||||
Page<UserGame> page = userGameRepository.findOwnedGames(
|
Page<UserGame> page = userGameRepository.findOwnedGames(
|
||||||
userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
|
userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
|
||||||
List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList();
|
List<GameDto> games = toDtos(page.getContent());
|
||||||
|
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, List<Long> excludeAppids, int count) {
|
||||||
|
return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, excludeAppids, count)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param games all of one user
|
||||||
|
*/
|
||||||
|
private List<GameDto> toDtos(List<UserGame> games) {
|
||||||
|
if (games.isEmpty()) return List.of();
|
||||||
|
List<Long> appids = games.stream().map(UserGame::getAppid).toList();
|
||||||
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
|
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
|
||||||
.collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
.collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||||
Map<Long, GameTimeToBeat> times = gameTimeToBeatRepository.findAllById(appids).stream()
|
Map<Long, GameTimeToBeat> times = gameTimeToBeatRepository.findAllById(appids).stream()
|
||||||
.collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t));
|
.collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t));
|
||||||
return page.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid())));
|
Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository.findAllById(appids).stream()
|
||||||
|
.collect(Collectors.toMap(GameAchievementSchemaMeta::getAppid, m -> m));
|
||||||
|
Map<Long, UserGameAchievementMeta> unlocks = userAchievementMetaRepository
|
||||||
|
.findByUserIdAndAppidIn(games.getFirst().getUserId(), appids).stream()
|
||||||
|
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
|
||||||
|
return games.stream()
|
||||||
|
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
|
||||||
|
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AchievementSummaryDto getAchievementSummary(Long userId) {
|
||||||
|
List<AchievementProgress> games = userGameRepository.findAchievementProgress(userId);
|
||||||
|
List<AchievementProgress> started = startedGames(games);
|
||||||
|
List<AchievementProgress> closest = started.stream()
|
||||||
|
.filter(g -> g.unlockedCapped() < g.total())
|
||||||
|
.sorted(Comparator.comparingDouble((AchievementProgress g) -> (double) g.unlockedCapped() / g.total())
|
||||||
|
.reversed()
|
||||||
|
.thenComparingInt(g -> g.total() - g.unlockedCapped())
|
||||||
|
.thenComparing(AchievementProgress::appid))
|
||||||
|
.limit(CLOSEST_GAMES)
|
||||||
|
.toList();
|
||||||
|
Map<Long, SteamGame> names = steamGameRepository.findAllById(closest.stream().map(AchievementProgress::appid).toList())
|
||||||
|
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||||
|
return new AchievementSummaryDto(
|
||||||
|
started.stream().mapToInt(AchievementProgress::unlockedCapped).sum(),
|
||||||
|
started.size(),
|
||||||
|
perfectGames(started),
|
||||||
|
averageCompletion(started),
|
||||||
|
(int) games.stream().filter(g -> !g.known()).count(),
|
||||||
|
(int) games.stream().filter(g -> g.hasAchievements() && g.unlocked() != null && !g.visible()).count(),
|
||||||
|
closest.stream()
|
||||||
|
.map(g -> new AchievementSummaryDto.Game(g.appid(),
|
||||||
|
names.containsKey(g.appid()) ? names.get(g.appid()).getName() : null,
|
||||||
|
g.unlockedCapped(), g.total()))
|
||||||
|
.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<AchievementProgress> startedGames(List<AchievementProgress> games) {
|
||||||
|
return games.stream().filter(g -> g.hasAchievements() && g.visible() && g.unlockedCapped() > 0).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int perfectGames(List<AchievementProgress> started) {
|
||||||
|
return (int) started.stream().filter(g -> g.unlockedCapped() == g.total()).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Double averageCompletion(List<AchievementProgress> started) {
|
||||||
|
if (started.isEmpty()) return null;
|
||||||
|
double mean = started.stream().mapToDouble(g -> g.unlockedCapped() * 100.0 / g.total()).average().orElse(0);
|
||||||
|
return Math.round(mean * 10.0) / 10.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,8 +154,7 @@ public class LibraryQueryService {
|
|||||||
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
||||||
long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
|
long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
|
||||||
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1)
|
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1)
|
||||||
.map(ug -> toDto(ug, steamGameRepository.findById(ug.getAppid()).orElse(null),
|
.map(ug -> toDtos(List.of(ug)).getFirst())
|
||||||
gameTimeToBeatRepository.findById(ug.getAppid()).orElse(null)))
|
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId));
|
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId));
|
||||||
}
|
}
|
||||||
@@ -126,8 +199,10 @@ public class LibraryQueryService {
|
|||||||
Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList())
|
Stream.concat(playing.stream(), finished.stream()).map(UserGame::getAppid).toList())
|
||||||
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||||
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
||||||
|
List<AchievementProgress> started = startedGames(userGameRepository.findAchievementProgress(userId));
|
||||||
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus,
|
return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus,
|
||||||
toPublicGames(playing, meta), toPublicGames(finished, meta));
|
toPublicGames(playing, meta), toPublicGames(finished, meta),
|
||||||
|
perfectGames(started), averageCompletion(started));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every status, also those with 0 games.
|
// Every status, also those with 0 games.
|
||||||
@@ -155,7 +230,12 @@ public class LibraryQueryService {
|
|||||||
.toList());
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time) {
|
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
|
||||||
|
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks) {
|
||||||
|
Integer achievementsTotal = schema != null && schema.isHasSchema() && schema.getAchievementsTotal() > 0
|
||||||
|
? schema.getAchievementsTotal() : null;
|
||||||
|
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
|
||||||
|
? AchievementProgress.capped(unlocks.getAchievementsUnlocked(), achievementsTotal) : null;
|
||||||
return new GameDto(
|
return new GameDto(
|
||||||
ug.getAppid(),
|
ug.getAppid(),
|
||||||
meta != null ? meta.getName() : null,
|
meta != null ? meta.getName() : null,
|
||||||
@@ -164,7 +244,9 @@ public class LibraryQueryService {
|
|||||||
ug.getStatus(),
|
ug.getStatus(),
|
||||||
ug.getRating(),
|
ug.getRating(),
|
||||||
ug.getPriority(),
|
ug.getPriority(),
|
||||||
time != null ? time.getNormallyMinutes() : null);
|
time != null ? time.getNormallyMinutes() : null,
|
||||||
|
achievementsUnlocked,
|
||||||
|
achievementsTotal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<UserSteamSync> getSyncStatus(Long userId) {
|
public Optional<UserSteamSync> getSyncStatus(Long userId) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import core_service.modules.steam_adapter.service.SteamApiService;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -48,6 +49,7 @@ public class LibrarySyncWorker {
|
|||||||
private final PlaytimeDayRepository playtimeDayRepository;
|
private final PlaytimeDayRepository playtimeDayRepository;
|
||||||
private final WishlistGameRepository wishlistGameRepository;
|
private final WishlistGameRepository wishlistGameRepository;
|
||||||
private final TimeToBeatService timeToBeatService;
|
private final TimeToBeatService timeToBeatService;
|
||||||
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
@Value("${app.library.running-stale-minutes:5}")
|
@Value("${app.library.running-stale-minutes:5}")
|
||||||
private long runningStaleMinutes;
|
private long runningStaleMinutes;
|
||||||
@@ -115,6 +117,7 @@ public class LibrarySyncWorker {
|
|||||||
}
|
}
|
||||||
userSteamSyncRepository.markHistoryBaseline(userId, steamId);
|
userSteamSyncRepository.markHistoryBaseline(userId, steamId);
|
||||||
timeToBeatService.refresh(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
|
timeToBeatService.refresh(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
|
||||||
|
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
|
||||||
finalStatus = Status.OK;
|
finalStatus = Status.OK;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Sync failed for user {}", userId, e);
|
log.error("Sync failed for user {}", userId, e);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published inside the library sync transaction after a successful sync.
|
||||||
|
*/
|
||||||
|
public record LibrarySyncedEvent(Long userId, String steamId) {}
|
||||||
@@ -30,6 +30,8 @@ import java.util.stream.Collectors;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class SteamApiService {
|
public class SteamApiService {
|
||||||
|
|
||||||
|
private static final String NO_STATS = "Requested app has no stats";
|
||||||
|
|
||||||
private final RestClient steamRestClient;
|
private final RestClient steamRestClient;
|
||||||
|
|
||||||
private static final int STORE_ITEMS_BATCH = 100;
|
private static final int STORE_ITEMS_BATCH = 100;
|
||||||
@@ -245,7 +247,8 @@ public class SteamApiService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-game achievement schema (global, language-aware).
|
* Per-game achievement schema (global, language-aware).
|
||||||
* Returns empty when game has no achievements or Steam returns no schema.
|
* Returns empty when the game has no achievements or Steam answers 4xx, its final answer for delisted apps and
|
||||||
|
* tools. A server or network failure throws, so the caller keeps what it stored before.
|
||||||
*/
|
*/
|
||||||
@RateLimiter(name = "steamApi")
|
@RateLimiter(name = "steamApi")
|
||||||
@Retry(name = "steamApi")
|
@Retry(name = "steamApi")
|
||||||
@@ -259,19 +262,20 @@ public class SteamApiService {
|
|||||||
.build())
|
.build())
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(SteamSchemaForGameResponse.class);
|
.body(SteamSchemaForGameResponse.class);
|
||||||
|
|
||||||
if (response != null && response.game() != null) {
|
if (response != null && response.game() != null) {
|
||||||
return Optional.of(response.game());
|
return Optional.of(response.game());
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (HttpClientErrorException e) {
|
||||||
log.warn("GetSchemaForGame failed for appid {}: {}", appid, e.getMessage());
|
log.info("No achievement schema for appid {}: {}", appid, e.getStatusCode());
|
||||||
}
|
}
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-user achievement state for a single game.
|
* Per-user achievement state for a single game.
|
||||||
* Returns empty when profile/stats are private (HTTP 403) or game has no achievements.
|
* Returns empty when profile/stats are private (HTTP 403), and no achievements when Steam answers 400 "Requested
|
||||||
|
* app has no stats". Any other failure throws, so the caller keeps what it stored before instead of storing
|
||||||
|
* "hidden" or "nothing unlocked".
|
||||||
*/
|
*/
|
||||||
@RateLimiter(name = "steamApi")
|
@RateLimiter(name = "steamApi")
|
||||||
@Retry(name = "steamApi")
|
@Retry(name = "steamApi")
|
||||||
@@ -292,8 +296,11 @@ public class SteamApiService {
|
|||||||
}
|
}
|
||||||
} catch (HttpClientErrorException.Forbidden e) {
|
} catch (HttpClientErrorException.Forbidden e) {
|
||||||
log.info("Achievements hidden by Steam privacy for steamId={} appid={}", steamId, appid);
|
log.info("Achievements hidden by Steam privacy for steamId={} appid={}", steamId, appid);
|
||||||
} catch (Exception e) {
|
} catch (HttpClientErrorException.BadRequest e) {
|
||||||
log.warn("GetPlayerAchievements failed for steamId={} appid={}: {}", steamId, appid, e.getMessage());
|
if (!e.getResponseBodyAsString().contains(NO_STATS)) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return Optional.of(new SteamPlayerAchievementsResponse.PlayerStats(steamId, null, List.of(), true, NO_STATS));
|
||||||
}
|
}
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ management:
|
|||||||
show-details: never
|
show-details: never
|
||||||
security:
|
security:
|
||||||
jwt:
|
jwt:
|
||||||
public-key: ${JWT_PUBLIC_KEY}
|
public-key: ${JWT_PUBLIC_KEY:}
|
||||||
|
jwks-url: ${JWT_JWKS_URL:}
|
||||||
app:
|
app:
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: "https://tbp.andreas-dahm.eu"
|
allowed-origins: "https://tbp.andreas-dahm.eu"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Playtime the last achievement refresh saw. The background sync asks Steam again only when the playtime changed.
|
||||||
|
-- NULL: never refreshed with this column, or Steam hid the achievements; the next sync asks again.
|
||||||
|
ALTER TABLE user_game_achievement_meta ADD COLUMN playtime_at_refresh INTEGER;
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package core_service.configuration.security;
|
||||||
|
|
||||||
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import io.jsonwebtoken.JwtException;
|
||||||
|
import io.jsonwebtoken.JwtParser;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.springframework.test.web.client.MockRestServiceServer;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTimeout;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||||
|
import static org.springframework.test.web.client.ExpectedCount.once;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||||
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||||
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||||
|
|
||||||
|
class JwksKeyLocatorTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
private static final String JWKS_URL = "http://auth/.well-known/jwks.json";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private JwtConfig jwtConfig;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private Clock clock;
|
||||||
|
|
||||||
|
private Instant now = Instant.parse("2026-09-25T10:00:00Z");
|
||||||
|
private RestClient.Builder restClientBuilder;
|
||||||
|
private MockRestServiceServer authServer;
|
||||||
|
private KeyPair first;
|
||||||
|
private KeyPair second;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws Exception {
|
||||||
|
lenient().when(clock.instant()).thenAnswer(invocation -> now);
|
||||||
|
restClientBuilder = RestClient.builder();
|
||||||
|
authServer = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||||
|
first = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
|
second = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String jwk(String kid, PublicKey key) {
|
||||||
|
byte[] der = key.getEncoded();
|
||||||
|
// The X.509 DER of an Ed25519 key is a 12-byte header, then the raw 32-byte key.
|
||||||
|
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(Arrays.copyOfRange(der, 12, der.length));
|
||||||
|
return """
|
||||||
|
{"kty":"OKP","crv":"Ed25519","x":"%s","kid":"%s","use":"sig","alg":"EdDSA"}""".formatted(x, kid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void authAnswers(String... jwks) {
|
||||||
|
String keys = Stream.of(jwks).collect(Collectors.joining(","));
|
||||||
|
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withSuccess("{\"keys\":[" + keys + "]}", APPLICATION_JSON));
|
||||||
|
}
|
||||||
|
|
||||||
|
private JwtParser parser(String jwksUrl, KeyPair fallback) {
|
||||||
|
lenient().when(jwtConfig.getJwksUrl()).thenReturn(jwksUrl);
|
||||||
|
lenient().when(jwtConfig.getPublicKey()).thenReturn(
|
||||||
|
fallback == null ? null : Base64.getEncoder().encodeToString(fallback.getPublic().getEncoded()));
|
||||||
|
JwksKeyLocator locator = new JwksKeyLocator(jwtConfig, restClientBuilder.build(), clock);
|
||||||
|
return Jwts.parser().keyLocator(locator).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String token(KeyPair keys, String kid) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.header().keyId(kid).and()
|
||||||
|
.subject("a@b.de")
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + 60_000))
|
||||||
|
.signWith(keys.getPrivate(), Jwts.SIG.EdDSA)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String subject(JwtParser parser, String token) {
|
||||||
|
return parser.parseSignedClaims(token).getPayload().getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void knownKid_VerifiesWithTheJwksKey() {
|
||||||
|
authAnswers(jwk("k1", first.getPublic()));
|
||||||
|
|
||||||
|
JwtParser parser = parser(JWKS_URL, null);
|
||||||
|
|
||||||
|
assertEquals("a@b.de", subject(parser, token(first, "k1")));
|
||||||
|
authServer.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownKid_LoadsTheJwksAgain() {
|
||||||
|
authAnswers(jwk("k1", first.getPublic()));
|
||||||
|
authAnswers(jwk("k1", first.getPublic()), jwk("k2", second.getPublic()));
|
||||||
|
|
||||||
|
JwtParser parser = parser(JWKS_URL, null);
|
||||||
|
|
||||||
|
assertEquals("a@b.de", subject(parser, token(second, "k2")));
|
||||||
|
assertEquals("a@b.de", subject(parser, token(second, "k2")));
|
||||||
|
authServer.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownKid_LoadsAtMostOncePerInterval() {
|
||||||
|
authAnswers(jwk("k1", first.getPublic()));
|
||||||
|
authAnswers(jwk("k1", first.getPublic()));
|
||||||
|
authAnswers(jwk("k2", second.getPublic()));
|
||||||
|
|
||||||
|
JwtParser parser = parser(JWKS_URL, null);
|
||||||
|
|
||||||
|
assertThrows(JwtException.class, () -> subject(parser, token(second, "k2")));
|
||||||
|
assertThrows(JwtException.class, () -> subject(parser, token(second, "made-up")));
|
||||||
|
now = now.plus(JwksKeyLocator.MIN_REFRESH_INTERVAL).minus(Duration.ofSeconds(1));
|
||||||
|
assertThrows(JwtException.class, () -> subject(parser, token(second, "k2")));
|
||||||
|
|
||||||
|
now = now.plus(Duration.ofSeconds(1));
|
||||||
|
assertEquals("a@b.de", subject(parser, token(second, "k2")));
|
||||||
|
authServer.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authDownAtStartup_TheFirstTokenLoadsTheJwks() {
|
||||||
|
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
|
||||||
|
authAnswers(jwk("k1", first.getPublic()));
|
||||||
|
|
||||||
|
JwtParser parser = parser(JWKS_URL, null);
|
||||||
|
|
||||||
|
assertEquals("a@b.de", subject(parser, token(first, "k1")));
|
||||||
|
authServer.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authDown_VerifiesWithThePublicKey() {
|
||||||
|
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
|
||||||
|
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
|
||||||
|
|
||||||
|
JwtParser parser = parser(JWKS_URL, first);
|
||||||
|
|
||||||
|
assertEquals("a@b.de", subject(parser, token(first, "k1")));
|
||||||
|
authServer.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noJwksUrl_VerifiesWithThePublicKeyWithAndWithoutKid() {
|
||||||
|
JwtParser parser = parser(null, first);
|
||||||
|
|
||||||
|
assertEquals("a@b.de", subject(parser, token(first, "k1")));
|
||||||
|
assertEquals("a@b.de", subject(parser, token(first, null)));
|
||||||
|
assertThrows(JwtException.class, () -> subject(parser, token(second, "k1")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void neitherJwksUrlNorPublicKey_FailsAtStartup() {
|
||||||
|
assertThrows(IllegalStateException.class, () -> parser(" ", null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownKid_ThreadWaitingOnTheLoadSeesTheFreshKeys() throws Exception {
|
||||||
|
authAnswers(jwk("k1", first.getPublic()));
|
||||||
|
CountDownLatch authMayAnswer = new CountDownLatch(1);
|
||||||
|
String rotated = "{\"keys\":[" + jwk("k1", first.getPublic()) + "," + jwk("k2", second.getPublic()) + "]}";
|
||||||
|
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(request -> {
|
||||||
|
try {
|
||||||
|
if (!authMayAnswer.await(5, TimeUnit.SECONDS)) {
|
||||||
|
throw new IllegalStateException("test did not release the JWKS answer");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
return withSuccess(rotated, APPLICATION_JSON).createResponse(request);
|
||||||
|
});
|
||||||
|
JwtParser parser = parser(JWKS_URL, null);
|
||||||
|
String tokenK2 = token(second, "k2");
|
||||||
|
Map<String, Object> outcome = new ConcurrentHashMap<>();
|
||||||
|
Runnable parse = () -> {
|
||||||
|
try {
|
||||||
|
outcome.put(Thread.currentThread().getName(), subject(parser, tokenK2));
|
||||||
|
} catch (Exception e) {
|
||||||
|
outcome.put(Thread.currentThread().getName(), e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Thread firstCaller = new Thread(parse, "first");
|
||||||
|
Thread secondCaller = new Thread(parse, "second");
|
||||||
|
|
||||||
|
firstCaller.start();
|
||||||
|
awaitState(firstCaller, Thread.State.TIMED_WAITING); // inside load(), waiting for Auth
|
||||||
|
secondCaller.start();
|
||||||
|
awaitState(secondCaller, Thread.State.WAITING); // waiting on the refresh lock
|
||||||
|
authMayAnswer.countDown();
|
||||||
|
firstCaller.join(5_000);
|
||||||
|
secondCaller.join(5_000);
|
||||||
|
|
||||||
|
assertEquals("a@b.de", outcome.get("first"));
|
||||||
|
assertEquals("a@b.de", outcome.get("second"), "second caller should see the keys the first caller loaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void authSlowBeforeTheFirstLoad_OtherRequestsVerifyWithThePublicKeyWithoutWaiting() throws Exception {
|
||||||
|
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(withServerError());
|
||||||
|
CountDownLatch authMayAnswer = new CountDownLatch(1);
|
||||||
|
authServer.expect(once(), requestTo(JWKS_URL)).andRespond(request -> {
|
||||||
|
try {
|
||||||
|
authMayAnswer.await(5, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
return withServerError().createResponse(request);
|
||||||
|
});
|
||||||
|
JwtParser parser = parser(JWKS_URL, first);
|
||||||
|
String token = token(first, "k1");
|
||||||
|
Thread loader = new Thread(() -> subject(parser, token), "loader");
|
||||||
|
|
||||||
|
loader.start();
|
||||||
|
awaitState(loader, Thread.State.TIMED_WAITING); // inside load(), waiting for Auth
|
||||||
|
try {
|
||||||
|
assertEquals("a@b.de", assertTimeout(Duration.ofSeconds(1), () -> subject(parser, token)));
|
||||||
|
} finally {
|
||||||
|
authMayAnswer.countDown();
|
||||||
|
loader.join(5_000);
|
||||||
|
}
|
||||||
|
authServer.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void awaitState(Thread thread, Thread.State state) throws InterruptedException {
|
||||||
|
long deadline = System.currentTimeMillis() + 5_000;
|
||||||
|
while (thread.getState() != state) {
|
||||||
|
if (System.currentTimeMillis() > deadline) {
|
||||||
|
throw new AssertionError(thread.getName() + " never reached " + state + ", is " + thread.getState());
|
||||||
|
}
|
||||||
|
Thread.sleep(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterTheFirstJwksLoad_ThePublicKeyNoLongerVerifies() {
|
||||||
|
authAnswers(jwk("k2", second.getPublic()));
|
||||||
|
authAnswers(jwk("k2", second.getPublic()));
|
||||||
|
|
||||||
|
JwtParser parser = parser(JWKS_URL, first);
|
||||||
|
|
||||||
|
assertEquals("a@b.de", subject(parser, token(second, "k2")));
|
||||||
|
assertThrows(JwtException.class, () -> subject(parser, token(first, "tbp-auth-1")),
|
||||||
|
"kid tbp-auth-1 is not in the JWKS any more, the old key must not verify");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import io.jsonwebtoken.Jwts;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
@@ -37,8 +38,7 @@ class JwtServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64);
|
lenient().when(jwtConfig.getPublicKey()).thenReturn(publicB64);
|
||||||
|
|
||||||
jwtService = new JwtService(jwtConfig);
|
jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create()));
|
||||||
jwtService.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String signToken(String userId, String email, Long steamId) {
|
private String signToken(String userId, String email, Long steamId) {
|
||||||
|
|||||||
+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;
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GamePriority;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
@@ -135,4 +136,53 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
|
|||||||
.content("{\"status\":\"PLAYING\"}"))
|
.content("{\"status\":\"PLAYING\"}"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateStatus_KeepsRatingNotesAndPriority() throws Exception {
|
||||||
|
UserGame game = stored();
|
||||||
|
game.setRating(4);
|
||||||
|
game.setNotes("co-op with Tom");
|
||||||
|
game.setPriority(GamePriority.HIGH);
|
||||||
|
userGameRepository.save(game);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"status\":\"PLAYING\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("PLAYING"))
|
||||||
|
.andExpect(jsonPath("$.notes").value("co-op with Tom"));
|
||||||
|
|
||||||
|
UserGame updated = stored();
|
||||||
|
assertEquals(GameStatus.PLAYING, updated.getStatus());
|
||||||
|
assertEquals(4, updated.getRating());
|
||||||
|
assertEquals("co-op with Tom", updated.getNotes());
|
||||||
|
assertEquals(GamePriority.HIGH, updated.getPriority());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateStatus_MissingStatus_Is400() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
|
||||||
|
assertEquals(GameStatus.BACKLOG, stored().getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateStatus_RemovedGame_Is404() throws Exception {
|
||||||
|
UserGame game = stored();
|
||||||
|
game.setRemovedAt(Instant.now());
|
||||||
|
userGameRepository.save(game);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"status\":\"PLAYING\"}"))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateStatus_GameNotOwned_Is404() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/library/games/999/status").contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"status\":\"PLAYING\"}"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GamePriority;
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
import core_service.modules.library.domain.GameTimeToBeat;
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.domain.UserGameId;
|
||||||
|
import core_service.modules.library.repo.GameTimeToBeatRepository;
|
||||||
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsInAnyOrder;
|
||||||
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dev profile authenticates every request as user 1.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class LibraryControllerPickerTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long USER_ID = 1L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clean() {
|
||||||
|
userGameRepository.deleteAll();
|
||||||
|
gameTimeToBeatRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) {
|
||||||
|
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
|
||||||
|
UserGame game = new UserGame(USER_ID, appid, 0, Instant.now(), Instant.now());
|
||||||
|
game.setStatus(status);
|
||||||
|
game.setPriority(priority);
|
||||||
|
userGameRepository.save(game);
|
||||||
|
if (normallyMinutes != null) {
|
||||||
|
gameTimeToBeatRepository.save(new GameTimeToBeat(appid, null, normallyMinutes, null, Instant.now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_EmptyBacklog_ReturnsNoGames() throws Exception {
|
||||||
|
game(10L, GameStatus.PLAYING, null, 60);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.games", hasSize(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_WithoutBudget_PicksOnlyBacklogGamesIncludingUnknownLength() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 60);
|
||||||
|
game(20L, GameStatus.BACKLOG, null, null);
|
||||||
|
game(30L, GameStatus.FINISHED, null, 60);
|
||||||
|
game(40L, GameStatus.DROPPED, null, 60);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)))
|
||||||
|
.andExpect(jsonPath("$.games[?(@.appid == 10)].timeToBeatMinutes").value(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_WithBudget_SkipsLongerAndUnknownGames() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 300);
|
||||||
|
game(20L, GameStatus.BACKLOG, null, 301);
|
||||||
|
game(30L, GameStatus.BACKLOG, null, null);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "300"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_ZeroBudgetMeansAnyLength() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 300);
|
||||||
|
game(20L, GameStatus.BACKLOG, null, null);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "0"))
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_SkipsExcludedGames() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 60);
|
||||||
|
game(20L, GameStatus.BACKLOG, null, 60);
|
||||||
|
game(30L, GameStatus.BACKLOG, null, 60);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("exclude", "10", "30"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_FillsSlotsByPriorityFirst() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 60);
|
||||||
|
game(20L, GameStatus.BACKLOG, GamePriority.LOW, 60);
|
||||||
|
game(30L, GameStatus.BACKLOG, GamePriority.HIGH, 60);
|
||||||
|
game(40L, GameStatus.BACKLOG, GamePriority.MEDIUM, 60);
|
||||||
|
game(50L, GameStatus.BACKLOG, null, 60);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("count", "2"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(30, 40)))
|
||||||
|
.andExpect(jsonPath("$.games[0].appid").value(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_DefaultsToThreeAndCapsTheCount() throws Exception {
|
||||||
|
for (long appid = 1; appid <= 12; appid++) {
|
||||||
|
game(appid, GameStatus.BACKLOG, null, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
|
.andExpect(jsonPath("$.games", hasSize(3)));
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("count", "500"))
|
||||||
|
.andExpect(jsonPath("$.games", hasSize(10)));
|
||||||
|
mockMvc.perform(get("/api/library/picker").param("count", "0"))
|
||||||
|
.andExpect(jsonPath("$.games", hasSize(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_SkipsRemovedGames() throws Exception {
|
||||||
|
game(10L, GameStatus.BACKLOG, null, 60);
|
||||||
|
game(20L, GameStatus.BACKLOG, null, 60);
|
||||||
|
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 20L)).orElseThrow();
|
||||||
|
removed.setRemovedAt(Instant.now());
|
||||||
|
userGameRepository.save(removed);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
|
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void picker_OnlyShowsTheCallersGames() throws Exception {
|
||||||
|
steamGameRepository.save(new SteamGame(99L, "Other", null, Instant.now()));
|
||||||
|
userGameRepository.save(new UserGame(2L, 99L, 0, Instant.now(), Instant.now()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/picker"))
|
||||||
|
.andExpect(jsonPath("$.games", hasSize(0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
package core_service.modules.library.api;
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameAchievementSchemaMeta;
|
||||||
import core_service.modules.library.domain.GamePriority;
|
import core_service.modules.library.domain.GamePriority;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
import core_service.modules.library.domain.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||||
|
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
import core_service.shared.test.AbstractIntegrationTest;
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
import io.jsonwebtoken.Jwts;
|
import io.jsonwebtoken.Jwts;
|
||||||
@@ -37,6 +41,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserGameRepository userGameRepository;
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private GameAchievementSchemaMetaRepository schemaMetaRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameAchievementMetaRepository userAchievementMetaRepository;
|
||||||
|
|
||||||
private static String ticket(long userId) {
|
private static String ticket(long userId) {
|
||||||
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
|
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId))
|
||||||
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
|
||||||
@@ -54,6 +64,8 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void seed() {
|
void seed() {
|
||||||
userGameRepository.deleteAll();
|
userGameRepository.deleteAll();
|
||||||
|
schemaMetaRepository.deleteAll();
|
||||||
|
userAchievementMetaRepository.deleteAll();
|
||||||
steamGameRepository.saveAll(List.of(
|
steamGameRepository.saveAll(List.of(
|
||||||
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
|
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
|
||||||
new SteamGame(620L, "Portal 2", null, Instant.now()),
|
new SteamGame(620L, "Portal 2", null, Instant.now()),
|
||||||
@@ -87,6 +99,21 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
|||||||
.andExpect(jsonPath("$..priority").isEmpty());
|
.andExpect(jsonPath("$..priority").isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicLibrary_ShowsPerfectGamesAndAverageCompletion() throws Exception {
|
||||||
|
schemaMetaRepository.saveAll(List.of(
|
||||||
|
new GameAchievementSchemaMeta(620L, 10, true, Instant.now()),
|
||||||
|
new GameAchievementSchemaMeta(220L, 4, true, Instant.now())));
|
||||||
|
userAchievementMetaRepository.saveAll(List.of(
|
||||||
|
new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 10, 900),
|
||||||
|
new UserGameAchievementMeta(USER_ID, 220L, Instant.now(), true, 1, 300)));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.perfectGames").value(1))
|
||||||
|
.andExpect(jsonPath("$.averageCompletion").value(62.5));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
|
void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception {
|
||||||
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());
|
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized());
|
||||||
|
|||||||
+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.InOrder;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -39,6 +40,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||||||
import static org.mockito.Mockito.inOrder;
|
import static org.mockito.Mockito.inOrder;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class LibrarySyncWorkerTest extends AbstractUnitTest {
|
class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||||
@@ -99,6 +101,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private TimeToBeatService timeToBeatService;
|
private TimeToBeatService timeToBeatService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ApplicationEventPublisher eventPublisher;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private LibrarySyncWorker worker;
|
private LibrarySyncWorker worker;
|
||||||
|
|
||||||
@@ -116,6 +121,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
|
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
|
||||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||||
|
verifyNoInteractions(eventPublisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -154,6 +160,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
|
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
|
||||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
|
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
|
||||||
|
verify(eventPublisher).publishEvent(new LibrarySyncedEvent(USER_ID, STEAM_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package core_service.modules.steam_adapter.service;
|
package core_service.modules.steam_adapter.service;
|
||||||
|
|
||||||
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
||||||
|
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
||||||
import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse;
|
import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.test.web.client.MockRestServiceServer;
|
import org.springframework.test.web.client.MockRestServiceServer;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -20,9 +23,11 @@ import static org.hamcrest.CoreMatchers.allOf;
|
|||||||
import static org.hamcrest.CoreMatchers.containsString;
|
import static org.hamcrest.CoreMatchers.containsString;
|
||||||
import static org.hamcrest.CoreMatchers.not;
|
import static org.hamcrest.CoreMatchers.not;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam;
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam;
|
||||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||||
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||||
|
|
||||||
class SteamApiServiceTest {
|
class SteamApiServiceTest {
|
||||||
@@ -258,4 +263,67 @@ class SteamApiServiceTest {
|
|||||||
|
|
||||||
server.verify();
|
server.verify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPlayerAchievements_Forbidden_IsEmpty() {
|
||||||
|
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
|
||||||
|
.andRespond(withStatus(HttpStatus.FORBIDDEN));
|
||||||
|
|
||||||
|
assertThat(steamApiService.getPlayerAchievements("76561197960435530", 620)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPlayerAchievements_ServerError_Throws() {
|
||||||
|
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
|
||||||
|
.andRespond(withServerError());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> steamApiService.getPlayerAchievements("76561197960435530", 620))
|
||||||
|
.isInstanceOf(RestClientException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSchemaForGame_ServerError_Throws() {
|
||||||
|
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
|
||||||
|
.andRespond(withServerError());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> steamApiService.getSchemaForGame(620)).isInstanceOf(RestClientException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSchemaForGame_NoGame_IsEmpty() {
|
||||||
|
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
|
||||||
|
.andRespond(withSuccess("{\"game\":null}", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
assertThat(steamApiService.getSchemaForGame(620)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPlayerAchievements_NoStatsForThisPlayer_IsVisibleWithNothingUnlocked() {
|
||||||
|
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
|
||||||
|
.andRespond(withStatus(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body("{\"playerstats\":{\"error\":\"Requested app has no stats\",\"success\":false}}"));
|
||||||
|
|
||||||
|
Optional<SteamPlayerAchievementsResponse.PlayerStats> stats = steamApiService.getPlayerAchievements("76561197960435530", 620);
|
||||||
|
assertThat(stats).isPresent();
|
||||||
|
assertThat(stats.get().success()).isTrue();
|
||||||
|
assertThat(stats.get().achievements()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSchemaForGame_ClientError_IsEmpty() {
|
||||||
|
server.expect(requestTo(containsString("/ISteamUserStats/GetSchemaForGame/v2/")))
|
||||||
|
.andRespond(withStatus(HttpStatus.BAD_REQUEST));
|
||||||
|
|
||||||
|
assertThat(steamApiService.getSchemaForGame(620)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPlayerAchievements_OtherBadRequest_Throws() {
|
||||||
|
server.expect(requestTo(containsString("/ISteamUserStats/GetPlayerAchievements/v1/")))
|
||||||
|
.andRespond(withStatus(HttpStatus.BAD_REQUEST).contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body("{\"playerstats\":{\"error\":\"Invalid SteamID\",\"success\":false}}"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> steamApiService.getPlayerAchievements("76561197960435530", 620))
|
||||||
|
.isInstanceOf(RestClientException.class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,7 @@ public abstract class AbstractIntegrationTest {
|
|||||||
static void secrets(DynamicPropertyRegistry registry) {
|
static void secrets(DynamicPropertyRegistry registry) {
|
||||||
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded()));
|
registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(TEST_KEYS.getPublic().getEncoded()));
|
||||||
registry.add("app.steam.api-key", () -> "test-key");
|
registry.add("app.steam.api-key", () -> "test-key");
|
||||||
|
registry.add("app.library.achievement-sync-pause-ms", () -> "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static KeyPair generateKeys() {
|
private static KeyPair generateKeys() {
|
||||||
|
|||||||
Reference in new issue
Block a user