refactor(library): listed_user_game view and one shared schema (V24)

This commit is contained in:
lobo committed 2026-09-30 09:37:16 +02:00
1 parent b499e79c41
commit 9a09d535fc
11 files changed
+118 -83

No files matched your search

@@ -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")));
@@ -39,7 +39,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/
String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null ";
List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId);
@Query("select ug from UserGame ug where " + LISTED + "and ug.source = core_service.modules.library.domain.GameSource.STEAM")
List<UserGame> findListedSteamGames(@Param("userId") Long userId);
List<UserGame> findByUserId(Long userId);
@@ -103,8 +104,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*
* @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<UserGame, UserGameId>
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<UserGame, UserGameId>
@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<UserGame, UserGameId>
*/
@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<Long> appids);
@@ -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<UserStats.OldestGame> 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<UserStats.GameMinutes> 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<UserStats.TagCount> 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")));
}
@@ -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<UserGame> games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream()
.filter(game -> game.getSource() == GameSource.STEAM && game.getHiddenAt() == null)
List<UserGame> games = userGameRepository.findListedSteamGames(userId).stream()
.sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed())
.toList();
Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository
@@ -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<Map<String, Object>> 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<Map<String, Object>> 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);
}
}
@@ -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;
@@ -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<String> 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<String> 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);
}
@@ -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"));
}
}
@@ -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()));
@@ -96,7 +96,7 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
}
private void library(List<UserGame> games, List<GameAchievementSchemaMeta> schemas, List<UserGameAchievementMeta> 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<Long> steamCallsAtNanos = Collections.synchronizedList(new ArrayList<>());