feat(library): add a speedrun mood from speedrun.com
Build, Test & Push Core Service / quality-check (push) Successful in 1m4s
Build, Test & Push Core Service / docker (push) Successful in 1m23s

The picker takes mood=SPEEDRUN for backlog games with a speedrun.com
game that has at least one verified run. The game detail returns the
link as speedrunUrl.

speedrun.com has no Steam or IGDB link, so games are matched by name
and, when IGDB knows it, by release year (one year off at most). One
queue serves all users at 1 request per second. Results are kept 30
days in game_speedrun.
This commit is contained in:
lobo committed 2026-09-26 10:24:22 +02:00
1 parent 3224257325
commit 393cdddf06
27 files changed
+1179 -22

No files matched your search

@@ -17,7 +17,7 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
* Notes on Core-specific structure (differs from auth-service):
* - JPA entities live in `..domain..` (not `..persistence..`).
* - Repository interfaces live in `..repo..`.
* - `steam_adapter` is an outbound port consumed by feature modules
* - `steam_adapter`, `igdb_adapter` and `speedrun_adapter` are outbound ports consumed by feature modules
* (e.g. `library`), so strict slice independence is relaxed for it.
*/
@AnalyzeClasses(packages = "core_service", importOptions = ImportOption.DoNotIncludeTests.class)
@@ -33,8 +33,9 @@ class ArchitectureTest extends AbstractArchitectureTest {
.should().notDependOnEachOther()
.ignoreDependency(
JavaClass.Predicates.resideInAPackage("core_service.modules.library.."),
JavaClass.Predicates.resideInAnyPackage("core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter.."))
.because("Feature modules should be isolated; steam_adapter and igdb_adapter are explicit shared ports.");
JavaClass.Predicates.resideInAnyPackage("core_service.modules.steam_adapter..", "core_service.modules.igdb_adapter..",
"core_service.modules.speedrun_adapter.."))
.because("Feature modules should be isolated; steam_adapter, igdb_adapter and speedrun_adapter are explicit shared ports.");
@ArchTest
static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices()
@@ -12,6 +12,7 @@ import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.LongStream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -126,6 +127,39 @@ class IgdbApiServiceTest extends AbstractUnitTest {
assertThat(tags.get(620L).themes()).contains("Horror");
}
@Test
void getReleaseYears_SteamAppLinkedToTwoIgdbGames_GetsBothYears() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"game\": 72, \"uid\": \"620\"}, {\"game\": 233, \"uid\": \"620\"}, {\"game\": 9, \"uid\": \"220\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(containsString("fields first_release_date;"), containsString("id = ("))))
// 2006-06-01 and 2017-05-05 UTC; game 9 has no date
.andRespond(withSuccess("[{\"id\": 72, \"first_release_date\": 1149120000}, {\"id\": 233, \"first_release_date\": 1493942400}, {\"id\": 9}]",
MediaType.APPLICATION_JSON));
assertThat(service.getReleaseYears(List.of(620L, 220L))).contains(Map.of(620L, Set.of(2006, 2017)));
}
@Test
void getReleaseYearsByGameIds_MapsTheIgdbIdToItsYear() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("id = (1025)")))
.andRespond(withSuccess("[{\"id\": 1025, \"first_release_date\": 1493942400}]", MediaType.APPLICATION_JSON));
assertThat(service.getReleaseYearsByGameIds(List.of(1025L))).contains(Map.of(1025L, Set.of(2017)));
}
@Test
void getReleaseYears_IgdbError_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
assertThat(service.getReleaseYears(List.of(620L))).isEmpty();
}
@Test
void getGameTags_NoSteamAppKnown_AsksNoGames() {
expectToken();
@@ -7,11 +7,13 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SpeedrunService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -26,6 +28,7 @@ import java.util.List;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasSize;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -59,11 +62,18 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
@MockitoBean
private MoodService moodService;
@MockitoBean
private SpeedrunService speedrunService;
@Autowired
private GameSpeedrunRepository gameSpeedrunRepository;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
gameSpeedrunRepository.deleteAll();
}
private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) {
@@ -95,6 +105,21 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
verify(moodService).refreshBacklogInBackground(USER_ID);
}
@Test
void picker_Speedrun_PicksOnlyGamesWithASpeedrunLink() throws Exception {
game(10L, GameStatus.BACKLOG, null, null);
game(20L, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.BACKLOG, null, null);
gameSpeedrunRepository.upsert(10L, "o1y9j9v6", "https://www.speedrun.com/celeste", Instant.now());
gameSpeedrunRepository.upsert(20L, null, null, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "SPEEDRUN"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
verify(speedrunService).refreshBacklogInBackground(USER_ID);
verifyNoInteractions(moodService);
}
@Test
void picker_MoodAndBudget_BothMustFit() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
@@ -0,0 +1,51 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameSpeedrun;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class GameSpeedrunRepositoryTest extends AbstractPersistenceTest {
@Autowired
private GameSpeedrunRepository gameSpeedrunRepository;
@Autowired
private EntityManager entityManager;
@BeforeEach
void clean() {
gameSpeedrunRepository.deleteAll();
}
@Test
void upsert_StoresNoMatchAndReplacesItWithTheLinkLater() {
gameSpeedrunRepository.upsert(620L, null, null, Instant.now());
assertThat(gameSpeedrunRepository.findById(620L).orElseThrow().getWeblink()).isNull();
gameSpeedrunRepository.upsert(620L, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
// The native upsert passes the persistence context; read the row again.
entityManager.clear();
GameSpeedrun row = gameSpeedrunRepository.findById(620L).orElseThrow();
assertThat(row.getSpeedrunGameId()).isEqualTo("om1mw4d2");
assertThat(row.getWeblink()).isEqualTo("https://www.speedrun.com/portal_2");
}
@Test
void findFreshAppids_LeavesOutOldAndMissingRows() {
gameSpeedrunRepository.upsert(620L, null, null, Instant.now());
gameSpeedrunRepository.upsert(220L, null, null, Instant.now().minus(Duration.ofDays(40)));
assertThat(gameSpeedrunRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
}
}
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -70,6 +71,12 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
@MockitoBean
private MoodService moodService;
@MockitoBean
private SpeedrunService speedrunService;
@Autowired
private GameSpeedrunRepository gameSpeedrunRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
@@ -77,6 +84,7 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
schemaMetaRepository.deleteAll();
userAchievementRepository.deleteAll();
userAchievementMetaRepository.deleteAll();
gameSpeedrunRepository.deleteAll();
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now()));
}
@@ -106,6 +114,23 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
org.mockito.Mockito.verify(moodService).refreshInBackground(List.of(620L));
}
@Test
void getGameDetail_ShowsTheSpeedrunLinkAndAsksForIt() {
gameSpeedrunRepository.upsert(620L, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now());
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
assertEquals("https://www.speedrun.com/portal_2", detail.speedrunUrl());
org.mockito.Mockito.verify(speedrunService).refreshInBackground(List.of(620L));
}
@Test
void getGameDetail_NoSpeedrunGame_HasNoLink() {
gameSpeedrunRepository.upsert(620L, null, null, Instant.now());
assertEquals(null, gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow().speedrunUrl());
}
@Test
void getGameDetail_StaleUserMeta_ShowsTheRefreshedCount() {
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now()));
@@ -0,0 +1,71 @@
package core_service.modules.library.service;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class SpeedrunMatchTest extends AbstractUnitTest {
private static SpeedrunApiService.Game hit(String id, String name, Integer year) {
return new SpeedrunApiService.Game(id, name, year, "https://www.speedrun.com/" + id);
}
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 normalize_DropsCaseAccentsTrademarksAndPunctuation() {
assertThat(SpeedrunMatch.normalize(" Pokémon™: Let's Go, Pikachu!® ")).isEqualTo("pokemon let s go pikachu");
assertThat(SpeedrunMatch.normalize("The Witcher 3: Wild Hunt")).isEqualTo(SpeedrunMatch.normalize("The Witcher® 3 - Wild Hunt"));
}
@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);
}
@Test
void candidates_UnknownYear_KeepsSpeedrunOrder() {
assertThat(SpeedrunMatch.candidates("Prey", Set.of(), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2006, PREY_2017);
}
@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();
}
@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");
}
@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");
}
@Test
void candidates_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();
}
}
@@ -0,0 +1,225 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
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.Mock;
import org.springframework.dao.DataAccessResourceFailureException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.LongStream;
import static org.mockito.ArgumentMatchers.any;
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.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class SpeedrunServiceTest extends AbstractUnitTest {
@Mock
private SpeedrunApiService speedrunApiService;
@Mock
private IgdbApiService igdbApiService;
@Mock
private GameSpeedrunRepository gameSpeedrunRepository;
@Mock
private SteamGameRepository steamGameRepository;
@Mock
private UserGameRepository userGameRepository;
@InjectMocks
private SpeedrunService service;
private static final String LINK = "https://www.speedrun.com/prey2017";
@BeforeEach
void setUp() {
when(speedrunApiService.isEnabled()).thenReturn(true);
}
private void names(long... appids) {
when(steamGameRepository.findAllById(any())).thenAnswer(call -> {
Iterable<Long> asked = call.getArgument(0);
List<SteamGame> games = new ArrayList<>();
for (Long appid : asked) {
if (LongStream.of(appids).anyMatch(known -> known == appid)) {
games.add(new SteamGame(appid, "Prey", null, Instant.EPOCH));
}
}
return games;
});
}
private static SpeedrunApiService.Game hit(String id, String name, int year) {
return new SpeedrunApiService.Game(id, name, year, "https://www.speedrun.com/" + id);
}
@Test
void refresh_GameWithRuns_StoresTheLink() {
names(620);
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(hit("prey2017", "Prey (2017)", 2017))));
when(speedrunApiService.hasVerifiedRuns("prey2017")).thenReturn(Optional.of(true));
service.refresh(List.of(620L));
verify(gameSpeedrunRepository).upsert(eq(620L), eq("prey2017"), eq(LINK), any());
}
@Test
void refresh_UsesTheIgdbYearToPickTheRemake() {
names(620);
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getReleaseYears(List.of(620L))).thenReturn(Optional.of(Map.of(620L, Set.of(2017))));
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(
hit("prey", "Prey", 2006), hit("prey2017", "Prey (2017)", 2017))));
when(speedrunApiService.hasVerifiedRuns("prey2017")).thenReturn(Optional.of(true));
service.refresh(List.of(620L));
verify(speedrunApiService, never()).hasVerifiedRuns("prey");
verify(gameSpeedrunRepository).upsert(eq(620L), eq("prey2017"), eq(LINK), any());
}
@Test
void refresh_NoGameWithRuns_StoresAnEmptyRowAfterTwoCandidates() {
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));
service.refresh(List.of(620L));
verify(speedrunApiService, times(2)).hasVerifiedRuns(anyString());
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
}
@Test
void refresh_FailedCall_StoresNothing() {
names(620);
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(hit("prey", "Prey", 2006))));
when(speedrunApiService.hasVerifiedRuns("prey")).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(gameSpeedrunRepository, never()).upsert(any(), any(), any(), any());
}
@Test
void refresh_FreshGame_IsNotAskedAgain() {
when(gameSpeedrunRepository.findFreshAppids(any(), any())).thenReturn(Set.of(620L));
service.refresh(List.of(620L));
verify(speedrunApiService, never()).findGames(anyString());
}
@Test
void refresh_StopsAfterThreeErrorsInARow() {
names(1, 2, 3, 4, 5);
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.empty());
service.refresh(List.of(1L, 2L, 3L, 4L, 5L));
verify(speedrunApiService, times(SpeedrunService.MAX_ERRORS_IN_A_ROW)).findGames("Prey");
}
@Test
void refresh_CalledDuringARun_QueuesTheGameForTheRunningThread() {
names(620, 220);
when(speedrunApiService.findGames("Prey")).thenAnswer(call -> {
// A second caller while the first one drains: it must not start a drain of its own.
service.refresh(List.of(220L));
return Optional.of(List.of());
}).thenReturn(Optional.of(List.of()));
service.refresh(List.of(620L));
verify(speedrunApiService, times(2)).findGames("Prey");
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
verify(gameSpeedrunRepository).upsert(eq(220L), isNull(), isNull(), any());
}
@Test
void refresh_DbErrorOnOneGame_TheOtherGamesOfTheBatchAreStillChecked() {
names(1, 2);
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of()));
doThrow(new DataAccessResourceFailureException("connection reset"))
.when(gameSpeedrunRepository).upsert(eq(1L), any(), any(), any());
service.refresh(List.of(1L, 2L));
verify(gameSpeedrunRepository).upsert(eq(2L), isNull(), isNull(), any());
}
@Test
void refresh_DbErrorOnOneGame_GamesQueuedByOtherCallersAreStillChecked() {
names(1, 3);
when(speedrunApiService.findGames("Prey")).thenAnswer(call -> {
service.refresh(List.of(3L));
return Optional.of(List.of());
}).thenReturn(Optional.of(List.of()));
doThrow(new DataAccessResourceFailureException("connection reset"))
.when(gameSpeedrunRepository).upsert(eq(1L), any(), any(), any());
service.refresh(List.of(1L));
verify(gameSpeedrunRepository).upsert(eq(3L), isNull(), isNull(), any());
}
@Test
void refresh_IgdbFailed_StoresNothingInsteadOfMatchingByNameOnly() {
names(620);
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getReleaseYears(List.of(620L))).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(speedrunApiService, never()).findGames(anyString());
verify(gameSpeedrunRepository, never()).upsert(any(), any(), any(), any());
}
@Test
void refresh_SearchesWithoutTrademarkSigns() {
when(steamGameRepository.findAllById(any())).thenReturn(List.of(new SteamGame(620L, "DOOM Eternal™", null, Instant.EPOCH)));
when(speedrunApiService.findGames("DOOM Eternal")).thenReturn(Optional.of(List.of()));
service.refresh(List.of(620L));
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
}
@Test
void refresh_GameWithoutASteamName_IsNotSearched() {
when(steamGameRepository.findAllById(any())).thenReturn(List.of(new SteamGame(620L, SteamGame.placeholderName(620L), null, Instant.EPOCH)));
service.refresh(List.of(620L));
verify(speedrunApiService, never()).findGames(anyString());
}
@Test
void refresh_Disabled_DoesNothing() {
when(speedrunApiService.isEnabled()).thenReturn(false);
service.refresh(List.of(620L));
verifyNoInteractions(gameSpeedrunRepository, steamGameRepository);
}
}
@@ -0,0 +1,97 @@
package core_service.modules.speedrun_adapter.service;
import core_service.shared.test.AbstractUnitTest;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.CoreMatchers.containsString;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.queryParam;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class SpeedrunApiServiceTest extends AbstractUnitTest {
private MockRestServiceServer server;
private SpeedrunApiService service;
private SpeedrunApiService create(boolean enabled) {
RestClient.Builder builder = RestClient.builder();
server = MockRestServiceServer.bindTo(builder).build();
return new SpeedrunApiService(builder.build(), enabled, RateLimiterRegistry.ofDefaults());
}
@BeforeEach
void setUp() {
service = create(true);
}
@Test
void findGames_ReadsGamesAndDropsModsAndCategoryExtensions() {
server.expect(requestTo(containsString("/games?name=Portal%202&max=20")))
.andRespond(withSuccess("""
{"data": [
{"id": "om1mw4d2", "names": {"international": "Portal 2"}, "released": 2011,
"weblink": "https://www.speedrun.com/portal_2", "links": [{"rel": "self"}, {"rel": "leaderboard"}]},
{"id": "v1pxk8p6", "names": {"international": "Portal 2 Bhop"}, "released": 2012,
"weblink": "https://www.speedrun.com/p2bh", "links": [{"rel": "base-game"}]},
{"id": "x", "names": {"international": " "}}
]}
""", MediaType.APPLICATION_JSON));
assertThat(service.findGames("Portal 2")).contains(List.of(
new SpeedrunApiService.Game("om1mw4d2", "Portal 2", 2011, "https://www.speedrun.com/portal_2")));
}
@Test
void findGames_EncodesTheWholeName() {
server.expect(requestTo(containsString("name=Tom%20%26%20Jerry%E2%84%A2%2B&max")))
.andRespond(withSuccess("{\"data\": []}", MediaType.APPLICATION_JSON));
assertThat(service.findGames("Tom & Jerry™+")).contains(List.of());
}
@Test
void findGames_ServerError_IsEmpty() {
server.expect(requestTo(containsString("/games"))).andRespond(withServerError());
assertThat(service.findGames("Celeste")).isEmpty();
}
@Test
void findGames_Disabled_CallsNothing() {
service = create(false);
assertThat(service.findGames("Celeste")).isEmpty();
server.verify();
}
@Test
void hasVerifiedRuns_TrueWithARunFalseWithout() {
server.expect(requestTo(containsString("/runs")))
.andExpect(queryParam("game", "o1y9j9v6"))
.andExpect(queryParam("status", "verified"))
.andExpect(queryParam("max", "1"))
.andRespond(withSuccess("{\"data\": [{\"id\": \"run1\"}]}", MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("/runs")))
.andRespond(withSuccess("{\"data\": []}", MediaType.APPLICATION_JSON));
assertThat(service.hasVerifiedRuns("o1y9j9v6")).isEqualTo(Optional.of(true));
assertThat(service.hasVerifiedRuns("o1y9j9v6")).isEqualTo(Optional.of(false));
}
@Test
void hasVerifiedRuns_ServerError_IsEmpty() {
server.expect(requestTo(containsString("/runs"))).andRespond(withServerError());
assertThat(service.hasVerifiedRuns("o1y9j9v6")).isEmpty();
}
}
@@ -18,8 +18,9 @@ import java.util.Base64;
* Full Spring context with Testcontainers.
*/
@Tag("IntegrationTest")
// The nightly sync would call Steam if a test ran at 23:55.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.library.nightly-sync-cron=-")
// The nightly sync would call Steam if a test ran at 23:55; speedrun.com needs no key, so it is switched off.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.library.nightly-sync-cron=-", "app.speedrun.enabled=false"})
@ActiveProfiles("dev")
public abstract class AbstractIntegrationTest {