test: background work inline, shared mock bases, lock-wait instead of sleep
Build, Test & Push Core Service / quality-check (push) Successful in 1m8s
Build, Test & Push Core Service / docker (push) Successful in 1m31s

This commit is contained in:
lobo committed 2026-09-30 12:08:57 +02:00
1 parent 7fe2823519
commit 3512159b41
18 files changed
+196 -156

No files matched your search

+4
View File
@@ -437,6 +437,10 @@ Configuration comes from environment variables:
``` ```
Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers. Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers.
In integration tests background work runs on the calling thread, so a test reads the result at once.
`AbstractMockedAdaptersTest` mocks Steam, IGDB and speedrun.com, `AbstractMockedLibraryServicesTest` the sync and
the refreshes of the picker and stats pages; each is one shared Spring context. Race tests wait until the other
connection waits for the row lock (`awaitLockWaiter`), not for a fixed time.
## Roadmap ## Roadmap
@@ -1,6 +1,6 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractMockedAdaptersTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
@@ -16,7 +16,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* The dev profile logs every request in, so only the CSRF header and CORS decide here. * The dev profile logs every request in, so only the CSRF header and CORS decide here.
*/ */
@AutoConfigureMockMvc @AutoConfigureMockMvc
class ClientHeaderFilterTest extends AbstractIntegrationTest { class ClientHeaderFilterTest extends AbstractMockedAdaptersTest {
private static final String FRONTEND = "https://tbp.andreas-dahm.eu"; private static final String FRONTEND = "https://tbp.andreas-dahm.eu";
@@ -8,7 +8,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.GameImportWorker; import core_service.modules.library.service.GameImportWorker;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractMockedAdaptersTest;
import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -16,7 +16,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; 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.MockMvc;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
@@ -48,7 +47,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* The dev profile authenticates every request as user 1. * The dev profile authenticates every request as user 1.
*/ */
@AutoConfigureMockMvc @AutoConfigureMockMvc
class GameImportControllerTest extends AbstractIntegrationTest { class GameImportControllerTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
private static final IgdbApiService.Game ZELDA = new IgdbApiService.Game(119388, "Zelda: Tears of the Kingdom", 2023, private static final IgdbApiService.Game ZELDA = new IgdbApiService.Game(119388, "Zelda: Tears of the Kingdom", 2023,
@@ -70,9 +69,6 @@ class GameImportControllerTest extends AbstractIntegrationTest {
@Autowired @Autowired
private JdbcTemplate jdbc; private JdbcTemplate jdbc;
@MockitoBean
private IgdbApiService igdbApiService;
@BeforeEach @BeforeEach
void seed() { void seed() {
jdbc.update("delete from game_alias"); jdbc.update("delete from game_alias");
@@ -104,7 +100,7 @@ class GameImportControllerTest extends AbstractIntegrationTest {
awaitStatus(id, "READY"); awaitStatus(id, "READY");
} }
// The matching and the save run in the background. // The matching and the save run on the worker's own lane threads, not on the test executor, so the test polls.
private void awaitStatus(long id, String expected) throws InterruptedException { private void awaitStatus(long id, String expected) throws InterruptedException {
for (int attempt = 0; attempt < 50; attempt++) { for (int attempt = 0; attempt < 50; attempt++) {
if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) { if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) {
@@ -10,13 +10,11 @@ import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncWorker; import core_service.modules.library.service.LibrarySyncWorker;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse; import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.shared.test.AbstractMockedAdaptersTest;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant; import java.time.Instant;
@@ -27,7 +25,6 @@ import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.nullValue;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -38,7 +35,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* The dev profile authenticates every request as user 1. * The dev profile authenticates every request as user 1.
*/ */
@AutoConfigureMockMvc @AutoConfigureMockMvc
class LibraryControllerAchievementsTest extends AbstractIntegrationTest { class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
private static final String REPAIRED_COVER = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1/x/header.jpg"; private static final String REPAIRED_COVER = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1/x/header.jpg";
@@ -46,9 +43,6 @@ class LibraryControllerAchievementsTest extends AbstractIntegrationTest {
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@MockitoBean
private SteamApiService steamApiService;
@Autowired @Autowired
private SteamGameRepository steamGameRepository; private SteamGameRepository steamGameRepository;
@@ -161,6 +155,6 @@ class LibraryControllerAchievementsTest extends AbstractIntegrationTest {
librarySyncWorker.runSyncNow(USER_ID, "76561198000000001"); librarySyncWorker.runSyncNow(USER_ID, "76561198000000001");
verify(steamApiService, timeout(5_000)).getSchemaForGame(620L); verify(steamApiService).getSchemaForGame(620L);
} }
} }
@@ -7,7 +7,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractMockedAdaptersTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
@@ -34,7 +34,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* The dev profile authenticates every request as user 1. * The dev profile authenticates every request as user 1.
*/ */
@AutoConfigureMockMvc @AutoConfigureMockMvc
class LibraryControllerHiddenTest extends AbstractIntegrationTest { class LibraryControllerHiddenTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
@@ -11,19 +11,14 @@ import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncService; import core_service.shared.test.AbstractMockedLibraryServicesTest;
import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SpeedrunService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
@@ -37,7 +32,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* The dev profile authenticates every request as user 1. * The dev profile authenticates every request as user 1.
*/ */
@AutoConfigureMockMvc @AutoConfigureMockMvc
class LibraryControllerPickerTest extends AbstractIntegrationTest { class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
@@ -56,15 +51,6 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
@Autowired @Autowired
private GameMoodsRepository gameMoodsRepository; private GameMoodsRepository gameMoodsRepository;
@MockitoBean
private LibrarySyncService syncService;
@MockitoBean
private MoodService moodService;
@MockitoBean
private SpeedrunService speedrunService;
@Autowired @Autowired
private GameSpeedrunRepository gameSpeedrunRepository; private GameSpeedrunRepository gameSpeedrunRepository;
@@ -7,10 +7,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncService; import core_service.shared.test.AbstractMockedLibraryServicesTest;
import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SpeedrunService;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -20,7 +17,6 @@ import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.sql.SQLException; import java.sql.SQLException;
@@ -43,7 +39,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* The dev profile authenticates every request as user 1. * The dev profile authenticates every request as user 1.
*/ */
@AutoConfigureMockMvc @AutoConfigureMockMvc
class LibraryControllerStatsTest extends AbstractIntegrationTest { class LibraryControllerStatsTest extends AbstractMockedLibraryServicesTest {
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
@@ -59,16 +55,6 @@ class LibraryControllerStatsTest extends AbstractIntegrationTest {
@Autowired @Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository; private GameTimeToBeatRepository gameTimeToBeatRepository;
// The same mocks as LibraryControllerPickerTest, so both share one Spring context.
@MockitoBean
private LibrarySyncService syncService;
@MockitoBean
private MoodService moodService;
@MockitoBean
private SpeedrunService speedrunService;
@BeforeEach @BeforeEach
void seed() { void seed() {
userGameRepository.deleteAll(); userGameRepository.deleteAll();
@@ -1,12 +1,10 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse; import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.shared.test.AbstractMockedAdaptersTest;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional; import java.util.Optional;
@@ -21,14 +19,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* The dev profile authenticates every request as user 1 with Steam ID 76561198000000001. * The dev profile authenticates every request as user 1 with Steam ID 76561198000000001.
*/ */
@AutoConfigureMockMvc @AutoConfigureMockMvc
class LibraryControllerSteamProfileTest extends AbstractIntegrationTest { class LibraryControllerSteamProfileTest extends AbstractMockedAdaptersTest {
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@MockitoBean
private SteamApiService steamApiService;
@Test @Test
void steamProfile_SendsNameAndAvatar() throws Exception { void steamProfile_SendsNameAndAvatar() throws Exception {
when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of(new SteamPlayerSummaryResponse.Player( when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of(new SteamPlayerSummaryResponse.Player(
@@ -4,14 +4,13 @@ import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractMockedAdaptersTest;
import core_service.shared.test.ContractTokens; import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -19,7 +18,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; 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.MockMvc;
import java.time.Instant; import java.time.Instant;
@@ -48,7 +46,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* The dev profile authenticates every request as user 1. * The dev profile authenticates every request as user 1.
*/ */
@AutoConfigureMockMvc @AutoConfigureMockMvc
class ManualGameControllerTest extends AbstractIntegrationTest { class ManualGameControllerTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
private static final IgdbApiService.Game ZELDA = new IgdbApiService.Game(119388, "Zelda: Tears of the Kingdom", 2023, private static final IgdbApiService.Game ZELDA = new IgdbApiService.Game(119388, "Zelda: Tears of the Kingdom", 2023,
@@ -71,9 +69,6 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
@Autowired @Autowired
private JdbcTemplate jdbc; private JdbcTemplate jdbc;
@MockitoBean
private IgdbApiService igdbApiService;
@BeforeEach @BeforeEach
void seed() { void seed() {
jdbc.update("delete from game_alias"); jdbc.update("delete from game_alias");
@@ -115,19 +110,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
SteamGame catalog = steamGameRepository.findById(-119388L).orElseThrow(); SteamGame catalog = steamGameRepository.findById(-119388L).orElseThrow();
assertEquals("Zelda: Tears of the Kingdom", catalog.getName()); assertEquals("Zelda: Tears of the Kingdom", catalog.getName());
assertEquals(ZELDA.imageUrl(), catalog.getImageUrl()); assertEquals(ZELDA.imageUrl(), catalog.getImageUrl());
assertEquals(3600, timeToBeatStored(-119388L).getNormallyMinutes()); assertEquals(3600, gameTimeToBeatRepository.findById(-119388L).orElseThrow().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 @Test
@@ -333,7 +316,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
@Test @Test
void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception { void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception {
add(119388, "Switch"); add(119388, "Switch");
timeToBeatStored(-119388L); assertTrue(gameTimeToBeatRepository.findById(-119388L).isPresent());
mockMvc.perform(get("/api/library/games").param("q", "zelda")) mockMvc.perform(get("/api/library/games").param("q", "zelda"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -3,13 +3,11 @@ package core_service.modules.library.service;
import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository; import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.shared.test.AbstractMockedAdaptersTest;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
@@ -28,7 +26,7 @@ import static org.mockito.Mockito.when;
* The game detail page refreshes inside its own @Transactional while the background sync refreshes the same * The game detail page refreshes inside its own @Transactional while the background sync refreshes the same
* (user, appid) in a transaction of its own, e.g. right after the user pressed "sync" and opened the most played game. * (user, appid) in a transaction of its own, e.g. right after the user pressed "sync" and opened the most played game.
*/ */
class AchievementSyncServiceConcurrencyTest extends AbstractIntegrationTest { class AchievementSyncServiceConcurrencyTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001"; private static final String STEAM_ID = "76561198000000001";
@@ -45,9 +43,6 @@ class AchievementSyncServiceConcurrencyTest extends AbstractIntegrationTest {
@Autowired @Autowired
private PlatformTransactionManager transactionManager; private PlatformTransactionManager transactionManager;
@MockitoBean
private SteamApiService steamApiService;
@BeforeEach @BeforeEach
@AfterEach @AfterEach
void clean() { void clean() {
@@ -61,20 +56,20 @@ class AchievementSyncServiceConcurrencyTest extends AbstractIntegrationTest {
new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2", new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2",
List.of(new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L)), true, null))); List.of(new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L)), true, null)));
CountDownLatch firstWrote = new CountDownLatch(1); CountDownLatch firstWrote = new CountDownLatch(1);
CountDownLatch secondStarted = new CountDownLatch(1); CountDownLatch secondDone = new CountDownLatch(1);
CompletableFuture<Void> detailPage = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager) CompletableFuture<Void> detailPage = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager)
.executeWithoutResult(tx -> { .executeWithoutResult(tx -> {
achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1)); achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1));
firstWrote.countDown(); firstWrote.countDown();
await(secondStarted); // The page's transaction stays open until the background run wrote the same rows; a run that
// Keep the page's transaction open while the background run writes the same rows. // waited for this transaction would time out the latch.
sleep(); await(secondDone);
})); }));
CompletableFuture<Void> backgroundRun = CompletableFuture.runAsync(() -> { CompletableFuture<Void> backgroundRun = CompletableFuture.runAsync(() -> {
await(firstWrote); await(firstWrote);
secondStarted.countDown();
achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1)); achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1));
secondDone.countDown();
}); });
assertDoesNotThrow(() -> CompletableFuture.allOf(detailPage, backgroundRun).get(30, TimeUnit.SECONDS)); assertDoesNotThrow(() -> CompletableFuture.allOf(detailPage, backgroundRun).get(30, TimeUnit.SECONDS));
@@ -90,11 +85,4 @@ class AchievementSyncServiceConcurrencyTest extends AbstractIntegrationTest {
} }
} }
private static void sleep() {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} }
@@ -55,12 +55,12 @@ class GameCorrectionServiceConcurrencyTest extends AbstractIntegrationTest {
void twoAuditorsCorrectTheSameFieldForTheFirstTime_BothSucceed() { void twoAuditorsCorrectTheSameFieldForTheFirstTime_BothSucceed() {
CountDownLatch firstCorrected = new CountDownLatch(1); CountDownLatch firstCorrected = new CountDownLatch(1);
// The first commits a moment after the second has started, so both see no row before either commits. // The first commits only when the second waits for its row, so both saw no row before either commits.
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager) CompletableFuture<Void> first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager)
.executeWithoutResult(tx -> { .executeWithoutResult(tx -> {
gameCorrectionService.correct(GAME, "name", "Portal Two", 1L); gameCorrectionService.correct(GAME, "name", "Portal Two", 1L);
firstCorrected.countDown(); firstCorrected.countDown();
pause(); awaitLockWaiter(jdbcTemplate);
})); }));
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> { CompletableFuture<Void> second = CompletableFuture.runAsync(() -> {
await(firstCorrected); await(firstCorrected);
@@ -86,14 +86,6 @@ class GameCorrectionServiceConcurrencyTest extends AbstractIntegrationTest {
assertThat(steamGameRepository.findById(GAME).orElseThrow().getImageUrl()).isEqualTo(IMAGE); assertThat(steamGameRepository.findById(GAME).orElseThrow().getImageUrl()).isEqualTo(IMAGE);
} }
private static void pause() {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private static void await(CountDownLatch latch) { private static void await(CountDownLatch latch) {
try { try {
if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out"); if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out");
@@ -11,13 +11,11 @@ import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.shared.test.AbstractMockedAdaptersTest;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@@ -32,7 +30,7 @@ import static org.mockito.Mockito.when;
@TestPropertySource(properties = { @TestPropertySource(properties = {
"spring.datasource.hikari.maximum-pool-size=1", "spring.datasource.hikari.maximum-pool-size=1",
"spring.datasource.hikari.connection-timeout=1000"}) "spring.datasource.hikari.connection-timeout=1000"})
class GameDetailServicePoolTest extends AbstractIntegrationTest { class GameDetailServicePoolTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001"; private static final String STEAM_ID = "76561198000000001";
@@ -52,9 +50,6 @@ class GameDetailServicePoolTest extends AbstractIntegrationTest {
@Autowired @Autowired
private UserGameAchievementMetaRepository userAchievementMetaRepository; private UserGameAchievementMetaRepository userAchievementMetaRepository;
@MockitoBean
private SteamApiService steamApiService;
@Autowired @Autowired
private GameAchievementSchemaRepository schemaRepository; private GameAchievementSchemaRepository schemaRepository;
@@ -12,23 +12,25 @@ import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository; import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameSpeedrunRepository; import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository; import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse; import core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.shared.test.AbstractMockedAdaptersTest;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.ResourceAccessException;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -37,7 +39,7 @@ import static org.mockito.Mockito.when;
/** /**
* The detail page refreshes stale rows in a transaction of its own, then reads them in its own read transaction. * The detail page refreshes stale rows in a transaction of its own, then reads them in its own read transaction.
*/ */
class GameDetailServiceTest extends AbstractIntegrationTest { class GameDetailServiceTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L; private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001"; private static final String STEAM_ID = "76561198000000001";
@@ -63,17 +65,11 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
@Autowired @Autowired
private UserGameAchievementMetaRepository userAchievementMetaRepository; private UserGameAchievementMetaRepository userAchievementMetaRepository;
@MockitoBean @Autowired
private SteamApiService steamApiService; private GameTimeToBeatRepository gameTimeToBeatRepository;
@MockitoBean @Autowired
private TimeToBeatService timeToBeatService; private GameMoodsRepository gameMoodsRepository;
@MockitoBean
private MoodService moodService;
@MockitoBean
private SpeedrunService speedrunService;
@Autowired @Autowired
private GameSpeedrunRepository gameSpeedrunRepository; private GameSpeedrunRepository gameSpeedrunRepository;
@@ -86,6 +82,8 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
userAchievementRepository.deleteAll(); userAchievementRepository.deleteAll();
userAchievementMetaRepository.deleteAll(); userAchievementMetaRepository.deleteAll();
gameSpeedrunRepository.deleteAll(); gameSpeedrunRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now())); steamGameRepository.save(new SteamGame(620L, "Portal 2", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now())); userGameRepository.save(new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now()));
} }
@@ -115,7 +113,7 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow(); GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
org.mockito.Mockito.verifyNoInteractions(steamApiService, speedrunService, timeToBeatService, moodService); org.mockito.Mockito.verifyNoInteractions(steamApiService, igdbApiService, speedrunApiService);
assertEquals(true, detail.hidden()); assertEquals(true, detail.hidden());
assertEquals(1, detail.achievementsTotal()); assertEquals(1, detail.achievementsTotal());
} }
@@ -124,20 +122,26 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() { void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.getTimeToBeat(Set.of(620L))).thenReturn(Optional.of(Map.of()));
when(igdbApiService.getGameTags(Set.of(620L))).thenReturn(Optional.of(Map.of()));
gameDetailService.getGameDetail(USER_ID, null, 620L); gameDetailService.getGameDetail(USER_ID, null, 620L);
org.mockito.Mockito.verify(timeToBeatService).refreshInBackground(List.of(620L)); assertTrue(gameTimeToBeatRepository.findById(620L).isPresent());
org.mockito.Mockito.verify(moodService).refreshInBackground(List.of(620L)); assertTrue(gameMoodsRepository.findById(620L).isPresent());
} }
@Test @Test
void getGameDetail_ShowsTheSpeedrunLinkAndAsksForIt() { void getGameDetail_ShowsTheSpeedrunLinkAndAsksForIt() {
gameSpeedrunRepository.upsert(620L, "om1mw4d2", "https://www.speedrun.com/portal_2", Instant.now()); gameSpeedrunRepository.upsert(620L, "om1mw4d2", "https://www.speedrun.com/portal_2",
Instant.now().minus(Duration.ofDays(31)));
when(speedrunApiService.isEnabled()).thenReturn(true);
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow(); GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
assertEquals("https://www.speedrun.com/portal_2", detail.speedrunUrl()); assertEquals("https://www.speedrun.com/portal_2", detail.speedrunUrl());
org.mockito.Mockito.verify(speedrunService).refreshInBackground(List.of(620L)); org.mockito.Mockito.verify(speedrunApiService).findGames("Portal 2");
} }
@Test @Test
@@ -1,16 +1,16 @@
package core_service.modules.library.service; package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync; import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse; import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService; import core_service.shared.test.AbstractMockedAdaptersTest;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -27,7 +27,7 @@ import static org.mockito.Mockito.when;
/** /**
* The claim and the finish of a sync are committed on their own, so other requests see RUNNING and FAILED. * The claim and the finish of a sync are committed on their own, so other requests see RUNNING and FAILED.
*/ */
class LibrarySyncWorkerConcurrencyTest extends AbstractIntegrationTest { class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 4711L; private static final long USER_ID = 4711L;
private static final long APPID = 991220L; private static final long APPID = 991220L;
@@ -42,8 +42,8 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractIntegrationTest {
@Autowired @Autowired
private JdbcTemplate jdbcTemplate; private JdbcTemplate jdbcTemplate;
@MockitoBean @Autowired
private SteamApiService steamApiService; private GameSpeedrunRepository gameSpeedrunRepository;
@BeforeEach @BeforeEach
@AfterEach @AfterEach
@@ -53,6 +53,23 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractIntegrationTest {
jdbcTemplate.update("DELETE FROM wishlist_game WHERE user_id = ?", USER_ID); jdbcTemplate.update("DELETE FROM wishlist_game WHERE user_id = ?", USER_ID);
jdbcTemplate.update("DELETE FROM user_steam_sync WHERE user_id = ?", USER_ID); jdbcTemplate.update("DELETE FROM user_steam_sync WHERE user_id = ?", USER_ID);
jdbcTemplate.update("DELETE FROM steam_game WHERE appid = ?", APPID); jdbcTemplate.update("DELETE FROM steam_game WHERE appid = ?", APPID);
jdbcTemplate.update("DELETE FROM game_speedrun WHERE appid = ?", APPID);
}
@Test
void runSyncNow_TheFollowUpStoresTheSpeedrunRow() {
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
when(steamApiService.getOwnedGames(STEAM_ID))
.thenReturn(List.of(new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 0, "icon", true, 0)));
when(speedrunApiService.isEnabled()).thenReturn(true);
when(speedrunApiService.findGames("Half-Life 2")).thenReturn(Optional.of(List.of(
new SpeedrunApiService.Game("hl2", "Half-Life 2", 2004, "https://www.speedrun.com/hl2"))));
when(speedrunApiService.hasVerifiedRuns("hl2")).thenReturn(Optional.of(true));
worker.runSyncNow(USER_ID, STEAM_ID);
verify(speedrunApiService).hasVerifiedRuns("hl2");
assertThat(gameSpeedrunRepository.findById(APPID)).isPresent();
} }
@Test @Test
@@ -2,12 +2,12 @@ package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractMockedAdaptersTest;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
@@ -26,7 +26,10 @@ import static org.mockito.Mockito.when;
/** /**
* Two users' syncs run in parallel and can miss the same app, e.g. every app right after the first deploy. * Two users' syncs run in parallel and can miss the same app, e.g. every app right after the first deploy.
*/ */
class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest { class TimeToBeatServiceConcurrencyTest extends AbstractMockedAdaptersTest {
@Autowired
private JdbcTemplate jdbc;
@Autowired @Autowired
private TimeToBeatService timeToBeatService; private TimeToBeatService timeToBeatService;
@@ -37,9 +40,6 @@ class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest {
@Autowired @Autowired
private PlatformTransactionManager transactionManager; private PlatformTransactionManager transactionManager;
@MockitoBean
private IgdbApiService igdbApiService;
// Before as well: other integration tests leave rows in this shared table, and a fresh row would hide the race. // Before as well: other integration tests leave rows in this shared table, and a fresh row would hide the race.
@BeforeEach @BeforeEach
@AfterEach @AfterEach
@@ -60,8 +60,8 @@ class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest {
timeToBeatService.refresh(List.of(620L)); timeToBeatService.refresh(List.of(620L));
firstWrote.countDown(); firstWrote.countDown();
await(secondStarted); await(secondStarted);
// Keep the transaction open while the second sync writes the same row. // Keep the transaction open until the second sync waits for the same row.
sleep(); awaitLockWaiter(jdbc);
})); }));
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> { CompletableFuture<Void> second = CompletableFuture.runAsync(() -> {
await(firstWrote); await(firstWrote);
@@ -82,11 +82,4 @@ class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest {
} }
} }
private static void sleep() {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
} }
@@ -1,17 +1,24 @@
package core_service.shared.test; package core_service.shared.test;
import core_service.shared.background.BackgroundPools;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.core.task.TaskExecutor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.bean.override.convention.TestBean;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Base64; import java.util.Base64;
import java.util.concurrent.atomic.AtomicReference;
/** /**
* Base class for integration tests. * Base class for integration tests.
@@ -37,6 +44,63 @@ public abstract class AbstractIntegrationTest {
postgres.start(); postgres.start();
} }
// Background work is done when the call returns, so a test reads the result at once. The default pool runs it on
// the calling thread. The follow-up pool runs it on a new thread and waits: an after-commit listener then has its
// own transactions, as in production, and does not join the finished one of the sync.
@TestBean(name = BackgroundPools.DEFAULT)
private TaskExecutor applicationTaskExecutor;
@TestBean(name = BackgroundPools.SYNC_FOLLOW_UP)
private TaskExecutor syncFollowUpExecutor;
static TaskExecutor applicationTaskExecutor() {
return new SyncTaskExecutor();
}
static TaskExecutor syncFollowUpExecutor() {
return task -> {
AtomicReference<Throwable> failure = new AtomicReference<>();
Thread thread = Thread.ofVirtual().start(() -> {
try {
task.run();
} catch (Throwable e) {
failure.set(e);
}
});
try {
if (!thread.join(Duration.ofSeconds(30))) {
throw new AssertionError("Follow-up task still runs after 30 s");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
if (failure.get() != null) {
throw new AssertionError("Follow-up task failed", failure.get());
}
};
}
/**
* Waits until another connection waits for a row lock: the race a concurrency test sets up is then in place.
*/
protected static void awaitLockWaiter(JdbcTemplate jdbc) {
for (int attempt = 0; attempt < 250; attempt++) {
Integer waiting = jdbc.queryForObject(
"select count(*) from pg_stat_activity where wait_event_type = 'Lock'", Integer.class);
if (waiting != null && waiting > 0) {
return;
}
try {
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
throw new AssertionError("No connection waits for a lock");
}
// No key is committed: a fresh one per JVM. Tests that sign tokens use TEST_KEYS.getPrivate(). // No key is committed: a fresh one per JVM. Tests that sign tokens use TEST_KEYS.getPrivate().
protected static final KeyPair TEST_KEYS = generateKeys(); protected static final KeyPair TEST_KEYS = generateKeys();
@@ -0,0 +1,22 @@
package core_service.shared.test;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.modules.steam_adapter.service.SteamApiService;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
* Integration tests with the three external APIs mocked, in one shared context. A mock answers empty, so a sync a
* request starts finds no games and leaves the library as it is.
*/
public abstract class AbstractMockedAdaptersTest extends AbstractIntegrationTest {
@MockitoBean
protected SteamApiService steamApiService;
@MockitoBean
protected IgdbApiService igdbApiService;
@MockitoBean
protected SpeedrunApiService speedrunApiService;
}
@@ -0,0 +1,21 @@
package core_service.shared.test;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SpeedrunService;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
/**
* Integration tests of the picker and stats pages: the sync and the background refreshes they start are mocked.
*/
public abstract class AbstractMockedLibraryServicesTest extends AbstractIntegrationTest {
@MockitoBean
protected LibrarySyncService syncService;
@MockitoBean
protected MoodService moodService;
@MockitoBean
protected SpeedrunService speedrunService;
}