diff --git a/readme.md b/readme.md index f47fed9..3535823 100644 --- a/readme.md +++ b/readme.md @@ -129,7 +129,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` 1. `GET /api/library/games`, `/games-count` and `/picker` call `LibrarySyncService.syncIfStale`. It starts a sync when the last one is older than 24 hours, older than 15 minutes after a failure, or was for another Steam ID. `POST /api/library/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`). -2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. +2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. A + request without a `steamId` claim (the user unlinked the Steam account in Auth) takes the user out of it. 3. `LibrarySyncWorker` runs in the background. `user_steam_sync` lets only one run per user start. 4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 8088fa3..cf00b3a 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -70,6 +70,8 @@ public class LibraryController { Long userId = principal.getIdAsLong(); if (principal.steamId() != null) { syncService.syncIfStale(userId, String.valueOf(principal.steamId())); + } else { + syncService.stopNightlySync(userId); } long count = queryService.countOwnedGames(userId); return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null)); diff --git a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java index 96a073e..466f9ad 100644 --- a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java @@ -52,6 +52,15 @@ public interface UserSteamSyncRepository extends JpaRepository findByUserSeenAtAfter(Instant since); } 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 3d867e8..bf28aea 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncService.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncService.java @@ -65,6 +65,14 @@ public class LibrarySyncService { accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId())); } + /** + * For a user whose token has no Steam ID any more (unlinked, or taken by a verified link in Auth): the nightly + * job would otherwise keep syncing the old ID until the user is 14 days inactive. + */ + public void stopNightlySync(Long userId) { + userSteamSyncRepository.clearUserSeen(userId); + } + /** * User-triggered sync. Throws SyncCooldownException if recently synced or running. */ diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerSteamIdGoneTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerSteamIdGoneTest.java new file mode 100644 index 0000000..60f7e3e --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerSteamIdGoneTest.java @@ -0,0 +1,62 @@ +package core_service.modules.library.api; + +import core_service.modules.library.repo.UserSteamSyncRepository; +import core_service.shared.test.AbstractIntegrationTest; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.Cookie; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * A user who unlinked the Steam ID, or lost it to a verified link, must drop out of the nightly sync. + */ +@AutoConfigureMockMvc +class LibraryControllerSteamIdGoneTest extends AbstractIntegrationTest { + + private static final long USER_ID = 77L; + + @Autowired + private MockMvc mockMvc; + @Autowired + private JdbcTemplate jdbc; + @Autowired + private UserSteamSyncRepository userSteamSyncRepository; + + @BeforeEach + void seedSyncedUser() { + jdbc.update("DELETE FROM user_steam_sync WHERE user_id = ?", USER_ID); + jdbc.update("INSERT INTO user_steam_sync (user_id, steam_id, last_synced_at, last_sync_status, user_seen_at) " + + "VALUES (?, '76561198000000001', now(), 'OK', now())", USER_ID); + } + + private static Cookie loginWithoutSteamId() { + return new Cookie("tbp_access", Jwts.builder() + .subject("unlinked@example.com") + .claim("userId", String.valueOf(USER_ID)) + .expiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA) + .compact()); + } + + @Test + void gamesCount_WithoutSteamIdClaim_StopsTheNightlySyncOfTheOldId() throws Exception { + mockMvc.perform(get("/api/library/games-count").cookie(loginWithoutSteamId())).andExpect(status().isOk()); + + assertTrue(userSteamSyncRepository.findByUserSeenAtAfter(Instant.now().minus(Duration.ofDays(14))).stream() + .noneMatch(sync -> sync.getUserId() == USER_ID)); + // The old ID stays: a later link with another ID must still soft-delete the old games. + assertTrue(userSteamSyncRepository.findById(USER_ID).orElseThrow().getSteamId().equals("76561198000000001")); + } +}