Public Access
feat(library): add a speedrun mood from speedrun.com
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:
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);
|
||||
}
|
||||
}
|
||||
+97
@@ -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 {
|
||||
|
||||
|
||||
Reference in new issue
Block a user