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

@@ -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";
@@ -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))) {
@@ -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);
}
}
@@ -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;
@@ -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;
@@ -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();
@@ -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(
@@ -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<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);
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())
@@ -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<Void> 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<Void> 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();
}
}
}
@@ -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<Void> first = CompletableFuture.runAsync(() -> new TransactionTemplate(transactionManager)
.executeWithoutResult(tx -> {
gameCorrectionService.correct(GAME, "name", "Portal Two", 1L);
firstCorrected.countDown();
pause();
awaitLockWaiter(jdbcTemplate);
}));
CompletableFuture<Void> 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");
@@ -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;
@@ -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
@@ -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
@@ -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<Void> 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();
}
}
}
@@ -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<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().
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;
}