diff --git a/readme.md b/readme.md index 9ac33b4..4eb3f95 100644 --- a/readme.md +++ b/readme.md @@ -402,6 +402,12 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` no old playtime, and one game gets at most 24 hours per day. Steam's `rtime_last_played` (unix seconds, 0 = never) is stored in `user_game.last_played_at`; a missing or zero value keeps the old one. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no games, the library stays as it is. + A game the user added by hand for PC, Steam, Mac or Linux (or with no platform), whose IGDB game is the IGDB + game of a Steam appid the user owns for the first time (`game_edition`), becomes that Steam row. The row moves + from minus its IGDB id to the appid and keeps status, rating, notes, priority, status history, backlog + position, questions, set rows, PlayStation links and paid game rewards. Playtime and last played come from + Steam. A hand-added game on another platform stays and joins the Steam game's set. A user with such a game + gets the IGDB edition refresh before the save, so the mapping is known in time. 5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam games and manual games. Hidden games only get their playtime updated. 6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only) diff --git a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java index 1883733..aefb528 100644 --- a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -20,7 +20,8 @@ import java.util.Map; import java.util.Optional; /** - * Merges one library key into another (V21). Plain SQL: a merge rewrites the key of rows in many tables at once. + * Merges one library key into another (V21), and moves one user's row to another key (Steam takeover). + * Plain SQL: a merge rewrites the key of rows in many tables at once. */ @Repository @RequiredArgsConstructor @@ -39,6 +40,10 @@ public class GameMergeRepository { + " and not exists (select 1 from reward_ledger o where o.user_id = l.user_id and o.kind = l.kind" + " and o.ref = cast(:to as text))"; + // A row added by hand for PC, Steam, Mac or Linux (or with no platform) is the Steam copy. + private static final String TAKEOVER_PLATFORM = + "lower(btrim(coalesce(ug.platform, ''))) in ('', 'pc', 'steam', 'mac', 'linux')"; + // What the snapshot saves besides the catalog tables: the link sets of both keys and the pairs and requests of from. private static final String LINK_SET_ROWS = "select * from game_link where set_id in " + "(select set_id from game_link where appid in (:from, :to))"; @@ -366,4 +371,75 @@ public class GameMergeRepository { return jdbc.update("delete from game_merge_undo where created_at < :cutoff", new MapSqlParameterSource("cutoff", Timestamp.from(cutoff))); } + + public boolean hasSteamTakeoverCandidates(long userId) { + return Boolean.TRUE.equals(jdbc.queryForObject(""" + select exists (select 1 from user_game ug + where ug.user_id = :user and ug.appid < 0 and ug.source = 'MANUAL' and %s) + """.formatted(TAKEOVER_PLATFORM), new MapSqlParameterSource("user", userId), Boolean.class)); + } + + /** + * @return the keys of {@code appids} the user has no row for and whose stored edition has no IGDB game + */ + public List findUnmappedNewlyOwned(long userId, Collection appids) { + if (appids.isEmpty()) { + return List.of(); + } + return jdbc.queryForList(""" + select e.appid from game_edition e + where e.appid in (:appids) and e.igdb_id is null + and not exists (select 1 from user_game t where t.user_id = :user and t.appid = e.appid) + """, new MapSqlParameterSource("user", userId).addValue("appids", appids), Long.class); + } + + /** + * @return a manual key of the user, by the Steam appid that takes its row; only a Steam appid the user has no row for + */ + public Map findSteamTakeovers(long userId, Collection steamAppids) { + if (steamAppids.isEmpty()) { + return Map.of(); + } + Map takeovers = new LinkedHashMap<>(); + jdbc.query(""" + select distinct on (ug.appid) ug.appid as from_appid, e.appid as to_appid + from user_game ug + join game_edition e on e.igdb_id = -ug.appid and e.appid in (:appids) + where ug.user_id = :user and ug.appid < 0 and ug.source = 'MANUAL' and %s + and not exists (select 1 from user_game t where t.user_id = ug.user_id and t.appid = e.appid) + order by ug.appid, e.appid + """.formatted(TAKEOVER_PLATFORM), + new MapSqlParameterSource("user", userId).addValue("appids", steamAppids), + rs -> { takeovers.put(rs.getLong("from_appid"), rs.getLong("to_appid")); }); + return takeovers; + } + + /** + * Moves one user's row to another key. The cascading FKs move status log, backlog order, questions, splits and + * set backups. Needs the steam_game row of {@code to} flushed. + * + * @return false when the user has no row on {@code from}, or already has one on {@code to} + */ + public boolean rekeyUserGame(long userId, long from, long to) { + MapSqlParameterSource params = new MapSqlParameterSource("user", userId).addValue("from", from).addValue("to", to); + if (jdbc.update(""" + update user_game set appid = :to where user_id = :user and appid = :from + and not exists (select 1 from user_game t where t.user_id = :user and t.appid = :to) + """, params) == 0) { + return false; + } + // A day both keys have adds up on the target. + jdbc.update(""" + insert into user_game_playtime_day (user_id, day, appid, minutes) + select user_id, day, :to, minutes from user_game_playtime_day where user_id = :user and appid = :from + on conflict (user_id, day, appid) do update + set minutes = user_game_playtime_day.minutes + excluded.minutes + """, params); + jdbc.update("delete from user_game_playtime_day where user_id = :user and appid = :from", params); + jdbc.update("update user_psn_title set appid = :to where user_id = :user and appid = :from", params); + jdbc.update("update user_psn_trophy_set set appid = :to where user_id = :user and appid = :from", params); + jdbc.update("update reward_ledger l set ref = cast(:to as text) where l.user_id = :user and " + LEDGER_MOVED, + params); + return true; + } } diff --git a/src/main/java/core_service/modules/library/service/GameEditionService.java b/src/main/java/core_service/modules/library/service/GameEditionService.java index db95fd3..9e97c37 100644 --- a/src/main/java/core_service/modules/library/service/GameEditionService.java +++ b/src/main/java/core_service/modules/library/service/GameEditionService.java @@ -45,10 +45,25 @@ public class GameEditionService { this.transaction = new TransactionTemplate(transactionManager); } - public void refresh(Collection appids) { - if (!igdbApiService.isEnabled() || appids.isEmpty()) return; - Set stale = stale(appids); - if (stale.isEmpty()) return; + /** + * @return the keys stored now; linkSameGames ran for them, but a key without a steam_game row is not linked yet + */ + public Set refresh(Collection appids) { + if (!igdbApiService.isEnabled() || appids.isEmpty()) return Set.of(); + return store(stale(appids)); + } + + /** + * Like {@link #refresh}, but for keys whose stored edition has no IGDB game: IGDB may know them by now, and a + * fresh null would block a takeover until the TTL ends. + */ + public Set refreshAgain(Collection appids) { + if (!igdbApiService.isEnabled() || appids.isEmpty()) return Set.of(); + return store(Set.copyOf(appids)); + } + + private Set store(Set stale) { + if (stale.isEmpty()) return Set.of(); IgdbLookup.Answer answer = IgdbLookup.byAppid(stale, igdbApiService::getEditions, igdbApiService::getEditionsByGameIds); @@ -63,6 +78,7 @@ public class GameEditionService { edition == null ? null : edition.sameGameId(), now); } linkSameGames(answer.answered()); + return answer.answered(); } /** diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 139ead1..00dd34f 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -10,6 +10,7 @@ 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.GameMergeRepository; import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SyncChangeRepository; @@ -59,6 +60,7 @@ public class LibrarySyncWorker { private final SteamApiService steamApiService; private final SteamGameRepository steamGameRepository; private final GameCorrectionRepository gameCorrectionRepository; + private final GameMergeRepository gameMergeRepository; private final UserGameRepository userGameRepository; private final UserSteamSyncRepository userSteamSyncRepository; private final SyncChangeRepository syncChangeRepository; @@ -137,6 +139,16 @@ public class LibrarySyncWorker { } // Before the empty check: an account without games can still have a public wishlist. Optional wishlist = hasSteam ? fetchWishlist(steamId, games) : Optional.empty(); + List ownedAppids = games.stream().map(g -> g.appid().longValue()).toList(); + // A game added by hand can be a Steam game this sync owns for the first time. Its mapping (game_edition) + // must be known before the save; the refresh after the save then skips these keys. + Set learned = new HashSet<>(); + if (!games.isEmpty() && gameMergeRepository.hasSteamTakeoverCandidates(userId)) { + // First: a fresh edition without an IGDB game (stored before IGDB knew the app) is asked again. + learned.addAll(gameEditionService.refreshAgain( + gameMergeRepository.findUnmappedNewlyOwned(userId, ownedAppids))); + learned.addAll(gameEditionService.refresh(ownedAppids)); + } PsnSyncStep.Prepared psn = psnSyncStep.run(userId); boolean saved = transaction.execute(tx -> { List wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> { @@ -174,8 +186,12 @@ public class LibrarySyncWorker { if (!saved) { return; } + // Keys stored before their steam_game row existed were not linked by that refresh. + if (!learned.isEmpty()) { + gameEditionService.linkSameGames(learned); + } // Also the games added by hand: here a failed refresh from the add is asked again. - List igdbAppids = new ArrayList<>(games.stream().map(g -> g.appid().longValue()).toList()); + List igdbAppids = new ArrayList<>(ownedAppids); igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId)); igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId))); timeToBeatService.refresh(igdbAppids); @@ -214,6 +230,10 @@ public class LibrarySyncWorker { Set correctedNames = gameCorrectionRepository.findCorrectedAppids(CorrectionField.NAME, appids); Map knownGames = steamGameRepository.findAllById(appids).stream() .collect(Collectors.toMap(SteamGame::getAppid, game -> game)); + for (SteamOwnedGamesResponse.Game g : games) { + upsertGame(g, knownGames.get(g.appid().longValue()), now, correctedNames); + } + takeOverManualGames(userId, appids); Map userGames = userGameRepository.findByUserId(userId).stream() .collect(Collectors.toMap(UserGame::getAppid, game -> game)); List changes = new ArrayList<>(); @@ -226,7 +246,6 @@ public class LibrarySyncWorker { List played = new ArrayList<>(); for (SteamOwnedGamesResponse.Game g : games) { Long appid = g.appid().longValue(); - upsertGame(g, knownGames.get(appid), now, correctedNames); if (upsertUserGame(userId, g, userGames.get(appid), now, playedOn, changes)) { played.add(appid); } @@ -243,6 +262,21 @@ public class LibrarySyncWorker { return changes; } + /** + * A game the user added by hand for a PC platform becomes the Steam row of its appid: the row moves to the + * Steam key here, and upsertUserGame takes it over. Runs before any UserGame is loaded in this transaction, + * so the persistence context holds no row under the old key. + */ + private void takeOverManualGames(Long userId, List appids) { + // user_game refers to steam_game; the JDBC update below does not flush JPA. + steamGameRepository.flush(); + gameMergeRepository.findSteamTakeovers(userId, appids).forEach((from, to) -> { + if (gameMergeRepository.rekeyUserGame(userId, from, to)) { + log.info("Sync user={} moved the game added by hand appid={} to its Steam appid={}", userId, from, to); + } + }); + } + private record Wishlist(List wanted, Map store) {} private Optional fetchWishlist(String steamId, List ownedGames) { @@ -304,6 +338,7 @@ public class LibrarySyncWorker { 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. + // Also a row moved here from minus its IGDB id (takeOverManualGames). boolean takesOverManualGame = existing != null && existing.getSource() != GameSource.STEAM; int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0; int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0; diff --git a/src/test/java/core_service/modules/library/api/SteamTakeoverFlowTest.java b/src/test/java/core_service/modules/library/api/SteamTakeoverFlowTest.java new file mode 100644 index 0000000..0675a50 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/SteamTakeoverFlowTest.java @@ -0,0 +1,331 @@ +package core_service.modules.library.api; + +import core_service.modules.igdb_adapter.service.IgdbApiService; +import core_service.modules.library.domain.SyncTrigger; +import core_service.modules.library.service.LibrarySyncWorker; +import core_service.modules.rewards.service.RewardService; +import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse; +import core_service.shared.test.AbstractMockedAdaptersTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * A game added by hand for PC becomes the Steam row when the sync sees the user own it on Steam. Real syncs with a + * mocked Steam; the first sync of an account is a baseline, so every test syncs once before the game is owned. + */ +class SteamTakeoverFlowTest extends AbstractMockedAdaptersTest { + + private static final long USER = 1L; + private static final long OTHER_USER = 2L; + private static final String STEAM_ID = "76561198000000001"; + private static final long STEAM = 920_001L; + private static final long STEAM_2 = 920_002L; + private static final long FILLER = 920_003L; + private static final long IGDB = 920_101L; + private static final long MANUAL = -IGDB; + private static final long OTHER_IGDB = 920_102L; + + @Autowired + private LibrarySyncWorker worker; + + @Autowired + private RewardService rewardService; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + @AfterEach + void clean() { + for (String table : List.of("user_game_playtime_day", "user_game_split", "user_game_set_backup", + "user_game_question", "backlog_order", "user_game_status_change", "user_game", "reward_ledger", + "sync_result", "user_steam_sync")) { + jdbc.update("delete from " + table + " where user_id in (?, ?)", USER, OTHER_USER); + } + jdbc.update("delete from game_link_pair where appid_a between -920200 and 920200 or appid_b between -920200 and 920200"); + jdbc.update("delete from game_link where appid between -920200 and 920200"); + jdbc.update("delete from game_edition where appid between -920200 and 920200"); + jdbc.update("delete from steam_game where appid between -920200 and 920200"); + } + + private void manual(long userId, String platform) { + jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", MANUAL, "By hand"); + jdbc.update(""" + insert into user_game (user_id, appid, source, platform, status, status_set_by_user, rating, notes, + priority, playtime_minutes) + values (?, ?, 'MANUAL', ?, 'FINISHED', true, 4, 'my notes', 'HIGH', 120) + """, userId, MANUAL, platform); + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED_MANUAL', ?, 10, now())", + userId, String.valueOf(MANUAL)); + } + + private void edition(long appid, long igdbId, long sameGameId) { + jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, ?, ?, now()) " + + "on conflict (appid) do update set igdb_id = excluded.igdb_id, same_game_id = excluded.same_game_id", + appid, igdbId, sameGameId); + } + + private void mapped() { + edition(STEAM, IGDB, IGDB); + edition(MANUAL, IGDB, IGDB); + } + + private static SteamOwnedGamesResponse.Game owned(long appid, int playtime) { + return new SteamOwnedGamesResponse.Game((int) appid, "Game " + appid, playtime, "icon", true, playtime, 1_700_000_000L); + } + + private void sync(SteamOwnedGamesResponse.Game... games) { + when(steamApiService.getWishlist(anyString())).thenReturn(Optional.empty()); + when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(games)); + worker.runSyncNow(USER, STEAM_ID, SyncTrigger.AUTO); + } + + /** The baseline sync, then the one that sees the game for the first time. */ + private void syncUntilOwned(SteamOwnedGamesResponse.Game... games) { + sync(owned(FILLER, 0)); + sync(games); + } + + private List keys(long userId) { + return jdbc.queryForList("select appid from user_game where user_id = ? and appid between -920200 and 920200 and appid <> 920003 " + + "order by appid", Long.class, userId); + } + + private Map row(long userId, long appid) { + return jdbc.queryForMap("select * from user_game where user_id = ? and appid = ?", userId, appid); + } + + private int count(String sql, Object... args) { + return jdbc.queryForObject(sql, Integer.class, args); + } + + private void assertTookOver() { + assertThat(keys(USER)).containsExactly(STEAM); + Map steam = row(USER, STEAM); + assertThat(steam).containsEntry("source", "STEAM").containsEntry("playtime_minutes", 300) + .containsEntry("status", "FINISHED").containsEntry("rating", 4).containsEntry("notes", "my notes") + .containsEntry("priority", "HIGH"); + assertThat(steam.get("platform")).isNull(); + assertThat(steam.get("last_played_at")).isNotNull(); + assertThat(count("select count(*) from user_game_status_change where user_id = ? and appid = ?", USER, STEAM)) + .isPositive(); + assertThat(count("select count(*) from user_game_status_change where user_id = ? and appid = ?", USER, MANUAL)) + .isZero(); + assertThat(count("select count(*) from user_game_playtime_day where user_id = ? and appid = ?", USER, STEAM)) + .isZero(); + assertThat(jdbc.queryForList("select ref from reward_ledger where user_id = ? and kind = 'FINISHED_MANUAL'", + String.class, USER)).containsExactly(String.valueOf(STEAM)); + } + + @Test + void runSync_GameAddedByHandForPcIsOwnedOnSteam_BecomesTheSteamRow() { + manual(USER, "PC"); + mapped(); + jdbc.update("insert into user_game_question (user_id, appid, question, playtime_minutes) values (?, ?, 'RATE', 120)", + USER, MANUAL); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertTookOver(); + assertThat(count("select count(*) from user_game_question where user_id = ? and appid = ?", USER, STEAM)).isOne(); + } + + @Test + void runSync_BacklogGameAddedByHand_KeepsItsBacklogPosition() { + manual(USER, "PC"); + jdbc.update("update user_game set status = 'BACKLOG', status_set_by_user = false where user_id = ?", USER); + mapped(); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertThat(keys(USER)).containsExactly(STEAM); + assertThat(count("select count(*) from backlog_order where user_id = ? and appid = ?", USER, MANUAL)).isZero(); + } + + @Test + void runSync_MappingLearnedInThisSync_BecomesTheSteamRowAndLinksTheSwitchRow() { + manual(USER, "PC"); + long switchKey = -OTHER_IGDB; + jdbc.update("insert into steam_game (appid, name) values (?, 'Switch copy')", switchKey); + jdbc.update("insert into user_game (user_id, appid, source, platform, status) values (?, ?, 'MANUAL', 'Switch', 'BACKLOG')", + USER, switchKey); + edition(switchKey, OTHER_IGDB, IGDB); + edition(MANUAL, IGDB, IGDB); + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.getEditions(anyCollection())) + .thenReturn(Optional.of(Map.of(STEAM, new IgdbApiService.Edition(IGDB, IGDB)))); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertThat(keys(USER)).containsExactlyInAnyOrder(STEAM, switchKey); + assertThat(count("select count(distinct set_id) from game_link where appid in (?, ?, ?)", STEAM, MANUAL, switchKey)) + .isOne(); + assertThat(count("select count(*) from game_link where appid in (?, ?)", STEAM, switchKey)).isEqualTo(2); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"Steam", "pc", "Mac", "Linux"}) + void runSync_PlatformOfTheSteamCopy_BecomesTheSteamRow(String platform) { + manual(USER, platform); + mapped(); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertTookOver(); + } + + @Test + void runSync_GameAddedByHandForSwitch_AddsTheSteamRowInOneSet() { + manual(USER, "Switch"); + edition(MANUAL, IGDB, IGDB); + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.getEditions(anyCollection())) + .thenReturn(Optional.of(Map.of(STEAM, new IgdbApiService.Edition(IGDB, IGDB)))); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertThat(keys(USER)).containsExactlyInAnyOrder(MANUAL, STEAM); + assertThat(row(USER, MANUAL)).containsEntry("source", "MANUAL"); + assertThat(count("select count(distinct set_id) from game_link where appid in (?, ?)", STEAM, MANUAL)).isOne(); + assertThat(count("select count(*) from counted_user_game where user_id = ? and appid in (?, ?)", USER, STEAM, MANUAL)) + .isOne(); + } + + @Test + void runSync_NoMapping_AddsASecondRowAsToday() { + manual(USER, "PC"); + edition(STEAM, OTHER_IGDB, OTHER_IGDB); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertThat(keys(USER)).containsExactlyInAnyOrder(MANUAL, STEAM); + assertThat(row(USER, MANUAL)).containsEntry("source", "MANUAL").containsEntry("playtime_minutes", 120); + } + + @Test + void runSync_TwiceAfterTakeover_ChangesNothingMore() { + manual(USER, "PC"); + mapped(); + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + int changes = count("select count(*) from user_game_status_change where user_id = ?", USER); + + sync(owned(FILLER, 0), owned(STEAM, 300)); + + assertTookOver(); + assertThat(count("select count(*) from user_game_status_change where user_id = ?", USER)).isEqualTo(changes); + assertThat(count("select count(*) from reward_ledger where user_id = ?", USER)).isOne(); + } + + @Test + void runSync_RemovedManualRow_ComesBackAsTheSteamRow() { + manual(USER, "PC"); + jdbc.update("update user_game set removed_at = now() where user_id = ?", USER); + mapped(); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertThat(keys(USER)).containsExactly(STEAM); + assertThat(row(USER, STEAM).get("removed_at")).isNull(); + assertThat(row(USER, STEAM)).containsEntry("rating", 4); + assertThat(count("select count(*) from sync_change where user_id = ? and kind = 'ADDED' and appid = ?", USER, STEAM)) + .isOne(); + } + + @Test + void runSync_HiddenManualRow_StaysHiddenUnderTheSteamKey() { + manual(USER, "PC"); + jdbc.update("update user_game set hidden_at = now() where user_id = ?", USER); + mapped(); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertThat(keys(USER)).containsExactly(STEAM); + assertThat(row(USER, STEAM).get("hidden_at")).isNotNull(); + } + + @Test + void runSync_UserHasARemovedSteamRow_KeepsBothRows() { + manual(USER, "PC"); + mapped(); + jdbc.update("insert into steam_game (appid, name) values (?, 'Steam copy')", STEAM); + jdbc.update("insert into user_game (user_id, appid, status, removed_at) values (?, ?, 'BACKLOG', now())", USER, STEAM); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertThat(keys(USER)).containsExactlyInAnyOrder(MANUAL, STEAM); + assertThat(row(USER, STEAM).get("removed_at")).isNull(); + assertThat(row(USER, MANUAL)).containsEntry("source", "MANUAL"); + } + + @Test + void runSync_TwoSteamAppsOfOneGame_TheLowestTakesTheRow() { + manual(USER, "PC"); + mapped(); + edition(STEAM_2, IGDB, IGDB); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM_2, 50), owned(STEAM, 300)); + + assertThat(keys(USER)).containsExactlyInAnyOrder(STEAM, STEAM_2); + assertThat(row(USER, STEAM)).containsEntry("rating", 4); + assertThat(row(USER, STEAM_2).get("rating")).isNull(); + } + + @Test + void runSync_OtherUserHasTheSameManualGame_IsUntouched() { + manual(USER, "PC"); + manual(OTHER_USER, "PC"); + mapped(); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertThat(keys(OTHER_USER)).containsExactly(MANUAL); + assertThat(row(OTHER_USER, MANUAL)).containsEntry("source", "MANUAL"); + assertThat(jdbc.queryForList("select ref from reward_ledger where user_id = ?", String.class, OTHER_USER)) + .containsExactly(String.valueOf(MANUAL)); + } + + @Test + void runSync_FinishedByHandBeforeTakeover_PaysNoSteamFinish() { + manual(USER, "PC"); + mapped(); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + rewardService.settleAll(); + + assertThat(jdbc.queryForList("select kind || ':' || ref from reward_ledger where user_id = ? " + + "and kind in ('FINISHED', 'FINISHED_MANUAL', 'FINISHED_EARLY')", String.class, USER)) + .containsExactly("FINISHED_MANUAL:" + STEAM); + } + + @Test + void runSync_FreshEditionWithoutIgdbGame_StillBecomesTheSteamRow() { + manual(USER, "PC"); + edition(MANUAL, IGDB, IGDB); + jdbc.update("insert into steam_game (appid, name) values (?, 'Owned by another user') on conflict do nothing", STEAM); + jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, null, null, now())", + STEAM); + when(igdbApiService.isEnabled()).thenReturn(true); + when(igdbApiService.getEditions(anyCollection())) + .thenReturn(Optional.of(Map.of(STEAM, new IgdbApiService.Edition(IGDB, IGDB)))); + + syncUntilOwned(owned(FILLER, 0), owned(STEAM, 300)); + + assertThat(keys(USER)).containsExactly(STEAM); + } +} diff --git a/src/test/java/core_service/modules/library/repo/GameMergeRepositoryTakeoverTest.java b/src/test/java/core_service/modules/library/repo/GameMergeRepositoryTakeoverTest.java new file mode 100644 index 0000000..ad2e7a1 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/GameMergeRepositoryTakeoverTest.java @@ -0,0 +1,201 @@ +package core_service.modules.library.repo; + +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.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(GameMergeRepository.class) +class GameMergeRepositoryTakeoverTest extends AbstractPersistenceTest { + + private static final long USER = 1L; + private static final long OTHER_USER = 2L; + private static final long STEAM = 100L; + private static final long STEAM_2 = 200L; + private static final long MANUAL = -7L; + + @Autowired + private GameMergeRepository repository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + for (String table : List.of("reward_ledger", "user_psn_trophy_set", "user_psn_title", "user_game_playtime_day", "user_game_split", + "user_game_set_backup", "user_game_question", "backlog_order", "user_game_status_change", "game_link", + "game_edition", "user_game")) { + jdbc.update("delete from " + table); + } + for (long appid : List.of(STEAM, STEAM_2, MANUAL, -8L, -9L, 300L)) { + jdbc.update("insert into steam_game (appid, name) values (?, ?) on conflict do nothing", appid, "Game " + appid); + } + edition(STEAM, 7); + edition(STEAM_2, 7); + edition(MANUAL, 7); + } + + private void edition(long appid, long igdbId) { + jdbc.update("insert into game_edition (appid, igdb_id, same_game_id, checked_at) values (?, ?, ?, now())", + appid, appid < 0 ? -appid : igdbId, igdbId); + } + + private void game(long userId, long appid, String source, String platform) { + jdbc.update("insert into user_game (user_id, appid, status, source, platform) values (?, ?, 'BACKLOG', ?, ?)", + userId, appid, source, platform); + } + + private Map takeovers() { + return repository.findSteamTakeovers(USER, List.of(STEAM, STEAM_2)); + } + + private int count(String sql, Object... args) { + return jdbc.queryForObject(sql, Integer.class, args); + } + + @Test + void findSteamTakeovers_PlatformRule() { + for (String platform : new String[]{"PC", "pc", "Steam", " ", null, "Mac", "Linux"}) { + jdbc.update("delete from user_game"); + game(USER, MANUAL, "MANUAL", platform); + assertThat(takeovers()).as("platform %s", platform).containsEntry(MANUAL, STEAM); + assertThat(repository.hasSteamTakeoverCandidates(USER)).isTrue(); + } + for (String platform : new String[]{"Switch", "PS5"}) { + jdbc.update("delete from user_game"); + game(USER, MANUAL, "MANUAL", platform); + assertThat(takeovers()).as("platform %s", platform).isEmpty(); + assertThat(repository.hasSteamTakeoverCandidates(USER)).isFalse(); + } + } + + @Test + void findSteamTakeovers_SkipsNonManualPositiveKeysAndKeysTheUserHas() { + game(USER, MANUAL, "PSN", "PC"); + game(USER, 300, "MANUAL", "PC"); + assertThat(takeovers()).isEmpty(); + + jdbc.update("delete from user_game"); + game(USER, MANUAL, "MANUAL", "PC"); + game(USER, STEAM, "STEAM", null); + game(USER, STEAM_2, "STEAM", null); + for (String state : new String[]{"removed_at = now()", "hidden_at = now()", "status = status"}) { + jdbc.update("update user_game set " + state + " where user_id = ? and appid in (?, ?)", USER, STEAM, STEAM_2); + assertThat(takeovers()).as(state).isEmpty(); + } + } + + @Test + void findSteamTakeovers_TwoSteamAppsOfOneGame_TakesTheLowest() { + game(USER, MANUAL, "MANUAL", "PC"); + + assertThat(takeovers()).containsExactly(Map.entry(MANUAL, STEAM)); + } + + @Test + void findSteamTakeovers_EmptyAppids_ReturnsEmpty() { + game(USER, MANUAL, "MANUAL", "PC"); + + assertThat(repository.findSteamTakeovers(USER, List.of())).isEmpty(); + } + + @Test + void rekeyUserGame_MovesRowAndChildren() { + game(USER, MANUAL, "MANUAL", "PC"); + jdbc.update("update user_game set status = 'PLAYING' where user_id = ?", USER); + jdbc.update("insert into user_game_question (user_id, appid, question, playtime_minutes) values (?, ?, 'RATE', 1)", + USER, MANUAL); + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 5)", + USER, MANUAL); + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED_MANUAL', ?, 10, now())", + USER, String.valueOf(MANUAL)); + + assertThat(repository.rekeyUserGame(USER, MANUAL, STEAM)).isTrue(); + + assertThat(count("select count(*) from user_game where user_id = ? and appid = ?", USER, MANUAL)).isZero(); + assertThat(count("select count(*) from user_game where user_id = ? and appid = ?", USER, STEAM)).isOne(); + assertThat(count("select count(*) from user_game_status_change where user_id = ? and appid = ?", USER, STEAM)) + .isEqualTo(2); + assertThat(count("select count(*) from user_game_question where appid = ?", STEAM)).isOne(); + assertThat(count("select count(*) from user_game_playtime_day where appid = ?", STEAM)).isOne(); + assertThat(jdbc.queryForObject("select ref from reward_ledger where user_id = ?", String.class, USER)) + .isEqualTo("100"); + } + + @Test + void rekeyUserGame_DayBothKeysHave_AddsUpOnTheTarget() { + game(USER, MANUAL, "MANUAL", "PC"); + for (long appid : new long[]{MANUAL, STEAM}) { + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 5)", + USER, appid); + } + + repository.rekeyUserGame(USER, MANUAL, STEAM); + + assertThat(jdbc.queryForList("select appid || ':' || minutes from user_game_playtime_day", String.class)) + .containsExactly("100:10"); + } + + @Test + void rekeyUserGame_KindHeldOnTarget_StaysOnOldRef() { + game(USER, MANUAL, "MANUAL", "PC"); + for (long ref : new long[]{MANUAL, STEAM}) { + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED_MANUAL', ?, 10, now())", + USER, String.valueOf(ref)); + } + + assertThat(repository.rekeyUserGame(USER, MANUAL, STEAM)).isTrue(); + + assertThat(jdbc.queryForList("select ref from reward_ledger where user_id = ?", String.class, USER)) + .containsExactlyInAnyOrder("-7", "100"); + } + + @Test + void rekeyUserGame_TargetRowExists_ReturnsFalseAndChangesNothing() { + game(USER, MANUAL, "MANUAL", "PC"); + game(USER, STEAM, "STEAM", null); + + assertThat(repository.rekeyUserGame(USER, MANUAL, STEAM)).isFalse(); + + assertThat(count("select count(*) from user_game where user_id = ? and appid = ?", USER, MANUAL)).isOne(); + } + + @Test + void rekeyUserGame_OtherUser_IsUntouched() { + game(USER, MANUAL, "MANUAL", "PC"); + game(OTHER_USER, MANUAL, "MANUAL", "PC"); + jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED_MANUAL', ?, 10, now())", + OTHER_USER, String.valueOf(MANUAL)); + + repository.rekeyUserGame(USER, MANUAL, STEAM); + + assertThat(count("select count(*) from user_game where user_id = ? and appid = ?", OTHER_USER, MANUAL)).isOne(); + assertThat(jdbc.queryForObject("select ref from reward_ledger where user_id = ?", String.class, OTHER_USER)) + .isEqualTo("-7"); + } + + @Test + void rekeyUserGame_MovesTrophySets() { + game(USER, MANUAL, "MANUAL", "PC"); + jdbc.update("delete from psn_trophy_set where np_communication_id = 'NPWR00001_00'"); + jdbc.update(""" + insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, + defined_silver, defined_gold, defined_platinum) + values ('NPWR00001_00', 'trophy2', 'PS5', 'Set', 1, 0, 0, 0)"""); + jdbc.update(""" + insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) + values (?, 'NPWR00001_00', ?, 1, false)""", USER, MANUAL); + + assertThat(repository.rekeyUserGame(USER, MANUAL, STEAM)).isTrue(); + + assertThat(jdbc.queryForObject("select appid from user_psn_trophy_set where user_id = ?", Long.class, USER)) + .isEqualTo(STEAM); + } +} diff --git a/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java b/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java index 302eca0..218e975 100644 --- a/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java +++ b/src/test/java/core_service/modules/library/service/GameEditionServiceTest.java @@ -20,6 +20,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyCollection; @@ -63,8 +64,9 @@ class GameEditionServiceTest extends AbstractUnitTest { when(igdbApiService.getEditions(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of( 620L, new IgdbApiService.Edition(72, 10)))); - service.refresh(List.of(220L, 620L, 700L)); + Set stored = service.refresh(List.of(220L, 620L, 700L)); + assertThat(stored).containsExactlyInAnyOrder(620L, 700L); verify(gameEditionRepository).upsert(eq(620L), eq(72L), eq(10L), any(Instant.class)); verify(gameEditionRepository).upsert(eq(700L), isNull(), isNull(), any(Instant.class)); verify(gameEditionRepository, never()).upsert(eq(220L), any(), any(), any()); @@ -97,8 +99,9 @@ class GameEditionServiceTest extends AbstractUnitTest { void refresh_IgdbOff_DoesNothing() { when(igdbApiService.isEnabled()).thenReturn(false); - service.refresh(List.of(620L)); + Set stored = service.refresh(List.of(620L)); + assertThat(stored).isEmpty(); verifyNoInteractions(gameEditionRepository, gameLinkRepository); } diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index e03d78e..fa3366d 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -10,6 +10,7 @@ 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.GameMergeRepository; import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SyncChangeRepository; @@ -107,6 +108,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { @Mock private GameCorrectionRepository gameCorrectionRepository; + @Mock + private GameMergeRepository gameMergeRepository; + @Mock private UserGameRepository userGameRepository; @@ -297,6 +301,45 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120); } + @Test + void runSync_ManualCandidate_RefreshesEditionsBeforeTheSaveAndLinksAfter() { + steamReturns(game(100, 0), STEAM_ID); + when(gameMergeRepository.hasSteamTakeoverCandidates(USER_ID)).thenReturn(true); + when(gameEditionService.refresh(List.of(220L))).thenReturn(Set.of(220L)); + + worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO); + + InOrder order = inOrder(gameEditionService, gameMergeRepository); + order.verify(gameEditionService).refresh(List.of(220L)); + order.verify(gameMergeRepository).findSteamTakeovers(eq(USER_ID), any()); + order.verify(gameEditionService).linkSameGames(Set.of(220L)); + } + + @Test + void runSync_NoManualCandidate_DoesNotRefreshBeforeTheSave() { + steamReturns(game(100, 0), STEAM_ID); + + worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO); + + verify(gameEditionService).refresh(anyList()); + verify(gameEditionService, never()).linkSameGames(any()); + } + + @Test + void runSync_Takeover_MovesTheRowBeforeLoadingTheUserGames() { + steamReturns(game(100, 0), STEAM_ID); + when(gameMergeRepository.findSteamTakeovers(eq(USER_ID), any())).thenReturn(Map.of(-119388L, 220L)); + when(gameMergeRepository.rekeyUserGame(USER_ID, -119388L, 220L)).thenReturn(true); + + worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO); + + InOrder order = inOrder(steamGameRepository, gameMergeRepository, userGameRepository); + order.verify(steamGameRepository).flush(); + order.verify(gameMergeRepository).findSteamTakeovers(eq(USER_ID), any()); + order.verify(gameMergeRepository).rekeyUserGame(USER_ID, -119388L, 220L); + order.verify(userGameRepository).findByUserId(USER_ID); + } + private static SteamOwnedGamesResponse.Game gameLastPlayed(Long rtimeLastPlayed) { return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 100, "icon", true, 0, rtimeLastPlayed); }