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 new file mode 100644 index 0000000..632bc60 --- /dev/null +++ b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java @@ -0,0 +1,43 @@ +package core_service.modules.igdb_adapter.api.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The parts of the Twitch token and IGDB v4 answers the adapter reads. + */ +public final class IgdbResponses { + + private IgdbResponses() { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Token( + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") Long expiresIn + ) {} + + /** + * @param game the IGDB game id + * @param uid the id on the other service, here the Steam appid as a string + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record ExternalGame( + @JsonProperty("game") Long game, + @JsonProperty("uid") String uid + ) {} + + /** + * Times in seconds; each can be missing. + * + * @param count how many players submitted a time + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record TimeToBeat( + @JsonProperty("game_id") Long gameId, + @JsonProperty("hastily") Integer hastily, + @JsonProperty("normally") Integer normally, + @JsonProperty("completely") Integer completely, + @JsonProperty("count") Integer count + ) {} +} 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 new file mode 100644 index 0000000..f55a0ac --- /dev/null +++ b/src/main/java/core_service/modules/igdb_adapter/config/IgdbClientConfig.java @@ -0,0 +1,20 @@ +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.web.client.RestClient; + +@Configuration +public class IgdbClientConfig { + + @Bean + public RestClient igdbRestClient(@Value("${app.igdb.base-url}") String baseUrl) { + return RestClient.builder().baseUrl(baseUrl).build(); + } + + @Bean + public RestClient twitchRestClient(@Value("${app.igdb.token-base-url}") String tokenBaseUrl) { + return RestClient.builder().baseUrl(tokenBaseUrl).build(); + } +} 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 new file mode 100644 index 0000000..fe85dfe --- /dev/null +++ b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java @@ -0,0 +1,176 @@ +package core_service.modules.igdb_adapter.service; + +import core_service.modules.igdb_adapter.api.dto.response.IgdbResponses; +import io.github.resilience4j.ratelimiter.RateLimiter; +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.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Time to beat from IGDB (api-docs.igdb.com), found by Steam appid. Needs a Twitch developer app; without its + * client id and secret the adapter is off and never calls out. + */ +@Service +@Slf4j +public class IgdbApiService { + + /** + * @param hastilyMinutes to the credits; each value is null when IGDB has no data for it + */ + public record TimeToBeat(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {} + + // 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; + private static final long STEAM_SOURCE = 1; + private static final Duration TOKEN_RENEW_BEFORE = Duration.ofHours(1); + + private final RestClient igdbRestClient; + private final RestClient twitchRestClient; + private final String clientId; + private final String clientSecret; + // Per IGDB request, not per call: one call makes two requests per batch. + private final RateLimiter rateLimiter; + + private String accessToken; + private Instant accessTokenRenewAt = Instant.EPOCH; + + public IgdbApiService(@Qualifier("igdbRestClient") RestClient igdbRestClient, + @Qualifier("twitchRestClient") RestClient twitchRestClient, + @Value("${app.igdb.client-id:}") String clientId, + @Value("${app.igdb.client-secret:}") String clientSecret, + RateLimiterRegistry rateLimiterRegistry) { + this.igdbRestClient = igdbRestClient; + this.twitchRestClient = twitchRestClient; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.rateLimiter = rateLimiterRegistry.rateLimiter("igdbApi"); + } + + public boolean isEnabled() { + return clientId != null && !clientId.isBlank() && clientSecret != null && !clientSecret.isBlank(); + } + + /** + * @return the times of the apps IGDB knows, apps without data are missing; empty when the adapter is off or a + * request failed, so the caller does not store "unknown" for apps it could not ask about + */ + public Optional> getTimeToBeat(Collection steamAppids) { + if (!isEnabled() || steamAppids.isEmpty()) { + return Optional.empty(); + } + try { + List appids = List.copyOf(steamAppids); + Map result = new HashMap<>(); + for (int from = 0; from < appids.size(); from += BATCH) { + result.putAll(getBatch(appids.subList(from, Math.min(from + BATCH, appids.size())))); + } + return Optional.of(result); + } catch (Exception e) { + log.error("Error communicating with IGDB for time to beat", e); + return Optional.empty(); + } + } + + private Map getBatch(List steamAppids) { + String uids = steamAppids.stream().map(id -> "\"" + id + "\"").collect(Collectors.joining(",")); + IgdbResponses.ExternalGame[] externals = query("/external_games", + "fields game,uid; where external_game_source = " + STEAM_SOURCE + " & uid = (" + uids + "); limit " + LIMIT + ";", + IgdbResponses.ExternalGame[].class); + // Editions and re-releases: one IGDB game can have several Steam apps. + Map> steamByIgdb = new HashMap<>(); + for (IgdbResponses.ExternalGame external : externals) { + if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")) { + steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid())); + } + } + if (steamByIgdb.isEmpty()) { + return Map.of(); + } + + String gameIds = steamByIgdb.keySet().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); + // A Steam app linked to several IGDB games takes the time most players submitted. + Map result = new HashMap<>(); + Arrays.stream(times) + .filter(time -> steamByIgdb.containsKey(time.gameId())) + .sorted(Comparator.comparing((IgdbResponses.TimeToBeat time) -> time.count() != null ? time.count() : 0).reversed()) + .forEach(time -> steamByIgdb.get(time.gameId()).forEach(appid -> result.putIfAbsent(appid, + new TimeToBeat(minutes(time.hastily()), minutes(time.normally()), minutes(time.completely()))))); + return result; + } + + private T query(String path, String body, Class type) { + String token = token(); + RateLimiter.waitForPermission(rateLimiter); + T answer; + try { + answer = igdbRestClient.post() + .uri(path) + .header("Client-ID", clientId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.TEXT_PLAIN) + .accept(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(type); + } catch (HttpClientErrorException.Unauthorized e) { + // Revoked, e.g. after a secret reset. The next call asks for a new token. + dropToken(token); + throw e; + } + if (answer == null) { + throw new IllegalStateException("IGDB " + path + " answered without a body"); + } + return answer; + } + + private synchronized String token() { + if (accessToken == null || Instant.now().isAfter(accessTokenRenewAt)) { + IgdbResponses.Token token = twitchRestClient.post() + .uri(builder -> builder.path("/oauth2/token") + .queryParam("client_id", clientId) + .queryParam("client_secret", clientSecret) + .queryParam("grant_type", "client_credentials") + .build()) + .retrieve() + .body(IgdbResponses.Token.class); + if (token == null || token.accessToken() == null) { + throw new IllegalStateException("Twitch answered without an access token"); + } + long expiresIn = token.expiresIn() != null ? token.expiresIn() : 0; + accessToken = token.accessToken(); + accessTokenRenewAt = Instant.now().plusSeconds(expiresIn).minus(TOKEN_RENEW_BEFORE); + } + return accessToken; + } + + private synchronized void dropToken(String token) { + if (token.equals(accessToken)) { + accessToken = null; + } + } + + private static Integer minutes(Integer seconds) { + return seconds != null && seconds > 0 ? Math.round(seconds / 60f) : null; + } +} 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 11312fe..0c4e12c 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -69,7 +69,8 @@ public class LibraryController { @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "playtimeMinutes,desc") String sort, @RequestParam(required = false) GameStatus status, - @RequestParam(required = false) String q + @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)); @@ -81,7 +82,7 @@ public class LibraryController { int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safePage = Math.max(0, page); Sort safeSort = parseSort(sort); - var dtoPage = queryService.listOwnedGames(userId, status, q, PageRequest.of(safePage, safeSize, safeSort)); + var dtoPage = queryService.listOwnedGames(userId, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, PageRequest.of(safePage, safeSize, safeSort)); return ResponseEntity.ok(PageDto.of(dtoPage)); } 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 8476d0b..d2d37cb 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 @@ -24,5 +24,12 @@ public record GameDetailDto( int achievementsUnlocked, int achievementsRemaining, double completionPercent, - List achievements -) {} + List achievements, + TimeToBeatDto timeToBeat +) { + + /** + * IGDB's averages in minutes; each is null when unknown. + */ + public record TimeToBeatDto(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {} +} 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 359d7db..fbdbcb8 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 @@ -3,6 +3,9 @@ package core_service.modules.library.api.dto; import core_service.modules.library.domain.GamePriority; import core_service.modules.library.domain.GameStatus; +/** + * @param timeToBeatMinutes IGDB's normal time to beat, null when unknown + */ public record GameDto( Long appid, String name, @@ -10,5 +13,6 @@ public record GameDto( int playtimeMinutes, GameStatus status, Integer rating, - GamePriority priority + GamePriority priority, + Integer timeToBeatMinutes ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java b/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java index a0322f0..07b2ed3 100644 --- a/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java +++ b/src/main/java/core_service/modules/library/api/dto/LibrarySummaryDto.java @@ -7,10 +7,12 @@ import java.util.Map; /** * @param byStatus has every status, also those with 0 games * @param suggestion a random backlog game, null when the backlog is empty + * @param backlogMinutes normal time to beat of the backlog games IGDB knows; 0 when none or IGDB is off */ public record LibrarySummaryDto( long total, Map byStatus, long neverPlayed, - GameDto suggestion + GameDto suggestion, + long backlogMinutes ) {} diff --git a/src/main/java/core_service/modules/library/domain/GameTimeToBeat.java b/src/main/java/core_service/modules/library/domain/GameTimeToBeat.java new file mode 100644 index 0000000..9348c1f --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameTimeToBeat.java @@ -0,0 +1,39 @@ +package core_service.modules.library.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +/** + * IGDB's time to beat of one Steam app; every time is null when IGDB has no data for it. + */ +@Entity +@Table(name = "game_time_to_beat") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class GameTimeToBeat { + + @Id + @Column(name = "appid") + private Long appid; + + @Column(name = "hastily_minutes") + private Integer hastilyMinutes; + + @Column(name = "normally_minutes") + private Integer normallyMinutes; + + @Column(name = "completely_minutes") + private Integer completelyMinutes; + + @Column(name = "fetched_at", nullable = false) + private Instant fetchedAt; +} diff --git a/src/main/java/core_service/modules/library/repo/GameTimeToBeatRepository.java b/src/main/java/core_service/modules/library/repo/GameTimeToBeatRepository.java new file mode 100644 index 0000000..2e0df63 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameTimeToBeatRepository.java @@ -0,0 +1,33 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.GameTimeToBeat; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Collection; +import java.util.Set; + +public interface GameTimeToBeatRepository extends JpaRepository { + + @Query("select t.appid from GameTimeToBeat t where t.appid in :appids and t.fetchedAt > :since") + Set findFreshAppids(@Param("appids") Collection appids, @Param("since") Instant since); + + // An upsert, not save(): two users' syncs can store the same new app at the same time. + @Modifying + @Transactional + @Query(value = "insert into game_time_to_beat (appid, hastily_minutes, normally_minutes, completely_minutes, fetched_at) " + + "values (:appid, :hastily, :normally, :completely, :fetchedAt) " + + "on conflict (appid) do update set hastily_minutes = excluded.hastily_minutes, " + + "normally_minutes = excluded.normally_minutes, completely_minutes = excluded.completely_minutes, " + + "fetched_at = excluded.fetched_at", + nativeQuery = true) + void upsert(@Param("appid") Long appid, + @Param("hastily") Integer hastilyMinutes, + @Param("normally") Integer normallyMinutes, + @Param("completely") Integer completelyMinutes, + @Param("fetchedAt") Instant fetchedAt); +} 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 666686a..45bd2de 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -49,16 +49,28 @@ public interface UserGameRepository extends JpaRepository /** * @param status null for every status * @param namePattern a {@link #containsPattern} result, null for every name + * @param maxMinutes only games whose normal time to beat is known and at most this; null for every game */ @Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " + "where ug.userId = :userId and ug.removedAt is null " + "and (:status is null or ug.status = :status) " + - "and (:namePattern is null or lower(sg.name) like :namePattern escape '\\')") + "and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " + + "and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " + + " where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes))") Page findOwnedGames(@Param("userId") Long userId, @Param("status") GameStatus status, @Param("namePattern") String namePattern, + @Param("maxMinutes") Integer maxMinutes, Pageable pageable); + /** + * Normal time to beat of the user's backlog, only games IGDB has data for. + */ + @Query("select coalesce(sum(t.normallyMinutes), 0) from UserGame ug join GameTimeToBeat t on t.appid = ug.appid " + + "where ug.userId = :userId and ug.removedAt is null " + + "and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG") + long sumBacklogMinutes(@Param("userId") Long userId); + /** * Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last * in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and 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 5ad8cc8..a1efc4c 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -10,6 +10,7 @@ import core_service.modules.library.domain.UserGameAchievement; import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameId; import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; +import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameAchievementSchemaRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository; @@ -43,6 +44,7 @@ public class GameDetailService { private final UserGameAchievementRepository userAchievementRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository; private final SteamApiService steamApiService; + private final GameTimeToBeatRepository gameTimeToBeatRepository; @Value("${app.library.achievement-ttl-hours:1}") private long achievementTtlHours; @@ -121,7 +123,10 @@ public class GameDetailService { unlocked, remaining, Math.round(pct * 10.0) / 10.0, - achievements + achievements, + gameTimeToBeatRepository.findById(userGame.getAppid()) + .map(t -> new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(), t.getCompletelyMinutes())) + .orElse(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 dec0a3a..097e983 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -8,9 +8,11 @@ import core_service.modules.library.api.dto.RecentGameDto; import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.domain.DayMinutes; import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserSteamSync; +import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; @@ -47,6 +49,7 @@ public class LibraryQueryService { private final SteamGameRepository steamGameRepository; private final PlaytimeDayRepository playtimeDayRepository; private final WishlistGameRepository wishlistGameRepository; + private final GameTimeToBeatRepository gameTimeToBeatRepository; @Value("${app.library.zone:Europe/Berlin}") private ZoneId zone; @@ -55,15 +58,18 @@ public class LibraryQueryService { return userGameRepository.countByUserIdAndRemovedAtIsNull(userId); } - public Page listOwnedGames(Long userId, GameStatus status, String nameQuery, Pageable pageable) { + public Page listOwnedGames(Long userId, GameStatus status, String nameQuery, Integer maxMinutes, + Pageable pageable) { Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), UserGameRepository.querySort(pageable.getSort())); Page page = userGameRepository.findOwnedGames( - userId, status, UserGameRepository.containsPattern(nameQuery), stable); + userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable); List appids = page.getContent().stream().map(UserGame::getAppid).toList(); Map meta = steamGameRepository.findAllById(appids).stream() .collect(Collectors.toMap(SteamGame::getAppid, g -> g)); - return page.map(ug -> toDto(ug, meta.get(ug.getAppid()))); + Map times = gameTimeToBeatRepository.findAllById(appids).stream() + .collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t)); + return page.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()))); } /** @@ -74,9 +80,10 @@ public class LibraryQueryService { long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0); GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : -1) - .map(ug -> toDto(ug, steamGameRepository.findById(ug.getAppid()).orElse(null))) + .map(ug -> toDto(ug, steamGameRepository.findById(ug.getAppid()).orElse(null), + gameTimeToBeatRepository.findById(ug.getAppid()).orElse(null))) .orElse(null); - return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion); + return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion, userGameRepository.sumBacklogMinutes(userId)); } /** @@ -148,7 +155,7 @@ public class LibraryQueryService { .toList()); } - private static GameDto toDto(UserGame ug, SteamGame meta) { + private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time) { return new GameDto( ug.getAppid(), meta != null ? meta.getName() : null, @@ -156,7 +163,8 @@ public class LibraryQueryService { ug.getPlaytimeMinutes(), ug.getStatus(), ug.getRating(), - ug.getPriority()); + ug.getPriority(), + time != null ? time.getNormallyMinutes() : 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 c5616bd..8d45e56 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -47,6 +47,7 @@ public class LibrarySyncWorker { private final UserSteamSyncRepository userSteamSyncRepository; private final PlaytimeDayRepository playtimeDayRepository; private final WishlistGameRepository wishlistGameRepository; + private final TimeToBeatService timeToBeatService; @Value("${app.library.running-stale-minutes:5}") private long runningStaleMinutes; @@ -113,6 +114,7 @@ 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()); finalStatus = Status.OK; } catch (Exception e) { log.error("Sync failed for user {}", userId, e); diff --git a/src/main/java/core_service/modules/library/service/TimeToBeatService.java b/src/main/java/core_service/modules/library/service/TimeToBeatService.java new file mode 100644 index 0000000..14fa522 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/TimeToBeatService.java @@ -0,0 +1,55 @@ +package core_service.modules.library.service; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +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.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TimeToBeatService { + + private final IgdbApiService igdbApiService; + private final GameTimeToBeatRepository gameTimeToBeatRepository; + + @Value("${app.library.time-to-beat-ttl-days:30}") + private long ttlDays; + + /** + * 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. + */ + public void refresh(Collection appids) { + if (!igdbApiService.isEnabled() || appids.isEmpty()) return; + // Sorted: parallel syncs then lock the upserted rows in the same order and cannot deadlock. + Set stale = new TreeSet<>(appids); + 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; + } + Instant now = Instant.now(); + for (Long appid : stale) { + IgdbApiService.TimeToBeat time = answer.get().get(appid); + if (time == null) { + gameTimeToBeatRepository.upsert(appid, null, null, null, now); + } else { + gameTimeToBeatRepository.upsert(appid, time.hastilyMinutes(), time.normallyMinutes(), time.completelyMinutes(), now); + } + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 520a286..4082101 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -43,6 +43,12 @@ app: api-key: ${STEAM_API_KEY} base-url: https://api.steampowered.com imageService-url: https://cdn.cloudflare.steamstatic.com/steam/apps/ + igdb: + # A Twitch developer app (dev.twitch.tv). Without both values time to beat is off. + client-id: ${IGDB_CLIENT_ID:} + client-secret: ${IGDB_CLIENT_SECRET:} + base-url: https://api.igdb.com/v4 + token-base-url: https://id.twitch.tv library: sync-ttl-hours: 24 force-sync-cooldown-seconds: 60 @@ -67,4 +73,9 @@ resilience4j: # Steam allows ~100k req/day. Cap at ~2 req/s to leave headroom for bursts. limit-for-period: 2 limit-refresh-period: 1s - timeout-duration: 5s \ No newline at end of file + timeout-duration: 5s + igdbApi: + # IGDB allows 4 requests/s. Taken per HTTP request. + limit-for-period: 4 + limit-refresh-period: 1s + timeout-duration: 10s \ No newline at end of file diff --git a/src/main/resources/db/migration/V6__game_time_to_beat.sql b/src/main/resources/db/migration/V6__game_time_to_beat.sql new file mode 100644 index 0000000..2a8035b --- /dev/null +++ b/src/main/resources/db/migration/V6__game_time_to_beat.sql @@ -0,0 +1,9 @@ +-- Time to beat per Steam app from IGDB, shared by all users. A row with only NULL times means IGDB has no data; +-- it is kept so the app is not asked again before the row is stale. +CREATE TABLE game_time_to_beat ( + appid BIGINT PRIMARY KEY, + hastily_minutes INTEGER, + normally_minutes INTEGER, + completely_minutes INTEGER, + fetched_at TIMESTAMPTZ NOT NULL +); diff --git a/src/test/java/core_service/architecture/ArchitectureTest.java b/src/test/java/core_service/architecture/ArchitectureTest.java index 01ae4ab..e52a464 100644 --- a/src/test/java/core_service/architecture/ArchitectureTest.java +++ b/src/test/java/core_service/architecture/ArchitectureTest.java @@ -33,8 +33,8 @@ class ArchitectureTest extends AbstractArchitectureTest { .should().notDependOnEachOther() .ignoreDependency( JavaClass.Predicates.resideInAPackage("core_service.modules.library.."), - JavaClass.Predicates.resideInAPackage("core_service.modules.steam_adapter..")) - .because("Feature modules should be isolated; steam_adapter is an explicit shared port."); + JavaClass.Predicates.resideInAnyPackage("core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter..")) + .because("Feature modules should be isolated; steam_adapter and igdb_adapter are explicit shared ports."); @ArchTest static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices() 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 new file mode 100644 index 0000000..ee541e7 --- /dev/null +++ b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java @@ -0,0 +1,178 @@ +package core_service.modules.igdb_adapter.service; + +import core_service.shared.test.AbstractUnitTest; +import io.github.resilience4j.ratelimiter.RateLimiterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.LongStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.CoreMatchers.allOf; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.not; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.ExpectedCount.times; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withUnauthorizedRequest; + +class IgdbApiServiceTest extends AbstractUnitTest { + + private static final String TOKEN = "{\"access_token\": \"token-1\", \"expires_in\": 5000000, \"token_type\": \"bearer\"}"; + + private MockRestServiceServer twitch; + private MockRestServiceServer igdb; + private IgdbApiService service; + + private IgdbApiService create(String clientId, String clientSecret) { + RestClient.Builder twitchBuilder = RestClient.builder(); + RestClient.Builder igdbBuilder = RestClient.builder(); + twitch = MockRestServiceServer.bindTo(twitchBuilder).build(); + igdb = MockRestServiceServer.bindTo(igdbBuilder).build(); + return new IgdbApiService(igdbBuilder.build(), twitchBuilder.build(), clientId, clientSecret, RateLimiterRegistry.ofDefaults()); + } + + @BeforeEach + void setUp() { + service = create("client-1", "secret-1"); + } + + private void expectToken() { + twitch.expect(once(), requestTo(containsString("/oauth2/token"))) + .andExpect(method(HttpMethod.POST)) + .andExpect(queryParam("client_id", "client-1")) + .andExpect(queryParam("client_secret", "secret-1")) + .andExpect(queryParam("grant_type", "client_credentials")) + .andRespond(withSuccess(TOKEN, MediaType.APPLICATION_JSON)); + } + + @Test + void isEnabled_OnlyWithIdAndSecret() { + assertThat(service.isEnabled()).isTrue(); + assertThat(create("", "secret").isEnabled()).isFalse(); + assertThat(create("id", null).isEnabled()).isFalse(); + } + + @Test + void getTimeToBeat_MapsSteamAppsToIgdbTimesInMinutes() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))) + .andExpect(header("Client-ID", "client-1")) + .andExpect(header("Authorization", "Bearer token-1")) + .andExpect(content().string(allOf(containsString("external_game_source = 1"), containsString("uid = (\"620\",\"220\")")))) + .andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"220\"}]", + MediaType.APPLICATION_JSON)); + igdb.expect(requestTo(containsString("/game_time_to_beats"))) + .andExpect(content().string(containsString("game_id = (72,233)"))) + .andRespond(withSuccess("[{\"id\": 9, \"game_id\": 72, \"hastily\": 30600, \"normally\": 45000, \"completely\": 81000, \"count\": 12}]", + MediaType.APPLICATION_JSON)); + + Optional> result = service.getTimeToBeat(List.of(620L, 220L)); + + assertThat(result).isPresent(); + assertThat(result.get()).containsOnlyKeys(620L); + assertThat(result.get().get(620L)).isEqualTo(new IgdbApiService.TimeToBeat(510, 750, 1350)); + } + + @Test + void getTimeToBeat_ReusesTheToken() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON)); + igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON)); + + service.getTimeToBeat(List.of(620L)); + service.getTimeToBeat(List.of(220L)); + + twitch.verify(); + } + + @Test + void getTimeToBeat_AsksIn250erBatches() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))) + .andExpect(content().string(allOf(containsString("\"250\""), not(containsString("\"251\"")), containsString("limit 500")))) + .andRespond(withSuccess("[]", MediaType.APPLICATION_JSON)); + igdb.expect(requestTo(containsString("/external_games"))) + .andExpect(content().string(allOf(containsString("\"251\""), not(containsString("\"250\""))))) + .andRespond(withSuccess("[]", MediaType.APPLICATION_JSON)); + + assertThat(service.getTimeToBeat(LongStream.rangeClosed(1, 251).boxed().toList())).contains(Map.of()); + + igdb.verify(); + } + + @Test + void getTimeToBeat_Error_IsEmptySoNothingIsStoredAsUnknown() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError()); + + assertThat(service.getTimeToBeat(List.of(620L))).isEmpty(); + } + + @Test + void getTimeToBeat_401_RequestsANewTokenOnTheNextCall() { + twitch.expect(times(2), requestTo(containsString("/oauth2/token"))) + .andRespond(withSuccess(TOKEN, MediaType.APPLICATION_JSON)); + igdb.expect(requestTo(containsString("/external_games"))) + .andRespond(withUnauthorizedRequest()); + igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON)); + + assertThat(service.getTimeToBeat(List.of(620L))).isEmpty(); + assertThat(service.getTimeToBeat(List.of(620L))).contains(Map.of()); + + twitch.verify(); + } + + @Test + void getTimeToBeat_OneIgdbGameWithTwoSteamApps_TimesBothApps() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))) + .andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 72, \"uid\": \"221\"}]", + MediaType.APPLICATION_JSON)); + igdb.expect(requestTo(containsString("/game_time_to_beats"))) + .andRespond(withSuccess("[{\"id\": 9, \"game_id\": 72, \"hastily\": 600, \"normally\": 1200, \"completely\": 1800}]", + MediaType.APPLICATION_JSON)); + + Optional> result = service.getTimeToBeat(List.of(620L, 221L)); + + assertThat(result).isPresent(); + assertThat(result.get()).containsOnlyKeys(620L, 221L); + } + + @Test + void getTimeToBeat_OneSteamAppWithTwoIgdbGames_TakesTheTimeMostPlayersSubmitted() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))) + .andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 73, \"uid\": \"620\"}]", + MediaType.APPLICATION_JSON)); + igdb.expect(requestTo(containsString("/game_time_to_beats"))) + .andExpect(content().string(containsString("count"))) + .andRespond(withSuccess("[{\"game_id\": 72, \"normally\": 600, \"count\": 2}, {\"game_id\": 73, \"normally\": 1200, \"count\": 40}]", + MediaType.APPLICATION_JSON)); + + assertThat(service.getTimeToBeat(List.of(620L)).orElseThrow().get(620L).normallyMinutes()).isEqualTo(20); + } + + @Test + void getTimeToBeat_Disabled_CallsNothing() { + IgdbApiService disabled = create("", ""); + + assertThat(disabled.getTimeToBeat(List.of(620L))).isEmpty(); + + twitch.verify(); + igdb.verify(); + } +} diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java index 234814e..0b6c67e 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerSummaryTest.java @@ -1,8 +1,10 @@ package core_service.modules.library.api; import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.shared.test.AbstractIntegrationTest; @@ -37,6 +39,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest { @Autowired private UserGameRepository userGameRepository; + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; + @BeforeEach void clean() { userGameRepository.deleteAll(); @@ -50,6 +55,7 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.neverPlayed").value(0)) .andExpect(jsonPath("$.byStatus.BACKLOG").value(0)) .andExpect(jsonPath("$.byStatus.DROPPED").value(0)) + .andExpect(jsonPath("$.backlogMinutes").value(0)) .andExpect(jsonPath("$.suggestion").value(nullValue())); } @@ -61,6 +67,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest { UserGame finished = new UserGame(USER_ID, 620L, 600, Instant.now(), Instant.now()); finished.setStatus(GameStatus.FINISHED); userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), finished)); + gameTimeToBeatRepository.saveAll(List.of( + new GameTimeToBeat(220L, 500, 700, 900, Instant.now()), + new GameTimeToBeat(620L, 1, 9999, 1, Instant.now()))); mockMvc.perform(get("/api/library/summary")) .andExpect(status().isOk()) @@ -71,7 +80,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.byStatus.PLAYING").value(0)) .andExpect(jsonPath("$.suggestion.appid").value(220)) .andExpect(jsonPath("$.suggestion.name").value("Half-Life 2")) - .andExpect(jsonPath("$.suggestion.status").value("BACKLOG")); + .andExpect(jsonPath("$.suggestion.status").value("BACKLOG")) + .andExpect(jsonPath("$.suggestion.timeToBeatMinutes").value(700)) + .andExpect(jsonPath("$.backlogMinutes").value(700)); } @Test 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 5663464..800539b 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.GameTimeToBeat; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.SteamGame; @@ -37,6 +38,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { @Autowired private SteamGameRepository steamGameRepository; + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; + @Autowired private PlatformTransactionManager transactionManager; @@ -56,7 +60,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { } private List appids(GameStatus status, String query) { - return userGameRepository.findOwnedGames(USER_ID, status, UserGameRepository.containsPattern(query), PAGE) + return userGameRepository.findOwnedGames(USER_ID, status, UserGameRepository.containsPattern(query), null, PAGE) .map(UserGame::getAppid).getContent(); } @@ -89,7 +93,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { @Test void findOwnedGames_CountsAllMatchesAcrossPages() { - var page = userGameRepository.findOwnedGames(USER_ID, null, UserGameRepository.containsPattern("half"), + var page = userGameRepository.findOwnedGames(USER_ID, null, UserGameRepository.containsPattern("half"), null, PageRequest.of(0, 1, Sort.by("appid"))); assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent()); @@ -97,7 +101,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { } private List sortedAppids(Sort sort) { - return userGameRepository.findOwnedGames(USER_ID, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort))) + return userGameRepository.findOwnedGames(USER_ID, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort))) .map(UserGame::getAppid).getContent(); } @@ -141,6 +145,28 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { userGameRepository.flush(); } + @Test + void findOwnedGames_MaxMinutes_KeepsOnlyGamesWithAKnownShortTime() { + gameTimeToBeatRepository.saveAll(List.of( + new GameTimeToBeat(220L, 500, 700, 1000, Instant.now()), + new GameTimeToBeat(620L, 300, 480, 900, Instant.now()), + new GameTimeToBeat(380L, null, null, null, Instant.now()))); + + List shortGames = userGameRepository.findOwnedGames(USER_ID, null, null, 600, PAGE).map(UserGame::getAppid).getContent(); + + assertEquals(List.of(620L), shortGames); + } + + @Test + void sumBacklogMinutes_AddsTheNormalTimeOfBacklogGamesWithData() { + gameTimeToBeatRepository.saveAll(List.of( + new GameTimeToBeat(380L, 100, 200, 300, Instant.now()), + new GameTimeToBeat(700L, null, 150, null, Instant.now()), + new GameTimeToBeat(620L, 1, 9999, 1, Instant.now()))); + + assertEquals(350, userGameRepository.sumBacklogMinutes(USER_ID)); + } + @Test void containsPattern_BlankIsNoFilter() { assertNull(UserGameRepository.containsPattern(" ")); 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 997c914..138e026 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -96,6 +96,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { @Mock private WishlistGameRepository wishlistGameRepository; + @Mock + private TimeToBeatService timeToBeatService; + @InjectMocks private LibrarySyncWorker worker; @@ -351,4 +354,13 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { verify(wishlistGameRepository).deleteByUserId(USER_ID); } + + @Test + void runSync_RefreshesTheTimeToBeatOfTheOwnedGames() { + steamReturns(game(0, 0), STEAM_ID); + + worker.runSync(USER_ID, STEAM_ID); + + verify(timeToBeatService).refresh(List.of(220L)); + } } diff --git a/src/test/java/core_service/modules/library/service/TimeToBeatServiceConcurrencyTest.java b/src/test/java/core_service/modules/library/service/TimeToBeatServiceConcurrencyTest.java new file mode 100644 index 0000000..f787d18 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/TimeToBeatServiceConcurrencyTest.java @@ -0,0 +1,92 @@ +package core_service.modules.library.service; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.repo.GameTimeToBeatRepository; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Two users' syncs run in parallel and can miss the same app, e.g. every app right after the first deploy. + */ +class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest { + + @Autowired + private TimeToBeatService timeToBeatService; + + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @MockitoBean + private IgdbApiService igdbApiService; + + // Before as well: other integration tests leave rows in this shared table, and a fresh row would hide the race. + @BeforeEach + @AfterEach + void clean() { + gameTimeToBeatRepository.deleteAll(); + } + + @Test + void refresh_TwoParallelSyncsOfTheSameNewApp_BothCommit() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.of(Map.of( + 620L, new IgdbApiService.TimeToBeat(510, 750, 1350)))); + CountDownLatch firstWrote = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + + CompletableFuture first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager) + .executeWithoutResult(tx -> { + timeToBeatService.refresh(List.of(620L)); + firstWrote.countDown(); + await(secondStarted); + // Keep the transaction open while the second sync writes the same row. + sleep(); + })); + CompletableFuture second = CompletableFuture.runAsync(() -> { + await(firstWrote); + secondStarted.countDown(); + new TransactionTemplate(transactionManager).executeWithoutResult(tx -> timeToBeatService.refresh(List.of(620L))); + }); + + assertDoesNotThrow(() -> CompletableFuture.allOf(first, second).get(30, TimeUnit.SECONDS)); + assertEquals(750, gameTimeToBeatRepository.findById(620L).orElseThrow().getNormallyMinutes()); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static void sleep() { + try { + Thread.sleep(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java b/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java new file mode 100644 index 0000000..bc09065 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/TimeToBeatServiceTest.java @@ -0,0 +1,85 @@ +package core_service.modules.library.service; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.repo.GameTimeToBeatRepository; +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TimeToBeatServiceTest extends AbstractUnitTest { + + @Mock + private IgdbApiService igdbApiService; + + @Mock + private GameTimeToBeatRepository gameTimeToBeatRepository; + + @InjectMocks + private TimeToBeatService service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "ttlDays", 30L); + } + + @Test + void refresh_AsksOnlyForMissingOrOldAppsAndStoresUnknownOnesToo() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L)); + when(igdbApiService.getTimeToBeat(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of( + 620L, new IgdbApiService.TimeToBeat(510, 750, 1350)))); + + service.refresh(List.of(220L, 620L, 700L)); + + verify(gameTimeToBeatRepository).upsert(eq(620L), eq(510), eq(750), eq(1350), any(Instant.class)); + verify(gameTimeToBeatRepository).upsert(eq(700L), isNull(), isNull(), isNull(), any(Instant.class)); + verify(gameTimeToBeatRepository, never()).upsert(eq(220L), any(), any(), any(), any()); + } + + @Test + void refresh_IgdbError_StoresNothing() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of()); + when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.empty()); + + service.refresh(List.of(620L)); + + verify(gameTimeToBeatRepository, never()).upsert(any(), any(), any(), any(), any()); + } + + @Test + void refresh_Disabled_DoesNotEvenReadTheCache() { + when(igdbApiService.isEnabled()).thenReturn(false); + + service.refresh(List.of(620L)); + + verify(gameTimeToBeatRepository, never()).findFreshAppids(anyCollection(), any(Instant.class)); + } + + @Test + void refresh_AllFresh_AsksIgdbNothing() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(620L)); + + service.refresh(List.of(620L)); + + verify(igdbApiService, never()).getTimeToBeat(any()); + } +}