feat(library): time to beat from IGDB

This commit is contained in:
lobo committed 2026-09-24 14:40:41 +02:00
1 parent 2f2f8a2918
commit 00a7991380
23 files changed
+853 -22

No files matched your search

@@ -33,8 +33,8 @@ class ArchitectureTest extends AbstractArchitectureTest {
.should().notDependOnEachOther()
.ignoreDependency(
JavaClass.Predicates.resideInAPackage("core_service.modules.library.."),
JavaClass.Predicates.resideInAPackage("core_service.modules.steam_adapter.."))
.because("Feature modules should be isolated; steam_adapter is an explicit shared port.");
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.");
@ArchTest
static final ArchRule no_cyclic_dependencies = SlicesRuleDefinition.slices()
@@ -0,0 +1,178 @@
package core_service.modules.igdb_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.HttpMethod;
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.Map;
import java.util.Optional;
import java.util.stream.LongStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.not;
import static org.springframework.test.web.client.ExpectedCount.once;
import static org.springframework.test.web.client.ExpectedCount.times;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
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;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withUnauthorizedRequest;
class IgdbApiServiceTest extends AbstractUnitTest {
private static final String TOKEN = "{\"access_token\": \"token-1\", \"expires_in\": 5000000, \"token_type\": \"bearer\"}";
private MockRestServiceServer twitch;
private MockRestServiceServer igdb;
private IgdbApiService service;
private IgdbApiService create(String clientId, String clientSecret) {
RestClient.Builder twitchBuilder = RestClient.builder();
RestClient.Builder igdbBuilder = RestClient.builder();
twitch = MockRestServiceServer.bindTo(twitchBuilder).build();
igdb = MockRestServiceServer.bindTo(igdbBuilder).build();
return new IgdbApiService(igdbBuilder.build(), twitchBuilder.build(), clientId, clientSecret, RateLimiterRegistry.ofDefaults());
}
@BeforeEach
void setUp() {
service = create("client-1", "secret-1");
}
private void expectToken() {
twitch.expect(once(), requestTo(containsString("/oauth2/token")))
.andExpect(method(HttpMethod.POST))
.andExpect(queryParam("client_id", "client-1"))
.andExpect(queryParam("client_secret", "secret-1"))
.andExpect(queryParam("grant_type", "client_credentials"))
.andRespond(withSuccess(TOKEN, MediaType.APPLICATION_JSON));
}
@Test
void isEnabled_OnlyWithIdAndSecret() {
assertThat(service.isEnabled()).isTrue();
assertThat(create("", "secret").isEnabled()).isFalse();
assertThat(create("id", null).isEnabled()).isFalse();
}
@Test
void getTimeToBeat_MapsSteamAppsToIgdbTimesInMinutes() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andExpect(header("Client-ID", "client-1"))
.andExpect(header("Authorization", "Bearer token-1"))
.andExpect(content().string(allOf(containsString("external_game_source = 1"), containsString("uid = (\"620\",\"220\")"))))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"220\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andExpect(content().string(containsString("game_id = (72,233)")))
.andRespond(withSuccess("[{\"id\": 9, \"game_id\": 72, \"hastily\": 30600, \"normally\": 45000, \"completely\": 81000, \"count\": 12}]",
MediaType.APPLICATION_JSON));
Optional<Map<Long, IgdbApiService.TimeToBeat>> result = service.getTimeToBeat(List.of(620L, 220L));
assertThat(result).isPresent();
assertThat(result.get()).containsOnlyKeys(620L);
assertThat(result.get().get(620L)).isEqualTo(new IgdbApiService.TimeToBeat(510, 750, 1350));
}
@Test
void getTimeToBeat_ReusesTheToken() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
service.getTimeToBeat(List.of(620L));
service.getTimeToBeat(List.of(220L));
twitch.verify();
}
@Test
void getTimeToBeat_AsksIn250erBatches() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andExpect(content().string(allOf(containsString("\"250\""), not(containsString("\"251\"")), containsString("limit 500"))))
.andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/external_games")))
.andExpect(content().string(allOf(containsString("\"251\""), not(containsString("\"250\"")))))
.andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
assertThat(service.getTimeToBeat(LongStream.rangeClosed(1, 251).boxed().toList())).contains(Map.of());
igdb.verify();
}
@Test
void getTimeToBeat_Error_IsEmptySoNothingIsStoredAsUnknown() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
assertThat(service.getTimeToBeat(List.of(620L))).isEmpty();
}
@Test
void getTimeToBeat_401_RequestsANewTokenOnTheNextCall() {
twitch.expect(times(2), requestTo(containsString("/oauth2/token")))
.andRespond(withSuccess(TOKEN, MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withUnauthorizedRequest());
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
assertThat(service.getTimeToBeat(List.of(620L))).isEmpty();
assertThat(service.getTimeToBeat(List.of(620L))).contains(Map.of());
twitch.verify();
}
@Test
void getTimeToBeat_OneIgdbGameWithTwoSteamApps_TimesBothApps() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 72, \"uid\": \"221\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andRespond(withSuccess("[{\"id\": 9, \"game_id\": 72, \"hastily\": 600, \"normally\": 1200, \"completely\": 1800}]",
MediaType.APPLICATION_JSON));
Optional<Map<Long, IgdbApiService.TimeToBeat>> result = service.getTimeToBeat(List.of(620L, 221L));
assertThat(result).isPresent();
assertThat(result.get()).containsOnlyKeys(620L, 221L);
}
@Test
void getTimeToBeat_OneSteamAppWithTwoIgdbGames_TakesTheTimeMostPlayersSubmitted() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 73, \"uid\": \"620\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andExpect(content().string(containsString("count")))
.andRespond(withSuccess("[{\"game_id\": 72, \"normally\": 600, \"count\": 2}, {\"game_id\": 73, \"normally\": 1200, \"count\": 40}]",
MediaType.APPLICATION_JSON));
assertThat(service.getTimeToBeat(List.of(620L)).orElseThrow().get(620L).normallyMinutes()).isEqualTo(20);
}
@Test
void getTimeToBeat_Disabled_CallsNothing() {
IgdbApiService disabled = create("", "");
assertThat(disabled.getTimeToBeat(List.of(620L))).isEmpty();
twitch.verify();
igdb.verify();
}
}
@@ -1,8 +1,10 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
@@ -37,6 +39,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
@@ -50,6 +55,7 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.neverPlayed").value(0))
.andExpect(jsonPath("$.byStatus.BACKLOG").value(0))
.andExpect(jsonPath("$.byStatus.DROPPED").value(0))
.andExpect(jsonPath("$.backlogMinutes").value(0))
.andExpect(jsonPath("$.suggestion").value(nullValue()));
}
@@ -61,6 +67,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
UserGame finished = new UserGame(USER_ID, 620L, 600, Instant.now(), Instant.now());
finished.setStatus(GameStatus.FINISHED);
userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), finished));
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(220L, 500, 700, 900, Instant.now()),
new GameTimeToBeat(620L, 1, 9999, 1, Instant.now())));
mockMvc.perform(get("/api/library/summary"))
.andExpect(status().isOk())
@@ -71,7 +80,9 @@ class LibraryControllerSummaryTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.byStatus.PLAYING").value(0))
.andExpect(jsonPath("$.suggestion.appid").value(220))
.andExpect(jsonPath("$.suggestion.name").value("Half-Life 2"))
.andExpect(jsonPath("$.suggestion.status").value("BACKLOG"));
.andExpect(jsonPath("$.suggestion.status").value("BACKLOG"))
.andExpect(jsonPath("$.suggestion.timeToBeatMinutes").value(700))
.andExpect(jsonPath("$.backlogMinutes").value(700));
}
@Test
@@ -1,6 +1,7 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.SteamGame;
@@ -37,6 +38,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@@ -56,7 +60,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> appids(GameStatus status, String query) {
return userGameRepository.findOwnedGames(USER_ID, status, UserGameRepository.containsPattern(query), PAGE)
return userGameRepository.findOwnedGames(USER_ID, status, UserGameRepository.containsPattern(query), null, PAGE)
.map(UserGame::getAppid).getContent();
}
@@ -89,7 +93,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test
void findOwnedGames_CountsAllMatchesAcrossPages() {
var page = userGameRepository.findOwnedGames(USER_ID, null, UserGameRepository.containsPattern("half"),
var page = userGameRepository.findOwnedGames(USER_ID, null, UserGameRepository.containsPattern("half"), null,
PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
@@ -97,7 +101,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> sortedAppids(Sort sort) {
return userGameRepository.findOwnedGames(USER_ID, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
return userGameRepository.findOwnedGames(USER_ID, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
.map(UserGame::getAppid).getContent();
}
@@ -141,6 +145,28 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
userGameRepository.flush();
}
@Test
void findOwnedGames_MaxMinutes_KeepsOnlyGamesWithAKnownShortTime() {
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(220L, 500, 700, 1000, Instant.now()),
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(380L, null, null, null, Instant.now())));
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, null, null, 600, PAGE).map(UserGame::getAppid).getContent();
assertEquals(List.of(620L), shortGames);
}
@Test
void sumBacklogMinutes_AddsTheNormalTimeOfBacklogGamesWithData() {
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(380L, 100, 200, 300, Instant.now()),
new GameTimeToBeat(700L, null, 150, null, Instant.now()),
new GameTimeToBeat(620L, 1, 9999, 1, Instant.now())));
assertEquals(350, userGameRepository.sumBacklogMinutes(USER_ID));
}
@Test
void containsPattern_BlankIsNoFilter() {
assertNull(UserGameRepository.containsPattern(" "));
@@ -96,6 +96,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private WishlistGameRepository wishlistGameRepository;
@Mock
private TimeToBeatService timeToBeatService;
@InjectMocks
private LibrarySyncWorker worker;
@@ -351,4 +354,13 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(wishlistGameRepository).deleteByUserId(USER_ID);
}
@Test
void runSync_RefreshesTheTimeToBeatOfTheOwnedGames() {
steamReturns(game(0, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(timeToBeatService).refresh(List.of(220L));
}
}
@@ -0,0 +1,92 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Two users' syncs run in parallel and can miss the same app, e.g. every app right after the first deploy.
*/
class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest {
@Autowired
private TimeToBeatService timeToBeatService;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@MockitoBean
private IgdbApiService igdbApiService;
// Before as well: other integration tests leave rows in this shared table, and a fresh row would hide the race.
@BeforeEach
@AfterEach
void clean() {
gameTimeToBeatRepository.deleteAll();
}
@Test
void refresh_TwoParallelSyncsOfTheSameNewApp_BothCommit() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.TimeToBeat(510, 750, 1350))));
CountDownLatch firstWrote = new CountDownLatch(1);
CountDownLatch secondStarted = new CountDownLatch(1);
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager)
.executeWithoutResult(tx -> {
timeToBeatService.refresh(List.of(620L));
firstWrote.countDown();
await(secondStarted);
// Keep the transaction open while the second sync writes the same row.
sleep();
}));
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> {
await(firstWrote);
secondStarted.countDown();
new TransactionTemplate(transactionManager).executeWithoutResult(tx -> timeToBeatService.refresh(List.of(620L)));
});
assertDoesNotThrow(() -> CompletableFuture.allOf(first, second).get(30, TimeUnit.SECONDS));
assertEquals(750, gameTimeToBeatRepository.findById(620L).orElseThrow().getNormallyMinutes());
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
private static void sleep() {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
@@ -0,0 +1,85 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TimeToBeatServiceTest extends AbstractUnitTest {
@Mock
private IgdbApiService igdbApiService;
@Mock
private GameTimeToBeatRepository gameTimeToBeatRepository;
@InjectMocks
private TimeToBeatService service;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "ttlDays", 30L);
}
@Test
void refresh_AsksOnlyForMissingOrOldAppsAndStoresUnknownOnesToo() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L));
when(igdbApiService.getTimeToBeat(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.TimeToBeat(510, 750, 1350))));
service.refresh(List.of(220L, 620L, 700L));
verify(gameTimeToBeatRepository).upsert(eq(620L), eq(510), eq(750), eq(1350), any(Instant.class));
verify(gameTimeToBeatRepository).upsert(eq(700L), isNull(), isNull(), isNull(), any(Instant.class));
verify(gameTimeToBeatRepository, never()).upsert(eq(220L), any(), any(), any(), any());
}
@Test
void refresh_IgdbError_StoresNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(gameTimeToBeatRepository, never()).upsert(any(), any(), any(), any(), any());
}
@Test
void refresh_Disabled_DoesNotEvenReadTheCache() {
when(igdbApiService.isEnabled()).thenReturn(false);
service.refresh(List.of(620L));
verify(gameTimeToBeatRepository, never()).findFreshAppids(anyCollection(), any(Instant.class));
}
@Test
void refresh_AllFresh_AsksIgdbNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(620L));
service.refresh(List.of(620L));
verify(igdbApiService, never()).getTimeToBeat(any());
}
}