Public Access
feat(import): import many games with background matching, review and undo
This commit is contained in:
1 parent
3bd1eda0ec
commit
2a5fcc1c00
20 files changed
+1639
-24
No files matched your search
@@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||
"user_steam_sync", "user_game");
|
||||
"user_steam_sync", "user_game", "game_import");
|
||||
|
||||
@Autowired
|
||||
private AccountCleanupRepository repository;
|
||||
@@ -42,6 +42,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
||||
jdbc.update("""
|
||||
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
|
||||
userId);
|
||||
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'SAVED', 0)", userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
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.GameStatus;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
import core_service.modules.library.domain.UserGameId;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.library.service.GameImportWorker;
|
||||
import core_service.shared.test.AbstractIntegrationTest;
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.ResultActions;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.hamcrest.Matchers.contains;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
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.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* The dev profile authenticates every request as user 1.
|
||||
*/
|
||||
@AutoConfigureMockMvc
|
||||
class GameImportControllerTest 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,
|
||||
null, List.of("Switch"), null);
|
||||
private static final IgdbApiService.Game ELDEN_RING = new IgdbApiService.Game(119133, "Elden Ring", 2022,
|
||||
null, List.of("PS5", "PC"), 1245620L);
|
||||
private static final IgdbApiService.Game NIGHTREIGN = new IgdbApiService.Game(325591, "Elden Ring Nightreign", 2025,
|
||||
null, List.of("PS5", "PC"), 2622380L);
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private UserGameRepository userGameRepository;
|
||||
|
||||
@Autowired
|
||||
private GameImportWorker worker;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@MockitoBean
|
||||
private IgdbApiService igdbApiService;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("delete from game_alias");
|
||||
userGameRepository.deleteAll();
|
||||
jdbc.update("delete from game_import");
|
||||
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||
when(igdbApiService.searchGames("Elden Ring")).thenReturn(Optional.of(List.of(NIGHTREIGN, ELDEN_RING)));
|
||||
when(igdbApiService.searchGames("Zelda Tears")).thenReturn(Optional.of(List.of(ZELDA)));
|
||||
when(igdbApiService.getGame(119388)).thenReturn(Optional.of(ZELDA));
|
||||
when(igdbApiService.getTimeToBeat(any())).thenReturn(Optional.of(Map.of()));
|
||||
when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.of(Map.of()));
|
||||
}
|
||||
|
||||
private ResultActions create(String json) throws Exception {
|
||||
return mockMvc.perform(post("/api/library/imports")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(json));
|
||||
}
|
||||
|
||||
private long createReady(String json) throws Exception {
|
||||
long id = ((Number) JsonPath.read(create(json).andExpect(status().isAccepted())
|
||||
.andReturn().getResponse().getContentAsString(), "$.id")).longValue();
|
||||
awaitReady(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private void awaitReady(long id) throws InterruptedException {
|
||||
awaitStatus(id, "READY");
|
||||
}
|
||||
|
||||
// The matching and the save run in the background.
|
||||
private void awaitStatus(long id, String expected) throws InterruptedException {
|
||||
for (int attempt = 0; attempt < 50; attempt++) {
|
||||
if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) {
|
||||
return;
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
throw new AssertionError("Import " + id + " not " + expected);
|
||||
}
|
||||
|
||||
private ResultActions save(long id, String rows) throws Exception {
|
||||
return mockMvc.perform(post("/api/library/imports/" + id + "/save")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"rows\": [" + rows + "]}"));
|
||||
}
|
||||
|
||||
private void saveAndWait(long id, String rows) throws Exception {
|
||||
save(id, rows).andExpect(status().isAccepted());
|
||||
awaitStatus(id, "SAVED");
|
||||
}
|
||||
|
||||
// Not woken: no worker takes it unless the test wakes one.
|
||||
private long insertImport(long userId, String status, String set) {
|
||||
long id = jdbc.queryForObject("insert into game_import (user_id, source, status, total) "
|
||||
+ "values (?, 'TEXT', ?, 0) returning id", Long.class, userId, status);
|
||||
if (!set.isEmpty()) {
|
||||
jdbc.update("update game_import set " + set + " where id = ?", id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private UserGame stored(long appid) {
|
||||
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
|
||||
}
|
||||
|
||||
private Long importIdOf(long appid) {
|
||||
return jdbc.queryForObject("select import_id from user_game where user_id = ? and appid = ?", Long.class,
|
||||
USER_ID, appid);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_MatchesInTheBackground_SameNameFirstAndMarksGamesInTheLibrary() throws Exception {
|
||||
jdbc.update("insert into steam_game (appid, name) values (1245620, 'ELDEN RING') on conflict do nothing");
|
||||
userGameRepository.save(UserGame.manual(USER_ID, 1245620L, "PC", GameStatus.BACKLOG, 0, Instant.now()));
|
||||
|
||||
long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \" Elden Ring \", \"platform\": \"PS5\"}, "
|
||||
+ "{\"title\": \"Unknown Game\"}]}");
|
||||
|
||||
mockMvc.perform(get("/api/library/imports/" + id))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.total").value(2))
|
||||
.andExpect(jsonPath("$.matched").value(2))
|
||||
.andExpect(jsonPath("$.rows[0].title").value("Elden Ring"))
|
||||
.andExpect(jsonPath("$.rows[0].platform").value("PS5"))
|
||||
.andExpect(jsonPath("$.rows[0].exact").value(true))
|
||||
.andExpect(jsonPath("$.rows[0].candidates[*].name").value(contains("Elden Ring", "Elden Ring Nightreign")))
|
||||
.andExpect(jsonPath("$.rows[0].candidates[*].inLibrary").value(contains(true, false)))
|
||||
// Mockito answers every other search with empty: IGDB failed.
|
||||
.andExpect(jsonPath("$.rows[1].searchFailed").value(true))
|
||||
.andExpect(jsonPath("$.rows[1].candidates").isEmpty());
|
||||
mockMvc.perform(get("/api/library/imports"))
|
||||
.andExpect(jsonPath("$.imports[0].id").value(id))
|
||||
.andExpect(jsonPath("$.imports[0].status").value("READY"))
|
||||
.andExpect(jsonPath("$.imports[0].rows").value(nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_SearchRow_TakesThePickedGameWithoutASearch() throws Exception {
|
||||
long id = createReady("{\"source\": \"SEARCH\", \"rows\": [{\"igdbId\": 119388}]}");
|
||||
|
||||
mockMvc.perform(get("/api/library/imports/" + id))
|
||||
.andExpect(jsonPath("$.rows[0].exact").value(true))
|
||||
.andExpect(jsonPath("$.rows[0].candidates[0].igdbId").value(119388));
|
||||
verify(igdbApiService, never()).searchGames(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_TooManyRowsOrNoTitle_Is400() throws Exception {
|
||||
String row = "{\"title\": \"Game\"}";
|
||||
create("{\"source\": \"TEXT\", \"rows\": [" + String.join(",", Collections.nCopies(1001, row)) + "]}")
|
||||
.andExpect(status().isBadRequest());
|
||||
create("{\"source\": \"TEXT\", \"rows\": [{\"platform\": \"PC\"}]}").andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_WhileAnotherImportIsMatching_Is409() throws Exception {
|
||||
jdbc.update("insert into game_import (user_id, source, status, total) values (?, 'TEXT', 'MATCHING', 0)", USER_ID);
|
||||
|
||||
create("{\"source\": \"TEXT\", \"rows\": [{\"title\": \"Elden Ring\"}]}")
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("IMPORT_RUNNING"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_ImportOfAnotherUser_Is404() throws Exception {
|
||||
Long id = jdbc.queryForObject("insert into game_import (user_id, source, status, total) "
|
||||
+ "values (2, 'TEXT', 'READY', 0) returning id", Long.class);
|
||||
|
||||
mockMvc.perform(get("/api/library/imports/" + id)).andExpect(status().isNotFound());
|
||||
save(id, "").andExpect(status().isNotFound());
|
||||
mockMvc.perform(delete("/api/library/imports/" + id + "/games").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_AddsTheCheckedLinesWithTheImportId_SkipsDuplicatesAndOnlyOnce() throws Exception {
|
||||
long id = createReady("{\"source\": \"CSV\", \"rows\": [{\"title\": \"Elden Ring\"}, {\"title\": \"Elden Ring\"}]}");
|
||||
when(igdbApiService.getGame(anyLong())).thenReturn(Optional.empty());
|
||||
|
||||
saveAndWait(id, "{\"lineNo\": 1, \"igdbId\": 119133, \"platform\": \" PS5 \", \"status\": \"PLAYING\", \"playtimeMinutes\": 90},"
|
||||
+ "{\"lineNo\": 2, \"igdbId\": 119133, \"platform\": \"PC\", \"status\": \"BACKLOG\"},"
|
||||
+ "{\"lineNo\": 9, \"igdbId\": 119133, \"platform\": \"PC\", \"status\": \"BACKLOG\"}");
|
||||
|
||||
mockMvc.perform(get("/api/library/imports/" + id))
|
||||
.andExpect(jsonPath("$.chosen").value(2))
|
||||
.andExpect(jsonPath("$.saved").value(2))
|
||||
.andExpect(jsonPath("$.added").value(1))
|
||||
.andExpect(jsonPath("$.step").value(nullValue()))
|
||||
.andExpect(jsonPath("$.savedAt").isNotEmpty());
|
||||
|
||||
UserGame game = stored(1245620L);
|
||||
assertEquals(GameSource.MANUAL, game.getSource());
|
||||
assertEquals("PS5", game.getPlatform());
|
||||
assertEquals(GameStatus.PLAYING, game.getStatus());
|
||||
assertEquals(90, game.getPlaytimeMinutes());
|
||||
assertEquals(id, importIdOf(1245620L));
|
||||
// The candidate was stored whole: no IGDB call for the save.
|
||||
verify(igdbApiService, never()).getGame(119133);
|
||||
save(id, "").andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_GameFromANewSearch_IsReadFromIgdb() throws Exception {
|
||||
long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \"Elden Ring\"}]}");
|
||||
|
||||
saveAndWait(id, "{\"lineNo\": 1, \"igdbId\": 119388, \"platform\": \"Switch\", \"status\": \"BACKLOG\"}");
|
||||
|
||||
assertEquals(id, importIdOf(-119388L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void undo_RemovesTheGamesStillAddedByHand_AndCountsTheChangedOnes() throws Exception {
|
||||
long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \"Elden Ring\"}, {\"title\": \"Zelda Tears\"}]}");
|
||||
saveAndWait(id, "{\"lineNo\": 1, \"igdbId\": 119133, \"platform\": \"PC\", \"status\": \"BACKLOG\"},"
|
||||
+ "{\"lineNo\": 2, \"igdbId\": 119388, \"platform\": \"Switch\", \"status\": \"BACKLOG\"}");
|
||||
mockMvc.perform(get("/api/library/imports/" + id + "/undo-check"))
|
||||
.andExpect(jsonPath("$.games").value(2))
|
||||
.andExpect(jsonPath("$.changed").value(0));
|
||||
|
||||
jdbc.update("update user_game set status = 'PLAYING', status_set_by_user = true where appid = -119388");
|
||||
mockMvc.perform(get("/api/library/imports/" + id + "/undo-check"))
|
||||
.andExpect(jsonPath("$.changed").value(1));
|
||||
// The Steam sync took Elden Ring over: the user owns it on Steam now.
|
||||
jdbc.update("update user_game set source = 'STEAM' where appid = 1245620");
|
||||
mockMvc.perform(get("/api/library/imports/" + id + "/undo-check"))
|
||||
.andExpect(jsonPath("$.games").value(1));
|
||||
|
||||
mockMvc.perform(delete("/api/library/imports/" + id + "/games").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
assertTrue(userGameRepository.findById(new UserGameId(USER_ID, -119388L)).isEmpty());
|
||||
assertEquals(GameSource.STEAM, stored(1245620L).getSource());
|
||||
assertEquals("UNDONE", jdbc.queryForObject("select status from game_import where id = ?", String.class, id));
|
||||
mockMvc.perform(delete("/api/library/imports/" + id + "/games").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||
.andExpect(status().isConflict());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resume_CountsTheStoppedTryAndGoesOnWhereItStopped() throws Exception {
|
||||
// Before this app started: a try the restart stopped.
|
||||
long id = insertImport(USER_ID, "MATCHING", "progress_at = now() - interval '1 day'");
|
||||
jdbc.update("insert into game_import_row (import_id, line_no, title, matched_at, exact) "
|
||||
+ "values (?, 1, 'Done before', now(), true)", id);
|
||||
jdbc.update("insert into game_import_row (import_id, line_no, title) values (?, 2, 'Elden Ring')", id);
|
||||
|
||||
worker.resume();
|
||||
awaitReady(id);
|
||||
|
||||
verify(igdbApiService, never()).searchGames("Done before");
|
||||
assertEquals(2, jdbc.queryForObject("select jsonb_array_length(candidates) from game_import_row "
|
||||
+ "where import_id = ? and line_no = 2", Integer.class, id));
|
||||
assertEquals(1, jdbc.queryForObject("select attempts from game_import where id = ?", Integer.class, id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void watchdog_StopsATryWithoutProgress_AndFailsAfterThreeTries() throws Exception {
|
||||
long stalled = insertImport(USER_ID, "MATCHING", "progress_at = now() - interval '10 minutes'");
|
||||
long lastTry = insertImport(2L, "SAVING", "progress_at = now() - interval '10 minutes', attempts = 2");
|
||||
long fine = insertImport(3L, "MATCHING", "progress_at = now()");
|
||||
|
||||
worker.watchdog();
|
||||
|
||||
awaitReady(stalled);
|
||||
assertEquals(1, jdbc.queryForObject("select attempts from game_import where id = ?", Integer.class, stalled));
|
||||
assertEquals("FAILED", jdbc.queryForObject("select status from game_import where id = ?", String.class, lastTry));
|
||||
assertEquals(0, jdbc.queryForObject("select attempts from game_import where id = ?", Integer.class, fine));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retry_PutsAFailedImportBackInTheQueueOfItsPhase() throws Exception {
|
||||
long id = insertImport(USER_ID, "FAILED", "attempts = 3");
|
||||
jdbc.update("insert into game_import_row (import_id, line_no, title) values (?, 1, 'Elden Ring')", id);
|
||||
|
||||
mockMvc.perform(post("/api/library/imports/" + id + "/retry").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||
.andExpect(status().isAccepted());
|
||||
|
||||
awaitReady(id);
|
||||
assertEquals(0, jdbc.queryForObject("select attempts from game_import where id = ?", Integer.class, id));
|
||||
mockMvc.perform(post("/api/library/imports/" + id + "/retry").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("IMPORT_NOT_FAILED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_WaitingImport_ShowsHowManyImportsAreBeforeIt() throws Exception {
|
||||
insertImport(2L, "QUEUED", "queued_at = now() - interval '2 minutes'");
|
||||
insertImport(3L, "MATCHING", "queued_at = now() - interval '1 minutes', progress_at = now()");
|
||||
long mine = insertImport(USER_ID, "QUEUED", "");
|
||||
long saving = insertImport(USER_ID, "SAVING", "");
|
||||
|
||||
mockMvc.perform(get("/api/library/imports/" + mine))
|
||||
.andExpect(jsonPath("$.status").value("QUEUED"))
|
||||
.andExpect(jsonPath("$.ahead").value(2));
|
||||
mockMvc.perform(get("/api/library/imports/" + saving)).andExpect(jsonPath("$.ahead").value(0));
|
||||
jdbc.update("delete from game_import");
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_AnswersAnObjectWithTheImports() throws Exception {
|
||||
long id = insertImport(USER_ID, "READY", "");
|
||||
|
||||
mockMvc.perform(get("/api/library/imports"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.imports[0].id").value(id));
|
||||
}
|
||||
|
||||
// A lane can start before ApplicationReadyEvent (an early create, the first watchdog run).
|
||||
@Test
|
||||
void resume_DoesNotCountAnImportALaneRunsNow() throws Exception {
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
when(igdbApiService.searchGames("Slow Game")).thenAnswer(invocation -> {
|
||||
release.await(5, TimeUnit.SECONDS);
|
||||
return Optional.of(List.of());
|
||||
});
|
||||
long id = insertImport(USER_ID, "QUEUED", "");
|
||||
jdbc.update("insert into game_import_row (import_id, line_no, title) values (?, 1, 'Slow Game')", id);
|
||||
worker.wakeMatching();
|
||||
awaitStatus(id, "MATCHING");
|
||||
|
||||
try {
|
||||
worker.resume();
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
|
||||
awaitReady(id);
|
||||
assertEquals(0, jdbc.queryForObject("select attempts from game_import where id = ?", Integer.class, id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_RevivedRowOfTheLibrary_IsNotRemovedByTheUndo() throws Exception {
|
||||
jdbc.update("insert into steam_game (appid, name) values (1245620, 'ELDEN RING') on conflict do nothing");
|
||||
jdbc.update("insert into user_game (user_id, appid, status, rating, notes, source, removed_at) "
|
||||
+ "values (?, 1245620, 'FINISHED', 5, 'Loved it', 'STEAM', now())", USER_ID);
|
||||
long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \"Elden Ring\"}]}");
|
||||
|
||||
saveAndWait(id, "{\"lineNo\": 1, \"igdbId\": 119133, \"platform\": \"PS5\", \"status\": \"PLAYING\"}");
|
||||
mockMvc.perform(delete("/api/library/imports/" + id + "/games").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
UserGame kept = stored(1245620L);
|
||||
assertEquals(5, kept.getRating());
|
||||
assertEquals("Loved it", kept.getNotes());
|
||||
assertNull(importIdOf(1245620L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_IgdbFailsForAPickFromANewSearch_KeepsTheLineForTheNextTry() throws Exception {
|
||||
long id = createReady("{\"source\": \"TEXT\", \"rows\": [{\"title\": \"Elden Ring\"}]}");
|
||||
when(igdbApiService.getGame(119388)).thenReturn(Optional.empty());
|
||||
|
||||
save(id, "{\"lineNo\": 1, \"igdbId\": 119388, \"platform\": \"Switch\", \"status\": \"BACKLOG\"}")
|
||||
.andExpect(status().isAccepted());
|
||||
awaitStatus(id, "FAILED");
|
||||
|
||||
assertNull(jdbc.queryForObject("select save_result from game_import_row where import_id = ?", String.class, id));
|
||||
assertEquals(3, jdbc.queryForObject("select attempts from game_import where id = ?", Integer.class, id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void undo_ImportThatFailedWhileSaving_RemovesTheGamesItAdded() throws Exception {
|
||||
long id = insertImport(USER_ID, "FAILED", "attempts = 3");
|
||||
jdbc.update("insert into steam_game (appid, name) values (-119388, 'Zelda') on conflict do nothing");
|
||||
jdbc.update("insert into user_game (user_id, appid, status, source, platform, import_id) "
|
||||
+ "values (?, -119388, 'BACKLOG', 'MANUAL', 'Switch', ?)", USER_ID, id);
|
||||
|
||||
mockMvc.perform(delete("/api/library/imports/" + id + "/games").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
assertTrue(userGameRepository.findById(new UserGameId(USER_ID, -119388L)).isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
||||
import core_service.modules.library.domain.GameImport;
|
||||
import core_service.modules.library.repo.GameImportRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataAccessResourceFailureException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class GameImportWorkerTest {
|
||||
|
||||
private static IgdbApiService.Game game(long igdbId, String name, String... platforms) {
|
||||
return new IgdbApiService.Game(igdbId, name, null, null, List.of(platforms), null);
|
||||
}
|
||||
|
||||
private static GameImport.Line line(String title, String platform) {
|
||||
return new GameImport.Line(1, title, platform, null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameName_IgnoresCaseAccentsPunctuationAndMarks() {
|
||||
assertThat(GameImportWorker.sameName("The Witcher® 3: Wild Hunt", "the witcher 3 wild hunt")).isTrue();
|
||||
assertThat(GameImportWorker.sameName("Pokémon Legends: Arceus", "Pokemon Legends Arceus")).isTrue();
|
||||
assertThat(GameImportWorker.sameName("Doom", "Doom II")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rank_SameNameFirst_ThenTheLinesPlatform_ThenIgdbsOrder_AtMostFive() {
|
||||
List<IgdbApiService.Game> found = List.of(game(1, "Doom Eternal", "PC"), game(2, "DOOM", "PC"),
|
||||
game(3, "Doom", "Switch"), game(4, "Doom 3"), game(5, "Doom 64"), game(6, "Doom II"));
|
||||
|
||||
assertThat(GameImportWorker.rank(line("Doom", "switch"), found)).extracting(IgdbApiService.Game::igdbId)
|
||||
.containsExactly(3L, 2L, 1L, 4L, 5L);
|
||||
assertThat(GameImportWorker.rank(line("Doom", null), found)).extracting(IgdbApiService.Game::igdbId)
|
||||
.startsWith(2L, 3L);
|
||||
assertThat(GameImportWorker.rank(line("x", null), IntStream.range(0, 9)
|
||||
.mapToObj(i -> game(i, "Game " + i)).toList())).hasSize(GameImportWorker.MAX_CANDIDATES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void watchdog_WakesALaneWhoseRunDiedOnADatabaseError() {
|
||||
GameImportRepository repository = mock(GameImportRepository.class);
|
||||
when(repository.findNext(GameImportWorker.MATCHING_QUEUE))
|
||||
.thenThrow(new DataAccessResourceFailureException("connection lost"))
|
||||
.thenReturn(Optional.of(7L), Optional.empty());
|
||||
when(repository.findStalled(any())).thenReturn(Map.of());
|
||||
GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class),
|
||||
mock(IgdbApiService.class), mock(TimeToBeatService.class), mock(MoodService.class), 5);
|
||||
try {
|
||||
worker.wakeMatching();
|
||||
verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE);
|
||||
|
||||
worker.watchdog();
|
||||
|
||||
// Import 7 waits in QUEUED: no try of it is running, so no watchdog rule ever picks it up.
|
||||
verify(repository, timeout(2000)).markReady(7L);
|
||||
} finally {
|
||||
worker.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,12 @@ import java.util.Base64;
|
||||
* Full Spring context with Testcontainers.
|
||||
*/
|
||||
@Tag("IntegrationTest")
|
||||
// The nightly sync would call Steam if a test ran at 23:55; speedrun.com needs no key, so it is switched off.
|
||||
// The nightly sync would call Steam if a test ran at 23:55; speedrun.com needs no key, so it is switched off. The
|
||||
// import watchdog runs only when a test calls it.
|
||||
// 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.speedrun.enabled=false",
|
||||
"app.library.import-watchdog-ms=86400000",
|
||||
"spring.datasource.hikari.maximum-pool-size=4"})
|
||||
@ActiveProfiles("dev")
|
||||
public abstract class AbstractIntegrationTest {
|
||||
|
||||
Reference in new issue
Block a user