Public Access
feat(library): add games by name from IGDB
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:
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);
|
||||
|
||||
Reference in new issue
Block a user