Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d50863cb8 | ||
|
|
33e0a59664 | ||
|
|
5d608b72e6 |
No files matched your search
@@ -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,37 +32,18 @@ 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) {
|
||||||
}
|
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
|
||||||
|
principal.get(),
|
||||||
final String jwt = authHeader.substring(7);
|
null,
|
||||||
if (!jwtService.isTokenValid(jwt)) {
|
Collections.emptyList()
|
||||||
filterChain.doFilter(request, response);
|
);
|
||||||
return;
|
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
}
|
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||||
|
|
||||||
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(
|
|
||||||
principal,
|
|
||||||
null,
|
|
||||||
Collections.emptyList()
|
|
||||||
);
|
|
||||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
|
||||||
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,11 +77,13 @@ 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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (steamId != null) {
|
||||||
achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(),
|
try {
|
||||||
Duration.ofHours(achievementTtlHours));
|
achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(),
|
||||||
} catch (Exception e) {
|
Duration.ofHours(achievementTtlHours));
|
||||||
log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage());
|
} catch (Exception e) {
|
||||||
|
log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
|
UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user