Compare commits

...
3 Commits
Author SHA1 Message Date
lobo 4d50863cb8 feat(library): add games by name from IGDB
Build, Test & Push Core Service / docker (push) Successful in 1m24s
Build, Test & Push Core Service / quality-check (push) Successful in 51s
Games the user owns outside Steam get the key minus their
IGDB id, or the Steam appid when IGDB knows the game on
Steam. The sync never removes a MANUAL row and takes it
over once the user owns the game on Steam. Users without
a Steam ID can use the library.
2026-09-25 14:37:21 +02:00
lobo 33e0a59664 docs(roadmap): add games by name from IGDB for other platforms 2026-09-25 13:45:13 +02:00
lobo 5d608b72e6 feat(security): accept the auth cookie and guard against CSRF
Core reads the login token from the tbp_access cookie or a Bearer
header, whichever verifies, and parses it once per request. Unsafe
requests need an X-TBP-Client or Authorization header, CORS allows
only the exact frontend origin, and a missing or expired token
answers 401 instead of 403.
2026-09-25 13:45:08 +02:00
49 changed files with 1783 additions and 161 deletions

No files matched your search

+5 -2
View File
@@ -37,8 +37,10 @@ To Be Played is split into three repositories:
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. Core reads `userId` and until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
`steamId` from the claims. `tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId` and `steamId` from the claims.
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
frontend origin.
A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is 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. accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header.
@@ -116,6 +118,7 @@ Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Ne
## Roadmap ## Roadmap
- Add a game by name from IGDB for platforms without Steam (needs an internal game id next to the Steam `appid`).
- Mood filter for the picker (needs genres or themes from IGDB). - Mood filter for the picker (needs genres or themes from IGDB).
- 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.
@@ -17,7 +17,8 @@ public class CacheConfig {
@Bean @Bean
public CacheManager cacheManager() { public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary"); CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary",
"igdbSearch");
cacheManager.setCaffeine(Caffeine.newBuilder() cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) .expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000)); .maximumSize(1000));
@@ -0,0 +1,41 @@
package core_service.configuration.security;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Optional;
import java.util.stream.Stream;
/**
* Where a request can carry the login token: the access cookie that Auth sets, or a Bearer header (API clients and
* tests). Both are tried; a stale cookie next to a valid header must not block the request.
*/
final class AccessTokens {
static final String ACCESS_COOKIE = "tbp_access";
private static final String BEARER = "Bearer ";
private AccessTokens() {
}
static Stream<String> candidates(HttpServletRequest request) {
return Stream.of(cookie(request), bearer(request)).flatMap(Optional::stream);
}
private static Optional<String> cookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
}
return Arrays.stream(cookies)
.filter(cookie -> ACCESS_COOKIE.equals(cookie.getName()) && !cookie.getValue().isBlank())
.map(Cookie::getValue)
.findFirst();
}
private static Optional<String> bearer(HttpServletRequest request) {
String header = request.getHeader("Authorization");
return header != null && header.startsWith(BEARER) ? Optional.of(header.substring(BEARER.length())) : Optional.empty();
}
}
@@ -0,0 +1,41 @@
package core_service.configuration.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Set;
/**
* CSRF guard for the login cookie. A request that changes data must carry {@code X-TBP-Client} or
* {@code Authorization}. A page on another origin can send neither without a CORS preflight, and CORS allows only
* our own origins.
*/
public class ClientHeaderFilter extends OncePerRequestFilter {
public static final String CLIENT_HEADER = "X-TBP-Client";
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS");
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
if (SAFE_METHODS.contains(request.getMethod())
|| request.getHeader(CLIENT_HEADER) != null
|| request.getHeader("Authorization") != null) {
filterChain.doFilter(request, response);
return;
}
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"code\":\"CLIENT_HEADER_MISSING\",\"message\":\"Missing X-TBP-Client header\"}");
}
}
@@ -17,6 +17,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.Optional;
/** /**
* Dev profile authentication filter. * Dev profile authentication filter.
@@ -56,28 +57,11 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
private java.util.Optional<UserPrincipal> principalFromJwt(HttpServletRequest request) { private Optional<UserPrincipal> principalFromJwt(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization"); return AccessTokens.candidates(request)
if (authHeader == null || !authHeader.startsWith("Bearer ")) { .map(jwtService::parseLoginToken)
return java.util.Optional.empty(); .flatMap(Optional::stream)
} .findFirst();
String jwt = authHeader.substring(7);
if (!jwtService.isTokenValid(jwt)) {
log.debug("Dev mode: invalid JWT, falling back to dummy principal");
return java.util.Optional.empty();
}
try {
String email = jwtService.extractEmail(jwt);
String userId = jwtService.extractUserId(jwt);
Long steamId = jwtService.extractSteamId(jwt);
if (email == null || userId == null) {
return java.util.Optional.empty();
}
return java.util.Optional.of(new UserPrincipal(userId, email, steamId));
} catch (Exception e) {
log.warn("Dev mode: JWT parsing failed: {}", e.getMessage());
return java.util.Optional.empty();
}
} }
private UserPrincipal dummyPrincipal() { private UserPrincipal dummyPrincipal() {
@@ -6,7 +6,6 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NonNull;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -17,11 +16,11 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.Optional;
@Component("jwtAuthenticationFilter") @Component("jwtAuthenticationFilter")
@Profile("!dev") @Profile("!dev")
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService; private final JwtService jwtService;
@@ -33,38 +32,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
@NonNull FilterChain filterChain @NonNull FilterChain filterChain
) throws ServletException, IOException { ) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization"); Optional<UserPrincipal> principal = AccessTokens.candidates(request)
.map(jwtService::parseLoginToken)
if (authHeader == null || !authHeader.startsWith("Bearer ")) { .flatMap(Optional::stream)
filterChain.doFilter(request, response); .findFirst();
return; if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
}
final String jwt = authHeader.substring(7);
if (!jwtService.isTokenValid(jwt)) {
filterChain.doFilter(request, response);
return;
}
try {
final String userEmail = jwtService.extractEmail(jwt);
final String userId = jwtService.extractUserId(jwt);
final Long steamId = jwtService.extractSteamId(jwt);
if (userEmail != null && userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserPrincipal principal = new UserPrincipal(userId, userEmail, steamId);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal, principal.get(),
null, null,
Collections.emptyList() Collections.emptyList()
); );
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); SecurityContextHolder.getContext().setAuthentication(authToken);
} }
} catch (Exception e) {
log.error("JWT Validation failed: {}", e.getMessage());
}
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
@@ -1,6 +1,7 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.ProfileTicketVerifier; import core_service.shared.security.ProfileTicketVerifier;
import core_service.shared.security.UserPrincipal;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
@@ -14,7 +15,7 @@ import java.util.Optional;
@Service @Service
public class JwtService implements ProfileTicketVerifier { public class JwtService implements ProfileTicketVerifier {
// Only non-login tokens carry this claim; isTokenValid rejects them. // Only non-login tokens carry this claim; parseLoginToken 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";
@@ -28,27 +29,20 @@ public class JwtService implements ProfileTicketVerifier {
return parser.parseSignedClaims(token).getPayload(); return parser.parseSignedClaims(token).getPayload();
} }
public String extractEmail(String token) {
return claims(token).getSubject();
}
public String extractUserId(String token) {
return claims(token).get("userId", String.class);
}
public Long extractSteamId(String token) {
return claims(token).get("steamId", Long.class);
}
/** /**
* True for a login token with a valid signature. A profile ticket is signed with the same key, but is no login. * The user of a login token with a valid signature. Empty for anything else, also for a profile ticket: it is
* signed with the same key, but is no login.
*/ */
public boolean isTokenValid(String token) { public Optional<UserPrincipal> parseLoginToken(String token) {
try { try {
Claims claims = claims(token); Claims claims = claims(token);
return claims.get(TYPE_CLAIM) == null; String userId = claims.get("userId", String.class);
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
return Optional.empty();
}
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class)));
} catch (Exception e) { } catch (Exception e) {
return false; return Optional.empty();
} }
} }
@@ -6,17 +6,21 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.time.Duration;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
@@ -43,10 +47,13 @@ public class SecurityConfig {
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins)); // Exact origins only: with credentials, a pattern would send the login cookie to every matching site.
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*")); configuration.setAllowedHeaders(List.of(
configuration.setAllowCredentials(false); "Content-Type", "Authorization", ClientHeaderFilter.CLIENT_HEADER, "X-Profile-Ticket"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(Duration.ofHours(1));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
@@ -59,11 +66,14 @@ public class SecurityConfig {
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 401, not Spring's default 403: the frontend renews the access cookie only on a 401.
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(WHITE_LIST_URLS).permitAll() .requestMatchers(WHITE_LIST_URLS).permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterAfter(new ClientHeaderFilter(), CorsFilter.class)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
@@ -27,6 +27,41 @@ public final class IgdbResponses {
@JsonProperty("uid") String uid @JsonProperty("uid") String uid
) {} ) {}
/**
* @param firstReleaseDate Unix seconds
* @param externalGames the same game on other services, e.g. Steam
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record Game(
@JsonProperty("id") Long id,
@JsonProperty("name") String name,
@JsonProperty("first_release_date") Long firstReleaseDate,
@JsonProperty("cover") Image cover,
@JsonProperty("artworks") java.util.List<Image> artworks,
@JsonProperty("screenshots") java.util.List<Image> screenshots,
@JsonProperty("platforms") java.util.List<Platform> platforms,
@JsonProperty("external_games") java.util.List<ExternalGameSource> externalGames
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Image(@JsonProperty("image_id") String imageId) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Platform(
@JsonProperty("name") String name,
@JsonProperty("abbreviation") String abbreviation
) {}
/**
* @param source the service, 1 is Steam
* @param uid the id on that service
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExternalGameSource(
@JsonProperty("external_game_source") Long source,
@JsonProperty("uid") String uid
) {}
/** /**
* Times in seconds; each can be missing. * Times in seconds; each can be missing.
* *
@@ -3,18 +3,29 @@ package core_service.modules.igdb_adapter.config;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
import java.time.Duration;
@Configuration @Configuration
public class IgdbClientConfig { public class IgdbClientConfig {
@Bean @Bean
public RestClient igdbRestClient(@Value("${app.igdb.base-url}") String baseUrl) { public RestClient igdbRestClient(@Value("${app.igdb.base-url}") String baseUrl) {
return RestClient.builder().baseUrl(baseUrl).build(); return RestClient.builder().baseUrl(baseUrl).requestFactory(requestFactory()).build();
} }
@Bean @Bean
public RestClient twitchRestClient(@Value("${app.igdb.token-base-url}") String tokenBaseUrl) { public RestClient twitchRestClient(@Value("${app.igdb.token-base-url}") String tokenBaseUrl) {
return RestClient.builder().baseUrl(tokenBaseUrl).build(); return RestClient.builder().baseUrl(tokenBaseUrl).requestFactory(requestFactory()).build();
}
// The search, the add and the detail page call IGDB on a request thread, so a hanging IGDB must not hold it.
private static SimpleClientHttpRequestFactory requestFactory() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(3));
requestFactory.setReadTimeout(Duration.ofSeconds(10));
return requestFactory;
} }
} }
@@ -6,6 +6,7 @@ import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpClientErrorException;
@@ -13,6 +14,7 @@ import org.springframework.web.client.RestClient;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
@@ -22,6 +24,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream;
/** /**
* Time to beat from IGDB (api-docs.igdb.com), found by Steam appid. Needs a Twitch developer app; without its * Time to beat from IGDB (api-docs.igdb.com), found by Steam appid. Needs a Twitch developer app; without its
@@ -36,6 +39,22 @@ public class IgdbApiService {
*/ */
public record TimeToBeat(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {} public record TimeToBeat(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {}
/**
* One IGDB game as the library needs it.
*
* @param year of the first release, null when unknown
* @param imageUrl a landscape artwork or screenshot, else the cover; null when IGDB has none
* @param platforms short platform names, e.g. "PS5", "Switch"
* @param steamAppid the same game on Steam, null when it is not there
*/
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
private static final int SEARCH_LIMIT = 10;
private static final String GAME_FIELDS = "fields name,first_release_date,cover.image_id,artworks.image_id,"
+ "screenshots.image_id,platforms.name,platforms.abbreviation,external_games.external_game_source,"
+ "external_games.uid;";
private static final String IMAGE_BASE = "https://images.igdb.com/igdb/image/upload/";
// IGDB answers at most 500 rows per query, and one Steam app can have more than one external_games row. // IGDB answers at most 500 rows per query, and one Steam app can have more than one external_games row.
private static final int BATCH = 250; private static final int BATCH = 250;
private static final int LIMIT = 500; private static final int LIMIT = 500;
@@ -89,6 +108,113 @@ public class IgdbApiService {
} }
} }
/**
* Games whose name matches, without editions and other versions. Empty when the adapter is off or IGDB failed.
*/
@Cacheable(value = "igdbSearch", key = "#query.trim().toLowerCase()", unless = "#result == null or #result.isEmpty()")
public Optional<List<Game>> searchGames(String query) {
if (!isEnabled()) {
return Optional.empty();
}
try {
IgdbResponses.Game[] games = query("/games", "search \"" + searchText(query) + "\"; " + GAME_FIELDS
+ " where version_parent = null; limit " + SEARCH_LIMIT + ";", IgdbResponses.Game[].class);
return Optional.of(Arrays.stream(games).map(IgdbApiService::toGame).flatMap(Optional::stream).toList());
} catch (Exception e) {
log.error("Error searching IGDB games", e);
return Optional.empty();
}
}
/**
* Empty when the adapter is off, IGDB failed or has no such game.
*/
public Optional<Game> getGame(long igdbId) {
if (!isEnabled()) {
return Optional.empty();
}
try {
IgdbResponses.Game[] games = query("/games", GAME_FIELDS + " where id = " + igdbId + ";",
IgdbResponses.Game[].class);
return Arrays.stream(games).findFirst().flatMap(IgdbApiService::toGame);
} catch (Exception e) {
log.error("Error reading IGDB game {}", igdbId, e);
return Optional.empty();
}
}
/**
* Like {@link #getTimeToBeat}, but for IGDB game ids.
*/
public Optional<Map<Long, TimeToBeat>> getTimeToBeatByGameIds(Collection<Long> igdbIds) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, TimeToBeat> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
String gameIds = ids.subList(from, Math.min(from + BATCH, ids.size())).stream()
.map(String::valueOf).collect(Collectors.joining(","));
IgdbResponses.TimeToBeat[] times = query("/game_time_to_beats",
"fields game_id,hastily,normally,completely,count; where game_id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.TimeToBeat[].class);
for (IgdbResponses.TimeToBeat time : times) {
if (time.gameId() != null) {
result.putIfAbsent(time.gameId(),
new TimeToBeat(minutes(time.hastily()), minutes(time.normally()), minutes(time.completely())));
}
}
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for time to beat", e);
return Optional.empty();
}
}
private static Optional<Game> toGame(IgdbResponses.Game game) {
if (game.id() == null || game.name() == null || game.name().isBlank()) {
return Optional.empty();
}
Integer year = game.firstReleaseDate() == null ? null
: Instant.ofEpochSecond(game.firstReleaseDate()).atZone(ZoneOffset.UTC).getYear();
List<String> platforms = game.platforms() == null ? List.of() : game.platforms().stream()
.map(p -> p.abbreviation() != null && !p.abbreviation().isBlank() ? p.abbreviation() : p.name())
.filter(name -> name != null && !name.isBlank())
.distinct()
.toList();
Long steamAppid = game.externalGames() == null ? null : game.externalGames().stream()
.filter(e -> e.source() != null && e.source() == STEAM_SOURCE && e.uid() != null && e.uid().matches("\\d{1,10}"))
.map(e -> Long.parseLong(e.uid()))
.findFirst()
.orElse(null);
return Optional.of(new Game(game.id(), game.name().trim(), year, imageUrl(game), platforms, steamAppid));
}
// The library shows wide pictures; a cover is tall and only the last choice.
private static String imageUrl(IgdbResponses.Game game) {
String wide = Stream.of(game.artworks(), game.screenshots())
.filter(images -> images != null)
.flatMap(List::stream)
.map(IgdbResponses.Image::imageId)
.filter(id -> id != null && !id.isBlank())
.findFirst()
.orElse(null);
if (wide != null) {
return IMAGE_BASE + "t_screenshot_med/" + wide + ".jpg";
}
return game.cover() != null && game.cover().imageId() != null && !game.cover().imageId().isBlank()
? IMAGE_BASE + "t_cover_big/" + game.cover().imageId() + ".jpg"
: null;
}
// The text goes into an Apicalypse string. Quotes, backslashes and ";" could end it and add query parts, and no
// game name needs them for a search.
private static String searchText(String text) {
return text.replaceAll("[\"\\\\;\\p{Cntrl}]", " ").trim();
}
private Map<Long, TimeToBeat> getBatch(List<Long> steamAppids) { private Map<Long, TimeToBeat> getBatch(List<Long> steamAppids) {
String uids = steamAppids.stream().map(id -> "\"" + id + "\"").collect(Collectors.joining(",")); String uids = steamAppids.stream().map(id -> "\"" + id + "\"").collect(Collectors.joining(","));
IgdbResponses.ExternalGame[] externals = query("/external_games", IgdbResponses.ExternalGame[] externals = query("/external_games",
@@ -62,14 +62,12 @@ public class LibraryController {
@GetMapping("/games-count") @GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
if (principal.steamId() == null) {
return ResponseEntity.ok(Map.of("count", 0, "hasSteamId", false));
}
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
String steamId = String.valueOf(principal.steamId()); if (principal.steamId() != null) {
syncService.syncIfStale(userId, steamId); syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
}
long count = queryService.countOwnedGames(userId); long count = queryService.countOwnedGames(userId);
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", true)); return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null));
} }
@GetMapping("/games") @GetMapping("/games")
@@ -82,12 +80,10 @@ public class LibraryController {
@RequestParam(required = false) String q, @RequestParam(required = false) String q,
@RequestParam(required = false) Integer maxMinutes @RequestParam(required = false) Integer maxMinutes
) { ) {
if (principal.steamId() == null) {
return ResponseEntity.ok(new PageDto<>(java.util.List.of(), 0, DEFAULT_SIZE, 0, 0));
}
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
String steamId = String.valueOf(principal.steamId()); if (principal.steamId() != null) {
syncService.syncIfStale(userId, steamId); syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
}
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page); int safePage = Math.max(0, page);
@@ -128,11 +124,8 @@ public class LibraryController {
@GetMapping("/games/{appid}") @GetMapping("/games/{appid}")
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid) { @PathVariable("appid") Long appid) {
if (principal.steamId() == null) {
return ResponseEntity.badRequest().body(Map.of("code", "NO_STEAM_ID", "message", "User has no Steam ID"));
}
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
String steamId = String.valueOf(principal.steamId()); String steamId = principal.steamId() != null ? String.valueOf(principal.steamId()) : null;
return gameDetailService.getGameDetail(userId, steamId, appid) return gameDetailService.getGameDetail(userId, steamId, appid)
.<ResponseEntity<?>>map(ResponseEntity::ok) .<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
@@ -0,0 +1,90 @@
package core_service.modules.library.api;
import core_service.modules.library.api.dto.AddGameRequest;
import core_service.modules.library.api.dto.AddedGameDto;
import core_service.modules.library.api.dto.GameSearchDto;
import core_service.modules.library.api.dto.ManualGameUpdate;
import core_service.modules.library.service.ManualGameService;
import core_service.shared.security.UserPrincipal;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
import java.util.Map;
/**
* Games added by hand from an IGDB search, next to the Steam library.
*/
@RestController
@RequestMapping("/api/library")
@RequiredArgsConstructor
public class ManualGameController {
private static final Map<String, String> NOT_FOUND = Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library");
private static final Map<String, String> STEAM_GAME = Map.of("code", "STEAM_GAME",
"message", "Steam games come from the sync and cannot be changed here");
private final ManualGameService manualGameService;
@GetMapping("/game-search")
public ResponseEntity<?> search(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam("q") @Size(min = 2, max = 100) String query) {
return manualGameService.search(principal.getIdAsLong(), query)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("code", "IGDB_FAILED", "message", "IGDB did not answer")));
}
@PostMapping("/games")
public ResponseEntity<?> add(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody AddGameRequest request) {
int minutes = request.playtimeMinutes() != null ? request.playtimeMinutes() : 0;
return switch (manualGameService.add(principal.getIdAsLong(), request.igdbId(), request.platform().trim(),
request.status(), minutes)) {
case ManualGameService.AddResult.Added added -> ResponseEntity
.created(URI.create("/api/library/games/" + added.appid()))
.body(new AddedGameDto(added.appid()));
case ManualGameService.AddResult.AlreadyInLibrary ignored -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code", "ALREADY_IN_LIBRARY", "message", "The game is already in the library"));
case ManualGameService.AddResult.UnknownGame ignored -> ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("code", "UNKNOWN_GAME", "message", "IGDB has no such game or did not answer"));
case ManualGameService.AddResult.SearchUnavailable ignored -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("code", "IGDB_OFF", "message", "Adding games needs the IGDB app"));
};
}
@PutMapping("/games/{appid}/manual")
public ResponseEntity<?> update(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") long appid,
@Valid @RequestBody ManualGameUpdate update) {
return toResponse(manualGameService.update(principal.getIdAsLong(), appid, update.platform().trim(),
update.playtimeMinutes()));
}
@DeleteMapping("/games/{appid}")
public ResponseEntity<?> remove(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") long appid) {
return toResponse(manualGameService.remove(principal.getIdAsLong(), appid));
}
private static ResponseEntity<?> toResponse(ManualGameService.ChangeResult result) {
return switch (result) {
case DONE -> ResponseEntity.noContent().build();
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
case STEAM_GAME -> ResponseEntity.status(HttpStatus.CONFLICT).body(STEAM_GAME);
};
}
}
@@ -0,0 +1,21 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* Core reads name and picture from IGDB itself; the client only picks the game.
*
* @param platform where the user owns the game, e.g. "PS5"
* @param playtimeMinutes typed by the user, null for 0
*/
public record AddGameRequest(
@NotNull @Min(1) Long igdbId,
@NotBlank @Size(max = ManualGameUpdate.MAX_PLATFORM_LENGTH) String platform,
@NotNull GameStatus status,
@Min(0) @Max(ManualGameUpdate.MAX_MINUTES) Integer playtimeMinutes
) {}
@@ -0,0 +1,3 @@
package core_service.modules.library.api.dto;
public record AddedGameDto(long appid) {}
@@ -1,6 +1,7 @@
package core_service.modules.library.api.dto; package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import java.time.Instant; import java.time.Instant;
@@ -25,7 +26,10 @@ public record GameDetailDto(
int achievementsRemaining, int achievementsRemaining,
double completionPercent, double completionPercent,
List<AchievementDto> achievements, List<AchievementDto> achievements,
TimeToBeatDto timeToBeat TimeToBeatDto timeToBeat,
GameSource source,
String platform,
String imageUrl
) { ) {
/** /**
@@ -1,12 +1,15 @@
package core_service.modules.library.api.dto; package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; 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 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 * @param achievementsUnlocked null when not fetched yet or hidden by Steam
* @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games
* @param imageUrl picture for a game that has no Steam art; null means the Steam CDN has one
*/ */
public record GameDto( public record GameDto(
Long appid, Long appid,
@@ -18,5 +21,8 @@ public record GameDto(
GamePriority priority, GamePriority priority,
Integer timeToBeatMinutes, Integer timeToBeatMinutes,
Integer achievementsUnlocked, Integer achievementsUnlocked,
Integer achievementsTotal Integer achievementsTotal,
GameSource source,
String platform,
String imageUrl
) {} ) {}
@@ -0,0 +1,16 @@
package core_service.modules.library.api.dto;
import java.util.List;
/**
* @param available false when Core has no IGDB app; then results is empty
*/
public record GameSearchDto(boolean available, List<Hit> results) {
/**
* @param appid the library key the game gets, see GameKeys
* @param inLibrary the user has this game already, from Steam or by hand
*/
public record Hit(long appid, long igdbId, String name, Integer year, String imageUrl, List<String> platforms,
boolean inLibrary) {}
}
@@ -0,0 +1,21 @@
package core_service.modules.library.api.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/**
* The fields only a game added by hand has. Replaces both.
*/
public record ManualGameUpdate(
@NotBlank @Size(max = MAX_PLATFORM_LENGTH) String platform,
@NotNull @Min(0) @Max(MAX_MINUTES) Integer playtimeMinutes
) {
// 100 000 hours: more than anyone plays, and far below the int limit.
public static final int MAX_MINUTES = 6_000_000;
// The size of the user_game.platform column.
public static final int MAX_PLATFORM_LENGTH = 32;
}
@@ -23,5 +23,10 @@ public record PublicLibraryDto(
Double averageCompletion Double averageCompletion
) { ) {
public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating) {} /**
* @param platform see {@link GameDto#platform()}
* @param imageUrl see {@link GameDto#imageUrl()}
*/
public record Game(Long appid, String name, int playtimeMinutes, GameStatus status, Integer rating,
String platform, String imageUrl) {}
} }
@@ -0,0 +1,23 @@
package core_service.modules.library.domain;
/**
* The library key of a game, stored as "appid": the Steam appid for a game on Steam, minus the IGDB game id for a
* game that is not on Steam. Steam appids are always positive, so the two cannot meet.
*/
public final class GameKeys {
private GameKeys() {
}
public static boolean isSteam(long key) {
return key > 0;
}
public static long ofIgdb(long igdbId) {
return -igdbId;
}
public static long igdbId(long key) {
return -key;
}
}
@@ -0,0 +1,11 @@
package core_service.modules.library.domain;
/**
* Where a library entry comes from. The Steam sync owns STEAM rows; it never removes or changes a MANUAL row,
* except that it takes over a MANUAL row once the user owns that game on Steam. A MANUAL row has no Steam
* achievements, even under a Steam appid: the user owns the game elsewhere.
*/
public enum GameSource {
STEAM,
MANUAL
}
@@ -31,6 +31,9 @@ public class SteamGame {
@Column(name = "last_seen_at", nullable = false) @Column(name = "last_seen_at", nullable = false)
private Instant lastSeenAt; private Instant lastSeenAt;
@Column(name = "image_url", length = 512)
private String imageUrl;
public SteamGame(Long appid, String name, String imgIconUrl, Instant lastSeenAt) { public SteamGame(Long appid, String name, String imgIconUrl, Instant lastSeenAt) {
this.appid = appid; this.appid = appid;
this.name = name; this.name = name;
@@ -62,6 +62,14 @@ public class UserGame {
@Column(name = "priority", length = 8) @Column(name = "priority", length = 8)
private GamePriority priority; private GamePriority priority;
@Enumerated(EnumType.STRING)
@Column(name = "source", nullable = false, length = 8)
private GameSource source = GameSource.STEAM;
/** The platform the user owns a MANUAL game on, e.g. "PS5". Null for Steam games. */
@Column(name = "platform", length = 32)
private String platform;
public UserGame(Long userId, Long appid, int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt) { public UserGame(Long userId, Long appid, int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt) {
this.userId = userId; this.userId = userId;
this.appid = appid; this.appid = appid;
@@ -70,4 +78,13 @@ public class UserGame {
this.lastSeenAt = lastSeenAt; this.lastSeenAt = lastSeenAt;
this.status = GameStatus.initialFor(playtimeMinutes); this.status = GameStatus.initialFor(playtimeMinutes);
} }
public static UserGame manual(Long userId, Long appid, String platform, GameStatus status, int playtimeMinutes,
Instant now) {
UserGame game = new UserGame(userId, appid, playtimeMinutes, now, now);
game.source = GameSource.MANUAL;
game.platform = platform;
game.status = status;
return game;
}
} }
@@ -23,8 +23,20 @@ import java.util.Optional;
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> { public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
/**
* A key no game has: Steam appids are positive and a game off Steam is minus its IGDB id, which starts at 1.
*/
long NO_GAME = 0;
List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId); List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId);
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.appid in :appids")
List<Long> findActiveAppids(@Param("userId") Long userId, @Param("appids") Collection<Long> appids);
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null " +
"and ug.source = core_service.modules.library.domain.GameSource.MANUAL")
List<Long> findActiveManualAppids(@Param("userId") Long userId);
long countByUserIdAndRemovedAtIsNull(Long userId); long countByUserIdAndRemovedAtIsNull(Long userId);
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where ug.userId = :userId and ug.removedAt is null") @Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where ug.userId = :userId and ug.removedAt is null")
@@ -42,7 +54,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
List<StatusCount> countByStatus(@Param("userId") Long userId); List<StatusCount> countByStatus(@Param("userId") Long userId);
/** /**
* @param excludeAppid never picked; -1 excludes nothing * @param excludeAppid never picked; {@link #NO_GAME} excludes nothing
*/ */
@Query(value = "select * from user_game where user_id = :userId and removed_at is null and status = 'BACKLOG' " + @Query(value = "select * from user_game where user_id = :userId and removed_at is null and status = 'BACKLOG' " +
"and appid <> :excludeAppid order by random() limit 1", nativeQuery = true) "and appid <> :excludeAppid order by random() limit 1", nativeQuery = true)
@@ -53,7 +65,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"from UserGame ug " + "from UserGame ug " +
"left join GameAchievementSchemaMeta sm on sm.appid = ug.appid " + "left join GameAchievementSchemaMeta sm on sm.appid = ug.appid " +
"left join UserGameAchievementMeta um on um.userId = ug.userId and um.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") "where ug.userId = :userId and ug.removedAt is null " +
"and ug.source = core_service.modules.library.domain.GameSource.STEAM")
List<AchievementProgress> findAchievementProgress(@Param("userId") Long userId); List<AchievementProgress> findAchievementProgress(@Param("userId") Long userId);
/** /**
@@ -64,8 +77,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/ */
default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Collection<Long> excludeAppids, int count) { default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Collection<Long> excludeAppids, int count) {
List<Long> excluded = new ArrayList<>(excludeAppids); List<Long> excluded = new ArrayList<>(excludeAppids);
// "not in ()" is invalid SQL, and no appid is -1 // "not in ()" is invalid SQL
excluded.add(-1L); excluded.add(NO_GAME);
return queryPickerGames(userId, maxMinutes, excluded, count); return queryPickerGames(userId, maxMinutes, excluded, count);
} }
@@ -134,9 +147,13 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
return "%" + escaped + "%"; return "%" + escaped + "%";
} }
/**
* Removes the Steam games the last sync did not see. Games added by hand never come from Steam and stay.
*/
@Modifying @Modifying
@Query("update UserGame ug set ug.removedAt = :now " + @Query("update UserGame ug set ug.removedAt = :now " +
"where ug.userId = :userId and ug.lastSeenAt < :syncStartedAt and ug.removedAt is null") "where ug.userId = :userId and ug.lastSeenAt < :syncStartedAt and ug.removedAt is null " +
"and ug.source = core_service.modules.library.domain.GameSource.STEAM")
int softDeleteMissingSince(@Param("userId") Long userId, int softDeleteMissingSince(@Param("userId") Long userId,
@Param("syncStartedAt") Instant syncStartedAt, @Param("syncStartedAt") Instant syncStartedAt,
@Param("now") Instant now); @Param("now") Instant now);
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
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.GameSource;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement; import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -133,6 +134,7 @@ public class AchievementSyncService {
private void runLibrarySync(Long userId, String steamId) { private void runLibrarySync(Long userId, String steamId) {
List<UserGame> games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream() List<UserGame> games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream()
.filter(game -> game.getSource() == GameSource.STEAM)
.sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed()) .sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed())
.toList(); .toList();
Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository
@@ -5,6 +5,7 @@ import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.AchievementProgress; 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.GameSource;
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.UserGameAchievement; import core_service.modules.library.domain.UserGameAchievement;
@@ -41,6 +42,7 @@ public class GameDetailService {
private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final AchievementSyncService achievementSyncService; private final AchievementSyncService achievementSyncService;
private final GameTimeToBeatRepository gameTimeToBeatRepository; private final GameTimeToBeatRepository gameTimeToBeatRepository;
private final TimeToBeatService timeToBeatService;
@Value("${app.library.achievement-ttl-hours:1}") @Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours; private long achievementTtlHours;
@@ -48,6 +50,8 @@ public class GameDetailService {
/** /**
* Not transactional on purpose: the refreshes commit in their own transactions, and the reads after them must * 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. * see those rows, not a cached copy from a transaction that started before the refresh.
*
* @param steamId null when the user has none; then the stored achievements are shown without a refresh
*/ */
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));
@@ -56,6 +60,11 @@ 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);
if (userGame.getSource() == GameSource.MANUAL) {
// Users without a Steam ID never sync, so the time to beat of their games is asked again here.
timeToBeatService.refreshInBackground(List.of(appid));
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
}
try { try {
achievementSyncService.refreshSchemaIfStale(appid); achievementSyncService.refreshSchemaIfStale(appid);
@@ -68,12 +77,14 @@ public class GameDetailService {
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of())); return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
} }
if (steamId != null) {
try { try {
achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(), achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(),
Duration.ofHours(achievementTtlHours)); Duration.ofHours(achievementTtlHours));
} catch (Exception e) { } catch (Exception e) {
log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage()); 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();
@@ -132,7 +143,10 @@ public class GameDetailService {
achievements, achievements,
gameTimeToBeatRepository.findById(userGame.getAppid()) gameTimeToBeatRepository.findById(userGame.getAppid())
.map(t -> new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), t.getCompletelyMinutes())) .map(t -> new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), t.getCompletelyMinutes()))
.orElse(null) .orElse(null),
userGame.getSource(),
userGame.getPlatform(),
meta != null ? meta.getImageUrl() : null
); );
} }
} }
@@ -11,6 +11,7 @@ import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.AchievementProgress; 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.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameSource;
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;
@@ -153,7 +154,7 @@ public class LibraryQueryService {
Map<GameStatus, Long> byStatus = countByStatus(userId); Map<GameStatus, Long> byStatus = countByStatus(userId);
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 : UserGameRepository.NO_GAME)
.map(ug -> toDtos(List.of(ug)).getFirst()) .map(ug -> toDtos(List.of(ug)).getFirst())
.orElse(null); .orElse(null);
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId)); return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId));
@@ -219,7 +220,8 @@ public class LibraryQueryService {
return games.stream() return games.stream()
.map(ug -> new PublicLibraryDto.Game(ug.getAppid(), .map(ug -> new PublicLibraryDto.Game(ug.getAppid(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null, meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating())) ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating(), ug.getPlatform(),
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getImageUrl() : null))
.toList(); .toList();
} }
@@ -232,8 +234,8 @@ public class LibraryQueryService {
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time, private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks) { GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks) {
Integer achievementsTotal = schema != null && schema.isHasSchema() && schema.getAchievementsTotal() > 0 Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
? schema.getAchievementsTotal() : null; && schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible() Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
? AchievementProgress.capped(unlocks.getAchievementsUnlocked(), achievementsTotal) : null; ? AchievementProgress.capped(unlocks.getAchievementsUnlocked(), achievementsTotal) : null;
return new GameDto( return new GameDto(
@@ -246,7 +248,10 @@ public class LibraryQueryService {
ug.getPriority(), ug.getPriority(),
time != null ? time.getNormallyMinutes() : null, time != null ? time.getNormallyMinutes() : null,
achievementsUnlocked, achievementsUnlocked,
achievementsTotal); achievementsTotal,
ug.getSource(),
ug.getPlatform(),
meta != null ? meta.getImageUrl() : null);
} }
public Optional<UserSteamSync> getSyncStatus(Long userId) { public Optional<UserSteamSync> getSyncStatus(Long userId) {
@@ -1,5 +1,6 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.domain.GameSource;
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.UserGameId; import core_service.modules.library.domain.UserGameId;
@@ -24,6 +25,7 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -116,7 +118,10 @@ public class LibrarySyncWorker {
log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed); log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed);
} }
userSteamSyncRepository.markHistoryBaseline(userId, steamId); userSteamSyncRepository.markHistoryBaseline(userId, steamId);
timeToBeatService.refresh(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList()); // Also the games added by hand: here a failed refresh from the add is asked again.
List<Long> timeToBeatAppids = new ArrayList<>(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
timeToBeatAppids.addAll(userGameRepository.findActiveManualAppids(userId));
timeToBeatService.refresh(timeToBeatAppids);
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId)); eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
finalStatus = Status.OK; finalStatus = Status.OK;
} catch (Exception e) { } catch (Exception e) {
@@ -179,6 +184,8 @@ public class LibrarySyncWorker {
Long appid = g.appid().longValue(); Long appid = g.appid().longValue();
UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null); UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null);
int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0; int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0;
// Hours typed by hand are no Steam playtime, so the first Steam value of a game added by hand is a baseline.
boolean takesOverManualGame = existing != null && existing.getSource() == GameSource.MANUAL;
int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0; int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0;
int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0; int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0;
if (existing == null) { if (existing == null) {
@@ -190,9 +197,11 @@ public class LibrarySyncWorker {
existing.setPlaytime2Weeks(playtime2Weeks); existing.setPlaytime2Weeks(playtime2Weeks);
existing.setLastSeenAt(now); existing.setLastSeenAt(now);
existing.setRemovedAt(null); existing.setRemovedAt(null);
existing.setSource(GameSource.STEAM);
existing.setPlatform(null);
} }
// A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted. // A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted.
if (playedOn != null && playtime > previousPlaytime) { if (playedOn != null && !takesOverManualGame && playtime > previousPlaytime) {
playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime); playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime);
} }
} }
@@ -0,0 +1,201 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.api.dto.GameSearchDto;
import core_service.modules.library.api.dto.ManualGameUpdate;
import core_service.modules.library.domain.GameKeys;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
/**
* Games the user adds by hand from IGDB, e.g. owned on PlayStation or Switch. See GameSource for how they live next
* to the Steam sync.
*/
@Service
@Slf4j
public class ManualGameService {
public sealed interface AddResult {
record Added(long appid) implements AddResult {}
record AlreadyInLibrary() implements AddResult {}
record UnknownGame() implements AddResult {}
record SearchUnavailable() implements AddResult {}
}
public enum ChangeResult { DONE, NOT_FOUND, STEAM_GAME }
private final IgdbApiService igdbApiService;
private final UserGameRepository userGameRepository;
private final SteamGameRepository steamGameRepository;
private final TimeToBeatService timeToBeatService;
private final TransactionTemplate transaction;
public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository,
SteamGameRepository steamGameRepository, TimeToBeatService timeToBeatService,
PlatformTransactionManager transactionManager) {
this.igdbApiService = igdbApiService;
this.userGameRepository = userGameRepository;
this.steamGameRepository = steamGameRepository;
this.timeToBeatService = timeToBeatService;
this.transaction = new TransactionTemplate(transactionManager);
}
/**
* @return empty when IGDB failed; an unavailable result when Core has no IGDB app
*/
public Optional<GameSearchDto> search(Long userId, String query) {
if (!igdbApiService.isEnabled()) {
return Optional.of(new GameSearchDto(false, List.of()));
}
return igdbApiService.searchGames(query).map(games -> {
Set<Long> owned = new HashSet<>(userGameRepository.findActiveAppids(userId,
games.stream().flatMap(game -> possibleKeys(game).stream()).toList()));
return new GameSearchDto(true, games.stream()
.map(game -> new GameSearchDto.Hit(key(game), game.igdbId(), game.name(), game.year(),
game.imageUrl(), fittingPlatforms(game), possibleKeys(game).stream().anyMatch(owned::contains)))
.toList());
});
}
/**
* Adds the game under its Steam appid when IGDB knows it on Steam: then the sync takes it over once the user owns
* it there, and it keeps one row. A game the user removed before comes back with its old status and notes.
*/
public AddResult add(Long userId, long igdbId, String platform, GameStatus status, int playtimeMinutes) {
if (!igdbApiService.isEnabled()) {
return new AddResult.SearchUnavailable();
}
// Outside the transaction: no database connection waits for IGDB.
Optional<IgdbApiService.Game> found = igdbApiService.getGame(igdbId);
if (found.isEmpty()) {
return new AddResult.UnknownGame();
}
IgdbApiService.Game game = found.get();
long appid = key(game);
AddResult result;
try {
result = addInTransaction(userId, appid, game, platform, status, playtimeMinutes);
} catch (DataIntegrityViolationException e) {
// A parallel add or sync inserted the row after the check. The second try sees it.
log.info("Add of appid={} for user={} raced another insert, trying again", appid, userId);
try {
result = addInTransaction(userId, appid, game, platform, status, playtimeMinutes);
} catch (DataIntegrityViolationException again) {
return new AddResult.AlreadyInLibrary();
}
}
if (result instanceof AddResult.Added) {
timeToBeatService.refreshInBackground(List.of(appid));
}
return result;
}
private AddResult addInTransaction(Long userId, long appid, IgdbApiService.Game game, String platform,
GameStatus status, int playtimeMinutes) {
return transaction.execute(tx -> {
Instant now = Instant.now();
Optional<UserGame> existing = userGameRepository.findById(new UserGameId(userId, appid));
if (existing.isPresent() && existing.get().getRemovedAt() == null) {
return new AddResult.AlreadyInLibrary();
}
long igdbKey = GameKeys.ofIgdb(game.igdbId());
if (appid != igdbKey && userGameRepository.findById(new UserGameId(userId, igdbKey))
.filter(added -> added.getRemovedAt() == null).isPresent()) {
return new AddResult.AlreadyInLibrary();
}
saveCatalogEntry(appid, game, now);
if (existing.isPresent()) {
UserGame revived = existing.get();
revived.setSource(GameSource.MANUAL);
revived.setPlatform(platform);
revived.setStatus(status);
revived.setPlaytimeMinutes(playtimeMinutes);
revived.setPlaytime2Weeks(0);
revived.setLastSeenAt(now);
revived.setRemovedAt(null);
} else {
userGameRepository.save(UserGame.manual(userId, appid, platform, status, playtimeMinutes, now));
}
return new AddResult.Added(appid);
});
}
public ChangeResult update(Long userId, long appid, String platform, int playtimeMinutes) {
return transaction.execute(tx -> changeManual(userId, appid, game -> {
game.setPlatform(platform);
game.setPlaytimeMinutes(playtimeMinutes);
}));
}
/**
* Deletes the row with its status, rating and notes. Steam games cannot be removed: the next sync would add
* them again.
*/
public ChangeResult remove(Long userId, long appid) {
return transaction.execute(tx -> changeManual(userId, appid, userGameRepository::delete));
}
private ChangeResult changeManual(Long userId, long appid, Consumer<UserGame> change) {
Optional<UserGame> game = userGameRepository.findById(new UserGameId(userId, appid))
.filter(found -> found.getRemovedAt() == null);
if (game.isEmpty()) {
return ChangeResult.NOT_FOUND;
}
if (game.get().getSource() != GameSource.MANUAL) {
return ChangeResult.STEAM_GAME;
}
change.accept(game.get());
return ChangeResult.DONE;
}
// A Steam row belongs to the sync, which has the better name and art; only a game off Steam takes IGDB's.
private void saveCatalogEntry(long appid, IgdbApiService.Game game, Instant now) {
Optional<SteamGame> existing = steamGameRepository.findById(appid);
if (existing.isEmpty()) {
SteamGame created = new SteamGame(appid, game.name(), null, now);
if (!GameKeys.isSteam(appid)) {
created.setImageUrl(game.imageUrl());
}
steamGameRepository.save(created);
} else if (!GameKeys.isSteam(appid)) {
existing.get().setName(game.name());
existing.get().setImageUrl(game.imageUrl());
existing.get().setLastSeenAt(now);
}
}
// A longer name would not fit the column; with no platform left, the user types one.
private static List<String> fittingPlatforms(IgdbApiService.Game game) {
return game.platforms().stream().filter(platform -> platform.length() <= ManualGameUpdate.MAX_PLATFORM_LENGTH).toList();
}
// IGDB can learn a game's Steam appid after the user added it under minus its IGDB id.
private static List<Long> possibleKeys(IgdbApiService.Game game) {
long igdbKey = GameKeys.ofIgdb(game.igdbId());
return key(game) == igdbKey ? List.of(igdbKey) : List.of(key(game), igdbKey);
}
private static long key(IgdbApiService.Game game) {
return game.steamAppid() != null ? game.steamAppid() : GameKeys.ofIgdb(game.igdbId());
}
}
@@ -1,17 +1,19 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameKeys;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
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.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.TreeSet; import java.util.TreeSet;
@@ -26,6 +28,18 @@ public class TimeToBeatService {
@Value("${app.library.time-to-beat-ttl-days:30}") @Value("${app.library.time-to-beat-ttl-days:30}")
private long ttlDays; private long ttlDays;
/**
* Same as {@link #refresh}, but the request does not wait for IGDB, and an error does not fail it.
*/
@Async
public void refreshInBackground(Collection<Long> appids) {
try {
refresh(appids);
} catch (Exception e) {
log.warn("Time to beat of {} not refreshed: {}", appids, e.getMessage());
}
}
/** /**
* Asks IGDB for the apps without a fresh row and stores the answer, also "no data". Does nothing when IGDB is * Asks IGDB for the apps without a fresh row and stores the answer, also "no data". Does nothing when IGDB is
* off, and stores nothing when IGDB fails, so the apps are asked again on the next sync. * off, and stores nothing when IGDB fails, so the apps are asked again on the next sync.
@@ -37,14 +51,35 @@ public class TimeToBeatService {
stale.removeAll(gameTimeToBeatRepository.findFreshAppids(appids, Instant.now().minus(Duration.ofDays(ttlDays)))); stale.removeAll(gameTimeToBeatRepository.findFreshAppids(appids, Instant.now().minus(Duration.ofDays(ttlDays))));
if (stale.isEmpty()) return; if (stale.isEmpty()) return;
Optional<Map<Long, IgdbApiService.TimeToBeat>> answer = igdbApiService.getTimeToBeat(stale); Set<Long> steamAppids = new TreeSet<>();
if (answer.isEmpty()) { Set<Long> igdbIds = new TreeSet<>();
log.warn("Time to beat for {} apps not refreshed, IGDB did not answer", stale.size()); for (Long appid : stale) {
return; if (GameKeys.isSteam(appid)) {
steamAppids.add(appid);
} else {
igdbIds.add(GameKeys.igdbId(appid));
}
}
Map<Long, IgdbApiService.TimeToBeat> times = new HashMap<>();
Set<Long> answered = new TreeSet<>();
if (!steamAppids.isEmpty()) {
igdbApiService.getTimeToBeat(steamAppids).ifPresent(found -> {
times.putAll(found);
answered.addAll(steamAppids);
});
}
if (!igdbIds.isEmpty()) {
igdbApiService.getTimeToBeatByGameIds(igdbIds).ifPresent(found -> {
found.forEach((igdbId, time) -> times.put(GameKeys.ofIgdb(igdbId), time));
igdbIds.forEach(igdbId -> answered.add(GameKeys.ofIgdb(igdbId)));
});
}
if (answered.size() < stale.size()) {
log.warn("Time to beat for {} apps not refreshed, IGDB did not answer", stale.size() - answered.size());
} }
Instant now = Instant.now(); Instant now = Instant.now();
for (Long appid : stale) { for (Long appid : answered) {
IgdbApiService.TimeToBeat time = answer.get().get(appid); IgdbApiService.TimeToBeat time = times.get(appid);
if (time == null) { if (time == null) {
gameTimeToBeatRepository.upsert(appid, null, null, null, now); gameTimeToBeatRepository.upsert(appid, null, null, null, now);
} else { } else {
@@ -15,5 +15,8 @@ public record PublicStatsDto(
List<TopGame> topOwnedGames, List<TopGame> topOwnedGames,
List<TopGame> topPlayedGames List<TopGame> topPlayedGames
) { ) {
public record TopGame(Long appid, String name, String imgIconUrl, long value) {} /**
* @param imageUrl picture for a game that has no Steam art, see GameDto
*/
public record TopGame(Long appid, String name, String imgIconUrl, String imageUrl, long value) {}
} }
@@ -69,11 +69,11 @@ public class StatsRepository {
public List<Map<String, Object>> topOwnedGames(int limit) { public List<Map<String, Object>> topOwnedGames(int limit) {
return jdbc.queryForList(""" return jdbc.queryForList("""
SELECT ug.appid, sg.name, sg.img_icon_url, COUNT(*) AS value SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value
FROM user_game ug FROM user_game ug
LEFT JOIN steam_game sg ON sg.appid = ug.appid LEFT JOIN steam_game sg ON sg.appid = ug.appid
WHERE ug.removed_at IS NULL WHERE ug.removed_at IS NULL
GROUP BY ug.appid, sg.name, sg.img_icon_url GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC ORDER BY value DESC
LIMIT ? LIMIT ?
""", limit); """, limit);
@@ -81,11 +81,11 @@ public class StatsRepository {
public List<Map<String, Object>> topPlayedGames(int limit) { public List<Map<String, Object>> topPlayedGames(int limit) {
return jdbc.queryForList(""" return jdbc.queryForList("""
SELECT ug.appid, sg.name, sg.img_icon_url, SUM(ug.playtime_minutes) AS value SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(ug.playtime_minutes) AS value
FROM user_game ug FROM user_game ug
LEFT JOIN steam_game sg ON sg.appid = ug.appid LEFT JOIN steam_game sg ON sg.appid = ug.appid
WHERE ug.removed_at IS NULL WHERE ug.removed_at IS NULL
GROUP BY ug.appid, sg.name, sg.img_icon_url GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC ORDER BY value DESC
LIMIT ? LIMIT ?
""", limit); """, limit);
@@ -53,6 +53,7 @@ public class PublicStatsService {
((Number) r.get("appid")).longValue(), ((Number) r.get("appid")).longValue(),
(String) r.get("name"), (String) r.get("name"),
(String) r.get("img_icon_url"), (String) r.get("img_icon_url"),
(String) r.get("image_url"),
((Number) r.get("value")).longValue() ((Number) r.get("value")).longValue()
)) ))
.toList(); .toList();
@@ -0,0 +1,8 @@
-- Games the user adds by hand from IGDB, next to the Steam sync. The key stays appid: the Steam appid when IGDB
-- knows the game on Steam, else minus the IGDB game id. So steam_game now also holds games that are not on Steam.
ALTER TABLE user_game
ADD COLUMN source VARCHAR(8) NOT NULL DEFAULT 'STEAM',
ADD COLUMN platform VARCHAR(32);
-- Picture for a game Steam has no art for, e.g. an IGDB artwork.
ALTER TABLE steam_game ADD COLUMN image_url VARCHAR(512);
@@ -0,0 +1,100 @@
package core_service.configuration.security;
import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.lenient;
/**
* Both login filters take the token from the access cookie or the Bearer header, the first one that verifies.
*/
class AccessTokenFiltersTest extends AbstractUnitTest {
private static final UserPrincipal USER = new UserPrincipal("42", "a@b.de", 76561198000000042L);
@Mock
private JwtService jwtService;
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
private Object principalAfter(jakarta.servlet.Filter filter, MockHttpServletRequest request) throws Exception {
filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain());
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication == null ? null : authentication.getPrincipal();
}
private void validToken(String token) {
lenient().when(jwtService.parseLoginToken(token)).thenReturn(Optional.of(USER));
}
@Test
void cookieAuthenticates() throws Exception {
validToken("good");
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good"));
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request));
}
@Test
void bearerAuthenticates() throws Exception {
validToken("good");
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer good");
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request));
}
@Test
void staleCookieFallsBackToTheBearerHeader() throws Exception {
validToken("good");
lenient().when(jwtService.parseLoginToken("stale")).thenReturn(Optional.empty());
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "stale"));
request.addHeader("Authorization", "Bearer good");
assertEquals(USER, principalAfter(new JwtAuthenticationFilter(jwtService), request));
}
@Test
void invalidTokenStaysAnonymous() throws Exception {
lenient().when(jwtService.parseLoginToken("bad")).thenReturn(Optional.empty());
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "bad"));
assertNull(principalAfter(new JwtAuthenticationFilter(jwtService), request));
}
@Test
void devFilter_UsesTheCookieUser() throws Exception {
validToken("good");
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(new Cookie(AccessTokens.ACCESS_COOKIE, "good"));
assertEquals(USER, principalAfter(new DevJwtAuthenticationFilter(jwtService), request));
}
@Test
void devFilter_WithoutTokenUsesTheDummyUser() throws Exception {
UserPrincipal principal = (UserPrincipal) principalAfter(new DevJwtAuthenticationFilter(jwtService),
new MockHttpServletRequest());
assertEquals("1", principal.id());
}
}
@@ -0,0 +1,56 @@
package core_service.configuration.security;
import core_service.shared.test.AbstractIntegrationTest;
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 static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile logs every request in, so only the CSRF header and CORS decide here.
*/
@AutoConfigureMockMvc
class ClientHeaderFilterTest extends AbstractIntegrationTest {
private static final String FRONTEND = "https://tbp.andreas-dahm.eu";
@Autowired
private MockMvc mockMvc;
@Test
void postWithoutClientHeader_Is403() throws Exception {
mockMvc.perform(post("/api/library/sync"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("CLIENT_HEADER_MISSING"));
}
@Test
void postWithClientHeader_PassesTheFilter() throws Exception {
int status = mockMvc.perform(post("/api/library/sync").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andReturn().getResponse().getStatus();
org.junit.jupiter.api.Assertions.assertNotEquals(403, status);
}
@Test
void corsPreflight_AllowsOnlyTheFrontendWithCookies() throws Exception {
mockMvc.perform(options("/api/library/sync")
.header("Origin", FRONTEND)
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "x-tbp-client"))
.andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", FRONTEND))
.andExpect(header().string("Access-Control-Allow-Credentials", "true"));
mockMvc.perform(options("/api/library/sync")
.header("Origin", "https://evil.andreas-dahm.eu")
.header("Access-Control-Request-Method", "POST"))
.andExpect(status().isForbidden());
}
}
@@ -0,0 +1,42 @@
package core_service.configuration.security;
import core_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import io.jsonwebtoken.Jwts;
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.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Date;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The real (non-dev) filter chain, where the token decides.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class ExpiredAccessTokenTest extends AbstractIntegrationTest {
@Autowired
private MockMvc mockMvc;
private static String expiredAccessToken() {
return Jwts.builder()
.subject("a@b.de")
.claim("userId", "42")
.issuedAt(new Date(System.currentTimeMillis() - 120_000))
.expiration(new Date(System.currentTimeMillis() - 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
}
@Test
void expiredAccessCookie_Is401SoTheFrontendRefreshes() throws Exception {
mockMvc.perform(get("/api/library/summary").cookie(new Cookie(AccessTokens.ACCESS_COOKIE, expiredAccessToken())))
.andExpect(status().isUnauthorized());
}
}
@@ -1,5 +1,6 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -54,51 +55,40 @@ class JwtServiceTest extends AbstractUnitTest {
} }
@Test @Test
void extractEmail_Success() { void parseLoginToken_ReturnsTheUser() {
var email = "test@example.com"; String token = signToken("0815", "test@example.com", 123456789L);
var token = signToken("0815", email, null);
assertEquals(email, jwtService.extractEmail(token)); assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)),
jwtService.parseLoginToken(token));
} }
@Test @Test
void extractUserId_Success() { void parseLoginToken_RejectsGarbage() {
var userId = "0815"; assertEquals(Optional.empty(), jwtService.parseLoginToken("not-a-jwt"));
var token = signToken(userId, "test@example.com", null);
assertEquals(userId, jwtService.extractUserId(token));
} }
@Test @Test
void extractSteamId_Success() { void parseLoginToken_RejectsAForeignKey() throws Exception {
var steamId = 123456789L;
var token = signToken("0815", "test@example.com", steamId);
assertEquals(steamId, jwtService.extractSteamId(token));
}
@Test
void isTokenValid_Success() {
var token = signToken("0815", "test@example.com", null);
assertTrue(jwtService.isTokenValid(token));
}
@Test
void isTokenValid_Failure_garbage() {
assertFalse(jwtService.isTokenValid("not-a-jwt"));
}
@Test
void isTokenValid_Failure_wrongKey() throws Exception {
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair(); KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String token = Jwts.builder() String token = Jwts.builder()
.subject("test@example.com") .subject("test@example.com")
.claim("userId", "0815")
.expiration(new Date(System.currentTimeMillis() + 60_000)) .expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(other.getPrivate(), Jwts.SIG.EdDSA) .signWith(other.getPrivate(), Jwts.SIG.EdDSA)
.compact(); .compact();
assertFalse(jwtService.isTokenValid(token)); assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
}
@Test
void parseLoginToken_RejectsATokenWithoutUserId() {
String token = Jwts.builder()
.subject("test@example.com")
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA)
.compact();
assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
} }
private String signTicket(String type, String profileUserId, long expiresInMs) { private String signTicket(String type, String profileUserId, long expiresInMs) {
@@ -130,7 +120,7 @@ class JwtServiceTest extends AbstractUnitTest {
} }
@Test @Test
void isTokenValid_ProfileTicketIsNoLogin() { void parseLoginToken_ProfileTicketIsNoLogin() {
assertFalse(jwtService.isTokenValid(signTicket("public-profile", "42", 60_000))); assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket("public-profile", "42", 60_000)));
} }
} }
@@ -175,4 +175,95 @@ class IgdbApiServiceTest extends AbstractUnitTest {
twitch.verify(); twitch.verify();
igdb.verify(); igdb.verify();
} }
private static final String ZELDA = "[{\"id\": 119388, \"name\": \"The Legend of Zelda: Tears of the Kingdom\","
+ " \"first_release_date\": 1683849600,"
+ " \"cover\": {\"id\": 1, \"image_id\": \"co5vmg\"},"
+ " \"screenshots\": [{\"id\": 2, \"image_id\": \"sc1\"}],"
+ " \"platforms\": [{\"id\": 130, \"name\": \"Nintendo Switch\", \"abbreviation\": \"Switch\"}]},"
+ " {\"id\": 119133, \"name\": \"Elden Ring\","
+ " \"artworks\": [{\"id\": 3, \"image_id\": \"ar1\"}], \"screenshots\": [{\"id\": 4, \"image_id\": \"sc2\"}],"
+ " \"platforms\": [{\"id\": 167, \"name\": \"PlayStation 5\", \"abbreviation\": \"PS5\"},"
+ " {\"id\": 6, \"name\": \"PC (Microsoft Windows)\", \"abbreviation\": \"PC\"}],"
+ " \"external_games\": [{\"id\": 5, \"external_game_source\": 5, \"uid\": \"x\"},"
+ " {\"id\": 6, \"external_game_source\": 1, \"uid\": \"1245620\"}]},"
+ " {\"id\": 7, \"name\": \" \"}]";
@Test
void searchGames_MapsNameYearPlatformsSteamAppAndAWidePicture() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(containsString("search \"zelda\";"), containsString("version_parent = null"),
containsString("limit 10"))))
.andRespond(withSuccess(ZELDA, MediaType.APPLICATION_JSON));
List<IgdbApiService.Game> games = service.searchGames(" zelda ").orElseThrow();
assertThat(games).containsExactly(
new IgdbApiService.Game(119388, "The Legend of Zelda: Tears of the Kingdom", 2023,
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/sc1.jpg", List.of("Switch"), null),
new IgdbApiService.Game(119133, "Elden Ring", null,
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L));
}
@Test
void searchGames_OnlyACover_UsesTheCover() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andRespond(withSuccess("[{\"id\": 1, \"name\": \"Tetris\", \"cover\": {\"image_id\": \"co1\"}}]",
MediaType.APPLICATION_JSON));
assertThat(service.searchGames("tetris").orElseThrow().getFirst().imageUrl())
.isEqualTo("https://images.igdb.com/igdb/image/upload/t_cover_big/co1.jpg");
}
@Test
void searchGames_QuotesAndSemicolonsCannotChangeTheQuery() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(containsString("search \"a where id = 1 b\";"),
not(containsString("\\")))))
.andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
assertThat(service.searchGames("a\"; where id = 1;\\ b")).contains(List.of());
}
@Test
void searchGames_OffOrError_IsEmpty() {
assertThat(create("", "").searchGames("zelda")).isEmpty();
service = create("client-1", "secret-1");
expectToken();
igdb.expect(requestTo(containsString("/games"))).andRespond(withServerError());
assertThat(service.searchGames("zelda")).isEmpty();
}
@Test
void getGame_AsksForTheOneId() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("where id = 119133;")))
.andRespond(withSuccess(ZELDA, MediaType.APPLICATION_JSON));
assertThat(service.getGame(119133)).map(IgdbApiService.Game::igdbId).contains(119388L);
}
@Test
void getGame_Unknown_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
assertThat(service.getGame(1)).isEmpty();
}
@Test
void getTimeToBeatByGameIds_MapsIgdbIdsToMinutes() {
expectToken();
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andExpect(content().string(containsString("game_id = (119388,7)")))
.andRespond(withSuccess("[{\"id\": 9, \"game_id\": 119388, \"normally\": 216000}]", MediaType.APPLICATION_JSON));
assertThat(service.getTimeToBeatByGameIds(List.of(119388L, 7L)))
.contains(Map.of(119388L, new IgdbApiService.TimeToBeat(null, 3600, null)));
}
} }
@@ -0,0 +1,42 @@
package core_service.modules.igdb_adapter.service;
import core_service.shared.test.AbstractIntegrationTest;
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.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The real, cache-proxied IgdbApiService with no IGDB reachable: the cache must let the empty answer through.
*/
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"app.igdb.client-id=test-client",
"app.igdb.client-secret=test-secret",
"app.igdb.base-url=http://127.0.0.1:1",
"app.igdb.token-base-url=http://127.0.0.1:1"
})
class IgdbSearchCacheTest extends AbstractIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private IgdbApiService igdbApiService;
@Test
void searchGames_IgdbDown_IsEmptyNotAnException() {
assertThat(igdbApiService.searchGames("zelda")).isEmpty();
}
@Test
void search_IgdbDown_Is502() throws Exception {
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
.andExpect(status().isBadGateway());
}
}
@@ -7,6 +7,7 @@ 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.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -56,7 +57,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test @Test
void updateBacklog_StoresAllFields() throws Exception { void updateBacklog_StoresAllFields() throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"FINISHED\",\"rating\":4,\"notes\":\"Great ending\",\"priority\":\"HIGH\"}")) .content("{\"status\":\"FINISHED\",\"rating\":4,\"notes\":\"Great ending\",\"priority\":\"HIGH\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("FINISHED")) .andExpect(jsonPath("$.status").value("FINISHED"))
@@ -76,7 +77,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
game.setNotes("old"); game.setNotes("old");
userGameRepository.save(game); userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"DROPPED\",\"notes\":\" \"}")) .content("{\"status\":\"DROPPED\",\"notes\":\" \"}"))
.andExpect(status().isOk()); .andExpect(status().isOk());
@@ -95,7 +96,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
"{\"status\":\"PLAYING\",\"rating\":6}", "{\"status\":\"PLAYING\",\"rating\":6}",
"{\"status\":\"PLAYING\",\"priority\":\"URGENT\"}"}) "{\"status\":\"PLAYING\",\"priority\":\"URGENT\"}"})
void updateBacklog_InvalidBody_Is400AndStoresNothing(String body) throws Exception { void updateBacklog_InvalidBody_Is400AndStoresNothing(String body) throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON).content(body)) mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
assertEquals(GameStatus.BACKLOG, stored().getStatus()); assertEquals(GameStatus.BACKLOG, stored().getStatus());
@@ -103,7 +104,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test @Test
void updateBacklog_FractionalRating_Is400() throws Exception { void updateBacklog_FractionalRating_Is400() throws Exception {
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\",\"rating\":4.7}")) .content("{\"status\":\"PLAYING\",\"rating\":4.7}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -113,7 +114,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test @Test
void updateBacklog_NotesTooLong_Is400() throws Exception { void updateBacklog_NotesTooLong_Is400() throws Exception {
String notes = "x".repeat(2001); String notes = "x".repeat(2001);
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\",\"notes\":\"" + notes + "\"}")) .content("{\"status\":\"PLAYING\",\"notes\":\"" + notes + "\"}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
@@ -124,7 +125,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
game.setRemovedAt(Instant.now()); game.setRemovedAt(Instant.now());
userGameRepository.save(game); userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/backlog").contentType(MediaType.APPLICATION_JSON) mockMvc.perform(put("/api/library/games/220/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}")) .content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound()) .andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
@@ -132,7 +133,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test @Test
void updateBacklog_GameNotOwned_Is404() throws Exception { void updateBacklog_GameNotOwned_Is404() throws Exception {
mockMvc.perform(put("/api/library/games/999/backlog").contentType(MediaType.APPLICATION_JSON) mockMvc.perform(put("/api/library/games/999/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}")) .content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
@@ -145,7 +146,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
game.setPriority(GamePriority.HIGH); game.setPriority(GamePriority.HIGH);
userGameRepository.save(game); userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON) mockMvc.perform(put("/api/library/games/220/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}")) .content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PLAYING")) .andExpect(jsonPath("$.status").value("PLAYING"))
@@ -160,7 +161,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test @Test
void updateStatus_MissingStatus_Is400() throws Exception { void updateStatus_MissingStatus_Is400() throws Exception {
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON) mockMvc.perform(put("/api/library/games/220/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{}")) .content("{}"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
@@ -173,7 +174,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
game.setRemovedAt(Instant.now()); game.setRemovedAt(Instant.now());
userGameRepository.save(game); userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON) mockMvc.perform(put("/api/library/games/220/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}")) .content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound()) .andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND")); .andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
@@ -181,7 +182,7 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
@Test @Test
void updateStatus_GameNotOwned_Is404() throws Exception { void updateStatus_GameNotOwned_Is404() throws Exception {
mockMvc.perform(put("/api/library/games/999/status").contentType(MediaType.APPLICATION_JSON) mockMvc.perform(put("/api/library/games/999/status").header(ClientHeaderFilter.CLIENT_HEADER, "test").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}")) .content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound()); .andExpect(status().isNotFound());
} }
@@ -0,0 +1,348 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.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.http.MediaType;
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.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
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 ManualGameControllerTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final IgdbApiService.Game ZELDA = new IgdbApiService.Game(119388, "Zelda: Tears of the Kingdom", 2023,
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/sc1.jpg", List.of("Switch"), null);
private static final IgdbApiService.Game ELDEN_RING = new IgdbApiService.Game(119133, "Elden Ring", 2022,
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L);
@Autowired
private MockMvc mockMvc;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@MockitoBean
private IgdbApiService igdbApiService;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
when(igdbApiService.getGame(119133)).thenReturn(Optional.of(ELDEN_RING));
when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.of(Map.of()));
when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.of(Map.of(
119388L, new IgdbApiService.TimeToBeat(null, 3600, null))));
}
private org.springframework.test.web.servlet.ResultActions add(long igdbId, String platform) throws Exception {
return mockMvc.perform(post("/api/library/games")
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"igdbId\": " + igdbId + ", \"platform\": \"" + platform + "\", \"status\": \"PLAYING\", "
+ "\"playtimeMinutes\": 600}"));
}
private UserGame stored(long appid) {
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
}
@Test
void add_GameNotOnSteam_GetsMinusItsIgdbIdAndItsTimeToBeat() throws Exception {
add(119388, " Switch ")
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/library/games/-119388"))
.andExpect(jsonPath("$.appid").value(-119388));
UserGame game = stored(-119388);
assertEquals(GameSource.MANUAL, game.getSource());
assertEquals("Switch", game.getPlatform());
assertEquals(GameStatus.PLAYING, game.getStatus());
assertEquals(600, game.getPlaytimeMinutes());
SteamGame catalog = steamGameRepository.findById(-119388L).orElseThrow();
assertEquals("Zelda: Tears of the Kingdom", catalog.getName());
assertEquals(ZELDA.imageUrl(), catalog.getImageUrl());
assertEquals(3600, timeToBeatStored(-119388L).getNormallyMinutes());
}
// The add asks IGDB for the time to beat in the background.
private GameTimeToBeat timeToBeatStored(long appid) throws InterruptedException {
for (int attempt = 0; attempt < 50; attempt++) {
Optional<GameTimeToBeat> found = gameTimeToBeatRepository.findById(appid);
if (found.isPresent()) {
return found.get();
}
Thread.sleep(100);
}
throw new AssertionError("No time to beat stored for appid " + appid);
}
@Test
void add_GameAddedBeforeIgdbKnewItOnSteam_Is409() throws Exception {
add(119388, "Switch").andExpect(status().isCreated());
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(new IgdbApiService.Game(119388, ZELDA.name(),
2023, null, List.of("Switch"), 2_000_000L)));
add(119388, "Switch").andExpect(status().isConflict());
assertTrue(userGameRepository.findById(new UserGameId(USER_ID, 2_000_000L)).isEmpty());
}
@Test
void add_GameOnSteam_UsesTheSteamAppidAndKeepsTheSteamCatalogEntry() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", "icon", Instant.now()));
add(119133, "PS5").andExpect(status().isCreated()).andExpect(jsonPath("$.appid").value(1245620));
assertEquals(GameSource.MANUAL, stored(1245620).getSource());
SteamGame catalog = steamGameRepository.findById(1245620L).orElseThrow();
assertEquals("ELDEN RING", catalog.getName());
assertNull(catalog.getImageUrl());
}
@Test
void add_GameAlreadyInTheLibrary_Is409() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now()));
add(119133, "PS5")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("ALREADY_IN_LIBRARY"));
assertEquals(GameSource.STEAM, stored(1245620).getSource());
}
@Test
void add_GameRemovedBefore_ComesBackWithItsNotes() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
UserGame refunded = new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now());
refunded.setNotes("Refunded on Steam");
refunded.setRemovedAt(Instant.now());
userGameRepository.save(refunded);
add(119133, "PS5").andExpect(status().isCreated());
UserGame back = stored(1245620);
assertNull(back.getRemovedAt());
assertEquals(GameSource.MANUAL, back.getSource());
assertEquals("Refunded on Steam", back.getNotes());
assertEquals(600, back.getPlaytimeMinutes());
}
@Test
void add_UnknownIgdbGame_Is400() throws Exception {
when(igdbApiService.getGame(anyLong())).thenReturn(Optional.empty());
add(5, "PS5").andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("UNKNOWN_GAME"));
}
@Test
void add_WithoutIgdb_Is503AndAsksNothing() throws Exception {
when(igdbApiService.isEnabled()).thenReturn(false);
add(119388, "Switch").andExpect(status().isServiceUnavailable());
verify(igdbApiService, never()).getGame(anyLong());
}
@Test
void add_BlankPlatform_Is400() throws Exception {
add(119388, " ").andExpect(status().isBadRequest());
}
@Test
void search_MarksGamesAlreadyInTheLibrary() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now()));
when(igdbApiService.searchGames("elden")).thenReturn(Optional.of(List.of(ELDEN_RING, ZELDA)));
mockMvc.perform(get("/api/library/game-search").param("q", "elden"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.available").value(true))
.andExpect(jsonPath("$.results[0].appid").value(1245620))
.andExpect(jsonPath("$.results[0].inLibrary").value(true))
.andExpect(jsonPath("$.results[0].platforms[0]").value("PS5"))
.andExpect(jsonPath("$.results[1].appid").value(-119388))
.andExpect(jsonPath("$.results[1].inLibrary").value(false));
}
@Test
void search_GameAddedBeforeIgdbKnewItOnSteam_IsInTheLibrary() throws Exception {
userGameRepository.save(UserGame.manual(USER_ID, -119388L, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(new IgdbApiService.Game(119388,
ZELDA.name(), 2023, null, List.of("Switch"), 2_000_000L))));
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
.andExpect(jsonPath("$.results[0].appid").value(2_000_000))
.andExpect(jsonPath("$.results[0].inLibrary").value(true));
}
@Test
void search_LeavesOutPlatformNamesTooLongToStore() throws Exception {
when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(new IgdbApiService.Game(119388,
ZELDA.name(), 2023, null, List.of("Switch", "A".repeat(33)), null))));
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
.andExpect(jsonPath("$.results[0].platforms.length()").value(1))
.andExpect(jsonPath("$.results[0].platforms[0]").value("Switch"));
}
@Test
void search_WithoutIgdb_SaysItIsNotAvailable() throws Exception {
when(igdbApiService.isEnabled()).thenReturn(false);
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.available").value(false))
.andExpect(jsonPath("$.results").isEmpty());
}
@Test
void search_IgdbFailed_Is502() throws Exception {
when(igdbApiService.searchGames(any())).thenReturn(Optional.empty());
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
.andExpect(status().isBadGateway());
}
@Test
void search_TooShort_Is400() throws Exception {
mockMvc.perform(get("/api/library/game-search").param("q", "z"))
.andExpect(status().isBadRequest());
}
@Test
void update_ChangesPlatformAndHoursOfAGameAddedByHand() throws Exception {
add(119388, "Switch");
mockMvc.perform(put("/api/library/games/-119388/manual")
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"platform\": \"Switch 2\", \"playtimeMinutes\": 1200}"))
.andExpect(status().isNoContent());
assertEquals("Switch 2", stored(-119388).getPlatform());
assertEquals(1200, stored(-119388).getPlaytimeMinutes());
}
@Test
void update_SteamGame_Is409() throws Exception {
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
mockMvc.perform(put("/api/library/games/220/manual")
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"platform\": \"PS5\", \"playtimeMinutes\": 1}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("STEAM_GAME"));
assertEquals(300, stored(220).getPlaytimeMinutes());
}
@Test
void remove_DeletesAGameAddedByHandButNoSteamGame() throws Exception {
add(119388, "Switch");
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
mockMvc.perform(delete("/api/library/games/-119388").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
mockMvc.perform(delete("/api/library/games/220").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isConflict());
mockMvc.perform(delete("/api/library/games/-1").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNotFound());
assertTrue(userGameRepository.findById(new UserGameId(USER_ID, -119388L)).isEmpty());
assertNull(stored(220).getRemovedAt());
}
@Test
void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception {
add(119388, "Switch");
mockMvc.perform(get("/api/library/games").param("q", "zelda"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].appid").value(-119388))
.andExpect(jsonPath("$.content[0].source").value("MANUAL"))
.andExpect(jsonPath("$.content[0].platform").value("Switch"))
.andExpect(jsonPath("$.content[0].imageUrl").value(ZELDA.imageUrl()))
.andExpect(jsonPath("$.content[0].timeToBeatMinutes").value(3600));
mockMvc.perform(get("/api/library/games/-119388"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Zelda: Tears of the Kingdom"))
.andExpect(jsonPath("$.source").value("MANUAL"))
.andExpect(jsonPath("$.hasAchievements").value(false));
}
// Signed like an Auth login token, for a user who saved no Steam ID.
private static String tokenWithoutSteamId() {
return io.jsonwebtoken.Jwts.builder()
.subject("no-steam@example.com")
.claim("userId", String.valueOf(USER_ID))
.expiration(new java.util.Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), io.jsonwebtoken.Jwts.SIG.EdDSA)
.compact();
}
@Test
void userWithoutSteamId_SeesTheGamesAddedByHand() throws Exception {
add(119388, "Switch");
jakarta.servlet.http.Cookie login = new jakarta.servlet.http.Cookie("tbp_access", tokenWithoutSteamId());
mockMvc.perform(get("/api/library/games").cookie(login))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].appid").value(-119388));
mockMvc.perform(get("/api/library/games-count").cookie(login))
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(1))
.andExpect(jsonPath("$.hasSteamId").value(false));
mockMvc.perform(get("/api/library/games/-119388").cookie(login))
.andExpect(status().isOk());
}
}
@@ -1,6 +1,7 @@
package core_service.modules.library.repo; package core_service.modules.library.repo;
import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
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;
@@ -64,6 +65,39 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
.map(UserGame::getAppid).getContent(); .map(UserGame::getAppid).getContent();
} }
@Test
void softDeleteMissingSince_KeepsGamesAddedByHand() {
steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 600,
Instant.now().minusSeconds(3600)));
Instant syncStartedAt = Instant.now();
int removed = userGameRepository.softDeleteMissingSince(USER_ID, syncStartedAt, Instant.now());
assertEquals(4, removed);
UserGame manual = userGameRepository.findById(new UserGameId(USER_ID, -1942L)).orElseThrow();
assertNull(manual.getRemovedAt());
assertEquals(GameSource.MANUAL, manual.getSource());
}
@Test
void findAchievementProgress_LeavesOutGamesAddedByHand() {
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
assertTrue(userGameRepository.findAchievementProgress(USER_ID).stream().noneMatch(p -> p.appid() == 999L));
}
@Test
void findRandomBacklogGameAndPicker_ExcludeNothingStillFindTheGameWithKeyMinusOne() {
userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(-1L, "IGDB game 1", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1L, "PS2", GameStatus.BACKLOG, 0, Instant.now()));
assertEquals(-1L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, List.of(), 3).size());
}
@Test @Test
void findOwnedGames_NoFilter_ReturnsOwnActiveLibrarySorted() { void findOwnedGames_NoFilter_ReturnsOwnActiveLibrarySorted() {
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow();
@@ -113,6 +113,17 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
verify(userAchievementMetaRepository, never()).save(any()); verify(userAchievementMetaRepository, never()).save(any());
} }
@Test
void syncLibrary_GameAddedByHand_AsksSteamNothing() {
UserGame manual = UserGame.manual(USER_ID, 620L, "PS5", core_service.modules.library.domain.GameStatus.PLAYING,
150, Instant.now());
library(List.of(manual), List.of(schema(620L, 10)), List.of());
service.syncLibrary(USER_ID, STEAM_ID);
verifyNoInteractions(steamApiService);
}
@Test @Test
void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() { void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120))); library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
@@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.GameDetailDto;
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;
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.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
@@ -63,6 +64,9 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
@MockitoBean @MockitoBean
private SteamApiService steamApiService; private SteamApiService steamApiService;
@MockitoBean
private TimeToBeatService timeToBeatService;
@BeforeEach @BeforeEach
void seed() { void seed() {
userGameRepository.deleteAll(); userGameRepository.deleteAll();
@@ -78,6 +82,26 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
return new SteamPlayerAchievementsResponse.Achievement(name, 1, 1_700_000_000L); return new SteamPlayerAchievementsResponse.Achievement(name, 1, 1_700_000_000L);
} }
@Test
void getGameDetail_GameAddedByHand_AsksSteamNothingAndShowsNoAchievements() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now().minus(Duration.ofDays(31))));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
org.mockito.Mockito.verifyNoInteractions(steamApiService);
assertEquals(false, detail.hasAchievements());
}
@Test
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAgain() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
gameDetailService.getGameDetail(USER_ID, null, 620L);
org.mockito.Mockito.verify(timeToBeatService).refreshInBackground(List.of(620L));
}
@Test @Test
void getGameDetail_StaleUserMeta_ShowsTheRefreshedCount() { void getGameDetail_StaleUserMeta_ShowsTheRefreshedCount() {
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now())); schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now()));
@@ -1,5 +1,6 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; 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;
@@ -32,6 +33,7 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyList;
@@ -177,6 +179,35 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
assertEquals(expected, saved.getValue().getStatus()); assertEquals(expected, saved.getValue().getStatus());
} }
@Test
void runSync_GameAddedByHandIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH);
manual.setRating(5);
manual.setNotes("Played on the couch");
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(manual));
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
assertEquals(GameSource.STEAM, manual.getSource());
assertNull(manual.getPlatform());
assertEquals(90, manual.getPlaytimeMinutes());
assertEquals(GameStatus.FINISHED, manual.getStatus());
assertEquals(5, manual.getRating());
assertEquals("Played on the couch", manual.getNotes());
}
@Test
void runSync_GameAddedByHandIsNowOnSteam_TheHandTypedHoursAreNoPlaytimeGrowth() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(manual));
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
@Test @Test
void runSync_ExistingGame_KeepsStatus() { void runSync_ExistingGame_KeepsStatus() {
UserGame existing = new UserGame(USER_ID, 220L, 0, Instant.EPOCH, Instant.EPOCH); UserGame existing = new UserGame(USER_ID, 220L, 0, Instant.EPOCH, Instant.EPOCH);
@@ -370,4 +401,14 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(timeToBeatService).refresh(List.of(220L)); verify(timeToBeatService).refresh(List.of(220L));
} }
@Test
void runSync_AlsoRefreshesTheTimeToBeatOfTheGamesAddedByHand() {
steamReturns(game(0, 0), STEAM_ID);
when(userGameRepository.findActiveManualAppids(USER_ID)).thenReturn(List.of(-1025L));
worker.runSync(USER_ID, STEAM_ID);
verify(timeToBeatService).refresh(List.of(220L, -1025L));
}
} }
@@ -0,0 +1,79 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.PlatformTransactionManager;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
class ManualGameServiceTest extends AbstractUnitTest {
private static final long USER_ID = 1L;
private static final IgdbApiService.Game ZELDA = new IgdbApiService.Game(119388, "Zelda: Tears of the Kingdom", 2023,
null, List.of("Switch"), null);
@Mock
private IgdbApiService igdbApiService;
@Mock
private UserGameRepository userGameRepository;
@Mock
private SteamGameRepository steamGameRepository;
@Mock
private TimeToBeatService timeToBeatService;
@Mock
private PlatformTransactionManager transactionManager;
private ManualGameService service;
@BeforeEach
void setUp() {
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, timeToBeatService,
transactionManager);
}
@Test
void add_RowInsertedMeanwhile_IsAlreadyInLibraryNotAnError() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.empty());
when(steamGameRepository.findById(-119388L)).thenReturn(Optional.empty());
when(userGameRepository.save(any())).thenThrow(
new DataIntegrityViolationException("duplicate key value violates unique constraint \"user_game_pkey\""));
ManualGameService.AddResult result = service.add(USER_ID, 119388, "Switch", GameStatus.BACKLOG, 0);
assertThat(result).isInstanceOf(ManualGameService.AddResult.AlreadyInLibrary.class);
}
@Test
void add_CatalogRowInsertedMeanwhile_TriesAgainAndAdds() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.empty());
when(steamGameRepository.findById(-119388L)).thenReturn(Optional.empty());
when(steamGameRepository.save(any()))
.thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"steam_game_pkey\""))
.thenReturn(null);
ManualGameService.AddResult result = service.add(USER_ID, 119388, "Switch", GameStatus.BACKLOG, 0);
assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L));
}
}
@@ -53,6 +53,35 @@ class TimeToBeatServiceTest extends AbstractUnitTest {
verify(gameTimeToBeatRepository, never()).upsert(eq(220L), any(), any(), any(), any()); verify(gameTimeToBeatRepository, never()).upsert(eq(220L), any(), any(), any(), any());
} }
@Test
void refresh_GamesOffSteam_AreAskedByIgdbIdAndStoredUnderTheirKey() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of()));
when(igdbApiService.getTimeToBeatByGameIds(Set.of(119388L, 7L))).thenReturn(Optional.of(Map.of(
119388L, new IgdbApiService.TimeToBeat(null, 3600, null))));
service.refresh(List.of(620L, -119388L, -7L));
verify(gameTimeToBeatRepository).upsert(eq(-119388L), isNull(), eq(3600), isNull(), any(Instant.class));
verify(gameTimeToBeatRepository).upsert(eq(-7L), isNull(), isNull(), isNull(), any(Instant.class));
verify(gameTimeToBeatRepository).upsert(eq(620L), isNull(), isNull(), isNull(), any(Instant.class));
}
@Test
void refresh_OnlyTheGamesOffSteamFailed_StoresTheSteamOnes() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.TimeToBeat(510, 750, 1350))));
when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.empty());
service.refresh(List.of(620L, -7L));
verify(gameTimeToBeatRepository).upsert(eq(620L), eq(510), eq(750), eq(1350), any(Instant.class));
verify(gameTimeToBeatRepository, never()).upsert(eq(-7L), any(), any(), any(), any());
}
@Test @Test
void refresh_IgdbError_StoresNothing() { void refresh_IgdbError_StoresNothing() {
when(igdbApiService.isEnabled()).thenReturn(true); when(igdbApiService.isEnabled()).thenReturn(true);