Public Access
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.
This commit is contained in:
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);
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user