From a8e208321fe8956ca4bba49cf12f1fadbf728510 Mon Sep 17 00:00:00 2001 From: lobo Date: Sun, 4 Oct 2026 11:36:09 +0200 Subject: [PATCH] feat(sync): record what each Steam sync changed 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. --- .../repo/AccountCleanupRepository.java | 2 +- .../library/api/LibraryController.java | 40 +++ .../library/api/dto/SyncChangeDto.java | 11 + .../library/api/dto/SyncChangesDto.java | 12 + .../library/api/dto/SyncChangesSeen.java | 7 + .../modules/library/domain/SyncChange.java | 29 ++ .../library/domain/SyncChangeKind.java | 9 + .../modules/library/domain/SyncChanges.java | 15 + .../modules/library/domain/SyncTrigger.java | 6 + .../library/repo/SyncChangeRepository.java | 143 ++++++++++ .../service/AchievementSyncService.java | 25 +- .../library/service/LibrarySyncService.java | 7 +- .../library/service/LibrarySyncWorker.java | 51 +++- .../library/service/SyncChangesService.java | 33 +++ .../db/migration/V30__Sync_Changes.sql | 19 ++ .../repo/AccountCleanupRepositoryTest.java | 4 +- .../LibraryControllerAchievementsTest.java | 3 +- .../library/api/SyncChangesFlowTest.java | 151 ++++++++++ .../repo/SyncChangeRepositoryTest.java | 261 ++++++++++++++++++ .../service/AchievementSyncServiceTest.java | 84 +++++- .../service/LibrarySyncServiceTest.java | 32 ++- .../LibrarySyncWorkerConcurrencyTest.java | 13 +- .../service/LibrarySyncWorkerTest.java | 181 +++++++++--- 23 files changed, 1058 insertions(+), 80 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/dto/SyncChangeDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/SyncChangesDto.java create mode 100644 src/main/java/core_service/modules/library/api/dto/SyncChangesSeen.java create mode 100644 src/main/java/core_service/modules/library/domain/SyncChange.java create mode 100644 src/main/java/core_service/modules/library/domain/SyncChangeKind.java create mode 100644 src/main/java/core_service/modules/library/domain/SyncChanges.java create mode 100644 src/main/java/core_service/modules/library/domain/SyncTrigger.java create mode 100644 src/main/java/core_service/modules/library/repo/SyncChangeRepository.java create mode 100644 src/main/java/core_service/modules/library/service/SyncChangesService.java create mode 100644 src/main/resources/db/migration/V30__Sync_Changes.sql create mode 100644 src/test/java/core_service/modules/library/api/SyncChangesFlowTest.java create mode 100644 src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index 582c6ec..1055157 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -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 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. diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index e0216bc..bc0bf37 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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 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 markSyncChangesSeen(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody SyncChangesSeen seen) { + syncChangesService.markSeen(principal.getIdAsLong(), seen.syncedAt()); + return ResponseEntity.noContent().build(); + } + @GetMapping("/steam-profile") public ResponseEntity getSteamProfile(@AuthenticationPrincipal UserPrincipal principal) { return ResponseEntity.ok(steamProfileService.getProfile(principal.steamId())); diff --git a/src/main/java/core_service/modules/library/api/dto/SyncChangeDto.java b/src/main/java/core_service/modules/library/api/dto/SyncChangeDto.java new file mode 100644 index 0000000..4687447 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/SyncChangeDto.java @@ -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 +) {} diff --git a/src/main/java/core_service/modules/library/api/dto/SyncChangesDto.java b/src/main/java/core_service/modules/library/api/dto/SyncChangesDto.java new file mode 100644 index 0000000..453b390 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/SyncChangesDto.java @@ -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 changes +) {} diff --git a/src/main/java/core_service/modules/library/api/dto/SyncChangesSeen.java b/src/main/java/core_service/modules/library/api/dto/SyncChangesSeen.java new file mode 100644 index 0000000..47aaa53 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/SyncChangesSeen.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/domain/SyncChange.java b/src/main/java/core_service/modules/library/domain/SyncChange.java new file mode 100644 index 0000000..540dad9 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/SyncChange.java @@ -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); + } +} diff --git a/src/main/java/core_service/modules/library/domain/SyncChangeKind.java b/src/main/java/core_service/modules/library/domain/SyncChangeKind.java new file mode 100644 index 0000000..4102da3 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/SyncChangeKind.java @@ -0,0 +1,9 @@ +package core_service.modules.library.domain; + +public enum SyncChangeKind { + ADDED, + REMOVED, + PLAYTIME, + ACHIEVEMENTS, + STATUS +} diff --git a/src/main/java/core_service/modules/library/domain/SyncChanges.java b/src/main/java/core_service/modules/library/domain/SyncChanges.java new file mode 100644 index 0000000..9a6afbe --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/SyncChanges.java @@ -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 changes) { + + public record Entry(SyncChange change, String name, String imageUrl) {} +} diff --git a/src/main/java/core_service/modules/library/domain/SyncTrigger.java b/src/main/java/core_service/modules/library/domain/SyncTrigger.java new file mode 100644 index 0000000..4e461cf --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/SyncTrigger.java @@ -0,0 +1,6 @@ +package core_service.modules.library.domain; + +public enum SyncTrigger { + MANUAL, + AUTO +} diff --git a/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java b/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java new file mode 100644 index 0000000..66b45d6 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/SyncChangeRepository.java @@ -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 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 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 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 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(); + } +} diff --git a/src/main/java/core_service/modules/library/service/AchievementSyncService.java b/src/main/java/core_service/modules/library/service/AchievementSyncService.java index 09bcbbf..6fd0583 100644 --- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java +++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java @@ -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 response = steamApiService.getPlayerAchievements(steamId, appid); boolean visible = response.isPresent() && (response.get().success() == null || response.get().success()); List 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(); } /** diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncService.java b/src/main/java/core_service/modules/library/service/LibrarySyncService.java index d33842d..2c65fee 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncService.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncService.java @@ -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". diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 602cca7..2496036 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -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 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 games, Instant now, - LocalDate playedOn) { + /** + * @return what changed for the user, for the sync result + */ + private List saveGames(Long userId, String steamId, List games, + Instant now, LocalDate playedOn) { List 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 correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME, appids); @@ -166,22 +181,31 @@ public class LibrarySyncWorker { .collect(Collectors.toMap(SteamGame::getAppid, game -> game)); Map userGames = userGameRepository.findByUserId(userId).stream() .collect(Collectors.toMap(UserGame::getAppid, game -> game)); + List changes = new ArrayList<>(); + // The same rule as softDeleteMissingSince. + Set 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 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 wanted, Map 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 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) { diff --git a/src/main/java/core_service/modules/library/service/SyncChangesService.java b/src/main/java/core_service/modules/library/service/SyncChangesService.java new file mode 100644 index 0000000..8199576 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/SyncChangesService.java @@ -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 find(long userId) { + return syncChangeRepository.find(userId); + } + + @Transactional + public void markSeen(long userId, Instant syncedAt) { + syncChangeRepository.markSeen(userId, syncedAt); + } +} diff --git a/src/main/resources/db/migration/V30__Sync_Changes.sql b/src/main/resources/db/migration/V30__Sync_Changes.sql new file mode 100644 index 0000000..4411c11 --- /dev/null +++ b/src/main/resources/db/migration/V30__Sync_Changes.sql @@ -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); diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index c6e3d84..72e3c37 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { private static final List 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); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java index cc66051..acedb58 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerAchievementsTest.java @@ -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); } diff --git a/src/test/java/core_service/modules/library/api/SyncChangesFlowTest.java b/src/test/java/core_service/modules/library/api/SyncChangesFlowTest.java new file mode 100644 index 0000000..e69b8be --- /dev/null +++ b/src/test/java/core_service/modules/library/api/SyncChangesFlowTest.java @@ -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()); + } +} diff --git a/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java b/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java new file mode 100644 index 0000000..6fc1043 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/SyncChangeRepositoryTest.java @@ -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 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(); + } +} diff --git a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java index bda1390..558ea00 100644 --- a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java @@ -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); + } } diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java index 9b6f4f9..29f1d2f 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java @@ -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); + } } diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java index dd1fec9..f0aead2 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerConcurrencyTest.java @@ -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 first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID)); + CompletableFuture 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 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 first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID)); + CompletableFuture 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 second = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID)); + CompletableFuture 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); diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 90e70b3..8a51507 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -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 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> 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> 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 replacedChanges(SyncTrigger trigger, int imported) { + ArgumentCaptor> 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); + } }