diff --git a/readme.md b/readme.md index 6755efb..b0ef529 100644 --- a/readme.md +++ b/readme.md @@ -52,7 +52,8 @@ To Be Played is split into three repositories: 40-hour weeks, oldest unplayed game); most played and most loyal game (most months with playtime); top IGDB genres and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the status history); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the - first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games. + first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view + `listed_user_game`, V24). - **Status history**: a database trigger logs every status change of a library game in `user_game_status_change` (old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older changes are not known. @@ -172,9 +173,9 @@ flowchart LR Igdb -->|"OAuth token"| Twitch["Twitch"] Speedrun --> SpeedrunApi["speedrun.com API"] LibRepo --> Db[("PostgreSQL tbp_core")] - Stats --> Db + Stats -->|"listed_user_game, deleted_user"| Db Auth -->|"service token
/internal/accounts"| Account - Account --> Db + Account -->|"per-user rows, deleted_user"| Db ``` | Module | Content | @@ -187,6 +188,13 @@ flowchart LR | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs | +**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in +SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `steam_game` and `deleted_user`; `account` deletes +the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes into +its list); `library` reads `deleted_user`. A module that moves to its own service takes these reads along as calls. +The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view +`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. + Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo` (repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build: diff --git a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java index bcb9f5b..5ec4ab6 100644 --- a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java +++ b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java @@ -22,8 +22,7 @@ public class BacklogSnapshotRepository { count(*) filter (where ug.playtime_minutes = 0) as never_played, coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes, count(*) as listed_games - from user_game ug left join game_time_to_beat t on t.appid = ug.appid - where ug.removed_at is null and ug.hidden_at is null + from listed_user_game ug left join game_time_to_beat t on t.appid = ug.appid """; private final NamedParameterJdbcTemplate jdbc; @@ -37,7 +36,7 @@ public class BacklogSnapshotRepository { return jdbc.update(""" insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games) select ug.user_id, :day, %s - and ug.user_id not in (select user_id from deleted_user) + where ug.user_id not in (select user_id from deleted_user) group by ug.user_id on conflict (user_id, day) do update set backlog_games = excluded.backlog_games, never_played = excluded.never_played, backlog_minutes = excluded.backlog_minutes, @@ -61,7 +60,7 @@ public class BacklogSnapshotRepository { * The pile of now, for the day that has no row yet. */ public UserStats.PileDay live(long userId, LocalDate day) { - return jdbc.queryForObject("select " + PILE_COLUMNS + "and ug.user_id = :userId", + return jdbc.queryForObject("select " + PILE_COLUMNS + "where ug.user_id = :userId", new MapSqlParameterSource("userId", userId), (rs, i) -> new UserStats.PileDay(day, rs.getLong("backlog_games"), rs.getLong("never_played"), rs.getLong("backlog_minutes"))); diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 02ead69..ec79b93 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -39,7 +39,8 @@ public interface UserGameRepository extends JpaRepository */ String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null "; - List findByUserIdAndRemovedAtIsNull(Long userId); + @Query("select ug from UserGame ug where " + LISTED + "and ug.source = core_service.modules.library.domain.GameSource.STEAM") + List findListedSteamGames(@Param("userId") Long userId); List findByUserId(Long userId); @@ -103,8 +104,7 @@ public interface UserGameRepository extends JpaRepository * * @param excludeAppid never picked; {@link #NO_GAME} excludes nothing */ - @Query(value = "select * from user_game where user_id = :userId and removed_at is null and hidden_at is null " + - "and status = 'BACKLOG' " + + @Query(value = "select * from listed_user_game where user_id = :userId and status = 'BACKLOG' " + "and appid <> :excludeAppid " + // Weighted random pick: the smallest -ln(u) / weight wins. "order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " + @@ -136,8 +136,8 @@ public interface UserGameRepository extends JpaRepository return queryPickerGames(userId, maxMinutes, storedMood, speedrun, excluded, count); } - @Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " + - "and ug.hidden_at is null and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " + + @Query(value = "select ug.* from listed_user_game ug where ug.user_id = :userId " + + "and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " + "and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " + " where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " + "and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " + @@ -220,7 +220,7 @@ public interface UserGameRepository extends JpaRepository @Modifying(clearAutomatically = true) @Transactional @Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.FINISHED " + - "where ug.userId = :userId and ug.appid = :appid and ug.removedAt is null and ug.hiddenAt is null " + + "where " + LISTED + "and ug.appid = :appid " + "and ug.statusSetByUser = false and ug.finishedHintDismissed is null " + "and ug.source = core_service.modules.library.domain.GameSource.STEAM " + "and ug.status in (core_service.modules.library.domain.GameStatus.BACKLOG, " + @@ -253,7 +253,7 @@ public interface UserGameRepository extends JpaRepository */ @Modifying @Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.PLAYING " + - "where ug.userId = :userId and ug.appid in :appids and ug.removedAt is null and ug.hiddenAt is null " + + "where " + LISTED + "and ug.appid in :appids " + "and ug.statusSetByUser = false " + "and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG") int playFromPileIfUntouched(@Param("userId") Long userId, @Param("appids") Collection appids); diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java index 830024b..15afea8 100644 --- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java @@ -23,7 +23,7 @@ import java.util.Optional; @RequiredArgsConstructor public class UserStatsRepository { - private static final String LISTED = "ug.user_id = :userId and ug.removed_at is null and ug.hidden_at is null "; + private static final String OF_USER = "ug.user_id = :userId "; private static final String FINISHED = "('FINISHED', 'COMPLETED')"; /** A status change that finished the game: from another status; not a game that came in finished. */ private static final String FINISH_CHANGE = @@ -46,9 +46,9 @@ public class UserStatsRepository { return jdbc.queryForObject(""" select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete, count(t.normally_minutes) as known - from user_game ug join game_time_to_beat t on t.appid = ug.appid + from listed_user_game ug join game_time_to_beat t on t.appid = ug.appid where %s and ug.status = 'BACKLOG' - """.formatted(LISTED), user(userId), + """.formatted(OF_USER), user(userId), (rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known"))); } @@ -62,10 +62,10 @@ public class UserStatsRepository { public Optional oldestUnplayed(long userId) { return jdbc.query(""" select ug.appid, sg.name, sg.image_url, ug.first_seen_at - from user_game ug join steam_game sg on sg.appid = ug.appid + from listed_user_game ug join steam_game sg on sg.appid = ug.appid where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0 order by ug.first_seen_at, ug.appid limit 1 - """.formatted(LISTED), user(userId), + """.formatted(OF_USER), user(userId), (rs, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant())) .stream().findFirst(); @@ -74,10 +74,10 @@ public class UserStatsRepository { public List mostPlayed(long userId, int limit) { return jdbc.query(""" select ug.appid, sg.name, sg.image_url, ug.playtime_minutes - from user_game ug join steam_game sg on sg.appid = ug.appid + from listed_user_game ug join steam_game sg on sg.appid = ug.appid where %s and ug.playtime_minutes > 0 order by ug.playtime_minutes desc, ug.appid limit :limit - """.formatted(LISTED), user(userId).addValue("limit", limit), + """.formatted(OF_USER), user(userId).addValue("limit", limit), (rs, i) -> new UserStats.GameMinutes(rs.getLong("appid"), rs.getString("name"), rs.getString("image_url"), rs.getLong("playtime_minutes"))); } @@ -90,7 +90,7 @@ public class UserStatsRepository { select l.appid, sg.name, sg.image_url, l.months, l.minutes from (select p.appid, count(distinct date_trunc('month', p.day)) as months, sum(p.minutes) as minutes %s group by p.appid) l - join user_game ug on ug.user_id = :userId and ug.appid = l.appid and ug.removed_at is null + join listed_user_game ug on ug.user_id = :userId and ug.appid = l.appid join steam_game sg on sg.appid = l.appid order by l.months desc, l.minutes desc, l.appid limit 1 """.formatted(PLAYTIME_DAYS_NOT_HIDDEN), user(userId), @@ -110,10 +110,10 @@ public class UserStatsRepository { private List tagCounts(long userId, String column, int limit) { return jdbc.query(""" select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes - from user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name) + from listed_user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name) where %s group by tag.name order by minutes desc, games desc, tag.name limit :limit - """.formatted(column, LISTED), user(userId).addValue("limit", limit), + """.formatted(column, OF_USER), user(userId).addValue("limit", limit), (rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"))); } @@ -128,8 +128,8 @@ public class UserStatsRepository { return jdbc.queryForObject(""" select count(*) filter (where ug.playtime_minutes > 0) as started, count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour - from user_game ug where %s - """.formatted(LISTED), user(userId), + from listed_user_game ug where %s + """.formatted(OF_USER), user(userId), (rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour"))); } @@ -141,20 +141,20 @@ public class UserStatsRepository { return jdbc.query(""" select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone) as integer) as year, count(*) as games - from user_game ug left join (%s) f on f.appid = ug.appid + from listed_user_game ug left join (%s) f on f.appid = ug.appid where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null group by year order by year - """.formatted(FIRST_FINISH, LISTED, FINISHED), user(userId).addValue("zone", zone.getId()), + """.formatted(FIRST_FINISH, OF_USER, FINISHED), user(userId).addValue("zone", zone.getId()), (rs, i) -> new UserStats.YearCount(rs.getInt("year"), rs.getLong("games"))); } public long finishedBeforeTracking(long userId) { Long games = jdbc.queryForObject(""" - select count(*) from user_game ug + select count(*) from listed_user_game ug where %s and ug.status in %s and ug.finished_at_estimate is null and not exists (select 1 from user_game_status_change sc where sc.user_id = ug.user_id and sc.appid = ug.appid and %s) - """.formatted(LISTED, FINISHED, FINISH_CHANGE), user(userId), Long.class); + """.formatted(OF_USER, FINISHED, FINISH_CHANGE), user(userId), Long.class); return games != null ? games : 0L; } @@ -170,10 +170,10 @@ public class UserStatsRepository { or sc.from_status is not null or sc.changed_at > uss.history_since)) as added, count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG') as done - from user_game_status_change sc join user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid + from user_game_status_change sc join listed_user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid left join user_steam_sync uss on uss.user_id = sc.user_id where %s and sc.changed_at >= :monthStart - """.formatted(LISTED), user(userId).addValue("monthStart", Timestamp.from(monthStart)), + """.formatted(OF_USER), user(userId).addValue("monthStart", Timestamp.from(monthStart)), (rs, i) -> new UserStats.MonthMoves(rs.getLong("added"), rs.getLong("done"))); } diff --git a/src/main/java/core_service/modules/library/service/AchievementSyncService.java b/src/main/java/core_service/modules/library/service/AchievementSyncService.java index 4547088..190f5fa 100644 --- a/src/main/java/core_service/modules/library/service/AchievementSyncService.java +++ b/src/main/java/core_service/modules/library/service/AchievementSyncService.java @@ -3,7 +3,6 @@ package core_service.modules.library.service; 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.GameSource; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievement; @@ -163,8 +162,7 @@ public class AchievementSyncService { } private void runLibrarySync(Long userId, String steamId) { - List games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream() - .filter(game -> game.getSource() == GameSource.STEAM && game.getHiddenAt() == null) + List games = userGameRepository.findListedSteamGames(userId).stream() .sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed()) .toList(); Map schemas = schemaMetaRepository diff --git a/src/main/java/core_service/modules/stats/repo/StatsRepository.java b/src/main/java/core_service/modules/stats/repo/StatsRepository.java index a616ff5..1cffeea 100644 --- a/src/main/java/core_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/core_service/modules/stats/repo/StatsRepository.java @@ -10,8 +10,7 @@ import java.util.Map; @Repository public class StatsRepository { - private static final String LISTED = - "removed_at IS NULL AND hidden_at IS NULL AND user_id NOT IN (SELECT user_id FROM deleted_user)"; + private static final String NOT_DELETED = "user_id NOT IN (SELECT user_id FROM deleted_user)"; private final JdbcTemplate jdbc; @@ -22,77 +21,75 @@ public class StatsRepository { public long countDistinctGames() { Long v = jdbc.queryForObject( - "SELECT COUNT(DISTINCT appid) FROM user_game WHERE " + LISTED, Long.class); + "SELECT COUNT(DISTINCT appid) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); return v != null ? v : 0L; } public long countLibraryEntries() { Long v = jdbc.queryForObject( - "SELECT COUNT(*) FROM user_game WHERE " + LISTED, Long.class); + "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); return v != null ? v : 0L; } public long countGamesPlayed() { Long v = jdbc.queryForObject( - "SELECT COUNT(*) FROM user_game WHERE " + LISTED + " AND playtime_minutes > 0", Long.class); + "SELECT COUNT(*) FROM listed_user_game WHERE " + NOT_DELETED + " AND playtime_minutes > 0", Long.class); return v != null ? v : 0L; } public long sumPlaytimeMinutes() { Long v = jdbc.queryForObject( - "SELECT COALESCE(SUM(playtime_minutes), 0) FROM user_game WHERE " + LISTED, Long.class); + "SELECT COALESCE(SUM(playtime_minutes), 0) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); return v != null ? v : 0L; } public long countUsersWithLibrary() { Long v = jdbc.queryForObject( - "SELECT COUNT(DISTINCT user_id) FROM user_game WHERE " + LISTED, Long.class); + "SELECT COUNT(DISTINCT user_id) FROM listed_user_game WHERE " + NOT_DELETED, Long.class); return v != null ? v : 0L; } public long biggestLibrary() { Long v = jdbc.queryForObject(""" SELECT COALESCE(MAX(c), 0) FROM ( - SELECT COUNT(*) AS c FROM user_game + SELECT COUNT(*) AS c FROM listed_user_game WHERE %s GROUP BY user_id ) t - """.formatted(LISTED), Long.class); + """.formatted(NOT_DELETED), Long.class); return v != null ? v : 0L; } public long biggestBacklog() { Long v = jdbc.queryForObject(""" SELECT COALESCE(MAX(c), 0) FROM ( - SELECT COUNT(*) AS c FROM user_game + SELECT COUNT(*) AS c FROM listed_user_game WHERE %s AND playtime_minutes = 0 GROUP BY user_id ) t - """.formatted(LISTED), Long.class); + """.formatted(NOT_DELETED), Long.class); return v != null ? v : 0L; } public List> topOwnedGames(int limit) { return jdbc.queryForList(""" SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value - FROM user_game ug + FROM listed_user_game ug LEFT JOIN steam_game sg ON sg.appid = ug.appid - WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL - AND ug.user_id NOT IN (SELECT user_id FROM deleted_user) + WHERE %s GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url ORDER BY value DESC LIMIT ? - """, limit); + """.formatted(NOT_DELETED), limit); } public List> topPlayedGames(int limit) { return jdbc.queryForList(""" SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(ug.playtime_minutes) AS value - FROM user_game ug + FROM listed_user_game ug LEFT JOIN steam_game sg ON sg.appid = ug.appid - WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL - AND ug.user_id NOT IN (SELECT user_id FROM deleted_user) + WHERE %s GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url ORDER BY value DESC LIMIT ? - """, limit); + """.formatted(NOT_DELETED), limit); } } diff --git a/src/main/resources/db/migration/V24__Listed_User_Game.sql b/src/main/resources/db/migration/V24__Listed_User_Game.sql new file mode 100644 index 0000000..61c4d32 --- /dev/null +++ b/src/main/resources/db/migration/V24__Listed_User_Game.sql @@ -0,0 +1,6 @@ +-- The games a user's lists and numbers show: still owned and not hidden. Native SQL reads this view; JPQL uses +-- UserGameRepository.LISTED. Postgres fixes the columns of "select *" at creation and blocks dropping or retyping a +-- column the view uses: a migration that adds, drops or retypes a column of user_game drops this view first and +-- creates it again. +CREATE VIEW listed_user_game AS +SELECT * FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL; diff --git a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java index 61d917a..d328e05 100644 --- a/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountCleanupRepositoryTest.java @@ -118,8 +118,11 @@ class AccountCleanupRepositoryTest extends AbstractPersistenceTest { // A new table with user rows must be in the purge list, or "Delete my account" leaves its rows behind. @Test void tables_CoverEveryTableWithAUserId() { - List withUserId = jdbc.queryForList("select table_name from information_schema.columns " - + "where table_schema = 'public' and column_name = 'user_id' order by table_name", String.class); + // Base tables only: a view like listed_user_game has no rows of its own. + List withUserId = jdbc.queryForList("select c.table_name from information_schema.columns c " + + "join information_schema.tables t on t.table_schema = c.table_schema and t.table_name = c.table_name " + + "where c.table_schema = 'public' and c.column_name = 'user_id' and t.table_type = 'BASE TABLE' " + + "order by c.table_name", String.class); assertThat(AccountCleanupRepository.TABLES).containsExactlyInAnyOrderElementsOf(withUserId); } diff --git a/src/test/java/core_service/modules/library/repo/ListedUserGameViewTest.java b/src/test/java/core_service/modules/library/repo/ListedUserGameViewTest.java new file mode 100644 index 0000000..aa348bb --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/ListedUserGameViewTest.java @@ -0,0 +1,23 @@ +package core_service.modules.library.repo; + +import core_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ListedUserGameViewTest extends AbstractPersistenceTest { + + private static final String COLUMNS = "select column_name from information_schema.columns " + + "where table_schema = 'public' and table_name = ? order by ordinal_position"; + + @Autowired + private JdbcTemplate jdbc; + + @Test + void view_HasEveryColumnOfUserGame() { + assertEquals(jdbc.queryForList(COLUMNS, String.class, "user_game"), + jdbc.queryForList(COLUMNS, String.class, "listed_user_game")); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index bd29a3a..5246004 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -124,6 +124,29 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findBacklogAppids(USER_ID))); } + @Test + void findListedSteamGames_LeavesOutHiddenRemovedAndHandAddedGames() { + userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setHiddenAt(Instant.now()); + userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now()); + steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now())); + userGameRepository.save(UserGame.manual(USER_ID, 999L, "PS5", GameStatus.PLAYING, 60, Instant.now())); + userGameRepository.flush(); + + assertEquals(Set.of(220L, 620L), + Set.copyOf(userGameRepository.findListedSteamGames(USER_ID).stream().map(UserGame::getAppid).toList())); + } + + @Test + void listedUserGameView_LeavesOutHiddenAndRemovedGames() { + userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setHiddenAt(Instant.now()); + userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now()); + userGameRepository.flush(); + + assertEquals(Set.of(220L, 620L), Set.copyOf(entityManager.createNativeQuery( + "select appid from listed_user_game where user_id = :userId", Long.class) + .setParameter("userId", USER_ID).getResultList())); + } + @Test void findAchievementProgress_LeavesOutGamesAddedByHand() { steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now())); diff --git a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java index de5f001..bda1390 100644 --- a/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/AchievementSyncServiceTest.java @@ -96,7 +96,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest { } private void library(List games, List schemas, List metas) { - when(userGameRepository.findByUserIdAndRemovedAtIsNull(USER_ID)).thenReturn(games); + when(userGameRepository.findListedSteamGames(USER_ID)).thenReturn(games); when(schemaMetaRepository.findAllById(any())).thenReturn(schemas); when(userAchievementMetaRepository.findByUserId(USER_ID)).thenReturn(metas); } @@ -117,28 +117,6 @@ class AchievementSyncServiceTest extends AbstractUnitTest { verify(userAchievementMetaRepository, never()).save(any()); } - @Test - void syncLibrary_GameAddedByHand_AsksSteamNothing() { - UserGame manual = UserGame.manual(USER_ID, 620L, "PS5", core_service.modules.library.domain.GameStatus.PLAYING, - 150, Instant.now()); - library(List.of(manual), List.of(schema(620L, 10)), List.of()); - - service.syncLibrary(USER_ID, STEAM_ID); - - verifyNoInteractions(steamApiService); - } - - @Test - void syncLibrary_HiddenGame_AsksSteamNothing() { - UserGame hidden = game(620L, 120); - hidden.setHiddenAt(Instant.now()); - library(List.of(hidden), List.of(), List.of()); - - service.syncLibrary(USER_ID, STEAM_ID); - - verifyNoInteractions(steamApiService); - } - @Test void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() { library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120))); @@ -358,7 +336,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest { @Test void syncLibrary_CallWhileRunning_RunsOnceMoreAfterwards() throws Exception { - when(userGameRepository.findByUserIdAndRemovedAtIsNull(USER_ID)).thenReturn(List.of(game(620L, 60))); + when(userGameRepository.findListedSteamGames(USER_ID)).thenReturn(List.of(game(620L, 60))); when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10))); // The second run sees the row the first run stored. when(userAchievementMetaRepository.findByUserId(USER_ID)) @@ -378,7 +356,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest { first.join(5_000); verify(steamApiService, times(1)).getPlayerAchievements(STEAM_ID, 620L); - verify(userGameRepository, times(2)).findByUserIdAndRemovedAtIsNull(USER_ID); + verify(userGameRepository, times(2)).findListedSteamGames(USER_ID); } @Test @@ -399,8 +377,8 @@ class AchievementSyncServiceTest extends AbstractUnitTest { AchievementSyncService paced = new AchievementSyncService(userGameRepository, schemaRepository, schemaMetaRepository, userAchievementRepository, userAchievementMetaRepository, steamApiService, mock(PlatformTransactionManager.class), 30, pauseMillis); - when(userGameRepository.findByUserIdAndRemovedAtIsNull(1L)).thenReturn(List.of(new UserGame(1L, 620L, 60, Instant.now(), Instant.now()))); - when(userGameRepository.findByUserIdAndRemovedAtIsNull(2L)).thenReturn(List.of(new UserGame(2L, 620L, 60, Instant.now(), Instant.now()))); + 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()))); when(schemaMetaRepository.findAllById(any())).thenReturn(List.of(schema(620L, 10))); when(userAchievementMetaRepository.findByUserId(anyLong())).thenReturn(List.of()); List steamCallsAtNanos = Collections.synchronizedList(new ArrayList<>());