feat(picker): add series and studio rows from IGDB credits
Build, Test & Push Core Service / quality-check (push) Successful in 1m22s
Build, Test & Push Core Service / docker (push) Successful in 1m36s

New V37 tables game_credit and game_credit_check store IGDB
collections and developer/publisher companies per game.
A scheduled job backfills existing games (250 per 30 s);
sync, import, manual add, merge and detail page refresh them.
SERIES row: next part of a collection the user played.
STUDIO row: backlog games from developers of top played games.
This commit is contained in:
lobo committed 2026-10-06 20:22:53 +02:00
1 parent 8dd398321c
commit 117ff61730
33 files changed
+1141 -32

No files matched your search

@@ -110,6 +110,71 @@ class IgdbApiServiceTest extends AbstractUnitTest {
Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of())));
}
@Test
void getCredits_ReturnsSeriesAndCompaniesPerSteamApp() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"220\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(
containsString("fields collections.name,involved_companies.company.name,"
+ "involved_companies.developer,involved_companies.publisher;"),
containsString("id = (72,233)"))))
.andRespond(withSuccess("[{\"id\": 72, \"collections\": [{\"id\": 10, \"name\": \"Mass Effect\"}],"
+ " \"involved_companies\": ["
+ "{\"id\": 1, \"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"developer\": true},"
+ "{\"id\": 3, \"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"publisher\": true},"
+ "{\"id\": 2, \"company\": {\"id\": 6, \"name\": \"Port Studio\"}, \"developer\": false, \"publisher\": false}]},"
+ " {\"id\": 233}]", MediaType.APPLICATION_JSON));
Map<Long, IgdbApiService.Credits> credits = service.getCredits(List.of(620L, 220L)).orElseThrow();
assertThat(credits).containsOnly(
Map.entry(620L, new IgdbApiService.Credits(List.of(new IgdbApiService.Series(10, "Mass Effect")),
List.of(new IgdbApiService.Company(5, "BioWare", true, true)))),
Map.entry(220L, new IgdbApiService.Credits(List.of(), List.of())));
}
@Test
void getCredits_TwoIgdbGamesOfOneApp_UnionByIds() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"620\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andRespond(withSuccess("[{\"id\": 72, \"collections\": [{\"id\": 10, \"name\": \"Mass Effect\"}],"
+ " \"involved_companies\": [{\"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"developer\": true}]},"
+ " {\"id\": 233, \"collections\": [{\"id\": 10, \"name\": \"Mass Effect\"}],"
+ " \"involved_companies\": [{\"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"publisher\": true}]}]",
MediaType.APPLICATION_JSON));
IgdbApiService.Credits credits = service.getCredits(List.of(620L)).orElseThrow().get(620L);
assertThat(credits.series()).containsExactly(new IgdbApiService.Series(10, "Mass Effect"));
assertThat(credits.companies()).containsExactly(new IgdbApiService.Company(5, "BioWare", true, true));
}
@Test
void getCreditsByGameIds_AsksTheGamesDirectly() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("id = (119388)")))
.andRespond(withSuccess("[{\"id\": 119388, \"collections\": [{\"id\": 3, \"name\": \"Zelda\"}]}]",
MediaType.APPLICATION_JSON));
assertThat(service.getCreditsByGameIds(List.of(119388L)).orElseThrow().get(119388L).series())
.containsExactly(new IgdbApiService.Series(3, "Zelda"));
}
@Test
void getCredits_ServerError_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
assertThat(service.getCredits(List.of(620L))).isEmpty();
}
@Test
void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() {
expectToken();
@@ -191,6 +191,20 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
assertThat(audit()).hasSize(1).first().asString().contains("GAME_MERGE");
}
@Test
void mergeDropsTheCreditsOfTheMergedKey() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
jdbc.update("delete from game_credit");
jdbc.update("delete from game_credit_check");
jdbc.update("insert into game_credit (appid, kind, igdb_id, name) values (?, 'SERIES', 10, 'Hades')", MANUAL);
jdbc.update("insert into game_credit_check (appid, checked_at) values (?, now())", MANUAL);
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
assertThat(jdbc.queryForObject("select count(*) from game_credit where appid = ?", Integer.class, MANUAL)).isZero();
assertThat(jdbc.queryForObject("select count(*) from game_credit_check where appid = ?", Integer.class, MANUAL)).isZero();
}
@Test
void merge_IntoAKeyWithItsOwnSet_JoinsBothSets() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
@@ -7,6 +7,8 @@ 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.domain.UserGameId;
import core_service.modules.library.repo.GameCreditCheckRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
@@ -76,9 +78,17 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
@Autowired
private UserGameAchievementMetaRepository achievementMetaRepository;
@Autowired
private GameCreditRepository gameCreditRepository;
@Autowired
private GameCreditCheckRepository gameCreditCheckRepository;
@BeforeEach
void clean() {
gameReleaseRepository.deleteAll();
gameCreditRepository.deleteAll();
gameCreditCheckRepository.deleteAll();
// A game with unlocked achievements is never coming; other test classes leave such rows.
achievementMetaRepository.deleteAll();
userGameRepository.deleteAll();
@@ -103,6 +113,14 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
}
}
private void credit(long appid, String kind, long igdbId, String name) {
gameCreditRepository.insert(appid, kind, igdbId, name);
}
private void release(long appid, LocalDate date) {
gameReleaseRepository.upsert(appid, date, false, Instant.now());
}
private void putOrder(Long... appids) throws Exception {
mockMvc.perform(put("/api/library/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
@@ -247,13 +265,20 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
}
gameMoodsRepository.upsert(1L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, new String[0], false, Instant.now());
credit(1L, "SERIES", 100L, "Series");
credit(12L, "SERIES", 100L, "Series");
credit(1L, "DEVELOPER", 200L, "Studio");
credit(13L, "DEVELOPER", 200L, "Studio");
mockMvc.perform(get("/api/library/picker"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.categories[*].source", contains("NEXT_UP", "SIMILAR", "LONGEST", "RANDOM")))
.andExpect(jsonPath("$.categories[*].source",
contains("NEXT_UP", "SERIES", "SIMILAR", "STUDIO", "LONGEST", "RANDOM")))
.andExpect(jsonPath("$.categories[*].games.length()", everyItem(lessThanOrEqualTo(3))))
.andExpect(jsonPath("$.categories[0].games[*].reason.source", everyItem(is("NEXT_UP"))))
.andExpect(jsonPath("$.categories[2].games[0].reason.firstSeenAt").exists());
.andExpect(jsonPath("$.categories[1].games[0].reason.source").value("SERIES"))
.andExpect(jsonPath("$.categories[3].games[0].reason.source").value("STUDIO"))
.andExpect(jsonPath("$.categories[4].games[0].reason.firstSeenAt").exists());
}
@Test
@@ -329,12 +354,155 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, null, 3000);
game(30L, GameStatus.BACKLOG, null, 60);
game(5L, GameStatus.FINISHED, null, null, 600, SEEN);
for (long appid : new long[]{5L, 10L, 20L, 30L}) {
credit(appid, "SERIES", 100L, "Series");
credit(appid, "DEVELOPER", 200L, "Studio");
}
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "300").param("exclude", "30"))
.andExpect(jsonPath(row("SERIES"), contains(10)))
.andExpect(jsonPath(row("STUDIO"), contains(10)))
.andExpect(jsonPath("$.categories[*].games[*].appid", everyItem(is(10))))
.andExpect(jsonPath("$.categories[*].games[*].appid", not(hasItem(20))));
}
@Test
void picker_NextInSeries_PicksThePartReleasedAfterThePlayedOne() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
game(3L, GameStatus.BACKLOG, null, null);
game(4L, GameStatus.BACKLOG, null, null);
for (long appid = 1; appid <= 4; appid++) {
credit(appid, "SERIES", 100L, "Mass Effect");
}
release(1L, LocalDate.of(2007, 11, 20));
release(2L, LocalDate.of(2010, 1, 26));
release(3L, LocalDate.of(2012, 3, 6));
release(4L, LocalDate.of(2005, 1, 1));
mockMvc.perform(get("/api/library/picker").param("category", "SERIES"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(2)))
.andExpect(jsonPath("$.categories[0].games[0].reason.source").value("SERIES"))
.andExpect(jsonPath("$.categories[0].games[0].reason.seriesName").value("Mass Effect"));
}
@Test
void picker_NextInSeries_WithoutDates_StillPicksAPart() throws Exception {
game(1L, GameStatus.PLAYING, null, null, 600, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Mass Effect");
credit(2L, "SERIES", 100L, "Mass Effect");
mockMvc.perform(get("/api/library/picker").param("category", "SERIES"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(2)));
}
@Test
void picker_NextInSeries_OneGamePerSeriesMostPlayedSeriesFirst() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 100, SEEN);
game(2L, GameStatus.FINISHED, null, null, 900, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
game(12L, GameStatus.BACKLOG, null, null);
game(13L, GameStatus.BACKLOG, null, null);
game(21L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Small");
credit(2L, "SERIES", 200L, "Big");
credit(11L, "SERIES", 100L, "Small");
credit(12L, "SERIES", 100L, "Small");
credit(13L, "SERIES", 100L, "Small");
credit(21L, "SERIES", 200L, "Big");
mockMvc.perform(get("/api/library/picker").param("category", "SERIES"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(21, 11)))
.andExpect(jsonPath("$.categories[0].games[*].reason.seriesName", contains("Big", "Small")));
}
@Test
void picker_NextInSeries_SeriesOnlyInBacklog_IsNotPlayed() throws Exception {
game(1L, GameStatus.BACKLOG, null, null, 500, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Mass Effect");
credit(2L, "SERIES", 100L, "Mass Effect");
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("SERIES"))));
}
@Test
void picker_NextInSeries_NeverPicksAComingGame() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Mass Effect");
credit(2L, "SERIES", 100L, "Mass Effect");
gameReleaseRepository.upsert(2L, LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(3), false, Instant.now());
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("SERIES"))));
}
@Test
void picker_SameStudio_PrefersDeveloperAndOneGamePerStudio() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
game(12L, GameStatus.BACKLOG, null, null);
game(13L, GameStatus.BACKLOG, null, null);
credit(1L, "DEVELOPER", 5L, "BioWare");
credit(1L, "PUBLISHER", 6L, "EA");
credit(11L, "DEVELOPER", 5L, "BioWare");
credit(12L, "PUBLISHER", 5L, "BioWare");
credit(13L, "PUBLISHER", 6L, "EA");
mockMvc.perform(get("/api/library/picker").param("category", "STUDIO"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(11)))
.andExpect(jsonPath("$.categories[0].games[0].reason.source").value("STUDIO"))
.andExpect(jsonPath("$.categories[0].games[0].reason.studioName").value("BioWare"));
}
@Test
void picker_SameStudio_PublisherOfPlayedGameIsNoAnchor() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
credit(1L, "PUBLISHER", 6L, "EA");
credit(11L, "DEVELOPER", 6L, "EA");
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("STUDIO"))));
}
@Test
void picker_SameStudio_NeverRepeatsAGame() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(2L, GameStatus.FINISHED, null, null, 500, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
credit(1L, "DEVELOPER", 5L, "BioWare");
credit(2L, "DEVELOPER", 7L, "Other");
credit(11L, "DEVELOPER", 5L, "BioWare");
credit(11L, "DEVELOPER", 7L, "Other");
mockMvc.perform(get("/api/library/picker").param("category", "STUDIO"))
.andExpect(jsonPath("$.categories[0].games", hasSize(1)));
}
@Test
void sameStudio_AStartedBacklogGameIsNotItsOwnAnchor() throws Exception {
game(11L, GameStatus.BACKLOG, null, null, 300, SEEN);
credit(11L, "DEVELOPER", 5L, "BioWare");
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("STUDIO"))));
}
@Test
void picker_NoCredits_LeavesOutSeriesAndStudioRows() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(10L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("SERIES"))))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("STUDIO"))));
}
@Test
void picker_Category_ReturnsOnlyThatRow() throws Exception {
game(10L, GameStatus.BACKLOG, null, null);
@@ -0,0 +1,106 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.PlayedSeries;
import core_service.modules.library.domain.PlayedStudio;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class GameCreditRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final PageRequest TOP = PageRequest.of(0, 6);
@Autowired
private GameCreditRepository repository;
@Autowired
private GameCreditCheckRepository checkRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
repository.deleteAll();
checkRepository.deleteAll();
gameReleaseRepository.deleteAll();
}
private UserGame game(long appid, GameStatus status, int playtime) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
game.setStatus(status);
return userGameRepository.saveAndFlush(game);
}
@Test
void findPlayedSeries_MostPlayedFirstWithTheLatestPlayedRelease() {
game(1L, GameStatus.FINISHED, 600);
game(2L, GameStatus.FINISHED, 300);
game(3L, GameStatus.BACKLOG, 5000);
game(4L, GameStatus.DROPPED, 0);
game(5L, GameStatus.PLAYING, 100);
gameReleaseRepository.upsert(1L, LocalDate.of(2007, 11, 20), false, Instant.now());
gameReleaseRepository.upsert(2L, LocalDate.of(2010, 1, 26), false, Instant.now());
repository.insert(1L, "SERIES", 10L, "Mass Effect");
repository.insert(2L, "SERIES", 10L, "Mass Effect");
repository.insert(3L, "SERIES", 10L, "Mass Effect");
repository.insert(4L, "SERIES", 20L, "Dropped Series");
repository.insert(5L, "SERIES", 30L, "Portal");
List<PlayedSeries> series = repository.findPlayedSeries(USER_ID, TOP);
assertThat(series).containsExactly(
new PlayedSeries(10L, "Mass Effect", LocalDate.of(2010, 1, 26)),
new PlayedSeries(30L, "Portal", null));
}
@Test
void findPlayedStudios_DevelopersOfPlayedGamesOnly() {
game(1L, GameStatus.FINISHED, 600);
game(2L, GameStatus.FINISHED, 900);
game(3L, GameStatus.BACKLOG, 0);
repository.insert(1L, "DEVELOPER", 5L, "BioWare");
repository.insert(1L, "PUBLISHER", 6L, "EA");
repository.insert(2L, "PUBLISHER", 6L, "EA");
repository.insert(3L, "DEVELOPER", 7L, "Unplayed Studio");
assertThat(repository.findPlayedStudios(USER_ID, TOP)).containsExactly(new PlayedStudio(5L, "BioWare"));
}
@Test
void findUncheckedAppids_SkipsCheckedRemovedAndHidden() {
game(1L, GameStatus.BACKLOG, 0);
game(2L, GameStatus.BACKLOG, 0);
game(3L, GameStatus.BACKLOG, 0);
game(4L, GameStatus.BACKLOG, 0);
game(5L, GameStatus.BACKLOG, 0);
userGameRepository.findById(new UserGameId(USER_ID, 3L)).orElseThrow().setRemovedAt(Instant.now());
userGameRepository.findById(new UserGameId(USER_ID, 4L)).orElseThrow().setHiddenAt(Instant.now());
userGameRepository.flush();
checkRepository.upsert(2L, Instant.now());
assertThat(checkRepository.findUncheckedAppids(10)).containsExactly(1L, 5L);
assertThat(checkRepository.findUncheckedAppids(1)).containsExactly(1L);
}
}
@@ -0,0 +1,167 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameCreditCheckRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class GameCreditServiceTest extends AbstractUnitTest {
@Mock
private IgdbApiService igdbApiService;
@Mock
private GameCreditRepository creditRepository;
@Mock
private GameCreditCheckRepository checkRepository;
private GameCreditService service;
@BeforeEach
void setUp() {
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
lenient().when(transactionManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
service = new GameCreditService(igdbApiService, creditRepository, checkRepository, new SyncTaskExecutor(),
transactionManager, 30, 250);
}
private static IgdbApiService.Credits credits() {
return new IgdbApiService.Credits(List.of(new IgdbApiService.Series(10, "Mass Effect")),
List.of(new IgdbApiService.Company(5, "BioWare", true, false),
new IgdbApiService.Company(6, "EA", false, true)));
}
@Test
void refresh_ReplacesTheCreditsAndWritesTheCheckOfAnsweredGames() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(620L, credits())));
service.refresh(List.of(620L, 700L));
verify(creditRepository).deleteByAppid(620L);
verify(creditRepository).insert(620L, "SERIES", 10L, "Mass Effect");
verify(creditRepository).insert(620L, "DEVELOPER", 5L, "BioWare");
verify(creditRepository).insert(620L, "PUBLISHER", 6L, "EA");
verify(checkRepository).upsert(eq(620L), any(Instant.class));
}
@Test
void refresh_StoresADeveloperThatAlsoPublishedOnce() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
IgdbApiService.Credits both = new IgdbApiService.Credits(List.of(),
List.of(new IgdbApiService.Company(5, "BioWare", true, true)));
when(igdbApiService.getCredits(any())).thenReturn(Optional.of(Map.of(620L, both)));
service.refresh(List.of(620L));
verify(creditRepository).insert(620L, "DEVELOPER", 5L, "BioWare");
verify(creditRepository, never()).insert(anyLong(), eq("PUBLISHER"), anyLong(), anyString());
}
@Test
void refresh_GameUnknownToIgdb_WritesOnlyTheCheck() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(any())).thenReturn(Optional.of(Map.of()));
service.refresh(List.of(700L));
verify(creditRepository).deleteByAppid(700L);
verify(creditRepository, never()).insert(anyLong(), anyString(), anyLong(), anyString());
verify(checkRepository).upsert(eq(700L), any(Instant.class));
}
@Test
void refresh_IgdbFailed_WritesNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(any())).thenReturn(Optional.empty());
service.refresh(List.of(700L));
verify(checkRepository, never()).upsert(any(), any());
verifyNoInteractions(creditRepository);
}
@Test
void backfill_RefreshesTheNextUncheckedBatchOnly() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findUncheckedAppids(250)).thenReturn(List.of(1L, 2L));
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(Set.of(1L, 2L))).thenReturn(Optional.of(Map.of(1L, credits())));
service.backfill();
verify(checkRepository).upsert(eq(1L), any(Instant.class));
verify(checkRepository).upsert(eq(2L), any(Instant.class));
}
@Test
void backfill_NothingUnchecked_CallsNoIgdb() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findUncheckedAppids(250)).thenReturn(List.of());
service.backfill();
verify(igdbApiService, never()).getCredits(any());
verify(igdbApiService, never()).getCreditsByGameIds(any());
}
@Test
void backfill_IgdbDown_WritesNothingAndDoesNotThrow() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findUncheckedAppids(250)).thenReturn(List.of(1L));
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(any())).thenThrow(new IllegalStateException("down"));
service.backfill();
verify(checkRepository, never()).upsert(any(), any());
}
@Test
void backfill_IgdbOff_DoesNothing() {
when(igdbApiService.isEnabled()).thenReturn(false);
service.backfill();
verifyNoInteractions(checkRepository);
}
@Test
void refreshInBackground_AllFresh_QueuesNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(620L));
service.refreshInBackground(List.of(620L));
verify(igdbApiService, never()).getCredits(any());
}
}
@@ -56,7 +56,7 @@ class GameImportWorkerTest {
.thenReturn(Optional.of(7L), Optional.empty());
when(repository.findStalled(any())).thenReturn(Map.of());
GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class),
mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), 5);
mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), mock(GameCreditService.class), 5);
try {
worker.wakeMatching();
verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE);
@@ -132,6 +132,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private GameEditionService gameEditionService;
@Mock
private GameCreditService gameCreditService;
@Mock
private ApplicationEventPublisher eventPublisher;
@@ -576,6 +579,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(moodService).refresh(List.of(220L, -1025L));
verify(releaseDateService).refresh(List.of(220L, -1025L));
verify(gameEditionService).refresh(List.of(220L, -1025L));
verify(gameCreditService).refresh(List.of(220L, -1025L));
}
@Test
@@ -53,6 +53,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
@Mock
private GameEditionService gameEditionService;
@Mock
private GameCreditService gameCreditService;
@Mock
private ReleaseDateService releaseDateService;
@@ -64,7 +67,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService,
moodService, gameEditionService, releaseDateService, transactionManager);
moodService, gameEditionService, gameCreditService, releaseDateService, transactionManager);
}
@Test
@@ -30,7 +30,7 @@ import java.util.concurrent.atomic.AtomicReference;
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.library.merge-undo-purge-cron=-", "app.inbox.purge-cron=-", "app.follow.digest-ms=86400000", "app.rewards.settle-cron=-", "app.speedrun.enabled=false",
"app.library.import-watchdog-ms=86400000",
"app.library.import-watchdog-ms=86400000", "app.library.credit-backfill-ms=86400000",
"spring.datasource.hikari.maximum-pool-size=4"})
@ActiveProfiles("dev")
public abstract class AbstractIntegrationTest {