feat(sync): record what each Steam sync changed
Build, Test & Push Core Service / quality-check (push) Successful in 1m17s
Build, Test & Push Core Service / docker (push) Successful in 1m28s

Allows dashboard to show what the last sync changed. Migration V30
creates sync_change and sync_result tables. Sync records additions,
removals, playtime, achievements, and status changes per game.
This commit is contained in:
lobo committed 2026-10-04 11:36:09 +02:00
1 parent 065048415c
commit a8e208321f
23 files changed
+1058 -80

No files matched your search

@@ -22,7 +22,7 @@ public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
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", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
"blocked_user", "deleted_user");
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
@@ -24,6 +24,9 @@ import core_service.modules.library.api.dto.PageDto;
import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.StatusUpdateDto;
import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.library.api.dto.SyncChangeDto;
import core_service.modules.library.api.dto.SyncChangesDto;
import core_service.modules.library.api.dto.SyncChangesSeen;
import core_service.modules.library.api.dto.SyncStatusDto;
import core_service.modules.library.api.dto.UserStatsDto;
import core_service.modules.library.api.dto.WishlistDto;
@@ -36,6 +39,8 @@ import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.domain.StatKey;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.SyncChanges;
import core_service.modules.library.service.AuditLogService;
import core_service.modules.library.service.BacklogService;
import core_service.modules.library.service.GameCorrectionService;
@@ -48,6 +53,7 @@ import core_service.modules.library.service.LibraryQueryService;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.StatGamesService;
import core_service.modules.library.service.SteamProfileService;
import core_service.modules.library.service.SyncChangesService;
import core_service.modules.library.service.SyncCooldownException;
import core_service.modules.library.service.UserStatsService;
import core_service.shared.security.UserPrincipal;
@@ -100,6 +106,7 @@ public class LibraryController {
private final ImageRepairService imageRepairService;
private final UserStatsService userStatsService;
private final StatGamesService statGamesService;
private final SyncChangesService syncChangesService;
private final GameCorrectionService gameCorrectionService;
private final GameMergeService gameMergeService;
private final GameLinkService gameLinkService;
@@ -353,6 +360,21 @@ public class LibraryController {
entry.undoable());
}
private static SyncChangesDto toDto(SyncChanges result) {
return new SyncChangesDto(result.syncedAt(), result.startedBy().name(), result.seen(), result.importedGames(),
result.changes().stream().map(LibraryController::toDto).toList());
}
private static SyncChangeDto toDto(SyncChanges.Entry entry) {
SyncChange change = entry.change();
return new SyncChangeDto(change.kind().name(), change.appid(), entry.name(), entry.imageUrl(), change.amount(),
nameOf(change.fromStatus()), nameOf(change.toStatus()));
}
private static String nameOf(GameStatus status) {
return status != null ? status.name() : null;
}
private static ResponseEntity<?> sameGame() {
return ResponseEntity.badRequest().body(new ErrorResponse("SAME_GAME", "A game cannot be linked with itself"));
}
@@ -485,6 +507,24 @@ public class LibraryController {
.orElseGet(() -> ResponseEntity.ok(new SyncStatusDto(null, null)));
}
@GetMapping("/sync-changes")
public ResponseEntity<SyncChangesDto> getSyncChanges(@AuthenticationPrincipal UserPrincipal principal) {
return syncChangesService.find(principal.getIdAsLong())
.map(LibraryController::toDto)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.noContent().build());
}
/**
* Idempotent: a result that is gone, seen already or newer than {@code syncedAt} stays as it is.
*/
@PutMapping("/sync-changes/seen")
public ResponseEntity<Void> markSyncChangesSeen(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody SyncChangesSeen seen) {
syncChangesService.markSeen(principal.getIdAsLong(), seen.syncedAt());
return ResponseEntity.noContent().build();
}
@GetMapping("/steam-profile")
public ResponseEntity<SteamProfileDto> getSteamProfile(@AuthenticationPrincipal UserPrincipal principal) {
return ResponseEntity.ok(steamProfileService.getProfile(principal.steamId()));
@@ -0,0 +1,11 @@
package core_service.modules.library.api.dto;
public record SyncChangeDto(
String kind,
long appid,
String name,
String imageUrl,
Integer amount,
String fromStatus,
String toStatus
) {}
@@ -0,0 +1,12 @@
package core_service.modules.library.api.dto;
import java.time.Instant;
import java.util.List;
public record SyncChangesDto(
Instant syncedAt,
String startedBy,
boolean seen,
int importedGames,
List<SyncChangeDto> changes
) {}
@@ -0,0 +1,7 @@
package core_service.modules.library.api.dto;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
public record SyncChangesSeen(@NotNull Instant syncedAt) {}
@@ -0,0 +1,29 @@
package core_service.modules.library.domain;
/**
* One change of one game in a sync.
*
* @param amount PLAYTIME: minutes, ACHIEVEMENTS: count, else null
*/
public record SyncChange(SyncChangeKind kind, long appid, Integer amount, GameStatus fromStatus, GameStatus toStatus) {
public static SyncChange added(long appid) {
return new SyncChange(SyncChangeKind.ADDED, appid, null, null, null);
}
public static SyncChange removed(long appid) {
return new SyncChange(SyncChangeKind.REMOVED, appid, null, null, null);
}
public static SyncChange playtime(long appid, int minutes) {
return new SyncChange(SyncChangeKind.PLAYTIME, appid, minutes, null, null);
}
public static SyncChange achievements(long appid, int count) {
return new SyncChange(SyncChangeKind.ACHIEVEMENTS, appid, count, null, null);
}
public static SyncChange status(long appid, GameStatus from, GameStatus to) {
return new SyncChange(SyncChangeKind.STATUS, appid, null, from, to);
}
}
@@ -0,0 +1,9 @@
package core_service.modules.library.domain;
public enum SyncChangeKind {
ADDED,
REMOVED,
PLAYTIME,
ACHIEVEMENTS,
STATUS
}
@@ -0,0 +1,15 @@
package core_service.modules.library.domain;
import java.time.Instant;
import java.util.List;
/**
* The last sync result of a user.
*
* @param syncedAt start of the sync run; identifies the result
* @param importedGames games of the first sync of an account, which lists no single changes
*/
public record SyncChanges(Instant syncedAt, SyncTrigger startedBy, boolean seen, int importedGames, List<Entry> changes) {
public record Entry(SyncChange change, String name, String imageUrl) {}
}
@@ -0,0 +1,6 @@
package core_service.modules.library.domain;
public enum SyncTrigger {
MANUAL,
AUTO
}
@@ -0,0 +1,143 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.SyncChangeKind;
import core_service.modules.library.domain.SyncChanges;
import core_service.modules.library.domain.SyncTrigger;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/**
* The last sync result of each user and its changes. The writes of a sync join the caller's transaction.
*/
@Repository
@RequiredArgsConstructor
public class SyncChangeRepository {
private final NamedParameterJdbcTemplate jdbc;
/**
* Replaces the result of the user. Writes only while {@code user_steam_sync.sync_started_at} is still this run's
* key, so a run that lost a stale claim cannot overwrite the newer result. A result without changes does not
* replace an unseen result: the user did not see that one yet. Runs in the caller's transaction.
*
* @return false when nothing was written
*/
public boolean replace(long userId, Instant syncStartedAt, SyncTrigger startedBy, int importedGames,
List<SyncChange> changes) {
boolean empty = changes.isEmpty() && importedGames == 0;
int written = jdbc.update("""
insert into sync_result (user_id, sync_started_at, started_by, imported_games, seen_at)
select cast(:userId as bigint), :startedAt, cast(:startedBy as varchar), cast(:importedGames as integer), null
where exists (select 1 from user_steam_sync where user_id = :userId and sync_started_at = :startedAt)
on conflict (user_id) do update
set sync_started_at = excluded.sync_started_at, started_by = excluded.started_by,
imported_games = excluded.imported_games, seen_at = null
where not (:empty and sync_result.seen_at is null and (sync_result.imported_games > 0
or exists (select 1 from sync_change where user_id = sync_result.user_id)))
""", new MapSqlParameterSource("userId", userId)
.addValue("startedAt", Timestamp.from(syncStartedAt))
.addValue("startedBy", startedBy.name())
.addValue("importedGames", importedGames)
.addValue("empty", empty));
if (written == 0) {
return false;
}
jdbc.update("delete from sync_change where user_id = :userId", new MapSqlParameterSource("userId", userId));
jdbc.batchUpdate("""
insert into sync_change (user_id, kind, appid, amount, from_status, to_status)
values (:userId, :kind, :appid, :amount, :fromStatus, :toStatus)
""", changes.stream().map(change -> row(userId, change)).toArray(SqlParameterSource[]::new));
return true;
}
/**
* The moves of the sync that the trigger log wrote in this transaction. {@code now()} is the start of the
* transaction, like in {@link UserSteamSyncRepository#markHistoryBaseline}: call it in the transaction that made
* the moves.
*/
public List<SyncChange> findStatusMovesOfThisTransaction(long userId) {
return jdbc.query("""
select appid, from_status, to_status from user_game_status_change
where user_id = :userId and changed_at = now() and by_user = false and from_status is not null
""", new MapSqlParameterSource("userId", userId),
(rs, i) -> SyncChange.status(rs.getLong("appid"), GameStatus.valueOf(rs.getString("from_status")),
GameStatus.valueOf(rs.getString("to_status"))));
}
/**
* Adds a change to the current result; nothing without one.
*/
public void add(long userId, SyncChange change) {
jdbc.update("""
insert into sync_change (user_id, kind, appid, amount, from_status, to_status)
select cast(:userId as bigint), cast(:kind as varchar), cast(:appid as bigint), cast(:amount as integer),
cast(:fromStatus as varchar), cast(:toStatus as varchar)
where exists (select 1 from sync_result where user_id = :userId)
""", row(userId, change));
}
/**
* Hidden games are left out when read, so hiding a game after the sync hides it here too.
*/
public Optional<SyncChanges> find(long userId) {
MapSqlParameterSource params = new MapSqlParameterSource("userId", userId);
return jdbc.query("""
select sync_started_at, started_by, imported_games, seen_at from sync_result where user_id = :userId
""", params, (rs, i) -> new SyncChanges(rs.getTimestamp("sync_started_at").toInstant(),
SyncTrigger.valueOf(rs.getString("started_by")), rs.getTimestamp("seen_at") != null,
rs.getInt("imported_games"), entries(params))).stream().findFirst();
}
/**
* @return 0 when the result is another one or already seen
*/
public int markSeen(long userId, Instant syncStartedAt) {
return jdbc.update("""
update sync_result set seen_at = now()
where user_id = :userId and sync_started_at = :startedAt and seen_at is null
""", new MapSqlParameterSource("userId", userId).addValue("startedAt", Timestamp.from(syncStartedAt)));
}
private List<SyncChanges.Entry> entries(MapSqlParameterSource params) {
return jdbc.query("""
select c.kind, c.appid, sum(c.amount) as amount, c.from_status, c.to_status, sg.name, sg.image_url
from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
left join steam_game sg on sg.appid = c.appid
where c.user_id = :userId and ug.hidden_at is null
group by c.kind, c.appid, c.from_status, c.to_status, sg.name, sg.image_url
order by c.kind, amount desc nulls last, sg.name
""", params, (rs, i) -> new SyncChanges.Entry(
new SyncChange(SyncChangeKind.valueOf(rs.getString("kind")), rs.getLong("appid"),
rs.getObject("amount") instanceof Number amount ? amount.intValue() : null,
status(rs.getString("from_status")), status(rs.getString("to_status"))),
rs.getString("name"), rs.getString("image_url")));
}
private static MapSqlParameterSource row(long userId, SyncChange change) {
return new MapSqlParameterSource("userId", userId)
.addValue("kind", change.kind().name())
.addValue("appid", change.appid())
.addValue("amount", change.amount())
.addValue("fromStatus", name(change.fromStatus()))
.addValue("toStatus", name(change.toStatus()));
}
private static GameStatus status(String name) {
return name == null ? null : GameStatus.valueOf(name);
}
private static String name(GameStatus status) {
return status == null ? null : status.name();
}
}
@@ -4,11 +4,13 @@ import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.EndingAchievement;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.SyncChangeRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -32,6 +34,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.OptionalInt;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -59,6 +62,7 @@ public class AchievementSyncService {
private final UserGameAchievementRepository userAchievementRepository;
private final UserGameAchievementMetaRepository userAchievementMetaRepository;
private final SteamApiService steamApiService;
private final SyncChangeRepository syncChangeRepository;
private final TransactionTemplate transactionTemplate;
private final Duration schemaTtl;
private final long pauseNanos;
@@ -75,6 +79,7 @@ public class AchievementSyncService {
UserGameAchievementRepository userAchievementRepository,
UserGameAchievementMetaRepository userAchievementMetaRepository,
SteamApiService steamApiService,
SyncChangeRepository syncChangeRepository,
PlatformTransactionManager transactionManager,
@Value("${app.library.schema-ttl-days:30}") long schemaTtlDays,
// Half of the Steam rate limit, so requests of users still get through.
@@ -85,6 +90,7 @@ public class AchievementSyncService {
this.userAchievementRepository = userAchievementRepository;
this.userAchievementMetaRepository = userAchievementMetaRepository;
this.steamApiService = steamApiService;
this.syncChangeRepository = syncChangeRepository;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
this.schemaTtl = Duration.ofDays(schemaTtlDays);
@@ -194,7 +200,9 @@ public class AchievementSyncService {
if (!game.isStatusSetByUser()
&& (game.getStatus() == GameStatus.BACKLOG || game.getStatus() == GameStatus.PLAYING)) {
// Also for an unchanged playtime: the rates can come later than the unlock.
finishIfEndingUnlocked(userId, game.getAppid());
if (finishIfEndingUnlocked(userId, game.getAppid())) {
syncChangeRepository.add(userId, SyncChange.status(game.getAppid(), game.getStatus(), GameStatus.FINISHED));
}
}
if (outcome == Outcome.VISIBLE) {
anyVisible = true;
@@ -222,7 +230,14 @@ public class AchievementSyncService {
return Outcome.UNCHANGED;
}
pause();
return refreshUser(userId, steamId, appid, playtime) ? Outcome.VISIBLE : Outcome.HIDDEN;
OptionalInt unlocked = refreshUser(userId, steamId, appid, playtime);
if (unlocked.isEmpty()) return Outcome.HIDDEN;
// Without an earlier visible refresh there is no baseline: the first refresh would show every unlock.
if (meta != null && meta.isStatsVisible() && unlocked.getAsInt() > meta.getAchievementsUnlocked()) {
syncChangeRepository.add(userId,
SyncChange.achievements(appid, unlocked.getAsInt() - meta.getAchievementsUnlocked()));
}
return Outcome.VISIBLE;
}
/**
@@ -278,9 +293,9 @@ public class AchievementSyncService {
}
/**
* @return false when Steam hides the achievements of this user
* @return the unlocked count, empty when Steam hides the achievements of this user
*/
private boolean refreshUser(Long userId, String steamId, Long appid, int playtimeMinutes) {
private OptionalInt refreshUser(Long userId, String steamId, Long appid, int playtimeMinutes) {
Optional<SteamPlayerAchievementsResponse.PlayerStats> response = steamApiService.getPlayerAchievements(steamId, appid);
boolean visible = response.isPresent() && (response.get().success() == null || response.get().success());
List<UserGameAchievement> rows = visible && response.get().achievements() != null
@@ -304,7 +319,7 @@ public class AchievementSyncService {
userAchievementMetaRepository.save(
new UserGameAchievementMeta(userId, appid, Instant.now(), visible, unlockedCount, playtimeAtRefresh));
});
return visible;
return visible ? OptionalInt.of(unlockedCount) : OptionalInt.empty();
}
/**
@@ -1,5 +1,6 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
@@ -60,7 +61,7 @@ public class LibrarySyncService {
if (sync != null && steamId.equals(sync.getSteamId()) && !isStale(sync)) {
return;
}
worker.runSync(userId, steamId);
worker.runSync(userId, steamId, SyncTrigger.AUTO);
}
/**
@@ -78,7 +79,7 @@ public class LibrarySyncService {
// Taken before the syncs: the loop can run past midnight.
LocalDate day = LocalDate.now(zone);
try {
accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId()));
accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId(), SyncTrigger.AUTO));
} finally {
writeBacklogSnapshots(day);
estimateFinishDates();
@@ -124,7 +125,7 @@ public class LibrarySyncService {
if (sync != null && isAlreadyRunning(sync)) {
throw new SyncCooldownException(forceSyncCooldownSeconds);
}
worker.runSync(userId, steamId);
worker.runSync(userId, steamId, SyncTrigger.MANUAL);
}
// At most one write per user and hour; the nightly sync only needs to know "used recently".
@@ -3,12 +3,16 @@ package core_service.modules.library.service;
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.SyncChange;
import core_service.modules.library.domain.SyncChangeKind;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.SyncChangeRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
@@ -56,6 +60,7 @@ public class LibrarySyncWorker {
private final GameCorrectionRepository gameCorrectionRepository;
private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository;
private final SyncChangeRepository syncChangeRepository;
private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository;
private final TimeToBeatService timeToBeatService;
@@ -71,21 +76,21 @@ public class LibrarySyncWorker {
private ZoneId zone;
@Async(BackgroundPools.DEFAULT)
public void runSync(Long userId, String steamId) {
sync(userId, steamId);
public void runSync(Long userId, String steamId, SyncTrigger trigger) {
sync(userId, steamId, trigger);
}
/**
* 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.
*/
public void runSyncNow(Long userId, String steamId) {
sync(userId, steamId);
public void runSyncNow(Long userId, String steamId, SyncTrigger trigger) {
sync(userId, steamId, trigger);
}
private record Claim(String historySteamId) {}
private void sync(Long userId, String steamId) {
private void sync(Long userId, String steamId, SyncTrigger trigger) {
Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes));
Instant syncStartedAt = Instant.now().truncatedTo(ChronoUnit.MICROS);
Claim claim = transaction.execute(tx -> {
@@ -128,7 +133,14 @@ public class LibrarySyncWorker {
log.warn("Sync user={} got no games for SteamID {}", userId, steamId);
return false;
}
saveGames(userId, steamId, games, syncStartedAt, playedOn);
List<SyncChange> changes = saveGames(userId, steamId, games, syncStartedAt, playedOn);
// The first sync of an account is a baseline: a count, not a list.
if (playedOn == null) {
int imported = (int) changes.stream().filter(c -> c.kind() == SyncChangeKind.ADDED).count();
syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, List.of());
} else {
syncChangeRepository.replace(userId, syncStartedAt, trigger, 0, changes);
}
return true;
});
if (!saved) {
@@ -157,8 +169,11 @@ public class LibrarySyncWorker {
}
}
private void saveGames(Long userId, String steamId, List<SteamOwnedGamesResponse.Game> games, Instant now,
LocalDate playedOn) {
/**
* @return what changed for the user, for the sync result
*/
private List<SyncChange> 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, appids);
@@ -166,22 +181,31 @@ public class LibrarySyncWorker {
.collect(Collectors.toMap(SteamGame::getAppid, game -> game));
Map<Long, UserGame> userGames = userGameRepository.findByUserId(userId).stream()
.collect(Collectors.toMap(UserGame::getAppid, game -> game));
List<SyncChange> changes = new ArrayList<>();
// The same rule as softDeleteMissingSince.
Set<Long> listed = new HashSet<>(appids);
userGames.values().stream()
.filter(game -> game.getRemovedAt() == null && game.getSource() == GameSource.STEAM
&& !listed.contains(game.getAppid()))
.forEach(game -> changes.add(SyncChange.removed(game.getAppid())));
List<Long> played = new ArrayList<>();
for (SteamOwnedGamesResponse.Game g : games) {
Long appid = g.appid().longValue();
upsertGame(g, knownGames.get(appid), now, correctedNames);
if (upsertUserGame(userId, g, userGames.get(appid), now, playedOn)) {
if (upsertUserGame(userId, g, userGames.get(appid), now, playedOn, changes)) {
played.add(appid);
}
}
if (!played.isEmpty()) {
userGameRepository.playFromPileIfUntouched(userId, played);
changes.addAll(syncChangeRepository.findStatusMovesOfThisTransaction(userId));
}
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);
return changes;
}
private record Wishlist(List<SteamApiService.WishlistItem> wanted, Map<Long, SteamApiService.StoreItem> store) {}
@@ -232,16 +256,20 @@ public class LibrarySyncWorker {
/**
* @param playedOn the day that gets the playtime growth, null to record none
* @param changes gets the ADDED and PLAYTIME changes of the game
* @return true when the game was played since the last sync
*/
private boolean upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, UserGame existing, Instant now,
LocalDate playedOn) {
LocalDate playedOn, List<SyncChange> changes) {
Long appid = g.appid().longValue();
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;
int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0;
int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0;
if (existing == null || existing.getRemovedAt() != null) {
changes.add(SyncChange.added(appid));
}
if (existing == null) {
UserGame created = new UserGame(userId, appid, playtime, now, now);
created.setPlaytime2Weeks(playtime2Weeks);
@@ -259,6 +287,9 @@ public class LibrarySyncWorker {
}
// A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted.
int growth = playtime - previousPlaytime;
if (existing != null && growth > 0) {
changes.add(SyncChange.playtime(appid, growth));
}
// Growth beyond Steam's last two weeks is older playtime, e.g. of a game this sync sees for the first time.
int minutesToday = Math.min(growth, playtime2Weeks);
if (minutesToday > 0) {
@@ -0,0 +1,33 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.SyncChanges;
import core_service.modules.library.repo.SyncChangeRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Optional;
/**
* What changed in the last sync of the user. The sync itself writes it through {@link SyncChangeRepository}.
*/
@Service
@RequiresRole(Role.USER)
@RequiredArgsConstructor
public class SyncChangesService {
private final SyncChangeRepository syncChangeRepository;
@Transactional(readOnly = true)
public Optional<SyncChanges> find(long userId) {
return syncChangeRepository.find(userId);
}
@Transactional
public void markSeen(long userId, Instant syncedAt) {
syncChangeRepository.markSeen(userId, syncedAt);
}
}
@@ -0,0 +1,19 @@
-- The last sync result per user; a newer sync replaces it. seen_at: the user closed the banner (all devices).
CREATE TABLE sync_result (
user_id BIGINT PRIMARY KEY,
sync_started_at TIMESTAMPTZ NOT NULL,
started_by VARCHAR(16) NOT NULL, -- SyncTrigger
imported_games INTEGER NOT NULL DEFAULT 0,
seen_at TIMESTAMPTZ
);
CREATE TABLE sync_change (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES sync_result (user_id) ON DELETE CASCADE,
kind VARCHAR(16) NOT NULL, -- SyncChangeKind
appid BIGINT NOT NULL,
amount INTEGER, -- PLAYTIME minutes, ACHIEVEMENTS count
from_status VARCHAR(16),
to_status VARCHAR(16)
);
CREATE INDEX idx_sync_change_user ON sync_change (user_id);
@@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_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", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped");
@Autowired
@@ -45,6 +45,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
jdbc.update("""
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
userId);
jdbc.update("insert into sync_result (user_id, sync_started_at, started_by) values (?, now(), 'AUTO')", userId);
jdbc.update("insert into sync_change (user_id, kind, appid) values (?, 'ADDED', 220)", userId);
jdbc.update("insert into game_merge_undo_user (merge_id, user_id, combined) values (1, ?, false)", userId);
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, 220, 620)", userId);
jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", userId);
@@ -1,6 +1,7 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -153,7 +154,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.of(List.of()));
when(steamApiService.getSchemaForGame(anyLong())).thenReturn(Optional.empty());
librarySyncWorker.runSyncNow(USER_ID, "76561198000000001");
librarySyncWorker.runSyncNow(USER_ID, "76561198000000001", SyncTrigger.AUTO);
verify(steamApiService).getSchemaForGame(620L);
}
@@ -0,0 +1,151 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.service.LibrarySyncWorker;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.shared.test.AbstractMockedAdaptersTest;
import core_service.shared.test.ContractTokens;
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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Optional;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Two real syncs with a mocked Steam. The dev profile authenticates a request without a token as user 1.
*/
@AutoConfigureMockMvc
class SyncChangesFlowTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001";
private static final int GROWN = 910_001;
private static final int UNTOUCHED = 910_002;
private static final int GONE = 910_003;
private static final int HIDDEN = 910_004;
private static final int NEW = 910_005;
@Autowired
private MockMvc mockMvc;
@Autowired
private LibrarySyncWorker worker;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void clean() {
for (String table : List.of("sync_result", "user_game_playtime_day", "user_game", "user_steam_sync")) {
jdbc.update("delete from " + table + " where user_id = ?", USER_ID);
}
jdbc.update("delete from steam_game where appid between 910001 and 910005");
}
private static SteamOwnedGamesResponse.Game owned(int appid, int playtime) {
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
}
private void sync(SyncTrigger trigger, SteamOwnedGamesResponse.Game... games) {
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.empty());
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(games));
worker.runSyncNow(USER_ID, STEAM_ID, trigger);
}
private void firstAndSecondSync() {
sync(SyncTrigger.AUTO, owned(GROWN, 100), owned(UNTOUCHED, 0), owned(GONE, 5), owned(HIDDEN, 10));
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER_ID, HIDDEN);
sync(SyncTrigger.MANUAL, owned(GROWN, 160), owned(UNTOUCHED, 30), owned(HIDDEN, 40), owned(NEW, 15));
}
@Test
void firstSync_IsAnImportWithoutRows() throws Exception {
sync(SyncTrigger.AUTO, owned(GROWN, 100), owned(UNTOUCHED, 0));
mockMvc.perform(get("/api/library/sync-changes"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.importedGames").value(2))
.andExpect(jsonPath("$.startedBy").value("AUTO"))
.andExpect(jsonPath("$.seen").value(false))
.andExpect(jsonPath("$.changes", hasSize(0)));
}
@Test
void secondSync_ListsWhatChanged_AndLeavesOutAHiddenGame() throws Exception {
firstAndSecondSync();
mockMvc.perform(get("/api/library/sync-changes"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.startedBy").value("MANUAL"))
.andExpect(jsonPath("$.importedGames").value(0))
.andExpect(jsonPath("$.changes[?(@.kind=='ADDED')].appid").value(containsInAnyOrder(NEW)))
.andExpect(jsonPath("$.changes[?(@.kind=='REMOVED')].appid").value(containsInAnyOrder(GONE)))
.andExpect(jsonPath("$.changes[?(@.kind=='REMOVED')].name").value(containsInAnyOrder("Game " + GONE)))
.andExpect(jsonPath("$.changes[?(@.kind=='PLAYTIME')].appid").value(containsInAnyOrder(GROWN, UNTOUCHED)))
.andExpect(jsonPath("$.changes[?(@.kind=='PLAYTIME' && @.appid==" + GROWN + ")].amount")
.value(containsInAnyOrder(60)))
.andExpect(jsonPath("$.changes[?(@.kind=='STATUS')].appid").value(containsInAnyOrder(UNTOUCHED)))
.andExpect(jsonPath("$.changes[?(@.kind=='STATUS')].fromStatus").value(containsInAnyOrder("BACKLOG")))
.andExpect(jsonPath("$.changes[?(@.kind=='STATUS')].toStatus").value(containsInAnyOrder("PLAYING")))
.andExpect(jsonPath("$.changes[?(@.appid==" + HIDDEN + ")]", hasSize(0)));
}
@Test
void markSeen_SetsSeen_AndAStaleTimestampChangesNothing() throws Exception {
firstAndSecondSync();
String syncedAt = jdbc.queryForObject("select to_char(sync_started_at at time zone 'UTC', "
+ "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') from sync_result where user_id = ?", String.class, USER_ID);
mockMvc.perform(put("/api/library/sync-changes/seen").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"syncedAt\":\"2020-01-01T00:00:00Z\"}"))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/sync-changes")).andExpect(jsonPath("$.seen").value(false));
mockMvc.perform(put("/api/library/sync-changes/seen").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"syncedAt\":\"" + syncedAt + "\"}"))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/sync-changes")).andExpect(jsonPath("$.seen").value(true));
}
@Test
void syncWithoutChanges_KeepsTheUnseenResult() throws Exception {
firstAndSecondSync();
sync(SyncTrigger.AUTO, owned(GROWN, 160), owned(UNTOUCHED, 30), owned(HIDDEN, 40), owned(NEW, 15));
mockMvc.perform(get("/api/library/sync-changes"))
.andExpect(jsonPath("$.startedBy").value("MANUAL"))
.andExpect(jsonPath("$.changes[?(@.kind=='ADDED')].appid").value(containsInAnyOrder(NEW)));
}
@Test
void userWithoutAResult_GetsNoContent() throws Exception {
firstAndSecondSync();
mockMvc.perform(get("/api/library/sync-changes")
.header("Authorization", "Bearer " + ContractTokens.loginToken(77L)))
.andExpect(status().isNoContent());
}
@Test
void noSyncYet_GetsNoContent() throws Exception {
mockMvc.perform(get("/api/library/sync-changes")).andExpect(status().isNoContent());
}
}
@@ -0,0 +1,261 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.SyncChangeKind;
import core_service.modules.library.domain.SyncChanges;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(SyncChangeRepository.class)
class SyncChangeRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final long OTHER_USER = 2L;
private static final long HALF_LIFE = 220L;
private static final long PORTAL = 620L;
private static final long HIDDEN = 700L;
private static final long REMOVED = 800L;
private static final Instant FIRST_RUN = Instant.now().minusSeconds(3600).truncatedTo(ChronoUnit.MICROS);
private static final Instant SECOND_RUN = FIRST_RUN.plusSeconds(60);
@Autowired
private SyncChangeRepository repository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(HALF_LIFE, "Half-Life 2", null, Instant.now()),
new SteamGame(PORTAL, "Portal 2", null, Instant.now()),
new SteamGame(HIDDEN, "Playtest", null, Instant.now()),
new SteamGame(REMOVED, "Refunded", null, Instant.now())));
UserGame hidden = new UserGame(USER_ID, HIDDEN, 0, Instant.now(), Instant.now());
hidden.setHiddenAt(Instant.now());
UserGame removed = new UserGame(USER_ID, REMOVED, 0, Instant.now(), Instant.now());
removed.setRemovedAt(Instant.now());
userGameRepository.saveAllAndFlush(List.of(
new UserGame(USER_ID, HALF_LIFE, 0, Instant.now(), Instant.now()),
new UserGame(USER_ID, PORTAL, 0, Instant.now(), Instant.now()),
hidden, removed));
}
private void claim(long userId, Instant startedAt) {
jdbc.update("""
insert into user_steam_sync (user_id, steam_id, sync_started_at) values (?, '76561198000000001', ?)
on conflict (user_id) do update set sync_started_at = excluded.sync_started_at""",
userId, Timestamp.from(startedAt));
}
private int rows() {
return jdbc.queryForObject("select count(*) from sync_change where user_id = ?", Integer.class, USER_ID);
}
private void markSeenNow() {
jdbc.update("update sync_result set seen_at = now() where user_id = ?", USER_ID);
}
private SyncChanges stored() {
return repository.find(USER_ID).orElseThrow();
}
@Test
void replace_DropsTheOldRowsAndResetsSeenAt() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
markSeenNow();
claim(USER_ID, SECOND_RUN);
boolean written = repository.replace(USER_ID, SECOND_RUN, SyncTrigger.MANUAL, 0,
List.of(SyncChange.removed(PORTAL)));
assertThat(written).isTrue();
SyncChanges result = stored();
assertThat(result.syncedAt()).isEqualTo(SECOND_RUN);
assertThat(result.startedBy()).isEqualTo(SyncTrigger.MANUAL);
assertThat(result.seen()).isFalse();
assertThat(result.changes()).extracting(e -> e.change()).containsExactly(SyncChange.removed(PORTAL));
}
@Test
void replace_EmptyResultOverAnUnseenOne_KeepsTheUnseenResultAndItsRows() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
claim(USER_ID, SECOND_RUN);
boolean written = repository.replace(USER_ID, SECOND_RUN, SyncTrigger.MANUAL, 0, List.of());
assertThat(written).isFalse();
SyncChanges result = stored();
assertThat(result.syncedAt()).isEqualTo(FIRST_RUN);
assertThat(result.changes()).extracting(e -> e.change()).containsExactly(SyncChange.added(HALF_LIFE));
}
@Test
void replace_EmptyResultOverASeenOne_WritesAnEmptyUnseenResult() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
markSeenNow();
claim(USER_ID, SECOND_RUN);
boolean written = repository.replace(USER_ID, SECOND_RUN, SyncTrigger.MANUAL, 0, List.of());
assertThat(written).isTrue();
SyncChanges result = stored();
assertThat(result.syncedAt()).isEqualTo(SECOND_RUN);
assertThat(result.seen()).isFalse();
assertThat(result.changes()).isEmpty();
}
@Test
void emptyManualSyncReplacesAnUnseenEmptyAutoResult() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of());
claim(USER_ID, SECOND_RUN);
repository.replace(USER_ID, SECOND_RUN, SyncTrigger.MANUAL, 0, List.of());
SyncChanges result = stored();
assertThat(result.syncedAt()).isEqualTo(SECOND_RUN);
assertThat(result.startedBy()).isEqualTo(SyncTrigger.MANUAL);
}
@Test
void replace_NoResultYet_WritesAnEmptyUnseenResult() {
claim(USER_ID, FIRST_RUN);
assertThat(repository.replace(USER_ID, FIRST_RUN, SyncTrigger.MANUAL, 0, List.of())).isTrue();
assertThat(stored().seen()).isFalse();
}
@Test
void replace_ImportOverAnUnseenResult_ReplacesIt() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
claim(USER_ID, SECOND_RUN);
assertThat(repository.replace(USER_ID, SECOND_RUN, SyncTrigger.AUTO, 5, List.of())).isTrue();
SyncChanges result = stored();
assertThat(result.importedGames()).isEqualTo(5);
assertThat(result.changes()).isEmpty();
}
@Test
void replace_NonEmptyResultOverAnUnseenOne_ReplacesIt() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
claim(USER_ID, SECOND_RUN);
assertThat(repository.replace(USER_ID, SECOND_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.removed(PORTAL)))).isTrue();
assertThat(stored().changes()).extracting(e -> e.change()).containsExactly(SyncChange.removed(PORTAL));
}
@Test
void replace_AnotherRunHoldsTheClaim_WritesNothing() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
claim(USER_ID, SECOND_RUN);
boolean written = repository.replace(USER_ID, FIRST_RUN.plusSeconds(1), SyncTrigger.MANUAL, 0,
List.of(SyncChange.removed(PORTAL)));
assertThat(written).isFalse();
assertThat(stored().syncedAt()).isEqualTo(FIRST_RUN);
assertThat(rows()).isEqualTo(1);
}
@Test
void findStatusMovesOfThisTransaction_ReturnsOnlyMovesOfTheSync() {
// Same transaction as the seed: the inserts (from null) are in the log with this now() too.
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", USER_ID, HALF_LIFE);
jdbc.update("update user_game set status = 'DROPPED', status_set_by_user = true where user_id = ? and appid = ?",
USER_ID, PORTAL);
assertThat(repository.findStatusMovesOfThisTransaction(USER_ID))
.containsExactly(SyncChange.status(HALF_LIFE, GameStatus.BACKLOG, GameStatus.PLAYING));
}
@Test
void add_WithoutAResult_WritesNothing() {
repository.add(USER_ID, SyncChange.achievements(HALF_LIFE, 2));
assertThat(rows()).isZero();
}
@Test
void add_WithAResult_AppendsTheRow() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of());
repository.add(USER_ID, SyncChange.status(HALF_LIFE, GameStatus.PLAYING, GameStatus.FINISHED));
assertThat(stored().changes()).extracting(e -> e.change())
.containsExactly(SyncChange.status(HALF_LIFE, GameStatus.PLAYING, GameStatus.FINISHED));
}
@Test
void find_HidesAHiddenGameKeepsARemovedOneAndSumsTheUnlocks() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(
SyncChange.removed(REMOVED), SyncChange.added(HIDDEN), SyncChange.playtime(PORTAL, 30)));
repository.add(USER_ID, SyncChange.achievements(HALF_LIFE, 2));
repository.add(USER_ID, SyncChange.achievements(HALF_LIFE, 3));
List<SyncChanges.Entry> entries = stored().changes();
assertThat(entries).extracting(e -> e.change()).containsExactlyInAnyOrder(
SyncChange.removed(REMOVED), SyncChange.playtime(PORTAL, 30), SyncChange.achievements(HALF_LIFE, 5));
assertThat(entries).filteredOn(e -> e.change().kind() == SyncChangeKind.REMOVED)
.extracting(SyncChanges.Entry::name).containsExactly("Refunded");
}
@Test
void find_NoResult_IsEmpty() {
assertThat(repository.find(OTHER_USER)).isEmpty();
}
@Test
void markSeen_ForTheResult_SetsSeen() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of());
assertThat(repository.markSeen(USER_ID, FIRST_RUN)).isEqualTo(1);
assertThat(stored().seen()).isTrue();
}
@Test
void markSeen_WithAnOldSyncedAt_ChangesNothing() {
claim(USER_ID, SECOND_RUN);
repository.replace(USER_ID, SECOND_RUN, SyncTrigger.AUTO, 0, List.of());
assertThat(repository.markSeen(USER_ID, FIRST_RUN)).isZero();
assertThat(stored().seen()).isFalse();
}
}
@@ -2,11 +2,14 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.SyncChangeRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -66,13 +69,15 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
private UserGameAchievementMetaRepository userAchievementMetaRepository;
@Mock
private SteamApiService steamApiService;
@Mock
private SyncChangeRepository syncChangeRepository;
private AchievementSyncService service;
@BeforeEach
void setUp() {
service = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
userAchievementRepository, userAchievementMetaRepository, steamApiService,
userAchievementRepository, userAchievementMetaRepository, steamApiService, syncChangeRepository,
mock(PlatformTransactionManager.class), 30, 0);
}
@@ -375,7 +380,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
void syncLibrary_TwoUsersInParallel_ShareOneSteamBudget() throws Exception {
long pauseMillis = 300;
AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
userAchievementRepository, userAchievementMetaRepository, steamApiService,
userAchievementRepository, userAchievementMetaRepository, steamApiService, syncChangeRepository,
mock(PlatformTransactionManager.class), 30, pauseMillis);
when(userGameRepository.findListedSteamGames(1L)).thenReturn(List.of(new UserGame(1L, 620L, 60, Instant.now(), Instant.now())));
when(userGameRepository.findListedSteamGames(2L)).thenReturn(List.of(new UserGame(2L, 620L, 60, Instant.now(), Instant.now())));
@@ -415,4 +420,79 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
verifyNoInteractions(steamApiService);
}
@Test
void syncLibrary_MoreUnlocksThanTheEarlierVisibleRefresh_AddsTheDifference() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D", "E"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 2));
}
@Test
void syncLibrary_FirstRefreshOfAGame_AddsNoUnlockRow() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of());
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D", "E"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository, never()).add(anyLong(), any());
}
@Test
void syncLibrary_EarlierRefreshWasHidden_AddsNoUnlockRow() {
UserGameAchievementMeta hidden = new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), false, 0, null);
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(hidden));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository, never()).add(anyLong(), any());
}
@Test
void syncLibrary_SameUnlockCount_AddsNoUnlockRow() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 2, 120)));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository, never()).add(anyLong(), any());
}
@Test
void syncLibrary_EndingUnlocked_AddsAFinishedMove() {
library(List.of(game(620L, 120)), List.of(schema(620L, 1)), List.of(unlocks(620L, 1, 120)));
endingOf620(true);
when(userGameRepository.finishIfUntouched(USER_ID, 620L)).thenReturn(1);
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository).add(USER_ID, SyncChange.status(620L, GameStatus.PLAYING, GameStatus.FINISHED));
}
@Test
void syncLibrary_EndingAlreadyHandled_AddsNoMove() {
library(List.of(game(620L, 120)), List.of(schema(620L, 1)), List.of(unlocks(620L, 1, 120)));
endingOf620(true);
when(userGameRepository.finishIfUntouched(USER_ID, 620L)).thenReturn(0);
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository, never()).add(anyLong(), any());
}
@Test
void refreshUserIfStale_AddsNoSyncRow() {
when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L))
.thenReturn(Optional.of(new UserGameAchievementMeta(USER_ID, 620L, Instant.now().minus(Duration.ofDays(2)), true, 1, 60)));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C"));
service.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 90, Duration.ofHours(1));
verify(userAchievementMetaRepository).save(any());
verifyNoInteractions(syncChangeRepository);
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
@@ -73,7 +74,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
}
@Test
@@ -82,7 +83,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), anyString());
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
@@ -91,7 +92,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
}
@Test
@@ -100,7 +101,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
}
@Test
@@ -109,7 +110,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), anyString());
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
@@ -118,7 +119,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), anyString());
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
@@ -130,7 +131,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncAllNightly();
InOrder order = inOrder(worker, backlogSnapshotRepository, finishedEstimateService);
order.verify(worker).runSyncNow(1L, "76561198000000001");
order.verify(worker).runSyncNow(1L, "76561198000000001", SyncTrigger.AUTO);
order.verify(backlogSnapshotRepository).writeAll(today);
order.verify(finishedEstimateService).fillMissing();
}
@@ -149,7 +150,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
void syncAllNightly_ASyncThrows_TheSnapshotIsStillWritten() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
new UserSteamSync(1L, "76561198000000001")));
doThrow(new IllegalStateException("db down")).when(worker).runSyncNow(anyLong(), anyString());
doThrow(new IllegalStateException("db down")).when(worker).runSyncNow(anyLong(), anyString(), any());
assertThrows(IllegalStateException.class, () -> syncService.syncAllNightly());
@@ -164,9 +165,9 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncAllNightly();
verify(worker).runSyncNow(1L, "76561198000000001");
verify(worker).runSyncNow(2L, "76561198005679790");
verify(worker, never()).runSync(anyLong(), anyString());
verify(worker).runSyncNow(1L, "76561198000000001", SyncTrigger.AUTO);
verify(worker).runSyncNow(2L, "76561198005679790", SyncTrigger.AUTO);
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
@@ -186,4 +187,13 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
verify(userSteamSyncRepository, times(1)).touchUserSeen(eq(USER_ID), any());
}
@Test
void forceSync_StartsAManualSync() {
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
syncService.forceSync(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
}
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.modules.library.repo.UserSteamSyncRepository;
@@ -66,7 +67,7 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
new SpeedrunApiService.Game("hl2", "Half-Life 2", 2004, "https://www.speedrun.com/hl2"))));
when(speedrunApiService.hasVerifiedRuns("hl2")).thenReturn(Optional.of(true));
worker.runSyncNow(USER_ID, STEAM_ID);
worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(speedrunApiService).hasVerifiedRuns("hl2");
assertThat(gameSpeedrunRepository.findById(APPID)).isPresent();
@@ -86,12 +87,12 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
return List.of(game("icon"));
});
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID));
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO));
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);
worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO);
secondDone.countDown();
});
CompletableFuture.allOf(first, second).get(30, TimeUnit.SECONDS);
@@ -107,7 +108,7 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
// img_icon_url is VARCHAR(512).
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(game("x".repeat(600))));
worker.runSyncNow(USER_ID, STEAM_ID);
worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO);
UserSteamSync row = userSteamSyncRepository.findById(USER_ID).orElseThrow();
assertThat(row.getLastSyncStatus()).isEqualTo(UserSteamSync.Status.FAILED);
@@ -134,12 +135,12 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
return List.of(game("icon"));
});
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID));
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO));
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));
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO));
assertThat(secondAsked.await(10, TimeUnit.SECONDS)).isTrue();
releaseFirst.countDown();
first.get(30, TimeUnit.SECONDS);
@@ -4,12 +4,15 @@ import core_service.modules.library.domain.CorrectionField;
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.SyncChange;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.SyncChangeRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
@@ -108,6 +111,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private UserSteamSyncRepository userSteamSyncRepository;
@Mock
private SyncChangeRepository syncChangeRepository;
@Mock
private PlaytimeDayRepository playtimeDayRepository;
@@ -143,7 +149,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(100, 0), null);
when(userSteamSyncRepository.markFinished(eq(USER_ID), any(), eq("OK"), any())).thenReturn(0);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verifyNoInteractions(eventPublisher);
}
@@ -153,7 +159,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
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);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
@@ -167,7 +173,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
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);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
@@ -180,7 +186,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
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);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
@@ -192,7 +198,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
game(0, 0)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
@@ -206,7 +212,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
game(playtime, 0)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
ArgumentCaptor<UserGame> saved = ArgumentCaptor.forClass(UserGame.class);
verify(userGameRepository).save(saved.capture());
@@ -221,7 +227,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(GameSource.STEAM, manual.getSource());
assertNull(manual.getPlatform());
@@ -237,7 +243,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
@@ -251,7 +257,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
game(90, 0)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(GameStatus.DROPPED, existing.getStatus());
assertEquals(90, existing.getPlaytimeMinutes());
@@ -263,7 +269,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
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);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).save(any(UserGame.class));
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120);
@@ -274,7 +280,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(160, 60), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60);
}
@@ -284,7 +290,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
UserGame existing = owned(0);
steamReturns(game(30, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).playFromPileIfUntouched(USER_ID, List.of(220L));
// Never through the entity: a status the user saves during the sync would be overwritten.
@@ -296,7 +302,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(100, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
}
@@ -306,7 +312,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(0);
steamReturns(game(5000, 0), null);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
}
@@ -316,7 +322,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(90, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
@@ -325,7 +331,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_NewGameAfterEarlierSyncs_RecordsItsWholePlaytime() {
steamReturns(game(45, 45), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 45);
}
@@ -334,7 +340,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_NewGameWithOldPlaytime_RecordsOnlyTheLastTwoWeeks() {
steamReturns(game(2378, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 30);
}
@@ -343,7 +349,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_NewGameNotPlayedForTwoWeeks_RecordsNothingAndLeavesThePileAlone() {
steamReturns(game(2378, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
@@ -354,7 +360,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(1000, 60), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60);
}
@@ -364,7 +370,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(160, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
verify(userGameRepository).playFromPileIfUntouched(USER_ID, List.of(220L));
@@ -374,7 +380,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_FirstSync_IsOnlyTheBaseline() {
steamReturns(game(5000, 60), null);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
@@ -384,7 +390,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(5000, 60), OTHER_STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
@@ -397,8 +403,8 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
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);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
verify(userSteamSyncRepository).markHistoryBaseline(USER_ID, STEAM_ID);
@@ -409,7 +415,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(0, 0), STEAM_ID);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userSteamSyncRepository, never()).markHistoryBaseline(anyLong(), any());
}
@@ -419,7 +425,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
UserGame existing = owned(100);
steamReturns(game(160, 75), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(75, existing.getPlaytime2Weeks());
}
@@ -435,7 +441,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getStoreItems(List.of(1257610L))).thenReturn(Map.of(
1257610L, new SteamApiService.StoreItem("Prison Architect 2", "7,49€", "29,99€", 75)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
ArgumentCaptor<List<WishlistGame>> saved = ArgumentCaptor.forClass(List.class);
InOrder order = inOrder(wishlistGameRepository);
@@ -455,7 +461,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(0, 0), STEAM_ID);
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(wishlistGameRepository, never()).deleteByUserId(anyLong());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
@@ -467,7 +473,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
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);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(wishlistGameRepository).saveAll(anyList());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
@@ -482,7 +488,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(wishlistGameRepository.findForUser(USER_ID)).thenReturn(List.of(
new WishlistGame(USER_ID, 1257610L, "Prison Architect 2", null, "29,99€", null, 0)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
ArgumentCaptor<List<WishlistGame>> saved = ArgumentCaptor.forClass(List.class);
verify(wishlistGameRepository).saveAll(saved.capture());
@@ -495,7 +501,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(0, 0), OTHER_STEAM_ID);
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(wishlistGameRepository).deleteByUserId(USER_ID);
}
@@ -506,7 +512,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
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);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(wishlistGameRepository).deleteByUserId(USER_ID);
}
@@ -515,7 +521,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_RefreshesTheTimeToBeatOfTheOwnedGames() {
steamReturns(game(0, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(timeToBeatService).refresh(List.of(220L));
}
@@ -525,7 +531,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(0, 0), STEAM_ID);
when(userGameRepository.findActiveManualAppids(USER_ID)).thenReturn(List.of(-1025L));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(timeToBeatService).refresh(List.of(220L, -1025L));
verify(moodService).refresh(List.of(220L, -1025L));
@@ -539,7 +545,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
hidden.setHiddenAt(hiddenAt);
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(hiddenAt, hidden.getHiddenAt());
assertEquals(90, hidden.getPlaytimeMinutes());
@@ -551,7 +557,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(0, 0), STEAM_ID);
when(userGameRepository.findHiddenAppids(USER_ID)).thenReturn(List.of(220L));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(timeToBeatService).refresh(List.of());
verify(moodService).refresh(List.of());
@@ -564,7 +570,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null),
game(90, 0)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(steamGameRepository).save(argThat((SteamGame g) -> g.getAppid() == 2000001L && "App 2000001".equals(g.getName())));
verify(steamGameRepository).save(argThat((SteamGame g) -> g.getAppid() == 220L));
@@ -578,7 +584,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(stored.getName()).isEqualTo("El Mucho");
}
@@ -589,7 +595,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamGameRepository.findAllById(List.of(220L))).thenReturn(List.of(stored));
steamReturns(game(10, 0), null);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
// The update keeps an auditor's name (SteamGameRepositoryTest); the entity itself is not renamed.
verify(steamGameRepository).setSourceName(220L, "Half-Life 2");
@@ -603,8 +609,103 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(gameCorrectionRepository.findCorrectedAppids(eq(CorrectionField.NAME), any())).thenReturn(Set.of(220L));
steamReturns(game(10, 0), null);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(steamGameRepository, never()).setSourceName(anyLong(), anyString());
}
@SuppressWarnings("unchecked")
private List<SyncChange> replacedChanges(SyncTrigger trigger, int imported) {
ArgumentCaptor<List<SyncChange>> changes = ArgumentCaptor.forClass(List.class);
verify(syncChangeRepository).replace(eq(USER_ID), any(), eq(trigger), eq(imported), changes.capture());
return changes.getValue();
}
private static SteamOwnedGamesResponse.Game gameOf(int appid, int playtime) {
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
}
@Test
void runSync_AfterEarlierSync_RecordsAddedRemovedAndPlaytimeWithTheTrigger() {
UserGame grown = new UserGame(USER_ID, 220L, 100, Instant.EPOCH, Instant.EPOCH);
UserGame gone = new UserGame(USER_ID, 300L, 10, Instant.EPOCH, Instant.EPOCH);
UserGame alreadyRemoved = new UserGame(USER_ID, 400L, 10, Instant.EPOCH, Instant.EPOCH);
alreadyRemoved.setRemovedAt(Instant.EPOCH);
UserGame byHand = UserGame.manual(USER_ID, 500L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(grown, gone, alreadyRemoved, byHand));
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(gameOf(220, 160), gameOf(600, 5)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
assertThat(replacedChanges(SyncTrigger.MANUAL, 0)).containsExactlyInAnyOrder(
SyncChange.removed(300L), SyncChange.added(600L), SyncChange.playtime(220L, 60));
}
@Test
void runSync_StatusMovesOfTheSave_AreRecorded() {
owned(100);
steamReturns(game(160, 60), STEAM_ID);
when(syncChangeRepository.findStatusMovesOfThisTransaction(USER_ID))
.thenReturn(List.of(SyncChange.status(220L, GameStatus.BACKLOG, GameStatus.PLAYING)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactlyInAnyOrder(
SyncChange.playtime(220L, 60), SyncChange.status(220L, GameStatus.BACKLOG, GameStatus.PLAYING));
}
@Test
void runSync_ReAddedGame_IsAddedAgain() {
UserGame removed = new UserGame(USER_ID, 220L, 100, Instant.EPOCH, Instant.EPOCH);
removed.setRemovedAt(Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(removed));
steamReturns(game(100, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactly(SyncChange.added(220L));
}
@Test
void runSync_FirstSync_StoresOnlyTheCountOfImportedGames() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(gameOf(220, 100), gameOf(300, 0)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 2)).isEmpty();
}
@Test
void runSync_GameAddedByHandIsNowOnSteam_IsNeitherAddedNorPlaytime() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).isEmpty();
}
@Test
void runSync_SteamAnswersNoGames_KeepsTheOldResult() {
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, SyncTrigger.MANUAL);
verifyNoInteractions(syncChangeRepository);
}
@Test
void runSync_SteamFails_KeepsTheOldResult() {
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, SyncTrigger.MANUAL);
verifyNoInteractions(syncChangeRepository);
}
}