feat(import): import many games with background matching, review and undo

This commit is contained in:
lobo committed 2026-09-29 10:20:54 +02:00
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 {