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 0860334..e06ef6e 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 @@ -44,6 +44,17 @@ public final class IgdbResponses { @JsonProperty("total_rating_count") Integer totalRatingCount ) {} + @JsonIgnoreProperties(ignoreUnknown = true) + public record GameTags( + @JsonProperty("id") Long id, + @JsonProperty("genres") java.util.List genres, + @JsonProperty("themes") java.util.List themes, + @JsonProperty("game_modes") java.util.List gameModes + ) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Named(@JsonProperty("name") String name) {} + @JsonIgnoreProperties(ignoreUnknown = true) public record Image(@JsonProperty("image_id") String imageId) {} 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 be5f4ce..f2d36c8 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 @@ -20,6 +20,7 @@ import java.util.Arrays; import java.util.Comparator; import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -39,6 +40,11 @@ public class IgdbApiService { */ public record TimeToBeat(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {} + /** + * IGDB's genre, theme and game mode names of one game, e.g. "Role-playing (RPG)", "Horror", "Co-operative". + */ + public record GameTags(List genres, List themes, List modes) {} + /** * One IGDB game as the library needs it. * @@ -115,6 +121,58 @@ public class IgdbApiService { } } + /** + * @return the tags of the Steam apps IGDB links to a game, empty lists when that game has no tags; apps IGDB does + * not link are missing. An app linked to several games (say a remaster) gets the tags of all of them. Empty when + * the adapter is off or a request failed. + */ + public Optional> getGameTags(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) { + Map> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size()))); + tagsOf(steamByIgdb.keySet()).forEach((igdbId, tags) -> + steamByIgdb.get(igdbId).forEach(appid -> result.merge(appid, tags, IgdbApiService::union))); + } + return Optional.of(result); + } catch (Exception e) { + log.error("Error communicating with IGDB for game tags", e); + return Optional.empty(); + } + } + + private static GameTags union(GameTags a, GameTags b) { + return new GameTags(union(a.genres(), b.genres()), union(a.themes(), b.themes()), union(a.modes(), b.modes())); + } + + private static List union(List a, List b) { + return List.copyOf(new LinkedHashSet<>(Stream.concat(a.stream(), b.stream()).toList())); + } + + /** + * Like {@link #getGameTags}, but for IGDB game ids. + */ + public Optional> getGameTagsByGameIds(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) { + result.putAll(tagsOf(ids.subList(from, Math.min(from + BATCH, ids.size())))); + } + return Optional.of(result); + } catch (Exception e) { + log.error("Error communicating with IGDB for game tags", e); + return Optional.empty(); + } + } + /** * Games whose name matches, without editions and other versions. Empty when the adapter is off or IGDB failed. */ @@ -240,18 +298,47 @@ public class IgdbApiService { return text.replaceAll("[\"\\\\;\\p{Cntrl}]", " ").trim(); } - private Map getBatch(List steamAppids) { + private Map tagsOf(Collection igdbIds) { + if (igdbIds.isEmpty()) { + return Map.of(); + } + String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(",")); + IgdbResponses.GameTags[] games = query("/games", + "fields genres.name,themes.name,game_modes.name; where id = (" + gameIds + "); limit " + LIMIT + ";", + IgdbResponses.GameTags[].class); + Map result = new HashMap<>(); + for (IgdbResponses.GameTags game : games) { + if (game.id() != null) { + result.put(game.id(), new GameTags(names(game.genres()), names(game.themes()), names(game.gameModes()))); + } + } + return result; + } + + private static List names(List named) { + return named == null ? List.of() : named.stream() + .map(IgdbResponses.Named::name) + .filter(name -> name != null && !name.isBlank()) + .toList(); + } + + // Editions and re-releases: one IGDB game can have several Steam apps. + private Map> steamAppidsByIgdbGame(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())); } } + return steamByIgdb; + } + + private Map getBatch(List steamAppids) { + Map> steamByIgdb = steamAppidsByIgdbGame(steamAppids); if (steamByIgdb.isEmpty()) { return Map.of(); } 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 8c0cb0d..e9599c2 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -12,10 +12,12 @@ import core_service.modules.library.api.dto.SteamProfileDto; import core_service.modules.library.api.dto.SyncStatusDto; import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.Mood; import core_service.modules.library.service.BacklogService; import core_service.modules.library.service.GameDetailService; import core_service.modules.library.service.LibraryQueryService; import core_service.modules.library.service.LibrarySyncService; +import core_service.modules.library.service.MoodService; import core_service.modules.library.service.SteamProfileService; import core_service.modules.library.service.SyncCooldownException; import core_service.shared.security.UserPrincipal; @@ -59,6 +61,7 @@ public class LibraryController { private final GameDetailService gameDetailService; private final BacklogService backlogService; private final SteamProfileService steamProfileService; + private final MoodService moodService; @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { @@ -178,6 +181,7 @@ public class LibraryController { @GetMapping("/picker") public ResponseEntity getPicker(@AuthenticationPrincipal UserPrincipal principal, @RequestParam(required = false) Integer maxMinutes, + @RequestParam(required = false) Mood mood, @RequestParam(required = false) List exclude, @RequestParam(defaultValue = "3") int count) { List excluded = exclude == null ? List.of() @@ -188,7 +192,10 @@ public class LibraryController { if (principal.steamId() != null) { syncService.syncIfStale(userId, String.valueOf(principal.steamId())); } - return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, excluded, safeCount)); + if (mood != null) { + moodService.refreshBacklogInBackground(userId); + } + return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount)); } @GetMapping("/sync-status") diff --git a/src/main/java/core_service/modules/library/domain/GameMoods.java b/src/main/java/core_service/modules/library/domain/GameMoods.java new file mode 100644 index 0000000..a0f6f92 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameMoods.java @@ -0,0 +1,35 @@ +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.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.List; + +/** + * The moods of one game, see {@link Mood}. + */ +@Entity +@Table(name = "game_mood") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GameMoods { + + @Id + @Column(name = "appid") + private Long appid; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "moods", nullable = false) + private List moods; + + @Column(name = "checked_at", nullable = false) + private Instant checkedAt; +} diff --git a/src/main/java/core_service/modules/library/domain/Mood.java b/src/main/java/core_service/modules/library/domain/Mood.java new file mode 100644 index 0000000..7c13727 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/Mood.java @@ -0,0 +1,48 @@ +package core_service.modules.library.domain; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * What the user feels like playing, for the picker. Derived from IGDB's genre, theme and game mode names; a game can + * have several moods or none. + */ +public enum Mood { + STORY, COOP, CHILL, ACTION, SCARY; + + private static final Set STORY_GENRES = Set.of("role-playing (rpg)", "visual novel"); + private static final Set STORY_THEMES = Set.of("drama"); + private static final Set COOP_MODES = Set.of("co-operative", "split screen"); + private static final Set CHILL_GENRES = Set.of("puzzle", "simulator", "card & board game", "point-and-click"); + private static final Set CHILL_THEMES = Set.of("sandbox"); + private static final Set ACTION_GENRES = Set.of("shooter", "fighting", "hack and slash/beat 'em up", "platform"); + private static final Set ACTION_THEMES = Set.of("action"); + private static final Set SCARY_THEMES = Set.of("horror"); + + public static Set of(Collection genres, Collection themes, Collection modes) { + Set genre = lowerCase(genres); + Set theme = lowerCase(themes); + Set mode = lowerCase(modes); + Set moods = EnumSet.noneOf(Mood.class); + // IGDB puts "Adventure" on most action games too (GTA V, Uncharted); alone it means a story game. + boolean storyAdventure = genre.contains("adventure") && !anyIn(genre, ACTION_GENRES); + if (storyAdventure || anyIn(genre, STORY_GENRES) || anyIn(theme, STORY_THEMES)) moods.add(STORY); + if (anyIn(mode, COOP_MODES)) moods.add(COOP); + // A horror puzzle game is no way to relax. + if ((anyIn(genre, CHILL_GENRES) || anyIn(theme, CHILL_THEMES)) && !theme.contains("horror")) moods.add(CHILL); + if (anyIn(genre, ACTION_GENRES) || anyIn(theme, ACTION_THEMES)) moods.add(ACTION); + if (anyIn(theme, SCARY_THEMES)) moods.add(SCARY); + return moods; + } + + private static Set lowerCase(Collection names) { + return names.stream().map(name -> name.trim().toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); + } + + private static boolean anyIn(Set names, Set wanted) { + return names.stream().anyMatch(wanted::contains); + } +} diff --git a/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java b/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java new file mode 100644 index 0000000..13f3a78 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java @@ -0,0 +1,29 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.GameMoods; +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 GameMoodsRepository extends JpaRepository { + + @Query("select m.appid from GameMoods m where m.appid in :appids and m.checkedAt > :since") + Set findFreshAppids(@Param("appids") Collection appids, @Param("since") Instant since); + + /** + * @param moods {@link core_service.modules.library.domain.Mood} names; empty when none fits + */ + // An upsert, not save(): two users' syncs can store the same new game at the same time. + @Modifying + @Transactional + @Query(value = "insert into game_mood (appid, moods, checked_at) values (:appid, cast(:moods as varchar(16)[]), :checkedAt) " + + "on conflict (appid) do update set moods = excluded.moods, checked_at = excluded.checked_at", + nativeQuery = true) + void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("checkedAt") Instant checkedAt); +} 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 e287b52..b8fa363 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -2,6 +2,7 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; @@ -37,6 +38,10 @@ public interface UserGameRepository extends JpaRepository "and ug.source = core_service.modules.library.domain.GameSource.MANUAL") List findActiveManualAppids(@Param("userId") Long userId); + @Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null " + + "and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG") + List findBacklogAppids(@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") @@ -81,23 +86,27 @@ public interface UserGameRepository extends JpaRepository * Random backlog games, HIGH priority first, then MEDIUM, then the rest. * * @param maxMinutes only games whose normal time to beat is known and at most this; null for every game + * @param mood only games known to have this mood; null for every game * @param excludeAppids never picked; may be empty */ - default List findPickerGames(Long userId, Integer maxMinutes, Collection excludeAppids, int count) { + default List findPickerGames(Long userId, Integer maxMinutes, Mood mood, Collection excludeAppids, int count) { List excluded = new ArrayList<>(excludeAppids); // "not in ()" is invalid SQL excluded.add(NO_GAME); - return queryPickerGames(userId, maxMinutes, excluded, count); + return queryPickerGames(userId, maxMinutes, mood == null ? null : mood.name(), excluded, count); } @Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " + "and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " + "and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " + " where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " + + "and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " + + " where m.appid = ug.appid and cast(:mood as varchar) = any(m.moods))) " + "order by case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end, random() " + "limit :count", nativeQuery = true) List queryPickerGames(@Param("userId") Long userId, @Param("maxMinutes") Integer maxMinutes, + @Param("mood") String mood, @Param("excludeAppids") Collection excludeAppids, @Param("count") int count); 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 9a7fe78..d8ae2ed 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -43,6 +43,7 @@ public class GameDetailService { private final AchievementSyncService achievementSyncService; private final GameTimeToBeatRepository gameTimeToBeatRepository; private final TimeToBeatService timeToBeatService; + private final MoodService moodService; @Value("${app.library.achievement-ttl-hours:1}") private long achievementTtlHours; @@ -63,6 +64,7 @@ public class GameDetailService { 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)); + moodService.refreshInBackground(List.of(appid)); return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of())); } 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 0b76773..ca87a15 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -14,6 +14,7 @@ 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.Mood; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievementMeta; @@ -81,8 +82,8 @@ public class LibraryQueryService { return new PageImpl<>(games, page.getPageable(), page.getTotalElements()); } - public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, List excludeAppids, int count) { - return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, excludeAppids, count))); + public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List excludeAppids, int count) { + return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count))); } /** 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 d04a82b..47d86f1 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -51,6 +51,7 @@ public class LibrarySyncWorker { private final PlaytimeDayRepository playtimeDayRepository; private final WishlistGameRepository wishlistGameRepository; private final TimeToBeatService timeToBeatService; + private final MoodService moodService; private final ApplicationEventPublisher eventPublisher; @Value("${app.library.running-stale-minutes:5}") @@ -119,9 +120,10 @@ public class LibrarySyncWorker { } userSteamSyncRepository.markHistoryBaseline(userId, steamId); // 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); + List igdbAppids = new ArrayList<>(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList()); + igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId)); + timeToBeatService.refresh(igdbAppids); + moodService.refresh(igdbAppids); eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId)); finalStatus = Status.OK; } catch (Exception e) { diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index 5893dfb..64abf56 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -54,15 +54,17 @@ public class ManualGameService { private final UserGameRepository userGameRepository; private final SteamGameRepository steamGameRepository; private final TimeToBeatService timeToBeatService; + private final MoodService moodService; private final TransactionTemplate transaction; public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository, SteamGameRepository steamGameRepository, TimeToBeatService timeToBeatService, - PlatformTransactionManager transactionManager) { + MoodService moodService, PlatformTransactionManager transactionManager) { this.igdbApiService = igdbApiService; this.userGameRepository = userGameRepository; this.steamGameRepository = steamGameRepository; this.timeToBeatService = timeToBeatService; + this.moodService = moodService; this.transaction = new TransactionTemplate(transactionManager); } @@ -141,6 +143,7 @@ public class ManualGameService { } if (result instanceof AddResult.Added) { timeToBeatService.refreshInBackground(List.of(appid)); + moodService.refreshInBackground(List.of(appid)); } return result; } diff --git a/src/main/java/core_service/modules/library/service/MoodService.java b/src/main/java/core_service/modules/library/service/MoodService.java new file mode 100644 index 0000000..42a53fc --- /dev/null +++ b/src/main/java/core_service/modules/library/service/MoodService.java @@ -0,0 +1,102 @@ +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.domain.Mood; +import core_service.modules.library.repo.GameMoodsRepository; +import core_service.modules.library.repo.UserGameRepository; +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.Set; +import java.util.TreeSet; + +/** + * Stores the {@link Mood}s of games from IGDB's genres, themes and game modes, the same way + * {@link TimeToBeatService} stores the time to beat: only for games without a fresh row, and nothing when IGDB fails. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class MoodService { + + private final IgdbApiService igdbApiService; + private final GameMoodsRepository gameMoodsRepository; + private final UserGameRepository userGameRepository; + + @Value("${app.library.mood-ttl-days:30}") + private long ttlDays; + + 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(gameMoodsRepository.findFreshAppids(appids, Instant.now().minus(Duration.ofDays(ttlDays)))); + if (stale.isEmpty()) 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 tags = new HashMap<>(); + Set answered = new TreeSet<>(); + if (!steamAppids.isEmpty()) { + igdbApiService.getGameTags(steamAppids).ifPresent(found -> { + tags.putAll(found); + answered.addAll(steamAppids); + }); + } + if (!igdbIds.isEmpty()) { + igdbApiService.getGameTagsByGameIds(igdbIds).ifPresent(found -> { + found.forEach((igdbId, gameTags) -> tags.put(GameKeys.ofIgdb(igdbId), gameTags)); + igdbIds.forEach(igdbId -> answered.add(GameKeys.ofIgdb(igdbId))); + }); + } + if (answered.size() < stale.size()) { + log.warn("Moods of {} games not refreshed, IGDB did not answer", stale.size() - answered.size()); + } + Instant now = Instant.now(); + for (Long appid : answered) { + IgdbApiService.GameTags gameTags = tags.get(appid); + Set moods = gameTags == null ? Set.of() : Mood.of(gameTags.genres(), gameTags.themes(), gameTags.modes()); + gameMoodsRepository.upsert(appid, moods.stream().map(Mood::name).sorted().toArray(String[]::new), now); + } + } + + /** + * 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("Moods of {} not refreshed: {}", appids, e.getMessage()); + } + } + + /** + * For the picker: a user without a Steam sync gets moods only here, and a Steam user before the next sync. + */ + @Async + public void refreshBacklogInBackground(Long userId) { + try { + refresh(userGameRepository.findBacklogAppids(userId)); + } catch (Exception e) { + log.warn("Backlog moods of user {} not refreshed: {}", userId, e.getMessage()); + } + } +} diff --git a/src/main/resources/db/migration/V9__game_moods.sql b/src/main/resources/db/migration/V9__game_moods.sql new file mode 100644 index 0000000..6f6a3d8 --- /dev/null +++ b/src/main/resources/db/migration/V9__game_moods.sql @@ -0,0 +1,8 @@ +-- Moods per library key (Steam appid, or minus the IGDB id), derived from IGDB's genres, themes and game modes and +-- shared by all users. An empty array means IGDB knows the game but no mood fits; the row is kept so the game is not +-- asked again before it is stale. +CREATE TABLE game_mood ( + appid BIGINT PRIMARY KEY, + moods VARCHAR(16)[] NOT NULL, + checked_at TIMESTAMPTZ NOT NULL +); 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 cf33a01..fb7218f 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 @@ -87,6 +87,76 @@ class IgdbApiServiceTest extends AbstractUnitTest { assertThat(result.get().get(620L)).isEqualTo(new IgdbApiService.TimeToBeat(510, 750, 1350)); } + @Test + void getGameTags_MapsSteamAppsToTheNamesOfTheirIgdbGame() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))) + .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("/games"))) + .andExpect(content().string(allOf(containsString("genres.name,themes.name,game_modes.name"), + containsString("id = (72,233)")))) + .andRespond(withSuccess("[{\"id\": 72, \"genres\": [{\"id\": 9, \"name\": \"Puzzle\"}]," + + " \"themes\": [{\"id\": 1, \"name\": \"Action\"}], \"game_modes\": [{\"id\": 3, \"name\": \"Co-operative\"}]}," + + " {\"id\": 233}]", MediaType.APPLICATION_JSON)); + + Map tags = service.getGameTags(List.of(620L, 220L)).orElseThrow(); + + assertThat(tags).containsOnly( + Map.entry(620L, new IgdbApiService.GameTags(List.of("Puzzle"), List.of("Action"), List.of("Co-operative"))), + Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of()))); + } + + // REVIEW-FINDING: one Steam app linked to two IGDB games (main entry + edition) keeps the tags of whichever game + // the HashMap yields first and drops the other's. Here game 72 has no tags and game 233 is Horror; 72 sits in the + // earlier bucket, so 620 is stored with no mood. Time to beat picks the most-voted entry; tags should be the union. + @Test + void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))) + .andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"620\"}]", + MediaType.APPLICATION_JSON)); + igdb.expect(requestTo(containsString("/games"))) + .andRespond(withSuccess("[{\"id\": 72}, {\"id\": 233, \"themes\": [{\"id\": 19, \"name\": \"Horror\"}]}]", + MediaType.APPLICATION_JSON)); + + Map tags = service.getGameTags(List.of(620L)).orElseThrow(); + + assertThat(tags.get(620L).themes()).contains("Horror"); + } + + @Test + void getGameTags_NoSteamAppKnown_AsksNoGames() { + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON)); + + assertThat(service.getGameTags(List.of(620L))).contains(Map.of()); + igdb.verify(); + } + + @Test + void getGameTagsByGameIds_AsksTheGamesDirectly() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))) + .andExpect(content().string(containsString("id = (119388)"))) + .andRespond(withSuccess("[{\"id\": 119388, \"genres\": [{\"name\": \"Adventure\"}]}]", MediaType.APPLICATION_JSON)); + + assertThat(service.getGameTagsByGameIds(List.of(119388L))).contains(Map.of(119388L, + new IgdbApiService.GameTags(List.of("Adventure"), List.of(), List.of()))); + } + + @Test + void getGameTags_OffOrError_IsEmpty() { + assertThat(create("", "").getGameTags(List.of(620L))).isEmpty(); + + service = create("client-1", "secret-1"); + expectToken(); + igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError()); + + assertThat(service.getGameTags(List.of(620L))).isEmpty(); + } + @Test void getTimeToBeat_ReusesTheToken() { expectToken(); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java index d5eff67..3368ff1 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -6,10 +6,12 @@ import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; +import core_service.modules.library.repo.GameMoodsRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.service.LibrarySyncService; +import core_service.modules.library.service.MoodService; import core_service.shared.test.AbstractIntegrationTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -48,13 +50,20 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest { @Autowired private GameTimeToBeatRepository gameTimeToBeatRepository; + @Autowired + private GameMoodsRepository gameMoodsRepository; + @MockitoBean private LibrarySyncService syncService; + @MockitoBean + private MoodService moodService; + @BeforeEach void clean() { userGameRepository.deleteAll(); gameTimeToBeatRepository.deleteAll(); + gameMoodsRepository.deleteAll(); } private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) { @@ -68,6 +77,41 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest { } } + @Test + void picker_WithMood_PicksOnlyGamesKnownToHaveIt() throws Exception { + game(10L, GameStatus.BACKLOG, null, null); + game(20L, GameStatus.BACKLOG, null, null); + game(30L, GameStatus.BACKLOG, null, null); + game(40L, GameStatus.FINISHED, null, null); + gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, Instant.now()); + gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, Instant.now()); + gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, Instant.now()); + + mockMvc.perform(get("/api/library/picker").param("mood", "CHILL")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); + mockMvc.perform(get("/api/library/picker")) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20, 30))); + verify(moodService).refreshBacklogInBackground(USER_ID); + } + + @Test + void picker_MoodAndBudget_BothMustFit() throws Exception { + game(10L, GameStatus.BACKLOG, null, 300); + game(20L, GameStatus.BACKLOG, null, 3000); + gameMoodsRepository.upsert(10L, new String[]{"STORY"}, Instant.now()); + gameMoodsRepository.upsert(20L, new String[]{"STORY"}, Instant.now()); + + mockMvc.perform(get("/api/library/picker").param("mood", "STORY").param("maxMinutes", "900")) + .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); + } + + @Test + void picker_UnknownMood_Is400() throws Exception { + mockMvc.perform(get("/api/library/picker").param("mood", "HARD")) + .andExpect(status().isBadRequest()); + } + @Test void picker_EmptyBacklog_ReturnsNoGames() throws Exception { game(10L, GameStatus.PLAYING, null, 60); diff --git a/src/test/java/core_service/modules/library/domain/MoodTest.java b/src/test/java/core_service/modules/library/domain/MoodTest.java new file mode 100644 index 0000000..251fc6d --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/MoodTest.java @@ -0,0 +1,48 @@ +package core_service.modules.library.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class MoodTest extends AbstractUnitTest { + + @Test + void of_MapsIgdbNamesIgnoringCase() { + assertThat(Mood.of(List.of("Role-playing (RPG)"), List.of(), List.of())).containsExactly(Mood.STORY); + assertThat(Mood.of(List.of(), List.of("drama"), List.of())).containsExactly(Mood.STORY); + assertThat(Mood.of(List.of(), List.of(), List.of("Split screen"))).containsExactly(Mood.COOP); + assertThat(Mood.of(List.of("Card & Board Game"), List.of(), List.of())).containsExactly(Mood.CHILL); + assertThat(Mood.of(List.of("Hack and slash/Beat 'em up"), List.of(), List.of())).containsExactly(Mood.ACTION); + assertThat(Mood.of(List.of(), List.of("Horror"), List.of())).containsExactly(Mood.SCARY); + } + + @Test + void of_AGameCanHaveSeveralMoods() { + assertThat(Mood.of(List.of("Shooter", "Role-playing (RPG)"), List.of("Horror"), List.of("Single player", "Co-operative"))) + .containsExactlyInAnyOrder(Mood.STORY, Mood.COOP, Mood.ACTION, Mood.SCARY); + } + + @Test + void of_Adventure_IsAStoryOnlyWithoutAnActionGenre() { + assertThat(Mood.of(List.of("Adventure"), List.of(), List.of())).containsExactly(Mood.STORY); + assertThat(Mood.of(List.of("Adventure", "Shooter"), List.of(), List.of())).containsExactly(Mood.ACTION); + } + + @Test + void of_SurvivalWithoutHorror_IsNotScary() { + assertThat(Mood.of(List.of(), List.of("Survival", "Sandbox"), List.of())).containsExactly(Mood.CHILL); + } + + @Test + void of_AHorrorGameIsNeverChill() { + assertThat(Mood.of(List.of("Puzzle"), List.of("Horror"), List.of())).containsExactly(Mood.SCARY); + } + + @Test + void of_NothingThatFits_HasNoMood() { + assertThat(Mood.of(List.of("Racing", "Sport"), List.of("Science fiction"), List.of("Multiplayer"))).isEmpty(); + } +} diff --git a/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java new file mode 100644 index 0000000..c50b8e5 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java @@ -0,0 +1,48 @@ +package core_service.modules.library.repo; + +import core_service.shared.test.AbstractPersistenceTest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameMoodsRepositoryTest extends AbstractPersistenceTest { + + @Autowired + private GameMoodsRepository gameMoodsRepository; + + @Autowired + private EntityManager entityManager; + + @BeforeEach + void clean() { + gameMoodsRepository.deleteAll(); + } + + @Test + void upsert_StoresTheMoodsAndReplacesThemOnTheNextRefresh() { + gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, Instant.now()); + assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).containsExactly("CHILL", "COOP"); + + gameMoodsRepository.upsert(620L, new String[0], Instant.now()); + // The native upsert passes the persistence context; read the row again. + entityManager.clear(); + assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).isEmpty(); + } + + @Test + void findFreshAppids_LeavesOutOldAndMissingRows() { + gameMoodsRepository.upsert(620L, new String[]{"STORY"}, Instant.now()); + gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, Instant.now().minus(Duration.ofDays(40))); + + assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30)))) + .isEqualTo(Set.of(620L)); + } +} 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 088a1f5..5afc9ab 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -80,6 +80,15 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertEquals(GameSource.MANUAL, manual.getSource()); } + @Test + void findBacklogAppids_OnlyActiveBacklogGamesOfTheUser() { + userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setStatus(GameStatus.BACKLOG); + userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now()); + userGameRepository.flush(); + + assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findBacklogAppids(USER_ID))); + } + @Test void findAchievementProgress_LeavesOutGamesAddedByHand() { steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now())); @@ -95,7 +104,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { 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()); + assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 3).size()); } @Test 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 00407e3..a5a2652 100644 --- a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java @@ -67,6 +67,9 @@ class GameDetailServiceTest extends AbstractIntegrationTest { @MockitoBean private TimeToBeatService timeToBeatService; + @MockitoBean + private MoodService moodService; + @BeforeEach void seed() { userGameRepository.deleteAll(); @@ -94,12 +97,13 @@ class GameDetailServiceTest extends AbstractIntegrationTest { } @Test - void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAgain() { + void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() { 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)); + org.mockito.Mockito.verify(moodService).refreshInBackground(List.of(620L)); } @Test 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 684ce35..8af01c3 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -103,6 +103,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { @Mock private TimeToBeatService timeToBeatService; + @Mock + private MoodService moodService; + @Mock private ApplicationEventPublisher eventPublisher; @@ -403,12 +406,13 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { } @Test - void runSync_AlsoRefreshesTheTimeToBeatOfTheGamesAddedByHand() { + void runSync_AlsoRefreshesTimeToBeatAndMoodsOfTheGamesAddedByHand() { 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)); + verify(moodService).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 index 46177ee..a93c265 100644 --- a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -41,6 +41,9 @@ class ManualGameServiceTest extends AbstractUnitTest { @Mock private TimeToBeatService timeToBeatService; + @Mock + private MoodService moodService; + @Mock private PlatformTransactionManager transactionManager; @@ -49,7 +52,7 @@ class ManualGameServiceTest extends AbstractUnitTest { @BeforeEach void setUp() { service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, timeToBeatService, - transactionManager); + moodService, transactionManager); } @Test diff --git a/src/test/java/core_service/modules/library/service/MoodServiceTest.java b/src/test/java/core_service/modules/library/service/MoodServiceTest.java new file mode 100644 index 0000000..62823e8 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/MoodServiceTest.java @@ -0,0 +1,104 @@ +package core_service.modules.library.service; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.repo.GameMoodsRepository; +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.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.AdditionalMatchers.aryEq; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class MoodServiceTest extends AbstractUnitTest { + + @Mock + private IgdbApiService igdbApiService; + + @Mock + private GameMoodsRepository gameMoodsRepository; + + @Mock + private UserGameRepository userGameRepository; + + @InjectMocks + private MoodService service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "ttlDays", 30L); + } + + @Test + void refresh_StoresTheMoodsOfStaleGamesAndAnEmptyListForGamesWithoutData() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L)); + when(igdbApiService.getGameTags(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of( + 620L, new IgdbApiService.GameTags(List.of("Puzzle"), List.of(), List.of("Co-operative"))))); + + service.refresh(List.of(220L, 620L, 700L)); + + verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), any(Instant.class)); + verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), any(Instant.class)); + verify(gameMoodsRepository, never()).upsert(eq(220L), any(), any()); + } + + @Test + void refreshBacklogInBackground_RefreshesTheBacklogOfTheUser() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(userGameRepository.findBacklogAppids(7L)).thenReturn(List.of(620L)); + when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of()); + when(igdbApiService.getGameTags(Set.of(620L))).thenReturn(Optional.of(Map.of())); + + service.refreshBacklogInBackground(7L); + + verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), any(Instant.class)); + } + + @Test + void refresh_GamesOffSteam_AreAskedByIgdbIdAndStoredUnderTheirKey() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of()); + when(igdbApiService.getGameTagsByGameIds(Set.of(119388L))).thenReturn(Optional.of(Map.of( + 119388L, new IgdbApiService.GameTags(List.of("Adventure"), List.of(), List.of())))); + + service.refresh(List.of(-119388L)); + + verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), any(Instant.class)); + } + + @Test + void refresh_IgdbFailed_StoresNothingSoTheGamesAreAskedAgain() { + when(igdbApiService.isEnabled()).thenReturn(true); + when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of()); + when(igdbApiService.getGameTags(any())).thenReturn(Optional.empty()); + + service.refresh(List.of(620L)); + + verify(gameMoodsRepository, never()).upsert(any(), any(), any()); + } + + @Test + void refresh_IgdbOff_DoesNothing() { + when(igdbApiService.isEnabled()).thenReturn(false); + + service.refresh(List.of(620L)); + + verifyNoInteractions(gameMoodsRepository); + } +}