feat(speedrun): match speedrun.com games only when sure
Build, Test & Push Core Service / quality-check (push) Successful in 2m32s
Build, Test & Push Core Service / docker (push) Successful in 1m29s

Loose name matches gave wrong links. Now a link needs one sure hit:
same name and within 1 year of IGDB, or one hit when no year is known.
V47 marks old matches (not corrected ones) for one re-check. Refs #8
This commit is contained in:
lobo committed 2026-10-11 17:23:32 +02:00
1 parent bb195a1f79
commit 9efed0ab1d
14 files changed
+602 -80

No files matched your search

@@ -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;
}
}
@@ -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<String> 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<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> 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<Map<Long, NameAndYear>> getNamesAndYears(Collection<Long> igdbIds) {
return inBatches(igdbIds, "game names", this::namesAndYearsOf);
}
private <T> Optional<Map<Long, T>> inBatches(Collection<Long> igdbIds, String what,
Function<List<Long>, Map<Long, T>> batchQuery) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, LocalDate> result = new HashMap<>();
Map<Long, T> 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<Long, LocalDate> 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<Long, NameAndYear> namesAndYearsOf(Collection<Long> igdbIds) {
Map<Long, NameAndYear> 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<Long> 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();
}
@@ -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<GameSpeedrun, Long> {
@@ -48,4 +49,12 @@ public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long
@Modifying
@Query("update GameSpeedrun s set s.checkedAt = :longAgo where s.appid = :appid")
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
/**
* Rows marked for a new check (checked_at <= before) of games some user lists, without an auditor's link.
*/
@Query(value = "select appid from game_speedrun where checked_at <= :before and not " + CORRECTED
+ " and exists (select 1 from listed_user_game ug where ug.appid = game_speedrun.appid) order by appid",
nativeQuery = true)
List<Long> findAppidsToRecheck(@Param("before") Instant before);
}
@@ -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<String> 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<SpeedrunApiService.Game> candidates(String name, Collection<Integer> years, List<SpeedrunApiService.Game> hits) {
static Optional<SpeedrunApiService.Game> sure(String name, Collection<Integer> years, List<SpeedrunApiService.Game> hits) {
NameAndYear ours = split(name);
Set<Integer> ourYears = new HashSet<>(years);
if (ours.year() != null) ourYears.add(ours.year());
return hits.stream()
List<SpeedrunApiService.Game> 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<SpeedrunApiService.Game> 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<String> 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<SpeedrunApiService.Game> only(List<SpeedrunApiService.Game> 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) {}
@@ -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.
* <p>
* 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".
* <p>
* {@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<Long> pending = new ConcurrentSkipListSet<>();
private final ConcurrentSkipListSet<Long> 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<Long> 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<Long> 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<Long> 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<Long> 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<Map<Long, Set<Integer>>> years = releaseYears(batch);
if (years.isEmpty()) {
Optional<Map<Long, IgdbApiService.NameAndYear>> 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<Long> poll(ConcurrentSkipListSet<Long> queue, int size) {
List<Long> 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<Integer> years) {
Optional<List<SpeedrunApiService.Game>> hits = speedrunApiService.findGames(SpeedrunMatch.searchText(name));
if (hits.isEmpty()) return false;
private boolean checkAndStore(Long appid, String name, Set<Integer> years, IgdbApiService.NameAndYear parent) {
SpeedrunApiService.Game match = null;
for (SpeedrunApiService.Game candidate : SpeedrunMatch.candidates(name, years, hits.get())) {
Optional<Boolean> hasRuns = speedrunApiService.hasVerifiedRuns(candidate.id());
for (Lookup lookup : lookups(name, years, parent)) {
Optional<List<SpeedrunApiService.Game>> hits = speedrunApiService.findGames(SpeedrunMatch.searchText(lookup.name()));
if (hits.isEmpty()) return false;
Optional<SpeedrunApiService.Game> sure = SpeedrunMatch.sure(lookup.name(), lookup.years(), hits.get());
if (sure.isEmpty()) continue;
Optional<Boolean> 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<Lookup> lookups(String name, Set<Integer> years, IgdbApiService.NameAndYear parent) {
List<Lookup> 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<Integer> 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<Map<Long, IgdbApiService.NameAndYear>> parentNames(List<Long> appids) {
Map<Long, List<Long>> 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<Map<Long, IgdbApiService.NameAndYear>> found = igdbApiService.getNamesAndYears(appidsByParent.keySet());
if (found.isEmpty()) return Optional.empty();
Map<Long, IgdbApiService.NameAndYear> 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.
@@ -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() {
}
}
@@ -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');
@@ -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<String> 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();
@@ -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();
@@ -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();
}
}
@@ -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<String> ids(String name, Set<Integer> years, List<SpeedrunApiService.Game> 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<SpeedrunApiService.Game> 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<SpeedrunApiService.Game> 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<SpeedrunApiService.Game> 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();
}
}
@@ -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());
@@ -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();
}