fix(sync): stop the nightly sync when the Steam ID is gone
Build, Test & Push Core Service / quality-check (push) Successful in 49s
Build, Test & Push Core Service / docker (push) Successful in 1m20s

After an unlink in Auth, or when a verified link took the ID away, the
nightly job kept syncing the old Steam ID for up to 14 days. A
games-count request without a steamId claim now clears user_seen_at.
steam_id stays, so a later link with another ID still soft-deletes the
old games.
This commit is contained in:
lobo committed 2026-09-26 11:20:04 +02:00
1 parent 393cdddf06
commit a389bdd0c7
5 files changed
+83 -1

No files matched your search

+2 -1
View File
@@ -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 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. 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`). `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. 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 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 the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
@@ -70,6 +70,8 @@ public class LibraryController {
Long userId = principal.getIdAsLong(); Long userId = principal.getIdAsLong();
if (principal.steamId() != null) { if (principal.steamId() != null) {
syncService.syncIfStale(userId, String.valueOf(principal.steamId())); syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
} else {
syncService.stopNightlySync(userId);
} }
long count = queryService.countOwnedGames(userId); long count = queryService.countOwnedGames(userId);
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null)); return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null));
@@ -52,6 +52,15 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
@Query(value = "UPDATE user_steam_sync SET user_seen_at = :now WHERE user_id = :userId", nativeQuery = true) @Query(value = "UPDATE user_steam_sync SET user_seen_at = :now WHERE user_id = :userId", nativeQuery = true)
void touchUserSeen(@Param("userId") Long userId, @Param("now") Instant now); void touchUserSeen(@Param("userId") Long userId, @Param("now") Instant now);
/**
* Takes the user out of the nightly sync. steam_id stays, so a later sync with another ID still sees the change.
*/
@Modifying
@Transactional
@Query(value = "UPDATE user_steam_sync SET user_seen_at = NULL WHERE user_id = :userId AND user_seen_at IS NOT NULL",
nativeQuery = true)
void clearUserSeen(@Param("userId") Long userId);
List<UserSteamSync> findByUserSeenAtAfter(Instant since); List<UserSteamSync> findByUserSeenAtAfter(Instant since);
} }
@@ -65,6 +65,14 @@ public class LibrarySyncService {
accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId())); 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. * User-triggered sync. Throws SyncCooldownException if recently synced or running.
*/ */
@@ -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"));
}
}