From 3512159b41f66af92cef44dd2833846a1c4d8e4a Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 30 Sep 2026 12:08:57 +0200 Subject: [PATCH] test: background work inline, shared mock bases, lock-wait instead of sleep --- readme.md | 4 ++ .../security/ClientHeaderFilterTest.java | 4 +- .../library/api/GameImportControllerTest.java | 10 +-- .../LibraryControllerAchievementsTest.java | 12 +--- .../api/LibraryControllerHiddenTest.java | 4 +- .../api/LibraryControllerPickerTest.java | 18 +----- .../api/LibraryControllerStatsTest.java | 18 +----- .../LibraryControllerSteamProfileTest.java | 9 +-- .../library/api/ManualGameControllerTest.java | 25 ++------ ...AchievementSyncServiceConcurrencyTest.java | 26 ++------ .../GameCorrectionServiceConcurrencyTest.java | 12 +--- .../service/GameDetailServicePoolTest.java | 9 +-- .../service/GameDetailServiceTest.java | 42 ++++++------ .../LibrarySyncWorkerConcurrencyTest.java | 29 +++++++-- .../TimeToBeatServiceConcurrencyTest.java | 23 +++---- .../shared/test/AbstractIntegrationTest.java | 64 +++++++++++++++++++ .../test/AbstractMockedAdaptersTest.java | 22 +++++++ .../AbstractMockedLibraryServicesTest.java | 21 ++++++ 18 files changed, 196 insertions(+), 156 deletions(-) create mode 100644 src/test/java/core_service/shared/test/AbstractMockedAdaptersTest.java create mode 100644 src/test/java/core_service/shared/test/AbstractMockedLibraryServicesTest.java diff --git a/readme.md b/readme.md index 9342a92..93530f0 100644 --- a/readme.md +++ b/readme.md @@ -437,6 +437,10 @@ Configuration comes from environment variables: ``` 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 diff --git a/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java b/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java index 537474d..a261c1f 100644 --- a/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java +++ b/src/test/java/core_service/configuration/security/ClientHeaderFilterTest.java @@ -1,6 +1,6 @@ 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.springframework.beans.factory.annotation.Autowired; 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. */ @AutoConfigureMockMvc -class ClientHeaderFilterTest extends AbstractIntegrationTest { +class ClientHeaderFilterTest extends AbstractMockedAdaptersTest { private static final String FRONTEND = "https://tbp.andreas-dahm.eu"; diff --git a/src/test/java/core_service/modules/library/api/GameImportControllerTest.java b/src/test/java/core_service/modules/library/api/GameImportControllerTest.java index 73fb1fb..ca03e52 100644 --- a/src/test/java/core_service/modules/library/api/GameImportControllerTest.java +++ b/src/test/java/core_service/modules/library/api/GameImportControllerTest.java @@ -8,7 +8,7 @@ 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 core_service.shared.test.AbstractMockedAdaptersTest; import com.jayway.jsonpath.JsonPath; import org.junit.jupiter.api.BeforeEach; 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.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; @@ -48,7 +47,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * The dev profile authenticates every request as user 1. */ @AutoConfigureMockMvc -class GameImportControllerTest extends AbstractIntegrationTest { +class GameImportControllerTest extends AbstractMockedAdaptersTest { private static final long USER_ID = 1L; 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 private JdbcTemplate jdbc; - @MockitoBean - private IgdbApiService igdbApiService; - @BeforeEach void seed() { jdbc.update("delete from game_alias"); @@ -104,7 +100,7 @@ class GameImportControllerTest extends AbstractIntegrationTest { 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 { for (int attempt = 0; attempt < 50; attempt++) { if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) { diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java index 808213b..cc66051 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java @@ -10,13 +10,11 @@ import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.service.LibrarySyncWorker; import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse; -import core_service.modules.steam_adapter.service.SteamApiService; -import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.AbstractMockedAdaptersTest; 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.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; @@ -27,7 +25,6 @@ import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; 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. */ @AutoConfigureMockMvc -class LibraryControllerAchievementsTest extends AbstractIntegrationTest { +class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest { 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"; @@ -46,9 +43,6 @@ class LibraryControllerAchievementsTest extends AbstractIntegrationTest { @Autowired private MockMvc mockMvc; - @MockitoBean - private SteamApiService steamApiService; - @Autowired private SteamGameRepository steamGameRepository; @@ -161,6 +155,6 @@ class LibraryControllerAchievementsTest extends AbstractIntegrationTest { librarySyncWorker.runSyncNow(USER_ID, "76561198000000001"); - verify(steamApiService, timeout(5_000)).getSchemaForGame(620L); + verify(steamApiService).getSchemaForGame(620L); } } diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java index 77c3a57..22bc4d5 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java @@ -7,7 +7,7 @@ import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; -import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.AbstractMockedAdaptersTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; 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. */ @AutoConfigureMockMvc -class LibraryControllerHiddenTest extends AbstractIntegrationTest { +class LibraryControllerHiddenTest extends AbstractMockedAdaptersTest { private static final long USER_ID = 1L; diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java index acb750a..1bdc43a 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -11,19 +11,14 @@ import core_service.modules.library.repo.GameSpeedrunRepository; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; -import core_service.modules.library.service.LibrarySyncService; -import core_service.modules.library.service.MoodService; -import core_service.modules.library.service.SpeedrunService; -import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.AbstractMockedLibraryServicesTest; 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.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.time.Instant; -import java.util.List; import static org.hamcrest.Matchers.containsInAnyOrder; 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. */ @AutoConfigureMockMvc -class LibraryControllerPickerTest extends AbstractIntegrationTest { +class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest { private static final long USER_ID = 1L; @@ -56,15 +51,6 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest { @Autowired private GameMoodsRepository gameMoodsRepository; - @MockitoBean - private LibrarySyncService syncService; - - @MockitoBean - private MoodService moodService; - - @MockitoBean - private SpeedrunService speedrunService; - @Autowired private GameSpeedrunRepository gameSpeedrunRepository; diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java index b4e4a38..eee64e7 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java @@ -7,10 +7,7 @@ import core_service.modules.library.domain.UserGame; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; -import core_service.modules.library.service.LibrarySyncService; -import core_service.modules.library.service.MoodService; -import core_service.modules.library.service.SpeedrunService; -import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.AbstractMockedLibraryServicesTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; 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.http.converter.HttpMessageNotWritableException; 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 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. */ @AutoConfigureMockMvc -class LibraryControllerStatsTest extends AbstractIntegrationTest { +class LibraryControllerStatsTest extends AbstractMockedLibraryServicesTest { private static final long USER_ID = 1L; @@ -59,16 +55,6 @@ class LibraryControllerStatsTest extends AbstractIntegrationTest { @Autowired 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 void seed() { userGameRepository.deleteAll(); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerSteamProfileTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerSteamProfileTest.java index fb68dcb..59cc189 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerSteamProfileTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerSteamProfileTest.java @@ -1,12 +1,10 @@ package core_service.modules.library.api; import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse; -import core_service.modules.steam_adapter.service.SteamApiService; -import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.AbstractMockedAdaptersTest; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; 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. */ @AutoConfigureMockMvc -class LibraryControllerSteamProfileTest extends AbstractIntegrationTest { +class LibraryControllerSteamProfileTest extends AbstractMockedAdaptersTest { @Autowired private MockMvc mockMvc; - @MockitoBean - private SteamApiService steamApiService; - @Test void steamProfile_SendsNameAndAvatar() throws Exception { when(steamApiService.getPlayerSummary("76561198000000001")).thenReturn(Optional.of(new SteamPlayerSummaryResponse.Player( diff --git a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java index 1731919..2358e9e 100644 --- a/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java +++ b/src/test/java/core_service/modules/library/api/ManualGameControllerTest.java @@ -4,14 +4,13 @@ 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.GameTimeToBeat; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; -import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.AbstractMockedAdaptersTest; import core_service.shared.test.ContractTokens; import org.junit.jupiter.api.BeforeEach; 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.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 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. */ @AutoConfigureMockMvc -class ManualGameControllerTest extends AbstractIntegrationTest { +class ManualGameControllerTest extends AbstractMockedAdaptersTest { private static final long USER_ID = 1L; 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 private JdbcTemplate jdbc; - @MockitoBean - private IgdbApiService igdbApiService; - @BeforeEach void seed() { jdbc.update("delete from game_alias"); @@ -115,19 +110,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest { SteamGame catalog = steamGameRepository.findById(-119388L).orElseThrow(); assertEquals("Zelda: Tears of the Kingdom", catalog.getName()); assertEquals(ZELDA.imageUrl(), catalog.getImageUrl()); - assertEquals(3600, timeToBeatStored(-119388L).getNormallyMinutes()); - } - - // The add asks IGDB for the time to beat in the background. - private GameTimeToBeat timeToBeatStored(long appid) throws InterruptedException { - for (int attempt = 0; attempt < 50; attempt++) { - Optional found = gameTimeToBeatRepository.findById(appid); - if (found.isPresent()) { - return found.get(); - } - Thread.sleep(100); - } - throw new AssertionError("No time to beat stored for appid " + appid); + assertEquals(3600, gameTimeToBeatRepository.findById(-119388L).orElseThrow().getNormallyMinutes()); } @Test @@ -333,7 +316,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest { @Test void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception { add(119388, "Switch"); - timeToBeatStored(-119388L); + assertTrue(gameTimeToBeatRepository.findById(-119388L).isPresent()); mockMvc.perform(get("/api/library/games").param("q", "zelda")) .andExpect(status().isOk()) diff --git a/src/test/java/core_service/modules/library/service/AchievementSyncServiceConcurrencyTest.java b/src/test/java/core_service/modules/library/service/AchievementSyncServiceConcurrencyTest.java index ae9bb32..8947277 100644 --- a/src/test/java/core_service/modules/library/service/AchievementSyncServiceConcurrencyTest.java +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceConcurrencyTest.java @@ -3,13 +3,11 @@ package core_service.modules.library.service; import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameAchievementRepository; import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; -import core_service.modules.steam_adapter.service.SteamApiService; -import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.AbstractMockedAdaptersTest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; 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.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 * (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 String STEAM_ID = "76561198000000001"; @@ -45,9 +43,6 @@ class AchievementSyncServiceConcurrencyTest extends AbstractIntegrationTest { @Autowired private PlatformTransactionManager transactionManager; - @MockitoBean - private SteamApiService steamApiService; - @BeforeEach @AfterEach void clean() { @@ -61,20 +56,20 @@ class AchievementSyncServiceConcurrencyTest extends AbstractIntegrationTest { new SteamPlayerAchievementsResponse.PlayerStats(STEAM_ID, "Portal 2", List.of(new SteamPlayerAchievementsResponse.Achievement("A", 1, 1_700_000_000L)), true, null))); CountDownLatch firstWrote = new CountDownLatch(1); - CountDownLatch secondStarted = new CountDownLatch(1); + CountDownLatch secondDone = new CountDownLatch(1); CompletableFuture detailPage = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager) .executeWithoutResult(tx -> { achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1)); firstWrote.countDown(); - await(secondStarted); - // Keep the page's transaction open while the background run writes the same rows. - sleep(); + // The page's transaction stays open until the background run wrote the same rows; a run that + // waited for this transaction would time out the latch. + await(secondDone); })); CompletableFuture backgroundRun = CompletableFuture.runAsync(() -> { await(firstWrote); - secondStarted.countDown(); achievementSyncService.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 60, Duration.ofHours(1)); + secondDone.countDown(); }); 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(); - } - } } diff --git a/src/test/java/core_service/modules/library/service/GameCorrectionServiceConcurrencyTest.java b/src/test/java/core_service/modules/library/service/GameCorrectionServiceConcurrencyTest.java index 0b85937..c62f359 100644 --- a/src/test/java/core_service/modules/library/service/GameCorrectionServiceConcurrencyTest.java +++ b/src/test/java/core_service/modules/library/service/GameCorrectionServiceConcurrencyTest.java @@ -55,12 +55,12 @@ class GameCorrectionServiceConcurrencyTest extends AbstractIntegrationTest { void twoAuditorsCorrectTheSameFieldForTheFirstTime_BothSucceed() { 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 first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager) .executeWithoutResult(tx -> { gameCorrectionService.correct(GAME, "name", "Portal Two", 1L); firstCorrected.countDown(); - pause(); + awaitLockWaiter(jdbcTemplate); })); CompletableFuture second = CompletableFuture.runAsync(() -> { await(firstCorrected); @@ -86,14 +86,6 @@ class GameCorrectionServiceConcurrencyTest extends AbstractIntegrationTest { 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) { try { if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("latch timed out"); diff --git a/src/test/java/core_service/modules/library/service/GameDetailServicePoolTest.java b/src/test/java/core_service/modules/library/service/GameDetailServicePoolTest.java index 08d80a6..626de8e 100644 --- a/src/test/java/core_service/modules/library/service/GameDetailServicePoolTest.java +++ b/src/test/java/core_service/modules/library/service/GameDetailServicePoolTest.java @@ -11,13 +11,11 @@ import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse; -import core_service.modules.steam_adapter.service.SteamApiService; -import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.AbstractMockedAdaptersTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.time.Instant; import java.util.List; @@ -32,7 +30,7 @@ import static org.mockito.Mockito.when; @TestPropertySource(properties = { "spring.datasource.hikari.maximum-pool-size=1", "spring.datasource.hikari.connection-timeout=1000"}) -class GameDetailServicePoolTest extends AbstractIntegrationTest { +class GameDetailServicePoolTest extends AbstractMockedAdaptersTest { private static final long USER_ID = 1L; private static final String STEAM_ID = "76561198000000001"; @@ -52,9 +50,6 @@ class GameDetailServicePoolTest extends AbstractIntegrationTest { @Autowired private UserGameAchievementMetaRepository userAchievementMetaRepository; - @MockitoBean - private SteamApiService steamApiService; - @Autowired private GameAchievementSchemaRepository schemaRepository; diff --git a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java index 5ead39e..c5eb9f6 100644 --- a/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameDetailServiceTest.java @@ -12,23 +12,25 @@ import core_service.modules.library.repo.GameAchievementSchemaMetaRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.GameAchievementSchemaRepository; 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.UserGameAchievementRepository; 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.SteamSchemaForGameResponse; -import core_service.modules.steam_adapter.service.SteamApiService; -import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.AbstractMockedAdaptersTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.web.client.ResourceAccessException; import java.time.Duration; import java.time.Instant; import java.util.List; 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.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. */ -class GameDetailServiceTest extends AbstractIntegrationTest { +class GameDetailServiceTest extends AbstractMockedAdaptersTest { private static final long USER_ID = 1L; private static final String STEAM_ID = "76561198000000001"; @@ -63,17 +65,11 @@ class GameDetailServiceTest extends AbstractIntegrationTest { @Autowired private UserGameAchievementMetaRepository userAchievementMetaRepository; - @MockitoBean - private SteamApiService steamApiService; + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; - @MockitoBean - private TimeToBeatService timeToBeatService; - - @MockitoBean - private MoodService moodService; - - @MockitoBean - private SpeedrunService speedrunService; + @Autowired + private GameMoodsRepository gameMoodsRepository; @Autowired private GameSpeedrunRepository gameSpeedrunRepository; @@ -86,6 +82,8 @@ class GameDetailServiceTest extends AbstractIntegrationTest { userAchievementRepository.deleteAll(); userAchievementMetaRepository.deleteAll(); gameSpeedrunRepository.deleteAll(); + gameTimeToBeatRepository.deleteAll(); + gameMoodsRepository.deleteAll(); steamGameRepository.save(new SteamGame(620L, "Portal 2", null, 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(); - org.mockito.Mockito.verifyNoInteractions(steamApiService, speedrunService, timeToBeatService, moodService); + org.mockito.Mockito.verifyNoInteractions(steamApiService, igdbApiService, speedrunApiService); assertEquals(true, detail.hidden()); assertEquals(1, detail.achievementsTotal()); } @@ -124,20 +122,26 @@ class GameDetailServiceTest extends AbstractIntegrationTest { void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() { 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); - org.mockito.Mockito.verify(timeToBeatService).refreshInBackground(List.of(620L)); - org.mockito.Mockito.verify(moodService).refreshInBackground(List.of(620L)); + assertTrue(gameTimeToBeatRepository.findById(620L).isPresent()); + assertTrue(gameMoodsRepository.findById(620L).isPresent()); } @Test 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(); 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 diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java index 099af0b..dd1fec9 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java @@ -1,16 +1,16 @@ package core_service.modules.library.service; 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.steam_adapter.api.dto.response.SteamOwnedGamesResponse; -import core_service.modules.steam_adapter.service.SteamApiService; -import core_service.shared.test.AbstractIntegrationTest; +import core_service.shared.test.AbstractMockedAdaptersTest; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.util.List; 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. */ -class LibrarySyncWorkerConcurrencyTest extends AbstractIntegrationTest { +class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest { private static final long USER_ID = 4711L; private static final long APPID = 991220L; @@ -42,8 +42,8 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractIntegrationTest { @Autowired private JdbcTemplate jdbcTemplate; - @MockitoBean - private SteamApiService steamApiService; + @Autowired + private GameSpeedrunRepository gameSpeedrunRepository; @BeforeEach @AfterEach @@ -53,6 +53,23 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractIntegrationTest { 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 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 diff --git a/src/test/java/core_service/modules/library/service/TimeToBeatServiceConcurrencyTest.java b/src/test/java/core_service/modules/library/service/TimeToBeatServiceConcurrencyTest.java index f787d18..7315239 100644 --- a/src/test/java/core_service/modules/library/service/TimeToBeatServiceConcurrencyTest.java +++ b/src/test/java/core_service/modules/library/service/TimeToBeatServiceConcurrencyTest.java @@ -2,12 +2,12 @@ package core_service.modules.library.service; import core_service.modules.igdb_adapter.service.IgdbApiService; 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.BeforeEach; import org.junit.jupiter.api.Test; 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.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. */ -class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest { +class TimeToBeatServiceConcurrencyTest extends AbstractMockedAdaptersTest { + + @Autowired + private JdbcTemplate jdbc; @Autowired private TimeToBeatService timeToBeatService; @@ -37,9 +40,6 @@ class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest { @Autowired 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. @BeforeEach @AfterEach @@ -60,8 +60,8 @@ class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest { timeToBeatService.refresh(List.of(620L)); firstWrote.countDown(); await(secondStarted); - // Keep the transaction open while the second sync writes the same row. - sleep(); + // Keep the transaction open until the second sync waits for the same row. + awaitLockWaiter(jdbc); })); CompletableFuture second = CompletableFuture.runAsync(() -> { await(firstWrote); @@ -82,11 +82,4 @@ class TimeToBeatServiceConcurrencyTest extends AbstractIntegrationTest { } } - private static void sleep() { - try { - Thread.sleep(500); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } } diff --git a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java index eb9a7fd..4efe47c 100644 --- a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java @@ -1,17 +1,24 @@ package core_service.shared.test; +import core_service.shared.background.BackgroundPools; import org.junit.jupiter.api.Tag; import org.springframework.boot.test.context.SpringBootTest; 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.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.convention.TestBean; import org.testcontainers.containers.PostgreSQLContainer; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; +import java.time.Duration; import java.util.Base64; +import java.util.concurrent.atomic.AtomicReference; /** * Base class for integration tests. @@ -37,6 +44,63 @@ public abstract class AbstractIntegrationTest { 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 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(). protected static final KeyPair TEST_KEYS = generateKeys(); diff --git a/src/test/java/core_service/shared/test/AbstractMockedAdaptersTest.java b/src/test/java/core_service/shared/test/AbstractMockedAdaptersTest.java new file mode 100644 index 0000000..69a1256 --- /dev/null +++ b/src/test/java/core_service/shared/test/AbstractMockedAdaptersTest.java @@ -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; +} diff --git a/src/test/java/core_service/shared/test/AbstractMockedLibraryServicesTest.java b/src/test/java/core_service/shared/test/AbstractMockedLibraryServicesTest.java new file mode 100644 index 0000000..bdf9e81 --- /dev/null +++ b/src/test/java/core_service/shared/test/AbstractMockedLibraryServicesTest.java @@ -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; +}