diff --git a/readme.md b/readme.md index 6a7f868..10e94a9 100644 --- a/readme.md +++ b/readme.md @@ -67,10 +67,13 @@ To Be Played is split into three repositories: N["next up: random of top 5 in order"] --> M M --> O["games + reason"] ``` -- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (and - a release year at most one year off, when IGDB knows it) with at least one verified run. The game detail returns +- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case, + punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at + least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its + name without an edition suffix. Several same-name games and no year to decide: no link. The game detail returns the speedrun.com link as `speedrunUrl`. speedrun.com has no Steam or IGDB link and no key, so one queue works for - all users, with up to 3 requests per game and 1 request per second; a result is kept 30 days. + all users, with up to 3 requests per game and 1 request per second; a result is kept 30 days. Migration V47 asks + every stored match again once at boot, behind user requests; links set by an auditor are never touched. - **Achievements**: after every library sync a background job fetches the achievement schema of each owned game and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never diff --git a/src/main/java/core_service/configuration/AsyncConfig.java b/src/main/java/core_service/configuration/AsyncConfig.java index 55188f1..9dafab7 100644 --- a/src/main/java/core_service/configuration/AsyncConfig.java +++ b/src/main/java/core_service/configuration/AsyncConfig.java @@ -8,7 +8,7 @@ import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; /** - * The two pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync + * The pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync * asks again. */ @Configuration @@ -43,4 +43,14 @@ public class AsyncConfig { executor.setThreadNamePrefix("sync-follow-up-"); return executor; } + + // One thread: speedrun.com has one rate limit and the queue has one drainer. The queue holds only drain triggers. + @Bean(name = BackgroundPools.SPEEDRUN) + public ThreadPoolTaskExecutor speedrunExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(1); + executor.setThreadNamePrefix("speedrun-"); + return executor; + } } 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 7b368ed..3785b94 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 @@ -30,6 +30,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.TreeSet; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -77,6 +78,9 @@ public class IgdbApiService { */ public record Game(long igdbId, String name, Integer year, String imageUrl, List platforms, Long steamAppid) {} + /** @param year null when IGDB knows no release date */ + public record NameAndYear(String name, Integer year) {} + // IGDB's own order puts obscure entries first, so Core asks for more and sorts by popularity itself. private static final int SEARCH_FETCH_LIMIT = 50; private static final int SEARCH_LIMIT = 20; @@ -382,18 +386,31 @@ public class IgdbApiService { * Like {@link #getReleaseDates}, but for IGDB game ids. */ public Optional> getReleaseDatesByGameIds(Collection igdbIds) { + return inBatches(igdbIds, "release dates", this::datesOf); + } + + /** + * The name and release year of IGDB games by id. Games IGDB does not know, or without a name, are missing. Empty + * when the adapter is off, nothing was asked or a request failed. + */ + public Optional> getNamesAndYears(Collection igdbIds) { + return inBatches(igdbIds, "game names", this::namesAndYearsOf); + } + + private Optional> inBatches(Collection igdbIds, String what, + Function, Map> batchQuery) { if (!isEnabled() || igdbIds.isEmpty()) { return Optional.empty(); } try { List ids = List.copyOf(igdbIds); - Map result = new HashMap<>(); + Map result = new HashMap<>(); for (int from = 0; from < ids.size(); from += BATCH) { - result.putAll(datesOf(ids.subList(from, Math.min(from + BATCH, ids.size())))); + result.putAll(batchQuery.apply(ids.subList(from, Math.min(from + BATCH, ids.size())))); } return Optional.of(result); } catch (Exception e) { - log.error("Error communicating with IGDB for release dates", e); + log.error("Error communicating with IGDB for {}", what, e); return Optional.empty(); } } @@ -601,10 +618,7 @@ public class IgdbApiService { if (igdbIds.isEmpty()) { return Map.of(); } - String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(",")); - IgdbResponses.Game[] games = query("/games", - "fields first_release_date; where id = (" + gameIds + "); limit " + LIMIT + ";", - IgdbResponses.Game[].class); + IgdbResponses.Game[] games = gamesById(igdbIds, "first_release_date"); Map result = new HashMap<>(); for (IgdbResponses.Game game : games) { if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) { @@ -614,6 +628,23 @@ public class IgdbApiService { return result; } + private Map namesAndYearsOf(Collection igdbIds) { + Map result = new HashMap<>(); + for (IgdbResponses.Game game : gamesById(igdbIds, "name,first_release_date")) { + String name = trimmedName(game); + if (game.id() != null && igdbIds.contains(game.id()) && !name.isEmpty()) { + result.put(game.id(), new NameAndYear(name, game.firstReleaseDate() == null ? null : year(game.firstReleaseDate()))); + } + } + return result; + } + + private IgdbResponses.Game[] gamesById(Collection igdbIds, String fields) { + String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(",")); + return query("/games", "fields " + fields + "; where id = (" + gameIds + "); limit " + LIMIT + ";", + IgdbResponses.Game[].class); + } + private static int year(long unixSeconds) { return date(unixSeconds).getYear(); } diff --git a/src/main/java/core_service/modules/library/repo/GameSpeedrunRepository.java b/src/main/java/core_service/modules/library/repo/GameSpeedrunRepository.java index d4d4a73..c37adb8 100644 --- a/src/main/java/core_service/modules/library/repo/GameSpeedrunRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameSpeedrunRepository.java @@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.Collection; +import java.util.List; import java.util.Set; public interface GameSpeedrunRepository extends JpaRepository { @@ -48,4 +49,12 @@ public interface GameSpeedrunRepository extends JpaRepository findAppidsToRecheck(@Param("before") Instant before); } diff --git a/src/main/java/core_service/modules/library/service/SpeedrunMatch.java b/src/main/java/core_service/modules/library/service/SpeedrunMatch.java index 5c3e266..0a8b83f 100644 --- a/src/main/java/core_service/modules/library/service/SpeedrunMatch.java +++ b/src/main/java/core_service/modules/library/service/SpeedrunMatch.java @@ -6,21 +6,33 @@ import core_service.modules.speedrun_adapter.service.SpeedrunApiService; import java.util.Collection; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * Finds a library game among speedrun.com's search hits. speedrun.com has no Steam or IGDB link, so the name must be - * the same and, when both years are known, the release year may differ by at most one. + * Finds a library game among speedrun.com's search hits, or none: a wrong link is worse than no link. speedrun.com has + * no Steam or IGDB link, so the name must be the same (case, punctuation, trademark signs and roman numerals do not + * count). With known years the year of the hit must be within one of ours. Without a year to decide, only a single + * same-name hit counts. */ final class SpeedrunMatch { - static final int MAX_CANDIDATES = 2; static final String WEBLINK_PREFIX = "https://www.speedrun.com/"; // speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey". private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$"); + // Other pages on speedrun.com ("Remastered", "Remake") are left alone on purpose. + private static final Pattern EDITION_SUFFIX = Pattern.compile( + "\\s*[-:\u2013]?\\s*(?:the\\s+)?(?:(?:game of the year|goty|special|definitive|complete|deluxe|digital deluxe|enhanced|gold|" + + "ultimate|anniversary|legendary|premium) edition|goty|director'?s cut)\\s*$", + Pattern.CASE_INSENSITIVE); + // Index + 2 is the number. + private static final List ROMAN_NUMERALS = List.of("ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", + "xi", "xii", "xiii", "xiv", "xv", "xvi", "xvii", "xviii", "xix", "xx"); + // "i" and "x" stay: "Mega Man X" is not "Mega Man 10". + private static final Pattern ROMAN_NUMERAL = Pattern.compile("\\b(?!x\\b)(" + String.join("|", ROMAN_NUMERALS) + ")\\b"); // The column sizes of game_speedrun. private static final int MAX_GAME_ID_LENGTH = 16; private static final int MAX_WEBLINK_LENGTH = 512; @@ -39,23 +51,40 @@ final class SpeedrunMatch { /** * @param years our release years of the game; empty when unknown * @param hits in speedrun.com's order - * @return the hits worth asking for runs, best first, at most {@link #MAX_CANDIDATES} + * @return the one hit that is this game; empty when no hit is, or when several could be */ - static List candidates(String name, Collection years, List hits) { + static Optional sure(String name, Collection years, List hits) { NameAndYear ours = split(name); Set ourYears = new HashSet<>(years); if (ours.year() != null) ourYears.add(ours.year()); - return hits.stream() + List same = hits.stream() .filter(hit -> hit.id() != null && hit.id().length() <= MAX_GAME_ID_LENGTH) .filter(hit -> isWeblink(hit.weblink())) - .filter(hit -> { - NameAndYear theirs = split(hit.name()); - Integer year = theirs.year() != null ? theirs.year() : hit.year(); - return theirs.name().equals(ours.name()) && (year == null || ourYears.isEmpty() - || ourYears.stream().anyMatch(ourYear -> Math.abs(ourYear - year) <= 1)); - }) - .limit(MAX_CANDIDATES) + .filter(hit -> split(hit.name()).name().equals(ours.name())) .toList(); + if (ourYears.isEmpty()) { + return only(same); + } + List dated = same.stream() + .filter(hit -> { + Integer year = yearOf(hit); + return year != null && ourYears.stream().anyMatch(ourYear -> Math.abs(ourYear - year) <= 1); + }) + .toList(); + if (!dated.isEmpty()) { + return only(dated); + } + return only(same).filter(hit -> yearOf(hit) == null); + } + + /** + * @return the name without "Special Edition", "Director's Cut" and the like; empty when it has no such suffix or + * nothing is left + */ + static Optional withoutEditionSuffix(String name) { + String trimmed = name.trim(); + String stripped = EDITION_SUFFIX.matcher(trimmed).replaceFirst("").trim(); + return stripped.isEmpty() || stripped.equals(trimmed) ? Optional.empty() : Optional.of(stripped); } /** @@ -65,11 +94,25 @@ final class SpeedrunMatch { return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim(); } + private static Optional only(List games) { + return games.size() == 1 ? Optional.of(games.get(0)) : Optional.empty(); + } + + private static Integer yearOf(SpeedrunApiService.Game hit) { + Integer fromName = split(hit.name()).year(); + return fromName != null ? fromName : hit.year(); + } + + private static String key(String name) { + return ROMAN_NUMERAL.matcher(GameNames.normalize(name)).replaceAll( + match -> String.valueOf(ROMAN_NUMERALS.indexOf(match.group()) + 2)); + } + private static NameAndYear split(String name) { Matcher matcher = YEAR_SUFFIX.matcher(name.trim()); return matcher.matches() - ? new NameAndYear(GameNames.normalize(matcher.group(1)), Integer.parseInt(matcher.group(2))) - : new NameAndYear(GameNames.normalize(name), null); + ? new NameAndYear(key(matcher.group(1)), Integer.parseInt(matcher.group(2))) + : new NameAndYear(key(name), null); } private record NameAndYear(String name, Integer year) {} diff --git a/src/main/java/core_service/modules/library/service/SpeedrunService.java b/src/main/java/core_service/modules/library/service/SpeedrunService.java index c693a31..0ff3926 100644 --- a/src/main/java/core_service/modules/library/service/SpeedrunService.java +++ b/src/main/java/core_service/modules/library/service/SpeedrunService.java @@ -1,9 +1,11 @@ package core_service.modules.library.service; import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.domain.GameEdition; import core_service.modules.library.domain.GameKeys; import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.repo.GameEditionRepository; import core_service.modules.library.repo.GameSpeedrunRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; @@ -12,6 +14,8 @@ import core_service.shared.background.BackgroundPools; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; import org.springframework.core.task.TaskExecutor; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -32,7 +36,14 @@ import java.util.concurrent.atomic.AtomicBoolean; /** * Stores which games have a speedrun.com page with verified runs, for {@link Mood#SPEEDRUN} and the link on the * detail page. speedrun.com is searched by name, one game at a time, so all callers share one queue and one thread - * works it off: a game in several backlogs is asked once, and the rate limit has one waiter. + * (the speedrun executor) works it off: a game in several backlogs is asked once, and the rate limit has one waiter. + *

+ * A game is looked up by its own name and years; when that gives no sure hit, an edition is looked up by the name and + * year of its IGDB parent game, a game without a parent by its name without an edition suffix. A sure hit without + * verified runs ends the lookup. See {@link SpeedrunMatch} for "sure". + *

+ * {@link #recheckMarked} asks the rows a migration marked (checked_at = epoch) again. Those games wait behind every + * game a user asked for. */ @Service @RequiredArgsConstructor @@ -42,27 +53,53 @@ public class SpeedrunService { static final int BATCH = 50; // speedrun.com is down or blocks us; the rest of the queue is asked on the next trigger. static final int MAX_ERRORS_IN_A_ROW = 3; + // A small batch, so a user request waits for a few games and not for 50. + static final int RECHECK_BATCH = 10; private final SpeedrunApiService speedrunApiService; private final IgdbApiService igdbApiService; private final GameSpeedrunRepository gameSpeedrunRepository; + private final GameEditionRepository gameEditionRepository; private final SteamGameRepository steamGameRepository; private final UserGameRepository userGameRepository; private final TaskExecutor syncFollowUpExecutor; + // Works both queues off on its own thread, so the follow-up pool never waits for speedrun.com. + private final TaskExecutor speedrunExecutor; private final ConcurrentSkipListSet pending = new ConcurrentSkipListSet<>(); + private final ConcurrentSkipListSet recheck = new ConcurrentSkipListSet<>(); private final AtomicBoolean running = new AtomicBoolean(); @Value("${app.library.speedrun-ttl-days:30}") private long ttlDays; /** - * Queues the games without a fresh row and, unless another thread already does it, works the queue off on this - * thread. A game takes up to 3 requests, and there is 1 request per second. + * Queues the games without a fresh row and returns; the speedrun thread works the queue off. A game takes up to 3 + * requests, and there is 1 request per second. */ public void refresh(Collection appids) { if (!speedrunApiService.isEnabled() || appids.isEmpty()) return; pending.addAll(appids); + speedrunExecutor.execute(this::work); + } + + /** + * Queues the rows the V47 migration marked, behind the games users asked for. + */ + @EventListener(ApplicationReadyEvent.class) + public void recheckMarked() { + if (!speedrunApiService.isEnabled()) return; + syncFollowUpExecutor.execute(() -> Refreshes.quietly("Speedrun re-check", () -> { + List marked = gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH); + if (marked.isEmpty()) return; + log.info("Speedrun re-check of {} games", marked.size()); + recheck.addAll(marked); + speedrunExecutor.execute(this::work); + })); + } + + private void work() { + // Another work() is draining: it also takes what was queued meanwhile. while (running.compareAndSet(false, true)) { try { drain(); @@ -70,7 +107,7 @@ public class SpeedrunService { running.set(false); } // Games can be queued after the last poll and before the flag was cleared. - if (pending.isEmpty()) return; + if (pending.isEmpty() && recheck.isEmpty()) return; } } @@ -99,12 +136,9 @@ public class SpeedrunService { private void drain() { int errorsInARow = 0; - while (!pending.isEmpty()) { - List batch = new ArrayList<>(); - Long next; - while (batch.size() < BATCH && (next = pending.pollFirst()) != null) { - batch.add(next); - } + while (!pending.isEmpty() || !recheck.isEmpty()) { + boolean forUsers = !pending.isEmpty(); + List batch = forUsers ? poll(pending, BATCH) : poll(recheck, RECHECK_BATCH); batch.removeAll(gameSpeedrunRepository.findFreshAppids(batch, Instant.now().minus(Duration.ofDays(ttlDays)))); if (batch.isEmpty()) continue; @@ -113,17 +147,24 @@ public class SpeedrunService { names.put(game.getAppid(), game.getName()); } Optional>> years = releaseYears(batch); - if (years.isEmpty()) { + Optional> parents = years.isEmpty() ? Optional.empty() : parentNames(batch); + if (parents.isEmpty()) { // Matching by name only would store a wrong remake for 30 days. log.warn("Speedrun games of {} not refreshed, IGDB did not answer", batch.size()); continue; } - for (Long appid : batch) { + for (int i = 0; i < batch.size(); i++) { + if (!forUsers && !pending.isEmpty()) { + // A user asked in the meantime: the rest of the re-check goes behind it. + recheck.addAll(batch.subList(i, batch.size())); + break; + } + Long appid = batch.get(i); String name = names.get(appid); if (name == null || name.equals(SteamGame.placeholderName(appid))) continue; boolean stored; try { - stored = checkAndStore(appid, name, years.get().getOrDefault(appid, Set.of())); + stored = checkAndStore(appid, name, years.get().getOrDefault(appid, Set.of()), parents.get().get(appid)); } catch (Exception e) { log.warn("Speedrun game of appid={} not stored: {}", appid, e.getMessage()); stored = false; @@ -133,31 +174,78 @@ public class SpeedrunService { } else if (++errorsInARow >= MAX_ERRORS_IN_A_ROW) { log.warn("Speedrun check failed {} times in a row, {} queued games dropped", errorsInARow, pending.size()); pending.clear(); + recheck.clear(); return; } } } } + private static List poll(ConcurrentSkipListSet queue, int size) { + List batch = new ArrayList<>(); + Long next; + while (batch.size() < size && (next = queue.pollFirst()) != null) { + batch.add(next); + } + return batch; + } + /** + * @param parent the IGDB game this one is an edition, remaster or port of; null when there is none * @return false when a speedrun.com call failed; then nothing is stored and the game is asked again later */ - private boolean checkAndStore(Long appid, String name, Set years) { - Optional> hits = speedrunApiService.findGames(SpeedrunMatch.searchText(name)); - if (hits.isEmpty()) return false; + private boolean checkAndStore(Long appid, String name, Set years, IgdbApiService.NameAndYear parent) { SpeedrunApiService.Game match = null; - for (SpeedrunApiService.Game candidate : SpeedrunMatch.candidates(name, years, hits.get())) { - Optional hasRuns = speedrunApiService.hasVerifiedRuns(candidate.id()); + for (Lookup lookup : lookups(name, years, parent)) { + Optional> hits = speedrunApiService.findGames(SpeedrunMatch.searchText(lookup.name())); + if (hits.isEmpty()) return false; + Optional sure = SpeedrunMatch.sure(lookup.name(), lookup.years(), hits.get()); + if (sure.isEmpty()) continue; + Optional hasRuns = speedrunApiService.hasVerifiedRuns(sure.get().id()); if (hasRuns.isEmpty()) return false; - if (hasRuns.get()) { - match = candidate; - break; - } + if (hasRuns.get()) match = sure.get(); + break; } gameSpeedrunRepository.upsert(appid, match == null ? null : match.id(), match == null ? null : match.weblink(), Instant.now()); return true; } + private static List lookups(String name, Set years, IgdbApiService.NameAndYear parent) { + List lookups = new ArrayList<>(); + lookups.add(new Lookup(name, years)); + if (parent != null && !SpeedrunMatch.searchText(parent.name()).equalsIgnoreCase(SpeedrunMatch.searchText(name))) { + lookups.add(new Lookup(parent.name(), parent.year() == null ? Set.of() : Set.of(parent.year()))); + } else { + SpeedrunMatch.withoutEditionSuffix(name).ifPresent(plain -> lookups.add(new Lookup(plain, years))); + } + return lookups; + } + + private record Lookup(String name, Set years) {} + + /** + * @return library key to the IGDB game it is an edition, remaster or port of; a game that is its own same game + * has no parent. Empty when IGDB failed. + */ + private Optional> parentNames(List appids) { + Map> appidsByParent = new HashMap<>(); + for (GameEdition edition : gameEditionRepository.findAllById(appids)) { + if (edition.getIgdbId() != null && edition.getSameGameId() != null + && !edition.getSameGameId().equals(edition.getIgdbId())) { + appidsByParent.computeIfAbsent(edition.getSameGameId(), id -> new ArrayList<>()).add(edition.getAppid()); + } + } + if (appidsByParent.isEmpty() || !igdbApiService.isEnabled()) { + return Optional.of(Map.of()); + } + Optional> found = igdbApiService.getNamesAndYears(appidsByParent.keySet()); + if (found.isEmpty()) return Optional.empty(); + Map parents = new HashMap<>(); + found.get().forEach((parentId, parent) -> + appidsByParent.get(parentId).forEach(appid -> parents.put(appid, parent))); + return Optional.of(parents); + } + /** * @return empty when IGDB failed. A game IGDB does not know, and every game when IGDB is off, has no years and is * matched by name only. diff --git a/src/main/java/core_service/shared/background/BackgroundPools.java b/src/main/java/core_service/shared/background/BackgroundPools.java index f346d65..ce1ae44 100644 --- a/src/main/java/core_service/shared/background/BackgroundPools.java +++ b/src/main/java/core_service/shared/background/BackgroundPools.java @@ -3,7 +3,7 @@ package core_service.shared.background; import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; /** - * The bean names of the two background pools (configuration.AsyncConfig). Every {@code @Async} names one of them: + * The bean names of the background pools (configuration.AsyncConfig). Every {@code @Async} names one of them: * with the scheduler there are two TaskExecutor beans, and a bare {@code @Async} would run unbounded. */ public final class BackgroundPools { @@ -12,11 +12,16 @@ public final class BackgroundPools { public static final String DEFAULT = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME; /** - * The achievement runs after a sync and every speedrun refresh: they hold a thread for minutes (Steam and - * speedrun.com pace), so page views keep the default pool. Small tasks, nothing is dropped. + * The achievement runs after a sync: they hold a thread for minutes (Steam pace), so page views keep the default pool. Small tasks, nothing is dropped. */ public static final String SYNC_FOLLOW_UP = "syncFollowUpExecutor"; + /** + * The one thread that works the speedrun queue off (speedrun.com pace, a re-check can take an hour). Other tasks + * never wait for it. + */ + public static final String SPEEDRUN = "speedrunExecutor"; + private BackgroundPools() { } } diff --git a/src/main/resources/db/migration/V47__Speedrun_Recheck.sql b/src/main/resources/db/migration/V47__Speedrun_Recheck.sql new file mode 100644 index 0000000..33ea348 --- /dev/null +++ b/src/main/resources/db/migration/V47__Speedrun_Recheck.sql @@ -0,0 +1,4 @@ +-- New speedrun.com matching rules (#8): every match not set by an auditor is asked again once. checked_at = epoch is +-- the marker SpeedrunService.recheckMarked queues at boot; the link stays until speedrun.com answers. +UPDATE game_speedrun s SET checked_at = 'epoch' +WHERE NOT EXISTS (SELECT 1 FROM game_correction c WHERE c.appid = s.appid AND c.field = 'SPEEDRUN'); diff --git a/src/test/java/core_service/configuration/AsyncConfigTest.java b/src/test/java/core_service/configuration/AsyncConfigTest.java index a3bd41f..9f2f8f3 100644 --- a/src/test/java/core_service/configuration/AsyncConfigTest.java +++ b/src/test/java/core_service/configuration/AsyncConfigTest.java @@ -4,6 +4,8 @@ import core_service.shared.test.AbstractUnitTest; import org.junit.jupiter.api.Test; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -45,6 +47,27 @@ class AsyncConfigTest extends AbstractUnitTest { assertFalse(droppedRan.get()); } + @Test + void speedrunPool_RunsOnOneNamedThread() throws InterruptedException { + ThreadPoolTaskExecutor executor = new AsyncConfig().speedrunExecutor(); + executor.initialize(); + CountDownLatch done = new CountDownLatch(2); + Set threads = ConcurrentHashMap.newKeySet(); + try { + for (int i = 0; i < 2; i++) { + executor.execute(() -> { + threads.add(Thread.currentThread().getName()); + done.countDown(); + }); + } + + assertTrue(done.await(5, TimeUnit.SECONDS)); + } finally { + executor.shutdown(); + } + assertEquals(Set.of("speedrun-1"), threads); + } + @Test void followUpPool_KeepsEveryTask() throws InterruptedException { ThreadPoolTaskExecutor executor = new AsyncConfig().syncFollowUpExecutor(); 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 a92a1fe..06c2dcd 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 @@ -279,6 +279,28 @@ class IgdbApiServiceTest extends AbstractUnitTest { assertThat(service.getReleaseDatesByGameIds(List.of(1025L, 1026L))).contains(Map.of(1025L, LocalDate.of(2017, 5, 5))); } + @Test + void getNamesAndYears_MapsTheIgdbIdToNameAndYear() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))) + .andExpect(content().string(containsString("fields name,first_release_date; where id = (1025,1026,1027)"))) + .andRespond(withSuccess("[{\"id\": 1025, \"name\": \" Skyrim \", \"first_release_date\": 1321833600}," + + " {\"id\": 1026, \"name\": \"Undated\"}, {\"id\": 1027, \"name\": \" \"}, {\"id\": 9, \"name\": \"Unasked\"}]", + MediaType.APPLICATION_JSON)); + + assertThat(service.getNamesAndYears(List.of(1025L, 1026L, 1027L))).contains(Map.of( + 1025L, new IgdbApiService.NameAndYear("Skyrim", 2011), + 1026L, new IgdbApiService.NameAndYear("Undated", null))); + } + + @Test + void getNamesAndYears_IgdbError_IsEmpty() { + expectToken(); + igdb.expect(requestTo(containsString("/games"))).andRespond(withServerError()); + + assertThat(service.getNamesAndYears(List.of(1025L))).isEmpty(); + } + @Test void getReleaseDates_IgdbError_IsEmpty() { expectToken(); diff --git a/src/test/java/core_service/modules/library/repo/GameSpeedrunRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameSpeedrunRepositoryTest.java index d0894e7..6b4808b 100644 --- a/src/test/java/core_service/modules/library/repo/GameSpeedrunRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/GameSpeedrunRepositoryTest.java @@ -1,6 +1,8 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.GameSpeedrun; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; import core_service.shared.test.AbstractPersistenceTest; import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; @@ -22,8 +24,15 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest { @Autowired private EntityManager entityManager; + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + @BeforeEach void clean() { + userGameRepository.deleteAll(); gameSpeedrunRepository.deleteAll(); } @@ -64,4 +73,35 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest { assertThat(row.getSpeedrunGameId()).isNull(); assertThat(row.getCheckedAt()).isAfter(Instant.EPOCH); } + + private void list(long appid) { + steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now())); + userGameRepository.saveAndFlush(new UserGame(1L, appid, 0, Instant.now(), Instant.now())); + } + + @Test + void findAppidsToRecheck_ListedMarkedRowsOnly() { + gameSpeedrunRepository.writeWeblink(620L, "https://www.speedrun.com/portal", Instant.EPOCH); + gameSpeedrunRepository.upsert(220L, null, null, Instant.now()); + gameSpeedrunRepository.writeWeblink(730L, null, Instant.EPOCH); + gameSpeedrunRepository.writeWeblink(440L, null, Instant.EPOCH); + entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, " + + "corrected_at) values (730, 'SPEEDRUN', 'null', 'null', 7, now())").executeUpdate(); + list(620); + list(220); + list(730); + + assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).containsExactly(620L); + } + + @Test + void findAppidsToRecheck_RowAnsweredOnce_IsNotPickedAgain() { + gameSpeedrunRepository.writeWeblink(620L, null, Instant.EPOCH); + list(620); + assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).containsExactly(620L); + + gameSpeedrunRepository.upsert(620L, null, null, Instant.now()); + + assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).isEmpty(); + } } diff --git a/src/test/java/core_service/modules/library/service/SpeedrunMatchTest.java b/src/test/java/core_service/modules/library/service/SpeedrunMatchTest.java index 6632db9..6c5bb92 100644 --- a/src/test/java/core_service/modules/library/service/SpeedrunMatchTest.java +++ b/src/test/java/core_service/modules/library/service/SpeedrunMatchTest.java @@ -18,48 +18,95 @@ class SpeedrunMatchTest extends AbstractUnitTest { private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006); private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017); - @Test - void candidates_YearSuffixTellsTheRemakeApart() { - assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017); - assertThat(SpeedrunMatch.candidates("Prey", Set.of(2006), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2006); + private static List ids(String name, Set years, List hits) { + return SpeedrunMatch.sure(name, years, hits).map(SpeedrunApiService.Game::id).stream().toList(); } @Test - void candidates_UnknownYear_KeepsSpeedrunOrder() { - assertThat(SpeedrunMatch.candidates("Prey", Set.of(), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2006, PREY_2017); + void sure_IgdbYearPicksTheRemake() { + assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).contains(PREY_2017); + assertThat(SpeedrunMatch.sure("Prey", Set.of(2006), List.of(PREY_2006, PREY_2017))).contains(PREY_2006); } @Test - void candidates_OneYearOffIsStillTheSameGame() { - SpeedrunApiService.Game celeste = hit("celeste", "Celeste", 2018); - - assertThat(SpeedrunMatch.candidates("Celeste", Set.of(2017), List.of(celeste))).containsExactly(celeste); - assertThat(SpeedrunMatch.candidates("Celeste", Set.of(2015), List.of(celeste))).isEmpty(); + void sure_NoYearAndTwoSameNames_IsEmpty() { + assertThat(SpeedrunMatch.sure("Prey", Set.of(), List.of(PREY_2006, PREY_2017))).isEmpty(); } @Test - void candidates_OnlyTheSameName() { - List hits = List.of(hit("snes", "Doom (SNES)", 1995), hit("doom3", "Doom 3", 2004), - hit("d2016", "DOOM", 2016)); - - assertThat(SpeedrunMatch.candidates("DOOM", Set.of(), hits)).extracting(SpeedrunApiService.Game::id).containsExactly("d2016"); + void sure_NoYearAndOneSameName_IsThatGame() { + assertThat(ids("DOOM", Set.of(), List.of(hit("snes", "Doom (SNES)", 1995), hit("doom3", "Doom 3", 2004), + hit("d2016", "DOOM", 2016)))).containsExactly("d2016"); } @Test - void candidates_AtMostTwo() { - List hits = List.of(hit("a", "Tetris", null), hit("b", "Tetris", null), hit("c", "Tetris", null)); - - assertThat(SpeedrunMatch.candidates("Tetris", Set.of(), hits)).extracting(SpeedrunApiService.Game::id).containsExactly("a", "b"); + void sure_TwoGamesInTheYearWindow_IsEmpty() { + assertThat(SpeedrunMatch.sure("Tetris", Set.of(2000), List.of(hit("a", "Tetris", 2000), hit("b", "Tetris", 2001)))).isEmpty(); } @Test - void candidates_RefusesLinksOutsideSpeedrunCom() { + void sure_OneYearOff_IsTheGame() { + assertThat(ids("Celeste", Set.of(2017), List.of(hit("celeste", "Celeste", 2018)))).containsExactly("celeste"); + } + + @Test + void sure_YearTwoOff_IsEmpty() { + assertThat(SpeedrunMatch.sure("Celeste", Set.of(2015), List.of(hit("celeste", "Celeste", 2018)))).isEmpty(); + } + + @Test + void sure_HitWithoutYear_OnlyWhenItIsTheOnlySameName() { + SpeedrunApiService.Game undated = hit("prey", "Prey", null); + + assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(undated))).contains(undated); + assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(undated, hit("other", "Prey", 2006)))).isEmpty(); + } + + @Test + void sure_RomanNumeralsMatchDigits() { + assertThat(ids("Final Fantasy VII", Set.of(1997), List.of(hit("ff7", "Final Fantasy 7", 1997)))).containsExactly("ff7"); + assertThat(SpeedrunMatch.sure("Mega Man X", Set.of(), List.of(hit("mm10", "Mega Man 10", 2010)))).isEmpty(); + } + + @Test + void sure_OtherNamesAreIgnored() { + assertThat(ids("Hades", Set.of(2020), List.of(hit("hades2", "Hades II", 2024), hit("hades", "Hades", 2020)))) + .containsExactly("hades"); + assertThat(SpeedrunMatch.sure("Hades", Set.of(2024), List.of(hit("hades2", "Hades II", 2024)))).isEmpty(); + } + + @Test + void sure_RefusesLinksOutsideSpeedrunCom() { List hits = List.of( new SpeedrunApiService.Game("a", "Celeste", 2018, "javascript:alert(1)"), new SpeedrunApiService.Game("b", "Celeste", 2018, "https://www.speedrun.com.evil.example/x"), new SpeedrunApiService.Game("c", "Celeste", 2018, null), new SpeedrunApiService.Game("d", "Celeste", 2018, SpeedrunMatch.WEBLINK_PREFIX + "x".repeat(500))); - assertThat(SpeedrunMatch.candidates("Celeste", Set.of(), hits)).isEmpty(); + assertThat(SpeedrunMatch.sure("Celeste", Set.of(), hits)).isEmpty(); + } + + @Test + void withoutEditionSuffix_StripsKnownSuffixes() { + assertThat(SpeedrunMatch.withoutEditionSuffix("The Elder Scrolls V: Skyrim Special Edition")) + .contains("The Elder Scrolls V: Skyrim"); + assertThat(SpeedrunMatch.withoutEditionSuffix("The Witcher 3: Wild Hunt - Game of the Year Edition")) + .contains("The Witcher 3: Wild Hunt"); + assertThat(SpeedrunMatch.withoutEditionSuffix("Borderlands GOTY")).contains("Borderlands"); + assertThat(SpeedrunMatch.withoutEditionSuffix("Death Stranding Director's Cut")).contains("Death Stranding"); + assertThat(SpeedrunMatch.withoutEditionSuffix("DOOM Eternal Deluxe Edition")).contains("DOOM Eternal"); + assertThat(SpeedrunMatch.withoutEditionSuffix("Mafia: Definitive Edition")).contains("Mafia"); + } + + @Test + void withoutEditionSuffix_StripsTheLeadingThe() { + assertThat(SpeedrunMatch.withoutEditionSuffix("Grand Theft Auto IV: The Complete Edition")).contains("Grand Theft Auto IV"); + } + + @Test + void withoutEditionSuffix_KeepsRemasteredAndPlainNames() { + assertThat(SpeedrunMatch.withoutEditionSuffix("Dark Souls Remastered")).isEmpty(); + assertThat(SpeedrunMatch.withoutEditionSuffix("Prey")).isEmpty(); + assertThat(SpeedrunMatch.withoutEditionSuffix("Gold Edition")).isEmpty(); } } diff --git a/src/test/java/core_service/modules/library/service/SpeedrunServiceTest.java b/src/test/java/core_service/modules/library/service/SpeedrunServiceTest.java index 845fbd6..e8d829c 100644 --- a/src/test/java/core_service/modules/library/service/SpeedrunServiceTest.java +++ b/src/test/java/core_service/modules/library/service/SpeedrunServiceTest.java @@ -1,7 +1,9 @@ package core_service.modules.library.service; import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.domain.GameEdition; import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.repo.GameEditionRepository; import core_service.modules.library.repo.GameSpeedrunRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; @@ -9,7 +11,7 @@ import core_service.modules.speedrun_adapter.service.SpeedrunApiService; 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.InOrder; import org.mockito.Mock; import org.mockito.Spy; import org.springframework.core.task.SyncTaskExecutor; @@ -29,6 +31,9 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -44,18 +49,23 @@ class SpeedrunServiceTest extends AbstractUnitTest { @Mock private GameSpeedrunRepository gameSpeedrunRepository; @Mock + private GameEditionRepository gameEditionRepository; + @Mock private SteamGameRepository steamGameRepository; @Mock private UserGameRepository userGameRepository; @Spy private TaskExecutor syncFollowUpExecutor = new SyncTaskExecutor(); - @InjectMocks + @Spy + private TaskExecutor speedrunExecutor = new SyncTaskExecutor(); private SpeedrunService service; private static final String LINK = "https://www.speedrun.com/prey2017"; @BeforeEach void setUp() { + service = new SpeedrunService(speedrunApiService, igdbApiService, gameSpeedrunRepository, gameEditionRepository, + steamGameRepository, userGameRepository, syncFollowUpExecutor, speedrunExecutor); when(speedrunApiService.isEnabled()).thenReturn(true); } @@ -72,6 +82,23 @@ class SpeedrunServiceTest extends AbstractUnitTest { }); } + private void game(long appid, String name) { + when(steamGameRepository.findAllById(any())).thenReturn(List.of(new SteamGame(appid, name, null, Instant.EPOCH))); + } + + private void edition(long appid, long igdbId, long sameGameId) { + GameEdition edition = mock(GameEdition.class); + lenient().when(edition.getAppid()).thenReturn(appid); + when(edition.getIgdbId()).thenReturn(igdbId); + when(edition.getSameGameId()).thenReturn(sameGameId); + when(gameEditionRepository.findAllById(any())).thenReturn(List.of(edition)); + } + + private void igdbYears(long appid, Integer... years) { + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.getReleaseYears(List.of(appid))).thenReturn(Optional.of(Map.of(appid, Set.of(years)))); + } + private static SpeedrunApiService.Game hit(String id, String name, int year) { return new SpeedrunApiService.Game(id, name, year, "https://www.speedrun.com/" + id); } @@ -103,18 +130,128 @@ class SpeedrunServiceTest extends AbstractUnitTest { } @Test - void refresh_NoGameWithRuns_StoresAnEmptyRowAfterTwoCandidates() { + void refresh_SureGameWithoutRuns_StoresNoLink() { names(620); - when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of( - hit("a", "Prey", 2006), hit("b", "Prey", 2006), hit("c", "Prey", 2006)))); - when(speedrunApiService.hasVerifiedRuns(anyString())).thenReturn(Optional.of(false)); + when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(hit("prey", "Prey", 2006)))); + when(speedrunApiService.hasVerifiedRuns("prey")).thenReturn(Optional.of(false)); service.refresh(List.of(620L)); - verify(speedrunApiService, times(2)).hasVerifiedRuns(anyString()); + verify(speedrunApiService, times(1)).hasVerifiedRuns(anyString()); verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any()); } + @Test + void refresh_SeveralSameNamesWithoutYear_StoresNoLinkWithoutAskingForRuns() { + names(620); + when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of( + hit("prey", "Prey", 2006), hit("prey2017", "Prey (2017)", 2017)))); + + service.refresh(List.of(620L)); + + verify(speedrunApiService, never()).hasVerifiedRuns(anyString()); + verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any()); + } + + @Test + void refresh_EditionMisses_ParentNameHits() { + game(620, "The Elder Scrolls V: Skyrim Special Edition"); + igdbYears(620, 2016); + edition(620, 100, 50); + when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.of(Map.of( + 50L, new IgdbApiService.NameAndYear("The Elder Scrolls V: Skyrim", 2011)))); + when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim Special Edition")).thenReturn(Optional.of(List.of())); + when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim")).thenReturn(Optional.of(List.of( + new SpeedrunApiService.Game("skyrim", "The Elder Scrolls V: Skyrim", 2011, "https://www.speedrun.com/skyrim")))); + when(speedrunApiService.hasVerifiedRuns("skyrim")).thenReturn(Optional.of(true)); + + service.refresh(List.of(620L)); + + verify(gameSpeedrunRepository).upsert(eq(620L), eq("skyrim"), eq("https://www.speedrun.com/skyrim"), any()); + } + + @Test + void refresh_NoParent_TriesTheNameWithoutEditionSuffix() { + game(620, "DOOM Eternal Deluxe Edition"); + when(speedrunApiService.findGames("DOOM Eternal Deluxe Edition")).thenReturn(Optional.of(List.of())); + when(speedrunApiService.findGames("DOOM Eternal")).thenReturn(Optional.of(List.of(hit("doome", "DOOM Eternal", 2020)))); + when(speedrunApiService.hasVerifiedRuns("doome")).thenReturn(Optional.of(true)); + + service.refresh(List.of(620L)); + + verify(gameSpeedrunRepository).upsert(eq(620L), eq("doome"), eq("https://www.speedrun.com/doome"), any()); + } + + @Test + void refresh_RemakeHasNoParent_DoesNotUseTheOriginal() { + game(620, "Resident Evil 2"); + igdbYears(620, 2019); + edition(620, 100, 100); + when(speedrunApiService.findGames("Resident Evil 2")).thenReturn(Optional.of(List.of(hit("re2old", "Resident Evil 2", 1998)))); + + service.refresh(List.of(620L)); + + verify(speedrunApiService, times(1)).findGames(anyString()); + verify(igdbApiService, never()).getNamesAndYears(any()); + verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any()); + } + + @Test + void refresh_RemakeWithEditionSuffix_DoesNotGetTheOriginal() { + game(620, "Mafia: Definitive Edition"); + igdbYears(620, 2020); + edition(620, 100, 100); + when(speedrunApiService.findGames("Mafia: Definitive Edition")).thenReturn(Optional.of(List.of(hit("mafia", "Mafia", 2002)))); + lenient().when(speedrunApiService.findGames("Mafia")).thenReturn(Optional.of(List.of(hit("mafia", "Mafia", 2002)))); + lenient().when(speedrunApiService.hasVerifiedRuns("mafia")).thenReturn(Optional.of(true)); + + service.refresh(List.of(620L)); + + verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any()); + } + + @Test + void refresh_EditionOfAMainGame_TriesTheNameWithoutEditionSuffix() { + game(620, "DOOM Eternal Deluxe Edition"); + igdbYears(620, 2020); + edition(620, 100, 100); + when(speedrunApiService.findGames("DOOM Eternal Deluxe Edition")).thenReturn(Optional.of(List.of())); + lenient().when(speedrunApiService.findGames("DOOM Eternal")).thenReturn(Optional.of(List.of(hit("doome", "DOOM Eternal", 2020)))); + lenient().when(speedrunApiService.hasVerifiedRuns("doome")).thenReturn(Optional.of(true)); + + service.refresh(List.of(620L)); + + verify(gameSpeedrunRepository).upsert(eq(620L), eq("doome"), eq("https://www.speedrun.com/doome"), any()); + } + + @Test + void refresh_ParentSearchFails_StoresNothing() { + game(620, "The Elder Scrolls V: Skyrim Special Edition"); + igdbYears(620, 2016); + edition(620, 100, 50); + when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.of(Map.of( + 50L, new IgdbApiService.NameAndYear("The Elder Scrolls V: Skyrim", 2011)))); + when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim Special Edition")).thenReturn(Optional.of(List.of())); + when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim")).thenReturn(Optional.empty()); + + service.refresh(List.of(620L)); + + verify(gameSpeedrunRepository, never()).upsert(any(), any(), any(), any()); + } + + @Test + void refresh_ParentNamesIgdbFailed_StoresNothing() { + game(620, "The Elder Scrolls V: Skyrim Special Edition"); + igdbYears(620, 2016); + edition(620, 100, 50); + when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.empty()); + + service.refresh(List.of(620L)); + + verify(speedrunApiService, never()).findGames(anyString()); + verify(gameSpeedrunRepository, never()).upsert(any(), any(), any(), any()); + } + @Test void refresh_FailedCall_StoresNothing() { names(620); @@ -228,6 +365,59 @@ class SpeedrunServiceTest extends AbstractUnitTest { verifyNoInteractions(gameSpeedrunRepository, steamGameRepository); } + @Test + void recheckMarked_QueuesTheMarkedGames() { + names(1, 2); + when(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).thenReturn(List.of(1L, 2L)); + when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of())); + + service.recheckMarked(); + + verify(gameSpeedrunRepository).upsert(eq(1L), isNull(), isNull(), any()); + verify(gameSpeedrunRepository).upsert(eq(2L), isNull(), isNull(), any()); + } + + @Test + void recheckMarked_Disabled_DoesNothing() { + when(speedrunApiService.isEnabled()).thenReturn(false); + + service.recheckMarked(); + + verifyNoInteractions(gameSpeedrunRepository, steamGameRepository); + } + + @Test + void recheckMarked_UserRequestQueuedAfterManyMarkedGames_IsServedNext() { + long[] marked = LongStream.rangeClosed(1, 30).toArray(); + names(LongStream.concat(LongStream.of(999), LongStream.of(marked)).toArray()); + when(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).thenReturn(LongStream.of(marked).boxed().toList()); + when(speedrunApiService.findGames("Prey")).thenAnswer(call -> { + // The first marked game is being asked when a user asks for another one. + service.refresh(List.of(999L)); + return Optional.of(List.of()); + }).thenReturn(Optional.of(List.of())); + + service.recheckMarked(); + + InOrder order = inOrder(gameSpeedrunRepository); + order.verify(gameSpeedrunRepository).upsert(eq(1L), isNull(), isNull(), any()); + order.verify(gameSpeedrunRepository).upsert(eq(999L), isNull(), isNull(), any()); + order.verify(gameSpeedrunRepository).upsert(eq(2L), isNull(), isNull(), any()); + verify(gameSpeedrunRepository, times(31)).upsert(any(), isNull(), isNull(), any()); + } + + @Test + void refresh_OnlyQueuesAndLeavesTheWorkToTheSpeedrunExecutor() { + TaskExecutor waiting = mock(TaskExecutor.class); + SpeedrunService queued = new SpeedrunService(speedrunApiService, igdbApiService, gameSpeedrunRepository, + gameEditionRepository, steamGameRepository, userGameRepository, syncFollowUpExecutor, waiting); + + queued.refresh(List.of(620L)); + + verify(waiting).execute(any()); + verifyNoInteractions(steamGameRepository); + } + @Test void onLibrarySynced_RefreshesTheBacklogOnTheListenerThread() { when(userGameRepository.findBacklogAppids(7L)).thenReturn(List.of()); diff --git a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java index cdb3217..9686812 100644 --- a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java @@ -53,6 +53,13 @@ public abstract class AbstractIntegrationTest { @TestBean(name = BackgroundPools.SYNC_FOLLOW_UP) private TaskExecutor syncFollowUpExecutor; + @TestBean(name = BackgroundPools.SPEEDRUN) + private TaskExecutor speedrunExecutor; + + static TaskExecutor speedrunExecutor() { + return new SyncTaskExecutor(); + } + static TaskExecutor applicationTaskExecutor() { return new SyncTaskExecutor(); }