From 4d50863cb882189dd8bef792df5afdba146a7c9b Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 25 Sep 2026 14:37:21 +0200 Subject: [PATCH] feat(library): add games by name from IGDB Games the user owns outside Steam get the key minus their IGDB id, or the Steam appid when IGDB knows the game on Steam. The sync never removes a MANUAL row and takes it over once the user owns the game on Steam. Users without a Steam ID can use the library. --- .../configuration/CacheConfig.java | 3 +- .../api/dto/response/IgdbResponses.java | 35 ++ .../igdb_adapter/config/IgdbClientConfig.java | 15 +- .../igdb_adapter/service/IgdbApiService.java | 126 +++++++ .../library/api/LibraryController.java | 23 +- .../library/api/ManualGameController.java | 90 +++++ .../library/api/dto/AddGameRequest.java | 21 ++ .../modules/library/api/dto/AddedGameDto.java | 3 + .../library/api/dto/GameDetailDto.java | 6 +- .../modules/library/api/dto/GameDto.java | 8 +- .../library/api/dto/GameSearchDto.java | 16 + .../library/api/dto/ManualGameUpdate.java | 21 ++ .../library/api/dto/PublicLibraryDto.java | 7 +- .../modules/library/domain/GameKeys.java | 23 ++ .../modules/library/domain/GameSource.java | 11 + .../modules/library/domain/SteamGame.java | 3 + .../modules/library/domain/UserGame.java | 17 + .../library/repo/UserGameRepository.java | 27 +- .../service/AchievementSyncService.java | 2 + .../library/service/GameDetailService.java | 26 +- .../library/service/LibraryQueryService.java | 15 +- .../library/service/LibrarySyncWorker.java | 13 +- .../library/service/ManualGameService.java | 201 ++++++++++ .../library/service/TimeToBeatService.java | 49 ++- .../modules/stats/api/dto/PublicStatsDto.java | 5 +- .../modules/stats/repo/StatsRepository.java | 8 +- .../stats/service/PublicStatsService.java | 1 + .../db/migration/V8__Manual_Games.sql | 8 + .../service/IgdbApiServiceTest.java | 91 +++++ .../service/IgdbSearchCacheTest.java | 42 +++ .../library/api/ManualGameControllerTest.java | 348 ++++++++++++++++++ .../library/repo/UserGameRepositoryTest.java | 34 ++ .../service/AchievementSyncServiceTest.java | 11 + .../service/GameDetailServiceTest.java | 24 ++ .../service/LibrarySyncWorkerTest.java | 41 +++ .../service/ManualGameServiceTest.java | 79 ++++ .../service/TimeToBeatServiceTest.java | 29 ++ 37 files changed, 1431 insertions(+), 51 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/ManualGameController.java create mode 100644 src/main/java/core_service/modules/library/api/dto/AddGameRequest.java create mode 100644 src/main/java/core_service/modules/library/api/dto/AddedGameDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/GameSearchDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/ManualGameUpdate.java create mode 100644 src/main/java/core_service/modules/library/domain/GameKeys.java create mode 100644 src/main/java/core_service/modules/library/domain/GameSource.java create mode 100644 src/main/java/core_service/modules/library/service/ManualGameService.java create mode 100644 src/main/resources/db/migration/V8__Manual_Games.sql create mode 100644 src/test/java/core_service/modules/igdb_adapter/service/IgdbSearchCacheTest.java create mode 100644 src/test/java/core_service/modules/library/api/ManualGameControllerTest.java create mode 100644 src/test/java/core_service/modules/library/service/ManualGameServiceTest.java diff --git a/src/main/java/core_service/configuration/CacheConfig.java b/src/main/java/core_service/configuration/CacheConfig.java index 219f0f2..be4c21d 100644 --- a/src/main/java/core_service/configuration/CacheConfig.java +++ b/src/main/java/core_service/configuration/CacheConfig.java @@ -17,7 +17,8 @@ public class CacheConfig { @Bean public CacheManager cacheManager() { - CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary"); + CaffeineCacheManager cacheManager = new CaffeineCacheManager("steamGameCount", "publicStats", "steamPlayerSummary", + "igdbSearch"); cacheManager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(10, TimeUnit.MINUTES) .maximumSize(1000)); diff --git a/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java index 632bc60..cebc111 100644 --- a/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java +++ b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java @@ -27,6 +27,41 @@ public final class IgdbResponses { @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 artworks, + @JsonProperty("screenshots") java.util.List screenshots, + @JsonProperty("platforms") java.util.List platforms, + @JsonProperty("external_games") java.util.List 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. * diff --git a/src/main/java/core_service/modules/igdb_adapter/config/IgdbClientConfig.java b/src/main/java/core_service/modules/igdb_adapter/config/IgdbClientConfig.java index f55a0ac..4715cfc 100644 --- a/src/main/java/core_service/modules/igdb_adapter/config/IgdbClientConfig.java +++ b/src/main/java/core_service/modules/igdb_adapter/config/IgdbClientConfig.java @@ -3,18 +3,29 @@ package core_service.modules.igdb_adapter.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestClient; +import java.time.Duration; + @Configuration public class IgdbClientConfig { @Bean 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 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; } } diff --git a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java index fe85dfe..dc95807 100644 --- a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java +++ b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java @@ -6,6 +6,7 @@ import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.Cacheable; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; @@ -13,6 +14,7 @@ import org.springframework.web.client.RestClient; import java.time.Duration; import java.time.Instant; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; @@ -22,6 +24,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; 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 @@ -36,6 +39,22 @@ public class IgdbApiService { */ 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 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. private static final int BATCH = 250; 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> 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 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> getTimeToBeatByGameIds(Collection igdbIds) { + if (!isEnabled() || igdbIds.isEmpty()) { + return Optional.empty(); + } + try { + List ids = List.copyOf(igdbIds); + Map 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 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 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 getBatch(List steamAppids) { String uids = steamAppids.stream().map(id -> "\"" + id + "\"").collect(Collectors.joining(",")); IgdbResponses.ExternalGame[] externals = query("/external_games", diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index ea3215d..846da6d 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -62,14 +62,12 @@ public class LibraryController { @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { - if (principal.steamId() == null) { - return ResponseEntity.ok(Map.of("count", 0, "hasSteamId", false)); - } Long userId = principal.getIdAsLong(); - String steamId = String.valueOf(principal.steamId()); - syncService.syncIfStale(userId, steamId); + if (principal.steamId() != null) { + syncService.syncIfStale(userId, String.valueOf(principal.steamId())); + } 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") @@ -82,12 +80,10 @@ public class LibraryController { @RequestParam(required = false) String q, @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(); - String steamId = String.valueOf(principal.steamId()); - syncService.syncIfStale(userId, steamId); + if (principal.steamId() != null) { + syncService.syncIfStale(userId, String.valueOf(principal.steamId())); + } int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safePage = Math.max(0, page); @@ -128,11 +124,8 @@ public class LibraryController { @GetMapping("/games/{appid}") public ResponseEntity getGameDetail(@AuthenticationPrincipal UserPrincipal principal, @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(); - String steamId = String.valueOf(principal.steamId()); + String steamId = principal.steamId() != null ? String.valueOf(principal.steamId()) : null; return gameDetailService.getGameDetail(userId, steamId, appid) .>map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) diff --git a/src/main/java/core_service/modules/library/api/ManualGameController.java b/src/main/java/core_service/modules/library/api/ManualGameController.java new file mode 100644 index 0000000..fe0d2ec --- /dev/null +++ b/src/main/java/core_service/modules/library/api/ManualGameController.java @@ -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 NOT_FOUND = Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"); + private static final Map 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) + .>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); + }; + } +} diff --git a/src/main/java/core_service/modules/library/api/dto/AddGameRequest.java b/src/main/java/core_service/modules/library/api/dto/AddGameRequest.java new file mode 100644 index 0000000..de88ba0 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/AddGameRequest.java @@ -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 +) {} diff --git a/src/main/java/core_service/modules/library/api/dto/AddedGameDto.java b/src/main/java/core_service/modules/library/api/dto/AddedGameDto.java new file mode 100644 index 0000000..7350fd3 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/AddedGameDto.java @@ -0,0 +1,3 @@ +package core_service.modules.library.api.dto; + +public record AddedGameDto(long appid) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index d2d37cb..09286f8 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -1,6 +1,7 @@ package core_service.modules.library.api.dto; import core_service.modules.library.domain.GamePriority; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import java.time.Instant; @@ -25,7 +26,10 @@ public record GameDetailDto( int achievementsRemaining, double completionPercent, List achievements, - TimeToBeatDto timeToBeat + TimeToBeatDto timeToBeat, + GameSource source, + String platform, + String imageUrl ) { /** diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java index ec12771..c5d0f4e 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java @@ -1,12 +1,15 @@ package core_service.modules.library.api.dto; import core_service.modules.library.domain.GamePriority; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; /** * @param timeToBeatMinutes IGDB's normal time to beat, null when unknown * @param achievementsTotal null when the game has no achievements or they were not fetched yet * @param achievementsUnlocked null when not fetched yet or hidden by Steam + * @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( Long appid, @@ -18,5 +21,8 @@ public record GameDto( GamePriority priority, Integer timeToBeatMinutes, Integer achievementsUnlocked, - Integer achievementsTotal + Integer achievementsTotal, + GameSource source, + String platform, + String imageUrl ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameSearchDto.java b/src/main/java/core_service/modules/library/api/dto/GameSearchDto.java new file mode 100644 index 0000000..95dcbf3 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/GameSearchDto.java @@ -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 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 platforms, + boolean inLibrary) {} +} diff --git a/src/main/java/core_service/modules/library/api/dto/ManualGameUpdate.java b/src/main/java/core_service/modules/library/api/dto/ManualGameUpdate.java new file mode 100644 index 0000000..2c056d2 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/ManualGameUpdate.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java index 7311604..fc46b3d 100644 --- a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java @@ -23,5 +23,10 @@ public record PublicLibraryDto( 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) {} } diff --git a/src/main/java/core_service/modules/library/domain/GameKeys.java b/src/main/java/core_service/modules/library/domain/GameKeys.java new file mode 100644 index 0000000..6aa3f1d --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameKeys.java @@ -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; + } +} diff --git a/src/main/java/core_service/modules/library/domain/GameSource.java b/src/main/java/core_service/modules/library/domain/GameSource.java new file mode 100644 index 0000000..2c449a0 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameSource.java @@ -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 +} diff --git a/src/main/java/core_service/modules/library/domain/SteamGame.java b/src/main/java/core_service/modules/library/domain/SteamGame.java index 6df1b23..f538fb8 100644 --- a/src/main/java/core_service/modules/library/domain/SteamGame.java +++ b/src/main/java/core_service/modules/library/domain/SteamGame.java @@ -31,6 +31,9 @@ public class SteamGame { @Column(name = "last_seen_at", nullable = false) private Instant lastSeenAt; + @Column(name = "image_url", length = 512) + private String imageUrl; + public SteamGame(Long appid, String name, String imgIconUrl, Instant lastSeenAt) { this.appid = appid; this.name = name; diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index 0e12cc5..1c7b74e 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -62,6 +62,14 @@ public class UserGame { @Column(name = "priority", length = 8) 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) { this.userId = userId; this.appid = appid; @@ -70,4 +78,13 @@ public class UserGame { this.lastSeenAt = lastSeenAt; 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; + } } diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index d991d53..519dd55 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -23,8 +23,20 @@ import java.util.Optional; public interface UserGameRepository extends JpaRepository { + /** + * 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 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 findActiveAppids(@Param("userId") Long userId, @Param("appids") Collection 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 findActiveManualAppids(@Param("userId") 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") @@ -42,7 +54,7 @@ public interface UserGameRepository extends JpaRepository List 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' " + "and appid <> :excludeAppid order by random() limit 1", nativeQuery = true) @@ -53,7 +65,8 @@ public interface UserGameRepository extends JpaRepository "from UserGame ug " + "left join GameAchievementSchemaMeta sm on sm.appid = ug.appid " + "left join UserGameAchievementMeta um on um.userId = ug.userId and um.appid = ug.appid " + - "where ug.userId = :userId and ug.removedAt is null") + "where ug.userId = :userId and ug.removedAt is null " + + "and ug.source = core_service.modules.library.domain.GameSource.STEAM") List findAchievementProgress(@Param("userId") Long userId); /** @@ -64,8 +77,8 @@ public interface UserGameRepository extends JpaRepository */ default List findPickerGames(Long userId, Integer maxMinutes, Collection excludeAppids, int count) { List excluded = new ArrayList<>(excludeAppids); - // "not in ()" is invalid SQL, and no appid is -1 - excluded.add(-1L); + // "not in ()" is invalid SQL + excluded.add(NO_GAME); return queryPickerGames(userId, maxMinutes, excluded, count); } @@ -134,9 +147,13 @@ public interface UserGameRepository extends JpaRepository return "%" + escaped + "%"; } + /** + * Removes the Steam games the last sync did not see. Games added by hand never come from Steam and stay. + */ @Modifying @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, @Param("syncStartedAt") Instant syncStartedAt, @Param("now") Instant now); diff --git a/src/main/java/core_service/modules/library/service/AchievementSyncService.java b/src/main/java/core_service/modules/library/service/AchievementSyncService.java index 01d869e..33f4638 100644 --- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java +++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java @@ -2,6 +2,7 @@ package core_service.modules.library.service; import core_service.modules.library.domain.GameAchievementSchema; import core_service.modules.library.domain.GameAchievementSchemaMeta; +import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievement; import core_service.modules.library.domain.UserGameAchievementMeta; @@ -133,6 +134,7 @@ public class AchievementSyncService { private void runLibrarySync(Long userId, String steamId) { List games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream() + .filter(game -> game.getSource() == GameSource.STEAM) .sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed()) .toList(); Map schemas = schemaMetaRepository diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 75bb733..9a7fe78 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -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.GameAchievementSchema; 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.UserGame; import core_service.modules.library.domain.UserGameAchievement; @@ -41,6 +42,7 @@ public class GameDetailService { private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final AchievementSyncService achievementSyncService; private final GameTimeToBeatRepository gameTimeToBeatRepository; + private final TimeToBeatService timeToBeatService; @Value("${app.library.achievement-ttl-hours:1}") 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 * 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 getGameDetail(Long userId, String steamId, Long appid) { Optional userGameOpt = userGameRepository.findById(new UserGameId(userId, appid)); @@ -56,6 +60,11 @@ public class GameDetailService { } UserGame userGame = userGameOpt.get(); 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 { achievementSyncService.refreshSchemaIfStale(appid); @@ -68,11 +77,13 @@ public class GameDetailService { return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of())); } - try { - achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(), - Duration.ofHours(achievementTtlHours)); - } catch (Exception e) { - log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage()); + if (steamId != null) { + try { + achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(), + Duration.ofHours(achievementTtlHours)); + } catch (Exception e) { + log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage()); + } } UserGameAchievementMeta userMeta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null); @@ -132,7 +143,10 @@ public class GameDetailService { achievements, gameTimeToBeatRepository.findById(userGame.getAppid()) .map(t -> new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), t.getCompletelyMinutes())) - .orElse(null) + .orElse(null), + userGame.getSource(), + userGame.getPlatform(), + meta != null ? meta.getImageUrl() : null ); } } diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 0dcd2d5..0b76773 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -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.DayMinutes; 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.GameTimeToBeat; import core_service.modules.library.domain.SteamGame; @@ -153,7 +154,7 @@ public class LibraryQueryService { Map byStatus = countByStatus(userId); long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); 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()) .orElse(null); return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId)); @@ -219,7 +220,8 @@ public class LibraryQueryService { return games.stream() .map(ug -> new PublicLibraryDto.Game(ug.getAppid(), 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(); } @@ -232,8 +234,8 @@ public class LibraryQueryService { private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time, GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks) { - Integer achievementsTotal = schema != null && schema.isHasSchema() && schema.getAchievementsTotal() > 0 - ? schema.getAchievementsTotal() : null; + Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema() + && schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null; Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible() ? AchievementProgress.capped(unlocks.getAchievementsUnlocked(), achievementsTotal) : null; return new GameDto( @@ -246,7 +248,10 @@ public class LibraryQueryService { ug.getPriority(), time != null ? time.getNormallyMinutes() : null, achievementsUnlocked, - achievementsTotal); + achievementsTotal, + ug.getSource(), + ug.getPlatform(), + meta != null ? meta.getImageUrl() : null); } public Optional getSyncStatus(Long userId) { diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 8288155..d04a82b 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -1,5 +1,6 @@ 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.UserGame; import core_service.modules.library.domain.UserGameId; @@ -24,6 +25,7 @@ import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -116,7 +118,10 @@ public class LibrarySyncWorker { log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed); } 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 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)); finalStatus = Status.OK; } catch (Exception e) { @@ -179,6 +184,8 @@ public class LibrarySyncWorker { Long appid = g.appid().longValue(); UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null); 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 playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0; if (existing == null) { @@ -190,9 +197,11 @@ public class LibrarySyncWorker { existing.setPlaytime2Weeks(playtime2Weeks); existing.setLastSeenAt(now); 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. - if (playedOn != null && playtime > previousPlaytime) { + if (playedOn != null && !takesOverManualGame && playtime > previousPlaytime) { playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime); } } diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java new file mode 100644 index 0000000..d3e8a20 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -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 search(Long userId, String query) { + if (!igdbApiService.isEnabled()) { + return Optional.of(new GameSearchDto(false, List.of())); + } + return igdbApiService.searchGames(query).map(games -> { + Set 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 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 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 change) { + Optional 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 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 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 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()); + } +} diff --git a/src/main/java/core_service/modules/library/service/TimeToBeatService.java b/src/main/java/core_service/modules/library/service/TimeToBeatService.java index 14fa522..1b147ac 100644 --- a/src/main/java/core_service/modules/library/service/TimeToBeatService.java +++ b/src/main/java/core_service/modules/library/service/TimeToBeatService.java @@ -1,17 +1,19 @@ package core_service.modules.library.service; import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.domain.GameKeys; import core_service.modules.library.repo.GameTimeToBeatRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.time.Duration; import java.time.Instant; import java.util.Collection; +import java.util.HashMap; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.TreeSet; @@ -26,6 +28,18 @@ public class TimeToBeatService { @Value("${app.library.time-to-beat-ttl-days:30}") 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 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 * 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)))); if (stale.isEmpty()) return; - Optional> answer = igdbApiService.getTimeToBeat(stale); - if (answer.isEmpty()) { - log.warn("Time to beat for {} apps not refreshed, IGDB did not answer", stale.size()); - return; + Set steamAppids = new TreeSet<>(); + Set igdbIds = new TreeSet<>(); + for (Long appid : stale) { + if (GameKeys.isSteam(appid)) { + steamAppids.add(appid); + } else { + igdbIds.add(GameKeys.igdbId(appid)); + } + } + Map times = new HashMap<>(); + Set 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(); - for (Long appid : stale) { - IgdbApiService.TimeToBeat time = answer.get().get(appid); + for (Long appid : answered) { + IgdbApiService.TimeToBeat time = times.get(appid); if (time == null) { gameTimeToBeatRepository.upsert(appid, null, null, null, now); } else { diff --git a/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java b/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java index b86dd7f..b054a96 100644 --- a/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java +++ b/src/main/java/core_service/modules/stats/api/dto/PublicStatsDto.java @@ -15,5 +15,8 @@ public record PublicStatsDto( List topOwnedGames, List 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) {} } diff --git a/src/main/java/core_service/modules/stats/repo/StatsRepository.java b/src/main/java/core_service/modules/stats/repo/StatsRepository.java index 6852b98..cd3d41a 100644 --- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java @@ -69,11 +69,11 @@ public class StatsRepository { public List> topOwnedGames(int limit) { 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 LEFT JOIN steam_game sg ON sg.appid = ug.appid 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 LIMIT ? """, limit); @@ -81,11 +81,11 @@ public class StatsRepository { public List> topPlayedGames(int limit) { 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 LEFT JOIN steam_game sg ON sg.appid = ug.appid 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 LIMIT ? """, limit); diff --git a/src/main/java/core_service/modules/stats/service/PublicStatsService.java b/src/main/java/core_service/modules/stats/service/PublicStatsService.java index 9639281..9ceaf28 100644 --- a/src/main/java/core_service/modules/stats/service/PublicStatsService.java +++ b/src/main/java/core_service/modules/stats/service/PublicStatsService.java @@ -53,6 +53,7 @@ public class PublicStatsService { ((Number) r.get("appid")).longValue(), (String) r.get("name"), (String) r.get("img_icon_url"), + (String) r.get("image_url"), ((Number) r.get("value")).longValue() )) .toList(); diff --git a/src/main/resources/db/migration/V8__Manual_Games.sql b/src/main/resources/db/migration/V8__Manual_Games.sql new file mode 100644 index 0000000..0873837 --- /dev/null +++ b/src/main/resources/db/migration/V8__Manual_Games.sql @@ -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); diff --git a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java index ee541e7..1369a4a 100644 --- a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java +++ b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java @@ -175,4 +175,95 @@ class IgdbApiServiceTest extends AbstractUnitTest { twitch.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 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))); + } } diff --git a/src/test/java/core_service/modules/igdb_adapter/service/IgdbSearchCacheTest.java b/src/test/java/core_service/modules/igdb_adapter/service/IgdbSearchCacheTest.java new file mode 100644 index 0000000..598494f --- /dev/null +++ b/src/test/java/core_service/modules/igdb_adapter/service/IgdbSearchCacheTest.java @@ -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()); + } +} diff --git a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java new file mode 100644 index 0000000..8debf89 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java @@ -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 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()); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 800539b..d9016b5 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -1,6 +1,7 @@ package core_service.modules.library.repo; 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.GameStatus; import core_service.modules.library.domain.StatusCount; @@ -64,6 +65,39 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { .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 void findOwnedGames_NoFilter_ReturnsOwnActiveLibrarySorted() { UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow(); diff --git a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java index fb819b3..835c175 100644 --- a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java @@ -113,6 +113,17 @@ class AchievementSyncServiceTest extends AbstractUnitTest { 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 void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() { library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120))); diff --git a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java index e35ae81..00407e3 100644 --- a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java @@ -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.GameAchievementSchemaMeta; 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.UserGameAchievementMeta; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; @@ -63,6 +64,9 @@ class GameDetailServiceTest extends AbstractIntegrationTest { @MockitoBean private SteamApiService steamApiService; + @MockitoBean + private TimeToBeatService timeToBeatService; + @BeforeEach void seed() { userGameRepository.deleteAll(); @@ -78,6 +82,26 @@ class GameDetailServiceTest extends AbstractIntegrationTest { 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 void getGameDetail_StaleUserMeta_ShowsTheRefreshedCount() { schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now())); diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 34213c6..684ce35 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -1,5 +1,6 @@ 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.UserGame; import core_service.modules.library.domain.UserGameId; @@ -32,6 +33,7 @@ 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.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; @@ -177,6 +179,35 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { 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 void runSync_ExistingGame_KeepsStatus() { 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)); } + + @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)); + } } diff --git a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java new file mode 100644 index 0000000..09b8476 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -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)); + } +} diff --git a/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java b/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java index bc09065..19840b0 100644 --- a/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java +++ b/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java @@ -53,6 +53,35 @@ class TimeToBeatServiceTest extends AbstractUnitTest { 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 void refresh_IgdbError_StoresNothing() { when(igdbApiService.isEnabled()).thenReturn(true);