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
+461
-204
No files matched your search
@@ -14,7 +14,11 @@ To Be Played is split into three repositories:
|
||||
## Features
|
||||
|
||||
- **Steam sync**: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly
|
||||
sync. Resilience4j retries and rate-limits every Steam call; Caffeine caches game counts and player summaries.
|
||||
sync. Resilience4j rate-limits every Steam call and tries the owned games up to three times (never again on a 4xx;
|
||||
the wishlist and the calls a page waits for once); Caffeine caches game counts and player summaries. A sync commits its claim (`RUNNING`, with
|
||||
`sync_started_at`), its writes and its finish apart, and calls Steam and IGDB outside any transaction: a second
|
||||
sync sees `RUNNING` and skips, a failed one stores `FAILED`. A run older than `app.library.running-stale-minutes`
|
||||
can be taken over; its own finish then changes nothing.
|
||||
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
||||
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
||||
- **Picker**: up to 10 random backlog games for a time budget (IGDB normal time to beat) and a mood, HIGH priority
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.util.Map;
|
||||
public class AccountCleanupRepository {
|
||||
|
||||
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
||||
private static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
|
||||
"user_steam_sync", "user_game", "game_import", "deleted_user");
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ public class UserSteamSync {
|
||||
@Column(name = "last_sync_status", length = 16)
|
||||
private Status lastSyncStatus;
|
||||
|
||||
@Column(name = "sync_started_at")
|
||||
private Instant syncStartedAt;
|
||||
|
||||
@Column(name = "history_steam_id", length = 32)
|
||||
private String historySteamId;
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
||||
|
||||
List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId);
|
||||
|
||||
List<UserGame> findByUserId(Long userId);
|
||||
|
||||
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.appid in :appids")
|
||||
List<Long> findActiveAppids(@Param("userId") Long userId, @Param("appids") Collection<Long> appids);
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package core_service.modules.library.repo;
|
||||
|
||||
import core_service.modules.library.domain.UserSteamSync;
|
||||
import jakarta.persistence.LockModeType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
@@ -9,38 +11,56 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Long> {
|
||||
|
||||
/**
|
||||
* The row before a claim, locked until the claim commits: a run that claims after another one finished sees
|
||||
* that run's Steam ID, not an older one.
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select s from UserSteamSync s where s.userId = :userId")
|
||||
Optional<UserSteamSync> findForClaim(@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* Atomic claim-run upsert. Returns 1 if this caller successfully claimed the run
|
||||
* (row created, or existing row was not RUNNING / RUNNING marker stale).
|
||||
* Returns 0 if another sync is already in flight, or the user is deleted (table deleted_user) — caller must skip.
|
||||
*
|
||||
* @param startedAt the run's key for {@link #markFinished}, in microseconds as Postgres stores it
|
||||
*/
|
||||
@Modifying
|
||||
@Query(value = """
|
||||
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status)
|
||||
SELECT :userId, :steamId, 'RUNNING'
|
||||
INSERT INTO user_steam_sync (user_id, steam_id, last_sync_status, sync_started_at)
|
||||
SELECT :userId, :steamId, 'RUNNING', :startedAt
|
||||
WHERE NOT EXISTS (SELECT 1 FROM deleted_user WHERE user_id = :userId)
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET steam_id = EXCLUDED.steam_id,
|
||||
last_sync_status = 'RUNNING'
|
||||
last_sync_status = 'RUNNING',
|
||||
sync_started_at = EXCLUDED.sync_started_at
|
||||
WHERE user_steam_sync.last_sync_status <> 'RUNNING'
|
||||
OR user_steam_sync.last_synced_at IS NULL
|
||||
OR user_steam_sync.last_synced_at < :runningStaleBefore
|
||||
OR user_steam_sync.sync_started_at IS NULL
|
||||
OR user_steam_sync.sync_started_at < :runningStaleBefore
|
||||
""", nativeQuery = true)
|
||||
int tryClaimRun(@Param("userId") Long userId,
|
||||
@Param("steamId") String steamId,
|
||||
@Param("startedAt") Instant startedAt,
|
||||
@Param("runningStaleBefore") Instant runningStaleBefore);
|
||||
|
||||
/**
|
||||
* @return 0 when another run took over the stale claim; that run's RUNNING stays
|
||||
*/
|
||||
@Modifying
|
||||
@Query(value = """
|
||||
UPDATE user_steam_sync
|
||||
SET last_synced_at = :now,
|
||||
last_sync_status = :status
|
||||
WHERE user_id = :userId
|
||||
AND sync_started_at = :startedAt
|
||||
""", nativeQuery = true)
|
||||
void markFinished(@Param("userId") Long userId,
|
||||
int markFinished(@Param("userId") Long userId,
|
||||
@Param("startedAt") Instant startedAt,
|
||||
@Param("status") String status,
|
||||
@Param("now") Instant now);
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ public class LibrarySyncService {
|
||||
|
||||
private boolean isAlreadyRunning(UserSteamSync sync) {
|
||||
if (sync.getLastSyncStatus() != UserSteamSync.Status.RUNNING) return false;
|
||||
if (sync.getLastSyncedAt() == null) return true;
|
||||
return sync.getLastSyncedAt().isAfter(Instant.now().minus(Duration.ofMinutes(runningStaleMinutes)));
|
||||
return sync.getSyncStartedAt() != null
|
||||
&& sync.getSyncStartedAt().isAfter(Instant.now().minus(Duration.ofMinutes(runningStaleMinutes)));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import core_service.modules.library.domain.CorrectionField;
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
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;
|
||||
@@ -21,17 +20,18 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -39,9 +39,11 @@ import java.util.stream.Collectors;
|
||||
import core_service.modules.library.domain.UserSteamSync.Status;
|
||||
|
||||
/**
|
||||
* Worker bean. Runs the actual Steam fetch + DB upserts.
|
||||
* Separate bean so Spring proxy applies @Async and @Transactional
|
||||
* (self-invocation from LibrarySyncService would bypass both).
|
||||
* Worker bean. Runs the actual Steam fetch + DB upserts. Separate bean so the Spring proxy applies @Async
|
||||
* (self-invocation from LibrarySyncService would bypass it).
|
||||
* <p>
|
||||
* The claim, the writes and the finish each commit on their own; the Steam and IGDB calls run outside any
|
||||
* transaction. So other requests see RUNNING, and FAILED is stored also after a database error.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -58,6 +60,7 @@ public class LibrarySyncWorker {
|
||||
private final TimeToBeatService timeToBeatService;
|
||||
private final MoodService moodService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final TransactionTemplate transaction;
|
||||
|
||||
@Value("${app.library.running-stale-minutes:5}")
|
||||
private long runningStaleMinutes;
|
||||
@@ -66,7 +69,6 @@ public class LibrarySyncWorker {
|
||||
private ZoneId zone;
|
||||
|
||||
@Async
|
||||
@Transactional
|
||||
public void runSync(Long userId, String steamId) {
|
||||
sync(userId, steamId);
|
||||
}
|
||||
@@ -75,101 +77,129 @@ public class LibrarySyncWorker {
|
||||
* Same as {@link #runSync}, but on the caller's thread. The nightly job uses it to sync one account after
|
||||
* the other instead of starting all at once against the Steam rate limit and the connection pool.
|
||||
*/
|
||||
@Transactional
|
||||
public void runSyncNow(Long userId, String steamId) {
|
||||
sync(userId, steamId);
|
||||
}
|
||||
|
||||
private record Claim(String historySteamId) {}
|
||||
|
||||
private void sync(Long userId, String steamId) {
|
||||
Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes));
|
||||
UserSteamSync before = userSteamSyncRepository.findById(userId).orElse(null);
|
||||
Instant syncStartedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
|
||||
Claim claim = transaction.execute(tx -> {
|
||||
UserSteamSync before = userSteamSyncRepository.findForClaim(userId).orElse(null);
|
||||
if (userSteamSyncRepository.tryClaimRun(userId, steamId, syncStartedAt, runningStaleBefore) == 0) {
|
||||
return null;
|
||||
}
|
||||
String previousSteamId = before != null ? before.getSteamId() : null;
|
||||
String historySteamId = before != null ? before.getHistorySteamId() : null;
|
||||
int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore);
|
||||
if (claimed == 0) {
|
||||
if (previousSteamId != null && !previousSteamId.equals(steamId)) {
|
||||
// The old games belong to the previous Steam account, also when Steam fails now. The upserts
|
||||
// restore the games this account also owns.
|
||||
userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
|
||||
wishlistGameRepository.deleteByUserId(userId);
|
||||
}
|
||||
return new Claim(before != null ? before.getHistorySteamId() : null);
|
||||
});
|
||||
if (claim == null) {
|
||||
log.info("Sync skipped for user {} — another run already in flight, or the account is deleted", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
Instant syncStartedAt = Instant.now();
|
||||
Status finalStatus = Status.FAILED;
|
||||
try {
|
||||
boolean steamIdChanged = previousSteamId != null && !previousSteamId.equals(steamId);
|
||||
// Growth needs a successful earlier sync of this account; else all its playtime would count as today.
|
||||
boolean recordPlaytime = steamId.equals(historySteamId);
|
||||
LocalDate today = LocalDate.ofInstant(syncStartedAt, zone);
|
||||
if (steamIdChanged) {
|
||||
// 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());
|
||||
wishlistGameRepository.deleteByUserId(userId);
|
||||
}
|
||||
List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId);
|
||||
LocalDate playedOn = steamId.equals(claim.historySteamId()) ? LocalDate.ofInstant(syncStartedAt, zone) : null;
|
||||
// Distinct: the maps in saveGames are loaded once, so a second line of a game would be a new game again.
|
||||
List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId).stream()
|
||||
.filter(g -> g.appid() != null)
|
||||
.collect(Collectors.toMap(SteamOwnedGamesResponse.Game::appid, g -> g, (first, second) -> first,
|
||||
LinkedHashMap::new))
|
||||
.values().stream().toList();
|
||||
log.info("Sync user={} fetched {} games from Steam", userId, games.size());
|
||||
// Before the empty check: an account without games can still have a public wishlist.
|
||||
syncWishlist(userId, steamId, games);
|
||||
Optional<Wishlist> wishlist = fetchWishlist(steamId, games);
|
||||
boolean saved = transaction.execute(tx -> {
|
||||
wishlist.ifPresentOrElse(found -> saveWishlist(userId, found),
|
||||
() -> log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId));
|
||||
if (games.isEmpty()) {
|
||||
// Steam errors and a private "Game details" setting also end here. A soft-delete would wipe the library.
|
||||
// A private "Game details" setting ends here. A soft-delete would wipe the library.
|
||||
log.warn("Sync user={} got no games for SteamID {}", userId, steamId);
|
||||
return false;
|
||||
}
|
||||
saveGames(userId, steamId, games, syncStartedAt, playedOn);
|
||||
return true;
|
||||
});
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
// Also the games added by hand: here a failed refresh from the add is asked again.
|
||||
List<Long> igdbAppids = new ArrayList<>(games.stream().map(g -> g.appid().longValue()).toList());
|
||||
igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId));
|
||||
igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId)));
|
||||
timeToBeatService.refresh(igdbAppids);
|
||||
moodService.refresh(igdbAppids);
|
||||
finalStatus = Status.OK;
|
||||
} catch (Exception e) {
|
||||
log.error("Sync failed for user {}", userId, e);
|
||||
} finally {
|
||||
Status status = finalStatus;
|
||||
transaction.executeWithoutResult(tx -> {
|
||||
if (userSteamSyncRepository.markFinished(userId, syncStartedAt, status.name(), Instant.now()) == 0) {
|
||||
log.warn("Sync user={} ran past running-stale-minutes; the run that took over sets the status", userId);
|
||||
} else if (status == Status.OK) {
|
||||
// The listeners run after this commit.
|
||||
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void saveGames(Long userId, String steamId, List<SteamOwnedGamesResponse.Game> games, Instant now,
|
||||
LocalDate playedOn) {
|
||||
List<Long> appids = games.stream().map(g -> g.appid().longValue()).toList();
|
||||
// setSourceName keeps them anyway; skipping saves an update and a flush per corrected game and sync.
|
||||
Set<Long> correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME,
|
||||
games.stream().map(SteamOwnedGamesResponse.Game::appid).filter(Objects::nonNull).map(Integer::longValue).toList());
|
||||
Set<Long> correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME, appids);
|
||||
Map<Long, SteamGame> knownGames = steamGameRepository.findAllById(appids).stream()
|
||||
.collect(Collectors.toMap(SteamGame::getAppid, game -> game));
|
||||
Map<Long, UserGame> userGames = userGameRepository.findByUserId(userId).stream()
|
||||
.collect(Collectors.toMap(UserGame::getAppid, game -> game));
|
||||
List<Long> played = new ArrayList<>();
|
||||
for (SteamOwnedGamesResponse.Game g : games) {
|
||||
if (g.appid() == null) continue;
|
||||
upsertGame(g, syncStartedAt, correctedNames);
|
||||
if (upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null)) {
|
||||
played.add(g.appid().longValue());
|
||||
Long appid = g.appid().longValue();
|
||||
upsertGame(g, knownGames.get(appid), now, correctedNames);
|
||||
if (upsertUserGame(userId, g, userGames.get(appid), now, playedOn)) {
|
||||
played.add(appid);
|
||||
}
|
||||
}
|
||||
if (!played.isEmpty()) {
|
||||
userGameRepository.playFromPileIfUntouched(userId, played);
|
||||
}
|
||||
|
||||
int removed = userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
|
||||
int removed = userGameRepository.softDeleteMissingSince(userId, now, Instant.now());
|
||||
if (removed > 0) {
|
||||
log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed);
|
||||
}
|
||||
userSteamSyncRepository.markHistoryBaseline(userId, steamId);
|
||||
// Also the games added by hand: here a failed refresh from the add is asked again.
|
||||
List<Long> igdbAppids = new ArrayList<>(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
|
||||
igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId));
|
||||
igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId)));
|
||||
timeToBeatService.refresh(igdbAppids);
|
||||
moodService.refresh(igdbAppids);
|
||||
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
|
||||
finalStatus = Status.OK;
|
||||
} catch (Exception e) {
|
||||
log.error("Sync failed for user {}", userId, e);
|
||||
} finally {
|
||||
userSteamSyncRepository.markFinished(userId, finalStatus.name(), Instant.now());
|
||||
}
|
||||
}
|
||||
|
||||
private void syncWishlist(Long userId, String steamId, List<SteamOwnedGamesResponse.Game> ownedGames) {
|
||||
Optional<List<SteamApiService.WishlistItem>> wishlist = steamApiService.getWishlist(steamId);
|
||||
if (wishlist.isEmpty()) {
|
||||
log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
|
||||
return;
|
||||
}
|
||||
Set<Long> owned = ownedGames.stream()
|
||||
.filter(g -> g.appid() != null)
|
||||
.map(g -> g.appid().longValue())
|
||||
.collect(Collectors.toSet());
|
||||
List<SteamApiService.WishlistItem> wanted = wishlist.get().stream()
|
||||
.filter(item -> !owned.contains(item.appid()))
|
||||
.toList();
|
||||
Map<Long, SteamApiService.StoreItem> store = wanted.isEmpty()
|
||||
private record Wishlist(List<SteamApiService.WishlistItem> wanted, Map<Long, SteamApiService.StoreItem> store) {}
|
||||
|
||||
private Optional<Wishlist> fetchWishlist(String steamId, List<SteamOwnedGamesResponse.Game> ownedGames) {
|
||||
Set<Long> owned = ownedGames.stream().map(g -> g.appid().longValue()).collect(Collectors.toSet());
|
||||
return steamApiService.getWishlist(steamId).map(items -> {
|
||||
List<SteamApiService.WishlistItem> wanted = items.stream().filter(item -> !owned.contains(item.appid())).toList();
|
||||
return new Wishlist(wanted, wanted.isEmpty()
|
||||
? Map.of()
|
||||
: steamApiService.getStoreItems(wanted.stream().map(SteamApiService.WishlistItem::appid).toList());
|
||||
: steamApiService.getStoreItems(wanted.stream().map(SteamApiService.WishlistItem::appid).toList()));
|
||||
});
|
||||
}
|
||||
|
||||
private void saveWishlist(Long userId, Wishlist wishlist) {
|
||||
// A failed store batch leaves apps out of `store`; they keep the name and price of the last sync.
|
||||
Map<Long, WishlistGame> previous = wishlistGameRepository.findForUser(userId).stream()
|
||||
.collect(Collectors.toMap(WishlistGame::getAppid, w -> w));
|
||||
wishlistGameRepository.deleteByUserId(userId);
|
||||
wishlistGameRepository.saveAll(wanted.stream().map(item -> {
|
||||
SteamApiService.StoreItem info = store.get(item.appid());
|
||||
wishlistGameRepository.saveAll(wishlist.wanted().stream().map(item -> {
|
||||
SteamApiService.StoreItem info = wishlist.store().get(item.appid());
|
||||
if (info != null) {
|
||||
return new WishlistGame(userId, item.appid(), info.name(), item.addedAt(),
|
||||
info.price(), info.originalPrice(), info.discountPct());
|
||||
@@ -182,9 +212,8 @@ public class LibrarySyncWorker {
|
||||
}).toList());
|
||||
}
|
||||
|
||||
private void upsertGame(SteamOwnedGamesResponse.Game g, Instant now, Set<Long> correctedNames) {
|
||||
private void upsertGame(SteamOwnedGamesResponse.Game g, SteamGame existing, Instant now, Set<Long> correctedNames) {
|
||||
Long appid = g.appid().longValue();
|
||||
SteamGame existing = steamGameRepository.findById(appid).orElse(null);
|
||||
// An unvetted app can come without a name, and the name column is NOT NULL.
|
||||
boolean hasName = g.name() != null && !g.name().isBlank();
|
||||
if (existing == null) {
|
||||
@@ -202,9 +231,9 @@ public class LibrarySyncWorker {
|
||||
* @param playedOn the day that gets the playtime growth, null to record none
|
||||
* @return true when the game was played since the last sync
|
||||
*/
|
||||
private boolean upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) {
|
||||
private boolean upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, UserGame existing, Instant now,
|
||||
LocalDate playedOn) {
|
||||
Long appid = g.appid().longValue();
|
||||
UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null);
|
||||
int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0;
|
||||
// Hours typed by hand are no Steam playtime, so the first Steam value of a game added by hand is a baseline.
|
||||
boolean takesOverManualGame = existing != null && existing.getSource() == GameSource.MANUAL;
|
||||
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
package core_service.modules.steam_adapter.api.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record SteamProfileAndGamesResponse(
|
||||
SteamPlayerSummaryResponse.Player player,
|
||||
List<SteamOwnedGamesResponse.Game> games
|
||||
) {
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import core_service.modules.steam_adapter.api.dto.response.SteamGlobalAchievemen
|
||||
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 core_service.modules.steam_adapter.api.dto.response.SteamSchemaForGameResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamStoreItemsResponse;
|
||||
import core_service.modules.steam_adapter.api.dto.response.SteamWishlistResponse;
|
||||
@@ -54,17 +53,13 @@ public class SteamApiService {
|
||||
|
||||
@Cacheable(value = "steamGameCount", key = "#steamId")
|
||||
@RateLimiter(name = "steamApi")
|
||||
@Retry(name = "steamApi")
|
||||
@Retry(name = "steamApiOnce", fallbackMethod = "noGamesCount")
|
||||
public int getOwnedGamesCount(String steamId) {
|
||||
if (apiKey == null || apiKey.isEmpty() || apiKey.equals("${STEAM_API_KEY}")) {
|
||||
log.error("Steam API Key is not configured correctly!");
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("Fetching owned games count for SteamID: {} using API Key starting with: {}",
|
||||
steamId, apiKey.substring(0, Math.min(apiKey.length(), 4)));
|
||||
|
||||
SteamOwnedGamesResponse response = steamRestClient.get()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/IPlayerService/GetOwnedGames/v1/")
|
||||
@@ -78,8 +73,6 @@ public class SteamApiService {
|
||||
.retrieve()
|
||||
.body(SteamOwnedGamesResponse.class);
|
||||
|
||||
log.info("Steam API Response object for {}: {}", steamId, response);
|
||||
|
||||
if (response != null && response.response() != null) {
|
||||
Integer count = response.response().gameCount();
|
||||
if (count == null) {
|
||||
@@ -94,30 +87,20 @@ public class SteamApiService {
|
||||
log.info("Successfully fetched {} games for SteamID: {}", count, steamId);
|
||||
return count;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error communicating with Steam API for game count of SteamID: {}", steamId, e);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public Optional<SteamProfileAndGamesResponse> getFullPlayerProfile(String steamId) {
|
||||
var playerOpt = getPlayerSummary(steamId);
|
||||
if (playerOpt.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
var games = getOwnedGames(steamId);
|
||||
return Optional.of(new SteamProfileAndGamesResponse(playerOpt.get(), games));
|
||||
// The fallbacks run after the last try; the callers keep their "Steam failed" rule.
|
||||
private int noGamesCount(String steamId, Exception e) {
|
||||
log.error("Error communicating with Steam API for game count of SteamID: {}", steamId, e);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Asked on every page load for the avatar in the header.
|
||||
@Cacheable(value = "steamPlayerSummary", key = "#steamId", unless = "#result == null")
|
||||
@RateLimiter(name = "steamApi")
|
||||
@Retry(name = "steamApi")
|
||||
@Retry(name = "steamApiOnce", fallbackMethod = "noPlayerSummary")
|
||||
public Optional<SteamPlayerSummaryResponse.Player> getPlayerSummary(String steamId) {
|
||||
try {
|
||||
log.info("Fetching Steam profile for SteamID: {}", steamId);
|
||||
|
||||
SteamPlayerSummaryResponse response = steamRestClient.get()
|
||||
@@ -132,18 +115,18 @@ public class SteamApiService {
|
||||
if (response != null && response.response() != null && !response.response().players().isEmpty()) {
|
||||
return Optional.of(response.response().players().getFirst());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error communicating with Steam API for SteamID: {}", steamId, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private Optional<SteamPlayerSummaryResponse.Player> noPlayerSummary(String steamId, Exception e) {
|
||||
log.error("Error communicating with Steam API for SteamID: {}", steamId, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@RateLimiter(name = "steamApi")
|
||||
// No fallback: after the last try the sync stores FAILED.
|
||||
@Retry(name = "steamApi")
|
||||
public List<SteamOwnedGamesResponse.Game> getOwnedGames(String steamId) {
|
||||
try {
|
||||
log.info("Fetching owned games for SteamID: {}", steamId);
|
||||
|
||||
SteamOwnedGamesResponse response = steamRestClient.get()
|
||||
@@ -162,11 +145,6 @@ public class SteamApiService {
|
||||
if (response != null && response.response() != null && response.response().games() != null) {
|
||||
return response.response().games();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error communicating with Steam API for games of SteamID: {}", steamId, e);
|
||||
}
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@@ -183,9 +161,8 @@ public class SteamApiService {
|
||||
* @return empty on a Steam error, so the caller can keep the list it has
|
||||
*/
|
||||
@RateLimiter(name = "steamApi")
|
||||
@Retry(name = "steamApi")
|
||||
@Retry(name = "steamApiOnce", fallbackMethod = "noWishlist")
|
||||
public Optional<List<WishlistItem>> getWishlist(String steamId) {
|
||||
try {
|
||||
SteamWishlistResponse response = steamRestClient.get()
|
||||
.uri(uriBuilder -> uriBuilder
|
||||
.path("/IWishlistService/GetWishlist/v1/")
|
||||
@@ -202,11 +179,12 @@ public class SteamApiService {
|
||||
.map(item -> new WishlistItem(item.appid(),
|
||||
item.dateAdded() != null ? Instant.ofEpochSecond(item.dateAdded()) : null))
|
||||
.toList());
|
||||
} catch (Exception e) {
|
||||
}
|
||||
|
||||
private Optional<List<WishlistItem>> noWishlist(String steamId, Exception e) {
|
||||
log.error("Error communicating with Steam API for the wishlist of SteamID: {}", steamId, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store name and current price per app, asked in batches of {@value #STORE_ITEMS_BATCH}. Apps Steam does not
|
||||
|
||||
@@ -87,6 +87,13 @@ resilience4j:
|
||||
retry-exceptions:
|
||||
- org.springframework.web.client.RestClientException
|
||||
- org.springframework.web.client.ResourceAccessException
|
||||
# A 4xx (a refused key, a private list) is the same on the next try.
|
||||
ignore-exceptions:
|
||||
- org.springframework.web.client.HttpClientErrorException
|
||||
# Calls a page waits for, and the optional wishlist: one try, else a Steam outage holds every page (or the
|
||||
# sync) for three read timeouts.
|
||||
steamApiOnce:
|
||||
max-attempts: 1
|
||||
ratelimiter:
|
||||
instances:
|
||||
steamApi:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- The claim of a sync is committed on its own now; a RUNNING row is stale by the start of its run, not by the end of
|
||||
-- the last one.
|
||||
ALTER TABLE user_steam_sync ADD COLUMN sync_started_at TIMESTAMPTZ;
|
||||
@@ -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