feat(library): add games by name from IGDB
Build, Test & Push Core Service / quality-check (push) Successful in 51s
Build, Test & Push Core Service / docker (push) Successful in 1m24s

Games the user owns outside Steam get the key minus their
IGDB id, or the Steam appid when IGDB knows the game on
Steam. The sync never removes a MANUAL row and takes it
over once the user owns the game on Steam. Users without
a Steam ID can use the library.
This commit is contained in:
lobo committed 2026-09-25 14:37:21 +02:00
1 parent 33e0a59664
commit 4d50863cb8
37 files changed
+1431 -51

No files matched your search

@@ -175,4 +175,95 @@ class IgdbApiServiceTest extends AbstractUnitTest {
twitch.verify();
igdb.verify();
}
private static final String ZELDA = "[{\"id\": 119388, \"name\": \"The Legend of Zelda: Tears of the Kingdom\","
+ " \"first_release_date\": 1683849600,"
+ " \"cover\": {\"id\": 1, \"image_id\": \"co5vmg\"},"
+ " \"screenshots\": [{\"id\": 2, \"image_id\": \"sc1\"}],"
+ " \"platforms\": [{\"id\": 130, \"name\": \"Nintendo Switch\", \"abbreviation\": \"Switch\"}]},"
+ " {\"id\": 119133, \"name\": \"Elden Ring\","
+ " \"artworks\": [{\"id\": 3, \"image_id\": \"ar1\"}], \"screenshots\": [{\"id\": 4, \"image_id\": \"sc2\"}],"
+ " \"platforms\": [{\"id\": 167, \"name\": \"PlayStation 5\", \"abbreviation\": \"PS5\"},"
+ " {\"id\": 6, \"name\": \"PC (Microsoft Windows)\", \"abbreviation\": \"PC\"}],"
+ " \"external_games\": [{\"id\": 5, \"external_game_source\": 5, \"uid\": \"x\"},"
+ " {\"id\": 6, \"external_game_source\": 1, \"uid\": \"1245620\"}]},"
+ " {\"id\": 7, \"name\": \" \"}]";
@Test
void searchGames_MapsNameYearPlatformsSteamAppAndAWidePicture() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(containsString("search \"zelda\";"), containsString("version_parent = null"),
containsString("limit 10"))))
.andRespond(withSuccess(ZELDA, MediaType.APPLICATION_JSON));
List<IgdbApiService.Game> games = service.searchGames(" zelda ").orElseThrow();
assertThat(games).containsExactly(
new IgdbApiService.Game(119388, "The Legend of Zelda: Tears of the Kingdom", 2023,
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/sc1.jpg", List.of("Switch"), null),
new IgdbApiService.Game(119133, "Elden Ring", null,
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L));
}
@Test
void searchGames_OnlyACover_UsesTheCover() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andRespond(withSuccess("[{\"id\": 1, \"name\": \"Tetris\", \"cover\": {\"image_id\": \"co1\"}}]",
MediaType.APPLICATION_JSON));
assertThat(service.searchGames("tetris").orElseThrow().getFirst().imageUrl())
.isEqualTo("https://images.igdb.com/igdb/image/upload/t_cover_big/co1.jpg");
}
@Test
void searchGames_QuotesAndSemicolonsCannotChangeTheQuery() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(containsString("search \"a where id = 1 b\";"),
not(containsString("\\")))))
.andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
assertThat(service.searchGames("a\"; where id = 1;\\ b")).contains(List.of());
}
@Test
void searchGames_OffOrError_IsEmpty() {
assertThat(create("", "").searchGames("zelda")).isEmpty();
service = create("client-1", "secret-1");
expectToken();
igdb.expect(requestTo(containsString("/games"))).andRespond(withServerError());
assertThat(service.searchGames("zelda")).isEmpty();
}
@Test
void getGame_AsksForTheOneId() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("where id = 119133;")))
.andRespond(withSuccess(ZELDA, MediaType.APPLICATION_JSON));
assertThat(service.getGame(119133)).map(IgdbApiService.Game::igdbId).contains(119388L);
}
@Test
void getGame_Unknown_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
assertThat(service.getGame(1)).isEmpty();
}
@Test
void getTimeToBeatByGameIds_MapsIgdbIdsToMinutes() {
expectToken();
igdb.expect(requestTo(containsString("/game_time_to_beats")))
.andExpect(content().string(containsString("game_id = (119388,7)")))
.andRespond(withSuccess("[{\"id\": 9, \"game_id\": 119388, \"normally\": 216000}]", MediaType.APPLICATION_JSON));
assertThat(service.getTimeToBeatByGameIds(List.of(119388L, 7L)))
.contains(Map.of(119388L, new IgdbApiService.TimeToBeat(null, 3600, null)));
}
}
@@ -0,0 +1,42 @@
package core_service.modules.igdb_adapter.service;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The real, cache-proxied IgdbApiService with no IGDB reachable: the cache must let the empty answer through.
*/
@AutoConfigureMockMvc
@TestPropertySource(properties = {
"app.igdb.client-id=test-client",
"app.igdb.client-secret=test-secret",
"app.igdb.base-url=http://127.0.0.1:1",
"app.igdb.token-base-url=http://127.0.0.1:1"
})
class IgdbSearchCacheTest extends AbstractIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private IgdbApiService igdbApiService;
@Test
void searchGames_IgdbDown_IsEmptyNotAnException() {
assertThat(igdbApiService.searchGames("zelda")).isEmpty();
}
@Test
void search_IgdbDown_Is502() throws Exception {
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
.andExpect(status().isBadGateway());
}
}
@@ -0,0 +1,348 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus;
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.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class ManualGameControllerTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
private static final IgdbApiService.Game ZELDA = new IgdbApiService.Game(119388, "Zelda: Tears of the Kingdom", 2023,
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/sc1.jpg", List.of("Switch"), null);
private static final IgdbApiService.Game ELDEN_RING = new IgdbApiService.Game(119133, "Elden Ring", 2022,
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L);
@Autowired
private MockMvc mockMvc;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@MockitoBean
private IgdbApiService igdbApiService;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
when(igdbApiService.getGame(119133)).thenReturn(Optional.of(ELDEN_RING));
when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.of(Map.of()));
when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.of(Map.of(
119388L, new IgdbApiService.TimeToBeat(null, 3600, null))));
}
private org.springframework.test.web.servlet.ResultActions add(long igdbId, String platform) throws Exception {
return mockMvc.perform(post("/api/library/games")
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"igdbId\": " + igdbId + ", \"platform\": \"" + platform + "\", \"status\": \"PLAYING\", "
+ "\"playtimeMinutes\": 600}"));
}
private UserGame stored(long appid) {
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
}
@Test
void add_GameNotOnSteam_GetsMinusItsIgdbIdAndItsTimeToBeat() throws Exception {
add(119388, " Switch ")
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/library/games/-119388"))
.andExpect(jsonPath("$.appid").value(-119388));
UserGame game = stored(-119388);
assertEquals(GameSource.MANUAL, game.getSource());
assertEquals("Switch", game.getPlatform());
assertEquals(GameStatus.PLAYING, game.getStatus());
assertEquals(600, game.getPlaytimeMinutes());
SteamGame catalog = steamGameRepository.findById(-119388L).orElseThrow();
assertEquals("Zelda: Tears of the Kingdom", catalog.getName());
assertEquals(ZELDA.imageUrl(), catalog.getImageUrl());
assertEquals(3600, timeToBeatStored(-119388L).getNormallyMinutes());
}
// The add asks IGDB for the time to beat in the background.
private GameTimeToBeat timeToBeatStored(long appid) throws InterruptedException {
for (int attempt = 0; attempt < 50; attempt++) {
Optional<GameTimeToBeat> found = gameTimeToBeatRepository.findById(appid);
if (found.isPresent()) {
return found.get();
}
Thread.sleep(100);
}
throw new AssertionError("No time to beat stored for appid " + appid);
}
@Test
void add_GameAddedBeforeIgdbKnewItOnSteam_Is409() throws Exception {
add(119388, "Switch").andExpect(status().isCreated());
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(new IgdbApiService.Game(119388, ZELDA.name(),
2023, null, List.of("Switch"), 2_000_000L)));
add(119388, "Switch").andExpect(status().isConflict());
assertTrue(userGameRepository.findById(new UserGameId(USER_ID, 2_000_000L)).isEmpty());
}
@Test
void add_GameOnSteam_UsesTheSteamAppidAndKeepsTheSteamCatalogEntry() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", "icon", Instant.now()));
add(119133, "PS5").andExpect(status().isCreated()).andExpect(jsonPath("$.appid").value(1245620));
assertEquals(GameSource.MANUAL, stored(1245620).getSource());
SteamGame catalog = steamGameRepository.findById(1245620L).orElseThrow();
assertEquals("ELDEN RING", catalog.getName());
assertNull(catalog.getImageUrl());
}
@Test
void add_GameAlreadyInTheLibrary_Is409() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now()));
add(119133, "PS5")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("ALREADY_IN_LIBRARY"));
assertEquals(GameSource.STEAM, stored(1245620).getSource());
}
@Test
void add_GameRemovedBefore_ComesBackWithItsNotes() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
UserGame refunded = new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now());
refunded.setNotes("Refunded on Steam");
refunded.setRemovedAt(Instant.now());
userGameRepository.save(refunded);
add(119133, "PS5").andExpect(status().isCreated());
UserGame back = stored(1245620);
assertNull(back.getRemovedAt());
assertEquals(GameSource.MANUAL, back.getSource());
assertEquals("Refunded on Steam", back.getNotes());
assertEquals(600, back.getPlaytimeMinutes());
}
@Test
void add_UnknownIgdbGame_Is400() throws Exception {
when(igdbApiService.getGame(anyLong())).thenReturn(Optional.empty());
add(5, "PS5").andExpect(status().isBadRequest()).andExpect(jsonPath("$.code").value("UNKNOWN_GAME"));
}
@Test
void add_WithoutIgdb_Is503AndAsksNothing() throws Exception {
when(igdbApiService.isEnabled()).thenReturn(false);
add(119388, "Switch").andExpect(status().isServiceUnavailable());
verify(igdbApiService, never()).getGame(anyLong());
}
@Test
void add_BlankPlatform_Is400() throws Exception {
add(119388, " ").andExpect(status().isBadRequest());
}
@Test
void search_MarksGamesAlreadyInTheLibrary() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now()));
when(igdbApiService.searchGames("elden")).thenReturn(Optional.of(List.of(ELDEN_RING, ZELDA)));
mockMvc.perform(get("/api/library/game-search").param("q", "elden"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.available").value(true))
.andExpect(jsonPath("$.results[0].appid").value(1245620))
.andExpect(jsonPath("$.results[0].inLibrary").value(true))
.andExpect(jsonPath("$.results[0].platforms[0]").value("PS5"))
.andExpect(jsonPath("$.results[1].appid").value(-119388))
.andExpect(jsonPath("$.results[1].inLibrary").value(false));
}
@Test
void search_GameAddedBeforeIgdbKnewItOnSteam_IsInTheLibrary() throws Exception {
userGameRepository.save(UserGame.manual(USER_ID, -119388L, "Switch", GameStatus.BACKLOG, 0, Instant.now()));
when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(new IgdbApiService.Game(119388,
ZELDA.name(), 2023, null, List.of("Switch"), 2_000_000L))));
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
.andExpect(jsonPath("$.results[0].appid").value(2_000_000))
.andExpect(jsonPath("$.results[0].inLibrary").value(true));
}
@Test
void search_LeavesOutPlatformNamesTooLongToStore() throws Exception {
when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(new IgdbApiService.Game(119388,
ZELDA.name(), 2023, null, List.of("Switch", "A".repeat(33)), null))));
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
.andExpect(jsonPath("$.results[0].platforms.length()").value(1))
.andExpect(jsonPath("$.results[0].platforms[0]").value("Switch"));
}
@Test
void search_WithoutIgdb_SaysItIsNotAvailable() throws Exception {
when(igdbApiService.isEnabled()).thenReturn(false);
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.available").value(false))
.andExpect(jsonPath("$.results").isEmpty());
}
@Test
void search_IgdbFailed_Is502() throws Exception {
when(igdbApiService.searchGames(any())).thenReturn(Optional.empty());
mockMvc.perform(get("/api/library/game-search").param("q", "zelda"))
.andExpect(status().isBadGateway());
}
@Test
void search_TooShort_Is400() throws Exception {
mockMvc.perform(get("/api/library/game-search").param("q", "z"))
.andExpect(status().isBadRequest());
}
@Test
void update_ChangesPlatformAndHoursOfAGameAddedByHand() throws Exception {
add(119388, "Switch");
mockMvc.perform(put("/api/library/games/-119388/manual")
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"platform\": \"Switch 2\", \"playtimeMinutes\": 1200}"))
.andExpect(status().isNoContent());
assertEquals("Switch 2", stored(-119388).getPlatform());
assertEquals(1200, stored(-119388).getPlaytimeMinutes());
}
@Test
void update_SteamGame_Is409() throws Exception {
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
mockMvc.perform(put("/api/library/games/220/manual")
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"platform\": \"PS5\", \"playtimeMinutes\": 1}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("STEAM_GAME"));
assertEquals(300, stored(220).getPlaytimeMinutes());
}
@Test
void remove_DeletesAGameAddedByHandButNoSteamGame() throws Exception {
add(119388, "Switch");
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
mockMvc.perform(delete("/api/library/games/-119388").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
mockMvc.perform(delete("/api/library/games/220").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isConflict());
mockMvc.perform(delete("/api/library/games/-1").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNotFound());
assertTrue(userGameRepository.findById(new UserGameId(USER_ID, -119388L)).isEmpty());
assertNull(stored(220).getRemovedAt());
}
@Test
void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception {
add(119388, "Switch");
mockMvc.perform(get("/api/library/games").param("q", "zelda"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].appid").value(-119388))
.andExpect(jsonPath("$.content[0].source").value("MANUAL"))
.andExpect(jsonPath("$.content[0].platform").value("Switch"))
.andExpect(jsonPath("$.content[0].imageUrl").value(ZELDA.imageUrl()))
.andExpect(jsonPath("$.content[0].timeToBeatMinutes").value(3600));
mockMvc.perform(get("/api/library/games/-119388"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Zelda: Tears of the Kingdom"))
.andExpect(jsonPath("$.source").value("MANUAL"))
.andExpect(jsonPath("$.hasAchievements").value(false));
}
// Signed like an Auth login token, for a user who saved no Steam ID.
private static String tokenWithoutSteamId() {
return io.jsonwebtoken.Jwts.builder()
.subject("no-steam@example.com")
.claim("userId", String.valueOf(USER_ID))
.expiration(new java.util.Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), io.jsonwebtoken.Jwts.SIG.EdDSA)
.compact();
}
@Test
void userWithoutSteamId_SeesTheGamesAddedByHand() throws Exception {
add(119388, "Switch");
jakarta.servlet.http.Cookie login = new jakarta.servlet.http.Cookie("tbp_access", tokenWithoutSteamId());
mockMvc.perform(get("/api/library/games").cookie(login))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].appid").value(-119388));
mockMvc.perform(get("/api/library/games-count").cookie(login))
.andExpect(status().isOk())
.andExpect(jsonPath("$.count").value(1))
.andExpect(jsonPath("$.hasSteamId").value(false));
mockMvc.perform(get("/api/library/games/-119388").cookie(login))
.andExpect(status().isOk());
}
}
@@ -1,6 +1,7 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.StatusCount;
@@ -64,6 +65,39 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
.map(UserGame::getAppid).getContent();
}
@Test
void softDeleteMissingSince_KeepsGamesAddedByHand() {
steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 600,
Instant.now().minusSeconds(3600)));
Instant syncStartedAt = Instant.now();
int removed = userGameRepository.softDeleteMissingSince(USER_ID, syncStartedAt, Instant.now());
assertEquals(4, removed);
UserGame manual = userGameRepository.findById(new UserGameId(USER_ID, -1942L)).orElseThrow();
assertNull(manual.getRemovedAt());
assertEquals(GameSource.MANUAL, manual.getSource());
}
@Test
void findAchievementProgress_LeavesOutGamesAddedByHand() {
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
assertTrue(userGameRepository.findAchievementProgress(USER_ID).stream().noneMatch(p -> p.appid() == 999L));
}
@Test
void findRandomBacklogGameAndPicker_ExcludeNothingStillFindTheGameWithKeyMinusOne() {
userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(-1L, "IGDB game 1", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1L, "PS2", GameStatus.BACKLOG, 0, Instant.now()));
assertEquals(-1L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, List.of(), 3).size());
}
@Test
void findOwnedGames_NoFilter_ReturnsOwnActiveLibrarySorted() {
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow();
@@ -113,6 +113,17 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
verify(userAchievementMetaRepository, never()).save(any());
}
@Test
void syncLibrary_GameAddedByHand_AsksSteamNothing() {
UserGame manual = UserGame.manual(USER_ID, 620L, "PS5", core_service.modules.library.domain.GameStatus.PLAYING,
150, Instant.now());
library(List.of(manual), List.of(schema(620L, 10)), List.of());
service.syncLibrary(USER_ID, STEAM_ID);
verifyNoInteractions(steamApiService);
}
@Test
void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
@@ -4,6 +4,7 @@ import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
@@ -63,6 +64,9 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
@MockitoBean
private SteamApiService steamApiService;
@MockitoBean
private TimeToBeatService timeToBeatService;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
@@ -78,6 +82,26 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
return new SteamPlayerAchievementsResponse.Achievement(name, 1, 1_700_000_000L);
}
@Test
void getGameDetail_GameAddedByHand_AsksSteamNothingAndShowsNoAchievements() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now().minus(Duration.ofDays(31))));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
org.mockito.Mockito.verifyNoInteractions(steamApiService);
assertEquals(false, detail.hasAchievements());
}
@Test
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAgain() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
gameDetailService.getGameDetail(USER_ID, null, 620L);
org.mockito.Mockito.verify(timeToBeatService).refreshInBackground(List.of(620L));
}
@Test
void getGameDetail_StaleUserMeta_ShowsTheRefreshedCount() {
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 5, true, Instant.now()));
@@ -1,5 +1,6 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
@@ -32,6 +33,7 @@ import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
@@ -177,6 +179,35 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
assertEquals(expected, saved.getValue().getStatus());
}
@Test
void runSync_GameAddedByHandIsNowOnSteam_BecomesASteamGameAndKeepsTheBacklog() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH);
manual.setRating(5);
manual.setNotes("Played on the couch");
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(manual));
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
assertEquals(GameSource.STEAM, manual.getSource());
assertNull(manual.getPlatform());
assertEquals(90, manual.getPlaytimeMinutes());
assertEquals(GameStatus.FINISHED, manual.getStatus());
assertEquals(5, manual.getRating());
assertEquals("Played on the couch", manual.getNotes());
}
@Test
void runSync_GameAddedByHandIsNowOnSteam_TheHandTypedHoursAreNoPlaytimeGrowth() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(manual));
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
@Test
void runSync_ExistingGame_KeepsStatus() {
UserGame existing = new UserGame(USER_ID, 220L, 0, Instant.EPOCH, Instant.EPOCH);
@@ -370,4 +401,14 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(timeToBeatService).refresh(List.of(220L));
}
@Test
void runSync_AlsoRefreshesTheTimeToBeatOfTheGamesAddedByHand() {
steamReturns(game(0, 0), STEAM_ID);
when(userGameRepository.findActiveManualAppids(USER_ID)).thenReturn(List.of(-1025L));
worker.runSync(USER_ID, STEAM_ID);
verify(timeToBeatService).refresh(List.of(220L, -1025L));
}
}
@@ -0,0 +1,79 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
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.dao.DataIntegrityViolationException;
import org.springframework.transaction.PlatformTransactionManager;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
class ManualGameServiceTest extends AbstractUnitTest {
private static final long USER_ID = 1L;
private static final IgdbApiService.Game ZELDA = new IgdbApiService.Game(119388, "Zelda: Tears of the Kingdom", 2023,
null, List.of("Switch"), null);
@Mock
private IgdbApiService igdbApiService;
@Mock
private UserGameRepository userGameRepository;
@Mock
private SteamGameRepository steamGameRepository;
@Mock
private TimeToBeatService timeToBeatService;
@Mock
private PlatformTransactionManager transactionManager;
private ManualGameService service;
@BeforeEach
void setUp() {
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, timeToBeatService,
transactionManager);
}
@Test
void add_RowInsertedMeanwhile_IsAlreadyInLibraryNotAnError() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.empty());
when(steamGameRepository.findById(-119388L)).thenReturn(Optional.empty());
when(userGameRepository.save(any())).thenThrow(
new DataIntegrityViolationException("duplicate key value violates unique constraint \"user_game_pkey\""));
ManualGameService.AddResult result = service.add(USER_ID, 119388, "Switch", GameStatus.BACKLOG, 0);
assertThat(result).isInstanceOf(ManualGameService.AddResult.AlreadyInLibrary.class);
}
@Test
void add_CatalogRowInsertedMeanwhile_TriesAgainAndAdds() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.empty());
when(steamGameRepository.findById(-119388L)).thenReturn(Optional.empty());
when(steamGameRepository.save(any()))
.thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"steam_game_pkey\""))
.thenReturn(null);
ManualGameService.AddResult result = service.add(USER_ID, 119388, "Switch", GameStatus.BACKLOG, 0);
assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L));
}
}
@@ -53,6 +53,35 @@ class TimeToBeatServiceTest extends AbstractUnitTest {
verify(gameTimeToBeatRepository, never()).upsert(eq(220L), any(), any(), any(), any());
}
@Test
void refresh_GamesOffSteam_AreAskedByIgdbIdAndStoredUnderTheirKey() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of()));
when(igdbApiService.getTimeToBeatByGameIds(Set.of(119388L, 7L))).thenReturn(Optional.of(Map.of(
119388L, new IgdbApiService.TimeToBeat(null, 3600, null))));
service.refresh(List.of(620L, -119388L, -7L));
verify(gameTimeToBeatRepository).upsert(eq(-119388L), isNull(), eq(3600), isNull(), any(Instant.class));
verify(gameTimeToBeatRepository).upsert(eq(-7L), isNull(), isNull(), isNull(), any(Instant.class));
verify(gameTimeToBeatRepository).upsert(eq(620L), isNull(), isNull(), isNull(), any(Instant.class));
}
@Test
void refresh_OnlyTheGamesOffSteamFailed_StoresTheSteamOnes() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameTimeToBeatRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.TimeToBeat(510, 750, 1350))));
when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.empty());
service.refresh(List.of(620L, -7L));
verify(gameTimeToBeatRepository).upsert(eq(620L), eq(510), eq(750), eq(1350), any(Instant.class));
verify(gameTimeToBeatRepository, never()).upsert(eq(-7L), any(), any(), any(), any());
}
@Test
void refresh_IgdbError_StoresNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);