Public Access
fix(sync): short sync transactions, real Steam retries, no N+1
This commit is contained in:
1 parent
2a5fcc1c00
commit
2360beeb81
18 files changed
+536
-279
No files matched your search
@@ -114,4 +114,13 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(repository.findOrphans(List.of(2L))).containsExactly(1L, 7L);
|
||||
assertThat(repository.findOrphans(List.of(1L, 2L, 7L))).isEmpty();
|
||||
}
|
||||
|
||||
// A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind.
|
||||
@Test
|
||||
void tables_CoverEveryTableWithAUserId() {
|
||||
List<String> withUserId = jdbc.queryForList("select table_name from information_schema.columns "
|
||||
+ "where table_schema = 'public' and column_name = 'user_id' order by table_name", String.class);
|
||||
|
||||
assertThat(AccountCleanupRepository.TABLES).containsExactlyInAnyOrderElementsOf(withUserId);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
private int claim(long userId) {
|
||||
return repository.tryClaimRun(userId, STEAM_ID, Instant.now().minusSeconds(600));
|
||||
return repository.tryClaimRun(userId, STEAM_ID, Instant.now(), Instant.now().minusSeconds(600));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -44,4 +44,20 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
assertThat(claim(3L)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryClaimRun_RunningRowStartedLongAgo_IsClaimedEvenWhenTheLastSyncEndedRecently() {
|
||||
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " +
|
||||
"values (4, ?, now(), 'RUNNING', now() - interval '20 minutes')", STEAM_ID);
|
||||
|
||||
assertThat(claim(4L)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void tryClaimRun_RunningRowStartedJustNow_IsRefusedEvenWhenTheLastSyncEndedLongAgo() {
|
||||
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, sync_started_at) " +
|
||||
"values (5, ?, now() - interval '1 day', 'RUNNING', now())", STEAM_ID);
|
||||
|
||||
assertThat(claim(5L)).isZero();
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
|
||||
UserSteamSync sync = new UserSteamSync(USER_ID, steamId);
|
||||
sync.setLastSyncStatus(status);
|
||||
sync.setLastSyncedAt(Instant.now().minus(age));
|
||||
sync.setSyncStartedAt(Instant.now().minus(age));
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(sync));
|
||||
}
|
||||
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
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 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;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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 {
|
||||
|
||||
private static final long USER_ID = 4711L;
|
||||
private static final long APPID = 991220L;
|
||||
private static final String STEAM_ID = "76561198000004711";
|
||||
|
||||
@Autowired
|
||||
private LibrarySyncWorker worker;
|
||||
|
||||
@Autowired
|
||||
private UserSteamSyncRepository userSteamSyncRepository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@MockitoBean
|
||||
private SteamApiService steamApiService;
|
||||
|
||||
@BeforeEach
|
||||
@AfterEach
|
||||
void clean() {
|
||||
jdbcTemplate.update("DELETE FROM user_game_playtime_day WHERE user_id = ?", USER_ID);
|
||||
jdbcTemplate.update("DELETE FROM user_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 steam_game WHERE appid = ?", APPID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSyncNow_SecondSyncWhileTheFirstWaitsForSteam_IsSkipped() throws Exception {
|
||||
// Last synced yesterday: the running check must not take that as a stale run.
|
||||
jdbcTemplate.update("INSERT INTO user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status) " +
|
||||
"VALUES (?, ?, NOW() - INTERVAL '1 day', 'OK')", USER_ID, STEAM_ID);
|
||||
CountDownLatch steamAsked = new CountDownLatch(1);
|
||||
CountDownLatch secondDone = new CountDownLatch(1);
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenAnswer(invocation -> {
|
||||
steamAsked.countDown();
|
||||
secondDone.await(5, TimeUnit.SECONDS);
|
||||
return List.of(game("icon"));
|
||||
});
|
||||
|
||||
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID));
|
||||
assertThat(steamAsked.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(userSteamSyncRepository.findById(USER_ID).orElseThrow().getLastSyncStatus())
|
||||
.isEqualTo(UserSteamSync.Status.RUNNING);
|
||||
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> {
|
||||
worker.runSyncNow(USER_ID, STEAM_ID);
|
||||
secondDone.countDown();
|
||||
});
|
||||
CompletableFuture.allOf(first, second).get(30, TimeUnit.SECONDS);
|
||||
|
||||
verify(steamApiService, times(1)).getOwnedGames(STEAM_ID);
|
||||
assertThat(userSteamSyncRepository.findById(USER_ID).orElseThrow().getLastSyncStatus())
|
||||
.isEqualTo(UserSteamSync.Status.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSyncNow_DatabaseErrorWhileSaving_StoresFailed() {
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
|
||||
// img_icon_url is VARCHAR(512).
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(game("x".repeat(600))));
|
||||
|
||||
worker.runSyncNow(USER_ID, STEAM_ID);
|
||||
|
||||
UserSteamSync row = userSteamSyncRepository.findById(USER_ID).orElseThrow();
|
||||
assertThat(row.getLastSyncStatus()).isEqualTo(UserSteamSync.Status.FAILED);
|
||||
assertThat(jdbcTemplate.queryForObject("SELECT count(*) FROM user_game WHERE user_id = ?", Integer.class, USER_ID))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSyncNow_StaleRunFinishesWhileTheRunThatTookOverStillRuns_RowStaysRunning() throws Exception {
|
||||
CountDownLatch firstAsked = new CountDownLatch(1);
|
||||
CountDownLatch secondAsked = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
CountDownLatch releaseSecond = new CountDownLatch(1);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenAnswer(invocation -> {
|
||||
if (calls.incrementAndGet() == 1) {
|
||||
firstAsked.countDown();
|
||||
releaseFirst.await(10, TimeUnit.SECONDS);
|
||||
} else {
|
||||
secondAsked.countDown();
|
||||
releaseSecond.await(10, TimeUnit.SECONDS);
|
||||
}
|
||||
return List.of(game("icon"));
|
||||
});
|
||||
|
||||
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID));
|
||||
assertThat(firstAsked.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
// The first run hangs past running-stale-minutes, so the next sync takes over.
|
||||
jdbcTemplate.update("UPDATE user_steam_sync SET sync_started_at = NOW() - INTERVAL '10 minutes' WHERE user_id = ?",
|
||||
USER_ID);
|
||||
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID));
|
||||
assertThat(secondAsked.await(10, TimeUnit.SECONDS)).isTrue();
|
||||
releaseFirst.countDown();
|
||||
first.get(30, TimeUnit.SECONDS);
|
||||
UserSteamSync.Status whileSecondRuns = userSteamSyncRepository.findById(USER_ID).orElseThrow().getLastSyncStatus();
|
||||
releaseSecond.countDown();
|
||||
second.get(30, TimeUnit.SECONDS);
|
||||
|
||||
// OK here lets a third sync claim and run next to the second one.
|
||||
assertThat(whileSecondRuns).isEqualTo(UserSteamSync.Status.RUNNING);
|
||||
}
|
||||
|
||||
private static SteamOwnedGamesResponse.Game game(String iconUrl) {
|
||||
return new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 60, iconUrl, true, 0);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
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.domain.UserSteamSync;
|
||||
import core_service.modules.library.domain.WishlistGame;
|
||||
import core_service.modules.library.repo.GameCorrectionRepository;
|
||||
@@ -25,8 +24,11 @@ import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
@@ -47,6 +49,8 @@ import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
@@ -71,9 +75,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
*/
|
||||
private void steamReturns(SteamOwnedGamesResponse.Game game, String previousSteamId) {
|
||||
if (previousSteamId != null) {
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(syncRow(previousSteamId, previousSteamId)));
|
||||
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(previousSteamId, previousSteamId)));
|
||||
}
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(game));
|
||||
}
|
||||
|
||||
@@ -85,7 +89,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
private UserGame owned(int playtime) {
|
||||
UserGame existing = new UserGame(USER_ID, 220L, playtime, Instant.EPOCH, Instant.EPOCH);
|
||||
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(existing));
|
||||
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(existing));
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -119,69 +123,83 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Spy
|
||||
private TransactionTemplate transaction = new TransactionTemplate(mock(PlatformTransactionManager.class));
|
||||
|
||||
@InjectMocks
|
||||
private LibrarySyncWorker worker;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(worker, "zone", ZoneId.of("Europe/Berlin"));
|
||||
lenient().when(userSteamSyncRepository.markFinished(eq(USER_ID), any(), anyString(), any())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_AnotherRunTookOverTheStaleClaim_PublishesNothing() {
|
||||
steamReturns(game(100, 0), null);
|
||||
when(userSteamSyncRepository.markFinished(eq(USER_ID), any(), eq("OK"), any())).thenReturn(0);
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verifyNoInteractions(eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_EmptySteamResult_MarksFailedAndKeepsLibrary() {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
|
||||
verifyNoInteractions(eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_EmptyResultForNewSteamId_RemovesOldLibrary() {
|
||||
when(userSteamSyncRepository.findById(USER_ID))
|
||||
when(userSteamSyncRepository.findForClaim(USER_ID))
|
||||
.thenReturn(Optional.of(new UserSteamSync(USER_ID, "76561198005679790")));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_SteamErrorForNewSteamId_RemovesOldLibrary() {
|
||||
when(userSteamSyncRepository.findById(USER_ID))
|
||||
when(userSteamSyncRepository.findForClaim(USER_ID))
|
||||
.thenReturn(Optional.of(new UserSteamSync(USER_ID, "76561198005679790")));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenThrow(new IllegalStateException("Steam down"));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_WithGames_MarksOk() {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||
game(0, 0)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
|
||||
verify(eventPublisher).publishEvent(new LibrarySyncedEvent(USER_ID, STEAM_ID));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"0, BACKLOG", "1, PLAYING"})
|
||||
void runSync_NewGame_GetsStatusFromPlaytime(int playtime, GameStatus expected) {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||
game(playtime, 0)));
|
||||
|
||||
@@ -197,7 +215,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.FINISHED, 60, Instant.EPOCH);
|
||||
manual.setRating(5);
|
||||
manual.setNotes("Played on the couch");
|
||||
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(manual));
|
||||
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
|
||||
steamReturns(game(90, 30), STEAM_ID);
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
@@ -213,7 +231,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Test
|
||||
void runSync_GameAddedByHandIsNowOnSteam_TheHandTypedHoursAreNoPlaytimeGrowth() {
|
||||
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
|
||||
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(manual));
|
||||
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
|
||||
steamReturns(game(90, 30), STEAM_ID);
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
@@ -225,8 +243,8 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
void runSync_ExistingGame_KeepsStatus() {
|
||||
UserGame existing = new UserGame(USER_ID, 220L, 0, Instant.EPOCH, Instant.EPOCH);
|
||||
existing.setStatus(GameStatus.DROPPED);
|
||||
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(existing));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(existing));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||
game(90, 0)));
|
||||
|
||||
@@ -236,6 +254,18 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
assertEquals(90, existing.getPlaytimeMinutes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_SteamListsAGameTwice_CountsItOnce() {
|
||||
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(STEAM_ID, STEAM_ID)));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(game(600, 120), game(600, 120)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(userGameRepository).save(any(UserGame.class));
|
||||
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_PlaytimeGrew_RecordsTheDifferenceForToday() {
|
||||
owned(100);
|
||||
@@ -358,10 +388,10 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void runSync_FailedFirstSyncOfNewSteamId_NextSyncIsStillOnlyTheBaseline() {
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(
|
||||
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(
|
||||
Optional.of(syncRow(OTHER_STEAM_ID, OTHER_STEAM_ID)),
|
||||
Optional.of(syncRow(STEAM_ID, OTHER_STEAM_ID)));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(), List.of(game(5000, 60)));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
@@ -425,19 +455,19 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(wishlistGameRepository, never()).deleteByUserId(anyLong());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_NoOwnedGames_StillSyncsTheWishlist() {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.of(List.of(new SteamApiService.WishlistItem(1257610L, null))));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(wishlistGameRepository).saveAll(anyList());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("FAILED"), any());
|
||||
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -469,8 +499,8 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void runSync_SteamIdChanged_LibraryFailed_RemovesTheOldAccountsWishlist() {
|
||||
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(syncRow(OTHER_STEAM_ID, OTHER_STEAM_ID)));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(OTHER_STEAM_ID, OTHER_STEAM_ID)));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
@@ -525,7 +555,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void runSync_GameWithoutName_GetsTheAppidAsName() {
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null),
|
||||
game(90, 0)));
|
||||
@@ -539,8 +569,8 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Test
|
||||
void runSync_GameWithoutName_KeepsTheStoredName() {
|
||||
SteamGame stored = new SteamGame(2000001L, "El Mucho", null, Instant.EPOCH);
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||
when(steamGameRepository.findById(2000001L)).thenReturn(Optional.of(stored));
|
||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
|
||||
when(steamGameRepository.findAllById(List.of(2000001L))).thenReturn(List.of(stored));
|
||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null)));
|
||||
|
||||
@@ -552,7 +582,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Test
|
||||
void runSync_NewName_GoesThroughTheGuardedUpdate() {
|
||||
SteamGame stored = new SteamGame(220L, "Half-Life 2: Corrected", null, Instant.EPOCH);
|
||||
when(steamGameRepository.findById(220L)).thenReturn(Optional.of(stored));
|
||||
when(steamGameRepository.findAllById(List.of(220L))).thenReturn(List.of(stored));
|
||||
steamReturns(game(10, 0), null);
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
@@ -565,7 +595,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void runSync_CorrectedName_SendsNoUpdate() {
|
||||
when(steamGameRepository.findById(220L)).thenReturn(Optional.of(new SteamGame(220L, "HL2 (corrected)", null, Instant.EPOCH)));
|
||||
when(steamGameRepository.findAllById(List.of(220L))).thenReturn(List.of(new SteamGame(220L, "HL2 (corrected)", null, Instant.EPOCH)));
|
||||
when(gameCorrectionRepository.findCorrectedAppids(eq(CorrectionField.NAME), any())).thenReturn(Set.of(220L));
|
||||
steamReturns(game(10, 0), null);
|
||||
|
||||
|
||||
+60
@@ -1,6 +1,8 @@
|
||||
package core_service.modules.steam_adapter.service;
|
||||
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse;
|
||||
import core_service.shared.test.AbstractIntegrationTest;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||
@@ -10,6 +12,10 @@ import org.mockito.Answers;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Duration;
|
||||
@@ -18,6 +24,7 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -98,4 +105,57 @@ class SteamApiServiceCacheTest extends AbstractIntegrationTest {
|
||||
assertThat(steamApiService.getPlayerSummary(STEAM_ID)).isPresent();
|
||||
assertThat(steamCalls).hasValue(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOwnedGames_TransientSteamError_IsTriedAgain() {
|
||||
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(1,
|
||||
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0))));
|
||||
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
|
||||
.thenAnswer(invocation -> {
|
||||
if (steamCalls.incrementAndGet() == 1) {
|
||||
throw new ResourceAccessException("Read timed out");
|
||||
}
|
||||
return games;
|
||||
});
|
||||
|
||||
assertThat(steamApiService.getOwnedGames(STEAM_ID)).hasSize(1);
|
||||
assertThat(steamCalls).hasValue(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOwnedGames_SteamDownForEveryTry_Throws() {
|
||||
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
|
||||
.thenAnswer(invocation -> {
|
||||
steamCalls.incrementAndGet();
|
||||
throw new ResourceAccessException("Read timed out");
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> steamApiService.getOwnedGames(STEAM_ID)).isInstanceOf(ResourceAccessException.class);
|
||||
assertThat(steamCalls).hasValue(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOwnedGames_SteamRefusesTheKey_IsNotTriedAgain() {
|
||||
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
|
||||
.thenAnswer(invocation -> {
|
||||
steamCalls.incrementAndGet();
|
||||
throw HttpClientErrorException.create(HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, null, null);
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> steamApiService.getOwnedGames(STEAM_ID)).isInstanceOf(HttpClientErrorException.class);
|
||||
assertThat(steamCalls).hasValue(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWishlist_SteamDownForEveryTry_IsEmptySoTheCallerKeepsTheOldList() {
|
||||
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamWishlistResponse.class))
|
||||
.thenAnswer(invocation -> {
|
||||
steamCalls.incrementAndGet();
|
||||
throw new ResourceAccessException("Read timed out");
|
||||
});
|
||||
|
||||
assertThat(steamApiService.getWishlist(STEAM_ID)).isEmpty();
|
||||
// One try: the wishlist is optional and must not hold up the sync of the library.
|
||||
assertThat(steamCalls).hasValue(1);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package core_service.modules.steam_adapter.service;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerAchievementsResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamPlayerSummaryResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamProfileAndGamesResponse;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
@@ -21,6 +20,7 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.LongStream;
|
||||
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withException;
|
||||
import static org.hamcrest.CoreMatchers.allOf;
|
||||
import static org.hamcrest.CoreMatchers.containsString;
|
||||
import static org.hamcrest.CoreMatchers.not;
|
||||
@@ -172,54 +172,6 @@ class SteamApiServiceTest {
|
||||
assertThat(result.getFirst().hasCommunityVisibleStats()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getFullPlayerProfile_shouldReturnCombinedResult() {
|
||||
String steamId = "76561197960435530";
|
||||
String summaryJson = """
|
||||
{
|
||||
"response": {
|
||||
"players": [
|
||||
{
|
||||
"steamid": "76561197960435530",
|
||||
"personaname": "TestUser",
|
||||
"profileurl": "url",
|
||||
"avatarfull": "avatar"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
String gamesJson = """
|
||||
{
|
||||
"response": {
|
||||
"game_count": 1,
|
||||
"games": [
|
||||
{
|
||||
"appid": 10,
|
||||
"name": "Counter-Strike",
|
||||
"playtime_forever": 32,
|
||||
"img_icon_url": "icon_url",
|
||||
"has_community_visible_stats": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
server.expect(requestTo(containsString("/ISteamUser/GetPlayerSummaries/v0002/")))
|
||||
.andRespond(withSuccess(summaryJson, MediaType.APPLICATION_JSON));
|
||||
|
||||
server.expect(requestTo(containsString("/IPlayerService/GetOwnedGames/v1/")))
|
||||
.andRespond(withSuccess(gamesJson, MediaType.APPLICATION_JSON));
|
||||
|
||||
Optional<SteamProfileAndGamesResponse> result = steamApiService.getFullPlayerProfile(steamId);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().player().personaname()).isEqualTo("TestUser");
|
||||
assertThat(result.get().games()).hasSize(1);
|
||||
assertThat(result.get().games().getFirst().name()).isEqualTo("Counter-Strike");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWishlist_ReturnsItemsWithTheDateAdded() {
|
||||
server.expect(requestTo(containsString("/IWishlistService/GetWishlist/v1/")))
|
||||
@@ -241,12 +193,14 @@ class SteamApiServiceTest {
|
||||
assertThat(steamApiService.getWishlist("76561197960435530")).contains(List.of());
|
||||
}
|
||||
|
||||
// Without the resilience4j proxy the error reaches the caller; with it the fallback makes it empty
|
||||
// (SteamApiServiceCacheTest).
|
||||
@Test
|
||||
void getWishlist_SteamError_IsEmptySoTheCallerKeepsTheOldList() {
|
||||
void getWishlist_SteamError_IsThrownForTheFallback() {
|
||||
server.expect(requestTo(containsString("/IWishlistService/GetWishlist/v1/")))
|
||||
.andRespond(withServerError());
|
||||
|
||||
assertThat(steamApiService.getWishlist("76561197960435530")).isEmpty();
|
||||
assertThatThrownBy(() -> steamApiService.getWishlist("76561197960435530")).isInstanceOf(RestClientException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -408,4 +362,14 @@ class SteamApiServiceTest {
|
||||
assertThat(steamApiService.getGlobalAchievementPercentages(620)).isEmpty();
|
||||
assertThat(steamApiService.getGlobalAchievementPercentages(620)).isEmpty();
|
||||
}
|
||||
|
||||
// The key is a query parameter; Spring 7 leaves the query out of an I/O error, and it must stay so in our logs.
|
||||
@Test
|
||||
void getOwnedGames_Timeout_HasNoApiKeyInItsMessage() {
|
||||
server.expect(requestTo(containsString("/IPlayerService/GetOwnedGames/v1/")))
|
||||
.andRespond(withException(new java.net.SocketTimeoutException("Read timed out")));
|
||||
|
||||
assertThatThrownBy(() -> steamApiService.getOwnedGames("76561198000000001"))
|
||||
.hasMessageContaining("Read timed out").hasMessageNotContaining("test-api-key");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user