diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncService.java b/src/main/java/core_service/modules/library/service/LibrarySyncService.java index 36a9ae3..ba478d2 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncService.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncService.java @@ -27,19 +27,22 @@ public class LibrarySyncService { @Value("${app.library.running-stale-minutes:5}") private long runningStaleMinutes; + @Value("${app.library.failed-retry-minutes:15}") + private long failedRetryMinutes; + /** - * Trigger sync only if data is older than TTL. Cheap gate check on caller thread, - * actual sync runs async via worker. + * Trigger sync only if data is older than TTL or was synced for another Steam ID. + * Cheap gate check on caller thread, actual sync runs async via worker. */ public void syncIfStale(Long userId, String steamId) { UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null); - if (sync != null && !isStale(sync)) { - return; - } if (sync != null && isAlreadyRunning(sync)) { log.debug("Sync already running for user {}", userId); return; } + if (sync != null && steamId.equals(sync.getSteamId()) && !isStale(sync)) { + return; + } worker.runSync(userId, steamId); } @@ -62,7 +65,10 @@ public class LibrarySyncService { private boolean isStale(UserSteamSync sync) { if (sync.getLastSyncedAt() == null) return true; - return sync.getLastSyncedAt().isBefore(Instant.now().minus(Duration.ofHours(syncTtlHours))); + Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED + ? Duration.ofMinutes(failedRetryMinutes) + : Duration.ofHours(syncTtlHours); + return sync.getLastSyncedAt().isBefore(Instant.now().minus(maxAge)); } private boolean isAlreadyRunning(UserSteamSync sync) { diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 67d382d..295ea07 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -3,6 +3,7 @@ package core_service.modules.library.service; 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.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserSteamSyncRepository; @@ -43,6 +44,9 @@ public class LibrarySyncWorker { @Transactional public void runSync(Long userId, String steamId) { Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes)); + String previousSteamId = userSteamSyncRepository.findById(userId) + .map(UserSteamSync::getSteamId) + .orElse(null); int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore); if (claimed == 0) { log.info("Sync skipped for user {} — another run already in flight", userId); @@ -52,8 +56,17 @@ public class LibrarySyncWorker { Instant syncStartedAt = Instant.now(); Status finalStatus = Status.FAILED; try { + if (previousSteamId != null && !previousSteamId.equals(steamId)) { + // The old games belong to the previous Steam account. The upserts below restore the games this account also owns. + userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now()); + } List games = steamApiService.getOwnedGames(steamId); log.info("Sync user={} fetched {} games from Steam", userId, games.size()); + if (games.isEmpty()) { + // Steam errors and a private "Game details" setting also end here. A soft-delete would wipe the library. + log.warn("Sync user={} got no games for SteamID {}", userId, steamId); + return; + } for (SteamOwnedGamesResponse.Game g : games) { if (g.appid() == null) continue; diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java new file mode 100644 index 0000000..5d36562 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java @@ -0,0 +1,104 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.UserSteamSync; +import core_service.modules.library.repo.UserSteamSyncRepository; +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LibrarySyncServiceTest extends AbstractUnitTest { + + private static final long USER_ID = 1L; + private static final String STEAM_ID = "76561198005679788"; + + @Mock + private UserSteamSyncRepository userSteamSyncRepository; + + @Mock + private LibrarySyncWorker worker; + + @InjectMocks + private LibrarySyncService syncService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(syncService, "syncTtlHours", 24L); + ReflectionTestUtils.setField(syncService, "forceSyncCooldownSeconds", 60L); + ReflectionTestUtils.setField(syncService, "runningStaleMinutes", 5L); + ReflectionTestUtils.setField(syncService, "failedRetryMinutes", 15L); + } + + private void givenSync(String steamId, UserSteamSync.Status status, Duration age) { + UserSteamSync sync = new UserSteamSync(USER_ID, steamId); + sync.setLastSyncStatus(status); + sync.setLastSyncedAt(Instant.now().minus(age)); + when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(sync)); + } + + @Test + void syncIfStale_NoSyncYet_Runs() { + when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty()); + + syncService.syncIfStale(USER_ID, STEAM_ID); + + verify(worker).runSync(USER_ID, STEAM_ID); + } + + @Test + void syncIfStale_FreshOkSync_Skips() { + givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofMinutes(10)); + + syncService.syncIfStale(USER_ID, STEAM_ID); + + verify(worker, never()).runSync(anyLong(), anyString()); + } + + @Test + void syncIfStale_FreshSyncForOtherSteamId_Runs() { + givenSync("76561198005679790", UserSteamSync.Status.OK, Duration.ofMinutes(10)); + + syncService.syncIfStale(USER_ID, STEAM_ID); + + verify(worker).runSync(USER_ID, STEAM_ID); + } + + @Test + void syncIfStale_FailedSyncAfterRetryInterval_Runs() { + givenSync(STEAM_ID, UserSteamSync.Status.FAILED, Duration.ofMinutes(16)); + + syncService.syncIfStale(USER_ID, STEAM_ID); + + verify(worker).runSync(USER_ID, STEAM_ID); + } + + @Test + void syncIfStale_FailedSyncWithinRetryInterval_Skips() { + givenSync(STEAM_ID, UserSteamSync.Status.FAILED, Duration.ofMinutes(5)); + + syncService.syncIfStale(USER_ID, STEAM_ID); + + verify(worker, never()).runSync(anyLong(), anyString()); + } + + @Test + void syncIfStale_RunningForOtherSteamId_Skips() { + givenSync("76561198005679790", UserSteamSync.Status.RUNNING, Duration.ofSeconds(10)); + + syncService.syncIfStale(USER_ID, STEAM_ID); + + verify(worker, never()).runSync(anyLong(), anyString()); + } +} diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java new file mode 100644 index 0000000..72d164a --- /dev/null +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -0,0 +1,92 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.UserSteamSync; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +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.AbstractUnitTest; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class LibrarySyncWorkerTest extends AbstractUnitTest { + + private static final long USER_ID = 1L; + private static final String STEAM_ID = "76561198005679788"; + + @Mock + private SteamApiService steamApiService; + + @Mock + private SteamGameRepository steamGameRepository; + + @Mock + private UserGameRepository userGameRepository; + + @Mock + private UserSteamSyncRepository userSteamSyncRepository; + + @InjectMocks + private LibrarySyncWorker worker; + + @Test + void runSync_EmptySteamResult_MarksFailedAndKeepsLibrary() { + when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), 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()); + } + + @Test + void runSync_EmptyResultForNewSteamId_RemovesOldLibrary() { + when(userSteamSyncRepository.findById(USER_ID)) + .thenReturn(Optional.of(new UserSteamSync(USER_ID, "76561198005679790"))); + when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), 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()); + } + + @Test + void runSync_SteamErrorForNewSteamId_RemovesOldLibrary() { + when(userSteamSyncRepository.findById(USER_ID)) + .thenReturn(Optional.of(new UserSteamSync(USER_ID, "76561198005679790"))); + when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), 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()); + } + + @Test + void runSync_WithGames_MarksOk() { + when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1); + when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of( + new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 0, "icon", true))); + + worker.runSync(USER_ID, STEAM_ID); + + verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any()); + verify(userSteamSyncRepository).markFinished(eq(USER_ID), eq("OK"), any()); + } +}