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

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

No files matched your search

@@ -17,7 +17,7 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
private static final List<String> USER_TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger",
"reward_equipped");
@Autowired
@@ -45,6 +45,8 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest {
jdbc.update("""
insert into user_steam_sync (user_id, steam_id, user_seen_at) values (?, '76561198000000001', now())""",
userId);
jdbc.update("insert into sync_result (user_id, sync_started_at, started_by) values (?, now(), 'AUTO')", userId);
jdbc.update("insert into sync_change (user_id, kind, appid) values (?, 'ADDED', 220)", userId);
jdbc.update("insert into game_merge_undo_user (merge_id, user_id, combined) values (1, ?, false)", userId);
jdbc.update("insert into game_link_request (user_id, appid_a, appid_b) values (?, 220, 620)", userId);
jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", userId);
@@ -1,6 +1,7 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -153,7 +154,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.of(List.of()));
when(steamApiService.getSchemaForGame(anyLong())).thenReturn(Optional.empty());
librarySyncWorker.runSyncNow(USER_ID, "76561198000000001");
librarySyncWorker.runSyncNow(USER_ID, "76561198000000001", SyncTrigger.AUTO);
verify(steamApiService).getSchemaForGame(620L);
}
@@ -0,0 +1,151 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.service.LibrarySyncWorker;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.shared.test.AbstractMockedAdaptersTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Optional;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasSize;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Two real syncs with a mocked Steam. The dev profile authenticates a request without a token as user 1.
*/
@AutoConfigureMockMvc
class SyncChangesFlowTest extends AbstractMockedAdaptersTest {
private static final long USER_ID = 1L;
private static final String STEAM_ID = "76561198000000001";
private static final int GROWN = 910_001;
private static final int UNTOUCHED = 910_002;
private static final int GONE = 910_003;
private static final int HIDDEN = 910_004;
private static final int NEW = 910_005;
@Autowired
private MockMvc mockMvc;
@Autowired
private LibrarySyncWorker worker;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void clean() {
for (String table : List.of("sync_result", "user_game_playtime_day", "user_game", "user_steam_sync")) {
jdbc.update("delete from " + table + " where user_id = ?", USER_ID);
}
jdbc.update("delete from steam_game where appid between 910001 and 910005");
}
private static SteamOwnedGamesResponse.Game owned(int appid, int playtime) {
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
}
private void sync(SyncTrigger trigger, SteamOwnedGamesResponse.Game... games) {
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.empty());
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(games));
worker.runSyncNow(USER_ID, STEAM_ID, trigger);
}
private void firstAndSecondSync() {
sync(SyncTrigger.AUTO, owned(GROWN, 100), owned(UNTOUCHED, 0), owned(GONE, 5), owned(HIDDEN, 10));
jdbc.update("update user_game set hidden_at = now() where user_id = ? and appid = ?", USER_ID, HIDDEN);
sync(SyncTrigger.MANUAL, owned(GROWN, 160), owned(UNTOUCHED, 30), owned(HIDDEN, 40), owned(NEW, 15));
}
@Test
void firstSync_IsAnImportWithoutRows() throws Exception {
sync(SyncTrigger.AUTO, owned(GROWN, 100), owned(UNTOUCHED, 0));
mockMvc.perform(get("/api/library/sync-changes"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.importedGames").value(2))
.andExpect(jsonPath("$.startedBy").value("AUTO"))
.andExpect(jsonPath("$.seen").value(false))
.andExpect(jsonPath("$.changes", hasSize(0)));
}
@Test
void secondSync_ListsWhatChanged_AndLeavesOutAHiddenGame() throws Exception {
firstAndSecondSync();
mockMvc.perform(get("/api/library/sync-changes"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.startedBy").value("MANUAL"))
.andExpect(jsonPath("$.importedGames").value(0))
.andExpect(jsonPath("$.changes[?(@.kind=='ADDED')].appid").value(containsInAnyOrder(NEW)))
.andExpect(jsonPath("$.changes[?(@.kind=='REMOVED')].appid").value(containsInAnyOrder(GONE)))
.andExpect(jsonPath("$.changes[?(@.kind=='REMOVED')].name").value(containsInAnyOrder("Game " + GONE)))
.andExpect(jsonPath("$.changes[?(@.kind=='PLAYTIME')].appid").value(containsInAnyOrder(GROWN, UNTOUCHED)))
.andExpect(jsonPath("$.changes[?(@.kind=='PLAYTIME' && @.appid==" + GROWN + ")].amount")
.value(containsInAnyOrder(60)))
.andExpect(jsonPath("$.changes[?(@.kind=='STATUS')].appid").value(containsInAnyOrder(UNTOUCHED)))
.andExpect(jsonPath("$.changes[?(@.kind=='STATUS')].fromStatus").value(containsInAnyOrder("BACKLOG")))
.andExpect(jsonPath("$.changes[?(@.kind=='STATUS')].toStatus").value(containsInAnyOrder("PLAYING")))
.andExpect(jsonPath("$.changes[?(@.appid==" + HIDDEN + ")]", hasSize(0)));
}
@Test
void markSeen_SetsSeen_AndAStaleTimestampChangesNothing() throws Exception {
firstAndSecondSync();
String syncedAt = jdbc.queryForObject("select to_char(sync_started_at at time zone 'UTC', "
+ "'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') from sync_result where user_id = ?", String.class, USER_ID);
mockMvc.perform(put("/api/library/sync-changes/seen").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"syncedAt\":\"2020-01-01T00:00:00Z\"}"))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/sync-changes")).andExpect(jsonPath("$.seen").value(false));
mockMvc.perform(put("/api/library/sync-changes/seen").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"syncedAt\":\"" + syncedAt + "\"}"))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/library/sync-changes")).andExpect(jsonPath("$.seen").value(true));
}
@Test
void syncWithoutChanges_KeepsTheUnseenResult() throws Exception {
firstAndSecondSync();
sync(SyncTrigger.AUTO, owned(GROWN, 160), owned(UNTOUCHED, 30), owned(HIDDEN, 40), owned(NEW, 15));
mockMvc.perform(get("/api/library/sync-changes"))
.andExpect(jsonPath("$.startedBy").value("MANUAL"))
.andExpect(jsonPath("$.changes[?(@.kind=='ADDED')].appid").value(containsInAnyOrder(NEW)));
}
@Test
void userWithoutAResult_GetsNoContent() throws Exception {
firstAndSecondSync();
mockMvc.perform(get("/api/library/sync-changes")
.header("Authorization", "Bearer " + ContractTokens.loginToken(77L)))
.andExpect(status().isNoContent());
}
@Test
void noSyncYet_GetsNoContent() throws Exception {
mockMvc.perform(get("/api/library/sync-changes")).andExpect(status().isNoContent());
}
}
@@ -0,0 +1,261 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.SyncChangeKind;
import core_service.modules.library.domain.SyncChanges;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(SyncChangeRepository.class)
class SyncChangeRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final long OTHER_USER = 2L;
private static final long HALF_LIFE = 220L;
private static final long PORTAL = 620L;
private static final long HIDDEN = 700L;
private static final long REMOVED = 800L;
private static final Instant FIRST_RUN = Instant.now().minusSeconds(3600).truncatedTo(ChronoUnit.MICROS);
private static final Instant SECOND_RUN = FIRST_RUN.plusSeconds(60);
@Autowired
private SyncChangeRepository repository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
steamGameRepository.saveAllAndFlush(List.of(
new SteamGame(HALF_LIFE, "Half-Life 2", null, Instant.now()),
new SteamGame(PORTAL, "Portal 2", null, Instant.now()),
new SteamGame(HIDDEN, "Playtest", null, Instant.now()),
new SteamGame(REMOVED, "Refunded", null, Instant.now())));
UserGame hidden = new UserGame(USER_ID, HIDDEN, 0, Instant.now(), Instant.now());
hidden.setHiddenAt(Instant.now());
UserGame removed = new UserGame(USER_ID, REMOVED, 0, Instant.now(), Instant.now());
removed.setRemovedAt(Instant.now());
userGameRepository.saveAllAndFlush(List.of(
new UserGame(USER_ID, HALF_LIFE, 0, Instant.now(), Instant.now()),
new UserGame(USER_ID, PORTAL, 0, Instant.now(), Instant.now()),
hidden, removed));
}
private void claim(long userId, Instant startedAt) {
jdbc.update("""
insert into user_steam_sync (user_id, steam_id, sync_started_at) values (?, '76561198000000001', ?)
on conflict (user_id) do update set sync_started_at = excluded.sync_started_at""",
userId, Timestamp.from(startedAt));
}
private int rows() {
return jdbc.queryForObject("select count(*) from sync_change where user_id = ?", Integer.class, USER_ID);
}
private void markSeenNow() {
jdbc.update("update sync_result set seen_at = now() where user_id = ?", USER_ID);
}
private SyncChanges stored() {
return repository.find(USER_ID).orElseThrow();
}
@Test
void replace_DropsTheOldRowsAndResetsSeenAt() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
markSeenNow();
claim(USER_ID, SECOND_RUN);
boolean written = repository.replace(USER_ID, SECOND_RUN, SyncTrigger.MANUAL, 0,
List.of(SyncChange.removed(PORTAL)));
assertThat(written).isTrue();
SyncChanges result = stored();
assertThat(result.syncedAt()).isEqualTo(SECOND_RUN);
assertThat(result.startedBy()).isEqualTo(SyncTrigger.MANUAL);
assertThat(result.seen()).isFalse();
assertThat(result.changes()).extracting(e -> e.change()).containsExactly(SyncChange.removed(PORTAL));
}
@Test
void replace_EmptyResultOverAnUnseenOne_KeepsTheUnseenResultAndItsRows() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
claim(USER_ID, SECOND_RUN);
boolean written = repository.replace(USER_ID, SECOND_RUN, SyncTrigger.MANUAL, 0, List.of());
assertThat(written).isFalse();
SyncChanges result = stored();
assertThat(result.syncedAt()).isEqualTo(FIRST_RUN);
assertThat(result.changes()).extracting(e -> e.change()).containsExactly(SyncChange.added(HALF_LIFE));
}
@Test
void replace_EmptyResultOverASeenOne_WritesAnEmptyUnseenResult() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
markSeenNow();
claim(USER_ID, SECOND_RUN);
boolean written = repository.replace(USER_ID, SECOND_RUN, SyncTrigger.MANUAL, 0, List.of());
assertThat(written).isTrue();
SyncChanges result = stored();
assertThat(result.syncedAt()).isEqualTo(SECOND_RUN);
assertThat(result.seen()).isFalse();
assertThat(result.changes()).isEmpty();
}
@Test
void emptyManualSyncReplacesAnUnseenEmptyAutoResult() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of());
claim(USER_ID, SECOND_RUN);
repository.replace(USER_ID, SECOND_RUN, SyncTrigger.MANUAL, 0, List.of());
SyncChanges result = stored();
assertThat(result.syncedAt()).isEqualTo(SECOND_RUN);
assertThat(result.startedBy()).isEqualTo(SyncTrigger.MANUAL);
}
@Test
void replace_NoResultYet_WritesAnEmptyUnseenResult() {
claim(USER_ID, FIRST_RUN);
assertThat(repository.replace(USER_ID, FIRST_RUN, SyncTrigger.MANUAL, 0, List.of())).isTrue();
assertThat(stored().seen()).isFalse();
}
@Test
void replace_ImportOverAnUnseenResult_ReplacesIt() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
claim(USER_ID, SECOND_RUN);
assertThat(repository.replace(USER_ID, SECOND_RUN, SyncTrigger.AUTO, 5, List.of())).isTrue();
SyncChanges result = stored();
assertThat(result.importedGames()).isEqualTo(5);
assertThat(result.changes()).isEmpty();
}
@Test
void replace_NonEmptyResultOverAnUnseenOne_ReplacesIt() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
claim(USER_ID, SECOND_RUN);
assertThat(repository.replace(USER_ID, SECOND_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.removed(PORTAL)))).isTrue();
assertThat(stored().changes()).extracting(e -> e.change()).containsExactly(SyncChange.removed(PORTAL));
}
@Test
void replace_AnotherRunHoldsTheClaim_WritesNothing() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(SyncChange.added(HALF_LIFE)));
claim(USER_ID, SECOND_RUN);
boolean written = repository.replace(USER_ID, FIRST_RUN.plusSeconds(1), SyncTrigger.MANUAL, 0,
List.of(SyncChange.removed(PORTAL)));
assertThat(written).isFalse();
assertThat(stored().syncedAt()).isEqualTo(FIRST_RUN);
assertThat(rows()).isEqualTo(1);
}
@Test
void findStatusMovesOfThisTransaction_ReturnsOnlyMovesOfTheSync() {
// Same transaction as the seed: the inserts (from null) are in the log with this now() too.
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", USER_ID, HALF_LIFE);
jdbc.update("update user_game set status = 'DROPPED', status_set_by_user = true where user_id = ? and appid = ?",
USER_ID, PORTAL);
assertThat(repository.findStatusMovesOfThisTransaction(USER_ID))
.containsExactly(SyncChange.status(HALF_LIFE, GameStatus.BACKLOG, GameStatus.PLAYING));
}
@Test
void add_WithoutAResult_WritesNothing() {
repository.add(USER_ID, SyncChange.achievements(HALF_LIFE, 2));
assertThat(rows()).isZero();
}
@Test
void add_WithAResult_AppendsTheRow() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of());
repository.add(USER_ID, SyncChange.status(HALF_LIFE, GameStatus.PLAYING, GameStatus.FINISHED));
assertThat(stored().changes()).extracting(e -> e.change())
.containsExactly(SyncChange.status(HALF_LIFE, GameStatus.PLAYING, GameStatus.FINISHED));
}
@Test
void find_HidesAHiddenGameKeepsARemovedOneAndSumsTheUnlocks() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of(
SyncChange.removed(REMOVED), SyncChange.added(HIDDEN), SyncChange.playtime(PORTAL, 30)));
repository.add(USER_ID, SyncChange.achievements(HALF_LIFE, 2));
repository.add(USER_ID, SyncChange.achievements(HALF_LIFE, 3));
List<SyncChanges.Entry> entries = stored().changes();
assertThat(entries).extracting(e -> e.change()).containsExactlyInAnyOrder(
SyncChange.removed(REMOVED), SyncChange.playtime(PORTAL, 30), SyncChange.achievements(HALF_LIFE, 5));
assertThat(entries).filteredOn(e -> e.change().kind() == SyncChangeKind.REMOVED)
.extracting(SyncChanges.Entry::name).containsExactly("Refunded");
}
@Test
void find_NoResult_IsEmpty() {
assertThat(repository.find(OTHER_USER)).isEmpty();
}
@Test
void markSeen_ForTheResult_SetsSeen() {
claim(USER_ID, FIRST_RUN);
repository.replace(USER_ID, FIRST_RUN, SyncTrigger.AUTO, 0, List.of());
assertThat(repository.markSeen(USER_ID, FIRST_RUN)).isEqualTo(1);
assertThat(stored().seen()).isTrue();
}
@Test
void markSeen_WithAnOldSyncedAt_ChangesNothing() {
claim(USER_ID, SECOND_RUN);
repository.replace(USER_ID, SECOND_RUN, SyncTrigger.AUTO, 0, List.of());
assertThat(repository.markSeen(USER_ID, FIRST_RUN)).isZero();
assertThat(stored().seen()).isFalse();
}
}
@@ -2,11 +2,14 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.SyncChangeRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameAchievementRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -66,13 +69,15 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
private UserGameAchievementMetaRepository userAchievementMetaRepository;
@Mock
private SteamApiService steamApiService;
@Mock
private SyncChangeRepository syncChangeRepository;
private AchievementSyncService service;
@BeforeEach
void setUp() {
service = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
userAchievementRepository, userAchievementMetaRepository, steamApiService,
userAchievementRepository, userAchievementMetaRepository, steamApiService, syncChangeRepository,
mock(PlatformTransactionManager.class), 30, 0);
}
@@ -375,7 +380,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
void syncLibrary_TwoUsersInParallel_ShareOneSteamBudget() throws Exception {
long pauseMillis = 300;
AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository,
userAchievementRepository, userAchievementMetaRepository, steamApiService,
userAchievementRepository, userAchievementMetaRepository, steamApiService, syncChangeRepository,
mock(PlatformTransactionManager.class), 30, pauseMillis);
when(userGameRepository.findListedSteamGames(1L)).thenReturn(List.of(new UserGame(1L, 620L, 60, Instant.now(), Instant.now())));
when(userGameRepository.findListedSteamGames(2L)).thenReturn(List.of(new UserGame(2L, 620L, 60, Instant.now(), Instant.now())));
@@ -415,4 +420,79 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
verifyNoInteractions(steamApiService);
}
@Test
void syncLibrary_MoreUnlocksThanTheEarlierVisibleRefresh_AddsTheDifference() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D", "E"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository).add(USER_ID, SyncChange.achievements(620L, 2));
}
@Test
void syncLibrary_FirstRefreshOfAGame_AddsNoUnlockRow() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of());
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C", "D", "E"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository, never()).add(anyLong(), any());
}
@Test
void syncLibrary_EarlierRefreshWasHidden_AddsNoUnlockRow() {
UserGameAchievementMeta hidden = new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), false, 0, null);
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(hidden));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository, never()).add(anyLong(), any());
}
@Test
void syncLibrary_SameUnlockCount_AddsNoUnlockRow() {
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 2, 120)));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B"));
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository, never()).add(anyLong(), any());
}
@Test
void syncLibrary_EndingUnlocked_AddsAFinishedMove() {
library(List.of(game(620L, 120)), List.of(schema(620L, 1)), List.of(unlocks(620L, 1, 120)));
endingOf620(true);
when(userGameRepository.finishIfUntouched(USER_ID, 620L)).thenReturn(1);
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository).add(USER_ID, SyncChange.status(620L, GameStatus.PLAYING, GameStatus.FINISHED));
}
@Test
void syncLibrary_EndingAlreadyHandled_AddsNoMove() {
library(List.of(game(620L, 120)), List.of(schema(620L, 1)), List.of(unlocks(620L, 1, 120)));
endingOf620(true);
when(userGameRepository.finishIfUntouched(USER_ID, 620L)).thenReturn(0);
service.syncLibrary(USER_ID, STEAM_ID);
verify(syncChangeRepository, never()).add(anyLong(), any());
}
@Test
void refreshUserIfStale_AddsNoSyncRow() {
when(userAchievementMetaRepository.findByUserIdAndAppid(USER_ID, 620L))
.thenReturn(Optional.of(new UserGameAchievementMeta(USER_ID, 620L, Instant.now().minus(Duration.ofDays(2)), true, 1, 60)));
when(steamApiService.getPlayerAchievements(STEAM_ID, 620L)).thenReturn(visible("A", "B", "C"));
service.refreshUserIfStale(USER_ID, STEAM_ID, 620L, 90, Duration.ofHours(1));
verify(userAchievementMetaRepository).save(any());
verifyNoInteractions(syncChangeRepository);
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
@@ -73,7 +74,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
}
@Test
@@ -82,7 +83,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), anyString());
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
@@ -91,7 +92,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
}
@Test
@@ -100,7 +101,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
}
@Test
@@ -109,7 +110,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), anyString());
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
@@ -118,7 +119,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncIfStale(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), anyString());
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
@@ -130,7 +131,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncAllNightly();
InOrder order = inOrder(worker, backlogSnapshotRepository, finishedEstimateService);
order.verify(worker).runSyncNow(1L, "76561198000000001");
order.verify(worker).runSyncNow(1L, "76561198000000001", SyncTrigger.AUTO);
order.verify(backlogSnapshotRepository).writeAll(today);
order.verify(finishedEstimateService).fillMissing();
}
@@ -149,7 +150,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
void syncAllNightly_ASyncThrows_TheSnapshotIsStillWritten() {
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
new UserSteamSync(1L, "76561198000000001")));
doThrow(new IllegalStateException("db down")).when(worker).runSyncNow(anyLong(), anyString());
doThrow(new IllegalStateException("db down")).when(worker).runSyncNow(anyLong(), anyString(), any());
assertThrows(IllegalStateException.class, () -> syncService.syncAllNightly());
@@ -164,9 +165,9 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
syncService.syncAllNightly();
verify(worker).runSyncNow(1L, "76561198000000001");
verify(worker).runSyncNow(2L, "76561198005679790");
verify(worker, never()).runSync(anyLong(), anyString());
verify(worker).runSyncNow(1L, "76561198000000001", SyncTrigger.AUTO);
verify(worker).runSyncNow(2L, "76561198005679790", SyncTrigger.AUTO);
verify(worker, never()).runSync(anyLong(), anyString(), any());
}
@Test
@@ -186,4 +187,13 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
verify(userSteamSyncRepository, times(1)).touchUserSeen(eq(USER_ID), any());
}
@Test
void forceSync_StartsAManualSync() {
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
syncService.forceSync(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
}
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.modules.library.repo.UserSteamSyncRepository;
@@ -66,7 +67,7 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
new SpeedrunApiService.Game("hl2", "Half-Life 2", 2004, "https://www.speedrun.com/hl2"))));
when(speedrunApiService.hasVerifiedRuns("hl2")).thenReturn(Optional.of(true));
worker.runSyncNow(USER_ID, STEAM_ID);
worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(speedrunApiService).hasVerifiedRuns("hl2");
assertThat(gameSpeedrunRepository.findById(APPID)).isPresent();
@@ -86,12 +87,12 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
return List.of(game("icon"));
});
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID));
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO));
assertThat(steamAsked.await(10, TimeUnit.SECONDS)).isTrue();
assertThat(userSteamSyncRepository.findById(USER_ID).orElseThrow().getLastSyncStatus())
.isEqualTo(UserSteamSync.Status.RUNNING);
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> {
worker.runSyncNow(USER_ID, STEAM_ID);
worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO);
secondDone.countDown();
});
CompletableFuture.allOf(first, second).get(30, TimeUnit.SECONDS);
@@ -107,7 +108,7 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
// img_icon_url is VARCHAR(512).
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(game("x".repeat(600))));
worker.runSyncNow(USER_ID, STEAM_ID);
worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO);
UserSteamSync row = userSteamSyncRepository.findById(USER_ID).orElseThrow();
assertThat(row.getLastSyncStatus()).isEqualTo(UserSteamSync.Status.FAILED);
@@ -134,12 +135,12 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
return List.of(game("icon"));
});
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID));
CompletableFuture<Void> first = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO));
assertThat(firstAsked.await(10, TimeUnit.SECONDS)).isTrue();
// The first run hangs past running-stale-minutes, so the next sync takes over.
jdbcTemplate.update("UPDATE user_steam_sync SET sync_started_at = NOW() - INTERVAL '10 minutes' WHERE user_id = ?",
USER_ID);
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID));
CompletableFuture<Void> second = CompletableFuture.runAsync(() -> worker.runSyncNow(USER_ID, STEAM_ID, SyncTrigger.AUTO));
assertThat(secondAsked.await(10, TimeUnit.SECONDS)).isTrue();
releaseFirst.countDown();
first.get(30, TimeUnit.SECONDS);
@@ -4,12 +4,15 @@ import core_service.modules.library.domain.CorrectionField;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.WishlistGame;
import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.SyncChangeRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
@@ -108,6 +111,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private UserSteamSyncRepository userSteamSyncRepository;
@Mock
private SyncChangeRepository syncChangeRepository;
@Mock
private PlaytimeDayRepository playtimeDayRepository;
@@ -143,7 +149,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(100, 0), null);
when(userSteamSyncRepository.markFinished(eq(USER_ID), any(), eq("OK"), any())).thenReturn(0);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verifyNoInteractions(eventPublisher);
}
@@ -153,7 +159,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
@@ -167,7 +173,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
@@ -180,7 +186,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenThrow(new IllegalStateException("Steam down"));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
@@ -192,7 +198,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
game(0, 0)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).softDeleteMissingSince(eq(USER_ID), any(), any());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
@@ -206,7 +212,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
game(playtime, 0)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
ArgumentCaptor<UserGame> saved = ArgumentCaptor.forClass(UserGame.class);
verify(userGameRepository).save(saved.capture());
@@ -221,7 +227,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(GameSource.STEAM, manual.getSource());
assertNull(manual.getPlatform());
@@ -237,7 +243,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
@@ -251,7 +257,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
game(90, 0)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(GameStatus.DROPPED, existing.getStatus());
assertEquals(90, existing.getPlaytimeMinutes());
@@ -263,7 +269,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(game(600, 120), game(600, 120)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).save(any(UserGame.class));
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120);
@@ -274,7 +280,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(160, 60), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60);
}
@@ -284,7 +290,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
UserGame existing = owned(0);
steamReturns(game(30, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).playFromPileIfUntouched(USER_ID, List.of(220L));
// Never through the entity: a status the user saves during the sync would be overwritten.
@@ -296,7 +302,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(100, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
}
@@ -306,7 +312,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(0);
steamReturns(game(5000, 0), null);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
}
@@ -316,7 +322,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(90, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
@@ -325,7 +331,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_NewGameAfterEarlierSyncs_RecordsItsWholePlaytime() {
steamReturns(game(45, 45), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 45);
}
@@ -334,7 +340,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_NewGameWithOldPlaytime_RecordsOnlyTheLastTwoWeeks() {
steamReturns(game(2378, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 30);
}
@@ -343,7 +349,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_NewGameNotPlayedForTwoWeeks_RecordsNothingAndLeavesThePileAlone() {
steamReturns(game(2378, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any());
@@ -354,7 +360,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(1000, 60), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60);
}
@@ -364,7 +370,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(160, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
verify(userGameRepository).playFromPileIfUntouched(USER_ID, List.of(220L));
@@ -374,7 +380,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_FirstSync_IsOnlyTheBaseline() {
steamReturns(game(5000, 60), null);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
@@ -384,7 +390,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
owned(100);
steamReturns(game(5000, 60), OTHER_STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
@@ -397,8 +403,8 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(), List.of(game(5000, 60)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
verify(userSteamSyncRepository).markHistoryBaseline(USER_ID, STEAM_ID);
@@ -409,7 +415,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(0, 0), STEAM_ID);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userSteamSyncRepository, never()).markHistoryBaseline(anyLong(), any());
}
@@ -419,7 +425,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
UserGame existing = owned(100);
steamReturns(game(160, 75), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(75, existing.getPlaytime2Weeks());
}
@@ -435,7 +441,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getStoreItems(List.of(1257610L))).thenReturn(Map.of(
1257610L, new SteamApiService.StoreItem("Prison Architect 2", "7,49€", "29,99€", 75)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
ArgumentCaptor<List<WishlistGame>> saved = ArgumentCaptor.forClass(List.class);
InOrder order = inOrder(wishlistGameRepository);
@@ -455,7 +461,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(0, 0), STEAM_ID);
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(wishlistGameRepository, never()).deleteByUserId(anyLong());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
@@ -467,7 +473,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.of(List.of(new SteamApiService.WishlistItem(1257610L, null))));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(wishlistGameRepository).saveAll(anyList());
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("FAILED"), any());
@@ -482,7 +488,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(wishlistGameRepository.findForUser(USER_ID)).thenReturn(List.of(
new WishlistGame(USER_ID, 1257610L, "Prison Architect 2", null, "29,99€", null, 0)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
ArgumentCaptor<List<WishlistGame>> saved = ArgumentCaptor.forClass(List.class);
verify(wishlistGameRepository).saveAll(saved.capture());
@@ -495,7 +501,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(0, 0), OTHER_STEAM_ID);
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(wishlistGameRepository).deleteByUserId(USER_ID);
}
@@ -506,7 +512,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(wishlistGameRepository).deleteByUserId(USER_ID);
}
@@ -515,7 +521,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_RefreshesTheTimeToBeatOfTheOwnedGames() {
steamReturns(game(0, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(timeToBeatService).refresh(List.of(220L));
}
@@ -525,7 +531,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(0, 0), STEAM_ID);
when(userGameRepository.findActiveManualAppids(USER_ID)).thenReturn(List.of(-1025L));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(timeToBeatService).refresh(List.of(220L, -1025L));
verify(moodService).refresh(List.of(220L, -1025L));
@@ -539,7 +545,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
hidden.setHiddenAt(hiddenAt);
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(hiddenAt, hidden.getHiddenAt());
assertEquals(90, hidden.getPlaytimeMinutes());
@@ -551,7 +557,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
steamReturns(game(0, 0), STEAM_ID);
when(userGameRepository.findHiddenAppids(USER_ID)).thenReturn(List.of(220L));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(timeToBeatService).refresh(List.of());
verify(moodService).refresh(List.of());
@@ -564,7 +570,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null),
game(90, 0)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(steamGameRepository).save(argThat((SteamGame g) -> g.getAppid() == 2000001L && "App 2000001".equals(g.getName())));
verify(steamGameRepository).save(argThat((SteamGame g) -> g.getAppid() == 220L));
@@ -578,7 +584,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null)));
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(stored.getName()).isEqualTo("El Mucho");
}
@@ -589,7 +595,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(steamGameRepository.findAllById(List.of(220L))).thenReturn(List.of(stored));
steamReturns(game(10, 0), null);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
// The update keeps an auditor's name (SteamGameRepositoryTest); the entity itself is not renamed.
verify(steamGameRepository).setSourceName(220L, "Half-Life 2");
@@ -603,8 +609,103 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(gameCorrectionRepository.findCorrectedAppids(eq(CorrectionField.NAME), any())).thenReturn(Set.of(220L));
steamReturns(game(10, 0), null);
worker.runSync(USER_ID, STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(steamGameRepository, never()).setSourceName(anyLong(), anyString());
}
@SuppressWarnings("unchecked")
private List<SyncChange> replacedChanges(SyncTrigger trigger, int imported) {
ArgumentCaptor<List<SyncChange>> changes = ArgumentCaptor.forClass(List.class);
verify(syncChangeRepository).replace(eq(USER_ID), any(), eq(trigger), eq(imported), changes.capture());
return changes.getValue();
}
private static SteamOwnedGamesResponse.Game gameOf(int appid, int playtime) {
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
}
@Test
void runSync_AfterEarlierSync_RecordsAddedRemovedAndPlaytimeWithTheTrigger() {
UserGame grown = new UserGame(USER_ID, 220L, 100, Instant.EPOCH, Instant.EPOCH);
UserGame gone = new UserGame(USER_ID, 300L, 10, Instant.EPOCH, Instant.EPOCH);
UserGame alreadyRemoved = new UserGame(USER_ID, 400L, 10, Instant.EPOCH, Instant.EPOCH);
alreadyRemoved.setRemovedAt(Instant.EPOCH);
UserGame byHand = UserGame.manual(USER_ID, 500L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(grown, gone, alreadyRemoved, byHand));
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(STEAM_ID, STEAM_ID)));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(gameOf(220, 160), gameOf(600, 5)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
assertThat(replacedChanges(SyncTrigger.MANUAL, 0)).containsExactlyInAnyOrder(
SyncChange.removed(300L), SyncChange.added(600L), SyncChange.playtime(220L, 60));
}
@Test
void runSync_StatusMovesOfTheSave_AreRecorded() {
owned(100);
steamReturns(game(160, 60), STEAM_ID);
when(syncChangeRepository.findStatusMovesOfThisTransaction(USER_ID))
.thenReturn(List.of(SyncChange.status(220L, GameStatus.BACKLOG, GameStatus.PLAYING)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactlyInAnyOrder(
SyncChange.playtime(220L, 60), SyncChange.status(220L, GameStatus.BACKLOG, GameStatus.PLAYING));
}
@Test
void runSync_ReAddedGame_IsAddedAgain() {
UserGame removed = new UserGame(USER_ID, 220L, 100, Instant.EPOCH, Instant.EPOCH);
removed.setRemovedAt(Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(removed));
steamReturns(game(100, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactly(SyncChange.added(220L));
}
@Test
void runSync_FirstSync_StoresOnlyTheCountOfImportedGames() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(gameOf(220, 100), gameOf(300, 0)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 2)).isEmpty();
}
@Test
void runSync_GameAddedByHandIsNowOnSteam_IsNeitherAddedNorPlaytime() {
UserGame manual = UserGame.manual(USER_ID, 220L, "PS5", GameStatus.PLAYING, 60, Instant.EPOCH);
when(userGameRepository.findByUserId(USER_ID)).thenReturn(List.of(manual));
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).isEmpty();
}
@Test
void runSync_SteamAnswersNoGames_KeepsTheOldResult() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
verifyNoInteractions(syncChangeRepository);
}
@Test
void runSync_SteamFails_KeepsTheOldResult() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenThrow(new IllegalStateException("Steam down"));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
verifyNoInteractions(syncChangeRepository);
}
}