feat(library): hide games from lists and stats
Build, Test & Push Core Service / quality-check (push) Successful in 58s
Build, Test & Push Core Service / docker (push) Successful in 1m25s

This commit is contained in:
lobo committed 2026-09-26 12:27:58 +02:00
1 parent 246e255058
commit c2d17d0bc7
27 files changed
+559 -56

No files matched your search

@@ -5,6 +5,7 @@ import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.HiddenUpdateDto;
import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PageDto;
import core_service.modules.library.api.dto.PickerDto;
@@ -88,7 +89,8 @@ public class LibraryController {
@RequestParam(defaultValue = "playtimeMinutes,desc") String sort,
@RequestParam(required = false) GameStatus status,
@RequestParam(required = false) String q,
@RequestParam(required = false) Integer maxMinutes
@RequestParam(required = false) Integer maxMinutes,
@RequestParam(defaultValue = "false") boolean hidden
) {
Long userId = principal.getIdAsLong();
if (principal.steamId() != null) {
@@ -98,7 +100,7 @@ public class LibraryController {
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page);
Sort safeSort = parseSort(sort);
var dtoPage = queryService.listOwnedGames(userId, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, PageRequest.of(safePage, safeSize, safeSort));
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, PageRequest.of(safePage, safeSize, safeSort));
return ResponseEntity.ok(PageDto.of(dtoPage));
}
@@ -162,6 +164,19 @@ public class LibraryController {
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
}
@PutMapping("/games/{appid}/hidden")
public ResponseEntity<?> updateHidden(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@Valid @RequestBody HiddenUpdateDto update) {
return switch (backlogService.setHidden(principal.getIdAsLong(), appid, update.hidden())) {
case DONE -> ResponseEntity.ok(update);
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"));
case MANUAL_GAME -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code", "MANUAL_GAME", "message", "A game added by hand can be deleted, not hidden"));
};
}
@PutMapping("/games/{appid}/status")
public ResponseEntity<?> updateStatus(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@@ -31,7 +31,9 @@ public record GameDetailDto(
String platform,
String imageUrl,
// null until a check finds a speedrun.com game with verified runs
String speedrunUrl
String speedrunUrl,
// see GameDto#hidden
boolean hidden
) {
/**
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.GameStatus;
* @param achievementsUnlocked null when not fetched yet or hidden by Steam
* @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games
* @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header
* @param hidden the user hid the game; it shows only under the library's hidden filter
*/
public record GameDto(
Long appid,
@@ -24,5 +25,6 @@ public record GameDto(
Integer achievementsTotal,
GameSource source,
String platform,
String imageUrl
String imageUrl,
boolean hidden
) {}
@@ -0,0 +1,5 @@
package core_service.modules.library.api.dto;
import jakarta.validation.constraints.NotNull;
public record HiddenUpdateDto(@NotNull Boolean hidden) {}
@@ -48,6 +48,10 @@ public class UserGame {
@Column(name = "removed_at")
private Instant removedAt;
/** Set for a Steam game the user hid; the sync never changes it. */
@Column(name = "hidden_at")
private Instant hiddenAt;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
private GameStatus status;
@@ -23,9 +23,13 @@ public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, Playti
@Param("day") LocalDate day, @Param("minutes") int minutes);
/**
* Only days with playtime; the caller fills the gaps.
* Only days with playtime; the caller fills the gaps. A hidden game's days never count, also those from before
* it was hidden.
*/
@Query("select new core_service.modules.library.domain.DayMinutes(p.day, sum(p.minutes)) from PlaytimeDay p " +
"where p.userId = :userId and p.day >= :from group by p.day order by p.day")
"where p.userId = :userId and p.day >= :from " +
"and not exists (select 1 from UserGame ug where ug.userId = p.userId and ug.appid = p.appid " +
"and ug.hiddenAt is not null) " +
"group by p.day order by p.day")
List<DayMinutes> minutesPerDay(@Param("userId") Long userId, @Param("from") LocalDate from);
}
@@ -29,6 +29,12 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/
long NO_GAME = 0;
/**
* The games a user's lists and numbers show: still owned and not hidden. Needs the alias {@code ug} and the
* parameter {@code :userId}.
*/
String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null ";
List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId);
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.appid in :appids")
@@ -38,24 +44,34 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"and ug.source = core_service.modules.library.domain.GameSource.MANUAL")
List<Long> findActiveManualAppids(@Param("userId") Long userId);
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null " +
@Query("select ug.appid from UserGame ug where " + LISTED +
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
List<Long> findBacklogAppids(@Param("userId") Long userId);
long countByUserIdAndRemovedAtIsNull(Long userId);
@Query("select ug.appid from UserGame ug where " + LISTED + "and ug.appid in :appids")
List<Long> findListedAppids(@Param("userId") Long userId, @Param("appids") Collection<Long> appids);
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where ug.userId = :userId and ug.removedAt is null")
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is not null")
List<Long> findHiddenAppids(@Param("userId") Long userId);
@Query("select count(ug) from UserGame ug where " + LISTED)
long countListed(@Param("userId") Long userId);
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where " + LISTED)
long sumPlaytime(@Param("userId") Long userId);
List<UserGame> findByUserIdAndRemovedAtIsNullAndStatusIn(Long userId, Collection<GameStatus> statuses, Pageable pageable);
@Query("select ug from UserGame ug where " + LISTED + "and ug.status in :statuses")
List<UserGame> findListedByStatusIn(@Param("userId") Long userId, @Param("statuses") Collection<GameStatus> statuses,
Pageable pageable);
List<UserGame> findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(
Long userId, int playtime2Weeks);
@Query("select ug from UserGame ug where " + LISTED + "and ug.playtime2Weeks > 0 order by ug.playtime2Weeks desc")
List<UserGame> findListedPlayedInTwoWeeks(@Param("userId") Long userId, Pageable pageable);
long countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(Long userId, int playtimeMinutes);
@Query("select count(ug) from UserGame ug where " + LISTED + "and ug.playtimeMinutes = 0")
long countListedNeverPlayed(@Param("userId") Long userId);
@Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " +
"where ug.userId = :userId and ug.removedAt is null group by ug.status")
"where " + LISTED + "group by ug.status")
List<StatusCount> countByStatus(@Param("userId") Long userId);
/**
@@ -66,7 +82,8 @@ 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 status = 'BACKLOG' " +
@Query(value = "select * from user_game where user_id = :userId and removed_at is null and hidden_at is null " +
"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 " +
@@ -78,7 +95,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"from UserGame ug " +
"left join GameAchievementSchemaMeta sm on sm.appid = ug.appid " +
"left join UserGameAchievementMeta um on um.userId = ug.userId and um.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " +
"where " + LISTED +
"and ug.source = core_service.modules.library.domain.GameSource.STEAM")
List<AchievementProgress> findAchievementProgress(@Param("userId") Long userId);
@@ -99,7 +116,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
}
@Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " +
"and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
"and ug.hidden_at is null 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 " +
@@ -116,17 +133,20 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
@Param("count") int count);
/**
* @param hidden true for only the hidden games, false for only the listed ones
* @param status null for every status
* @param namePattern a {@link #containsPattern} result, null for every name
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
*/
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " +
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
"and (:status is null or ug.status = :status) " +
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " +
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes))")
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
@Param("hidden") boolean hidden,
@Param("status") GameStatus status,
@Param("namePattern") String namePattern,
@Param("maxMinutes") Integer maxMinutes,
@@ -136,7 +156,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
* Normal time to beat of the user's backlog, only games IGDB has data for.
*/
@Query("select coalesce(sum(t.normallyMinutes), 0) from UserGame ug join GameTimeToBeat t on t.appid = ug.appid " +
"where ug.userId = :userId and ug.removedAt is null " +
"where " + LISTED +
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
long sumBacklogMinutes(@Param("userId") Long userId);
@@ -143,7 +143,7 @@ public class AchievementSyncService {
private void runLibrarySync(Long userId, String steamId) {
List<UserGame> games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream()
.filter(game -> game.getSource() == GameSource.STEAM)
.filter(game -> game.getSource() == GameSource.STEAM && game.getHiddenAt() == null)
.sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed())
.toList();
Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.BacklogEntryDto;
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.UserGameId;
@@ -9,6 +10,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Optional;
@Service
@@ -48,6 +50,28 @@ public class BacklogService {
});
}
public enum HideResult { DONE, NOT_FOUND, MANUAL_GAME }
/**
* Hides only a Steam game; one added by hand can be deleted instead. Unhiding works for every game. Hiding again
* keeps the first time.
*/
@Transactional
public HideResult setHidden(Long userId, Long appid, boolean hidden) {
Optional<UserGame> game = userGameRepository.findById(new UserGameId(userId, appid))
.filter(found -> found.getRemovedAt() == null);
if (game.isEmpty()) return HideResult.NOT_FOUND;
if (!hidden) {
game.get().setHiddenAt(null);
return HideResult.DONE;
}
if (game.get().getSource() != GameSource.STEAM) return HideResult.MANUAL_GAME;
if (game.get().getHiddenAt() == null) {
game.get().setHiddenAt(Instant.now());
}
return HideResult.DONE;
}
private static BacklogEntryDto toDto(UserGame game) {
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
}
@@ -65,8 +65,12 @@ public class GameDetailService {
}
UserGame userGame = userGameOpt.get();
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
// Only backlog games are checked in bulk; any other game gets its link on a later visit.
speedrunService.refreshInBackground(List.of(appid));
// A hidden game gets no calls to Steam, IGDB or speedrun.com; it shows what is stored.
boolean hidden = userGame.getHiddenAt() != null;
if (!hidden) {
// Only backlog games are checked in bulk; any other game gets its link on a later visit.
speedrunService.refreshInBackground(List.of(appid));
}
if (userGame.getSource() == GameSource.MANUAL) {
// Users without a Steam ID never sync, so the time to beat of their games is asked again here.
timeToBeatService.refreshInBackground(List.of(appid));
@@ -74,10 +78,12 @@ public class GameDetailService {
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
}
try {
achievementSyncService.refreshSchemaIfStale(appid);
} catch (Exception e) {
log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage());
if (!hidden) {
try {
achievementSyncService.refreshSchemaIfStale(appid);
} catch (Exception e) {
log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage());
}
}
boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
@@ -85,7 +91,7 @@ public class GameDetailService {
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
}
if (steamId != null) {
if (steamId != null && !hidden) {
try {
achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(),
Duration.ofHours(achievementTtlHours));
@@ -155,7 +161,8 @@ public class GameDetailService {
userGame.getSource(),
userGame.getPlatform(),
meta != null ? meta.getImageUrl() : null,
gameSpeedrunRepository.findById(userGame.getAppid()).map(GameSpeedrun::getWeblink).orElse(null)
gameSpeedrunRepository.findById(userGame.getAppid()).map(GameSpeedrun::getWeblink).orElse(null),
userGame.getHiddenAt() != null
);
}
}
@@ -80,10 +80,10 @@ public class ImageRepairService {
}
/**
* Only for a Steam game the user owns; anything else is dropped.
* Only for a listed Steam game of the user: a hidden game gets no Steam calls. Anything else is dropped.
*/
public void report(Long userId, Long appid, ImageKind kind) {
if (!GameKeys.isSteam(appid) || userGameRepository.findActiveAppids(userId, List.of(appid)).isEmpty()) {
if (!GameKeys.isSteam(appid) || userGameRepository.findListedAppids(userId, List.of(appid)).isEmpty()) {
return;
}
brokenImageRepository.report(appid, kind.name(), Instant.now());
@@ -55,6 +55,7 @@ public class LibraryQueryService {
private static final int PUBLIC_PLAYING = 8;
private static final int PUBLIC_FINISHED = 12;
private static final int CLOSEST_GAMES = 5;
private static final int RECENT_GAMES = 5;
private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository;
@@ -69,15 +70,15 @@ public class LibraryQueryService {
private ZoneId zone;
public long countOwnedGames(Long userId) {
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
return userGameRepository.countListed(userId);
}
public Page<GameDto> listOwnedGames(Long userId, GameStatus status, String nameQuery, Integer maxMinutes,
Pageable pageable) {
public Page<GameDto> listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery,
Integer maxMinutes, Pageable pageable) {
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
UserGameRepository.querySort(pageable.getSort()));
Page<UserGame> page = userGameRepository.findOwnedGames(
userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
List<GameDto> games = toDtos(page.getContent());
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
}
@@ -155,7 +156,7 @@ public class LibraryQueryService {
public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) {
Map<GameStatus, Long> byStatus = countByStatus(userId);
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
long neverPlayed = userGameRepository.countListedNeverPlayed(userId);
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : UserGameRepository.NO_GAME)
.map(ug -> toDtos(List.of(ug)).getFirst())
.orElse(null);
@@ -175,8 +176,7 @@ public class LibraryQueryService {
.toList();
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
List<UserGame> recent = userGameRepository
.findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(userId, 0);
List<UserGame> recent = userGameRepository.findListedPlayedInTwoWeeks(userId, PageRequest.of(0, RECENT_GAMES));
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList())
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
List<RecentGameDto> recentGames = recent.stream()
@@ -193,10 +193,10 @@ public class LibraryQueryService {
public PublicLibraryDto getPublicLibrary(Long userId) {
Map<GameStatus, Long> byStatus = countByStatus(userId);
List<UserGame> playing = userGameRepository.findByUserIdAndRemovedAtIsNullAndStatusIn(userId,
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId,
List.of(GameStatus.PLAYING),
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))));
List<UserGame> finished = userGameRepository.findByUserIdAndRemovedAtIsNullAndStatusIn(userId,
List<UserGame> finished = userGameRepository.findListedByStatusIn(userId,
List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid"))));
Map<Long, SteamGame> meta = steamGameRepository.findAllById(
@@ -254,7 +254,8 @@ public class LibraryQueryService {
achievementsTotal,
ug.getSource(),
ug.getPlatform(),
meta != null ? meta.getImageUrl() : null);
meta != null ? meta.getImageUrl() : null,
ug.getHiddenAt() != null);
}
public Optional<UserSteamSync> getSyncStatus(Long userId) {
@@ -26,6 +26,7 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -122,6 +123,7 @@ public class LibrarySyncWorker {
// Also the games added by hand: here a failed refresh from the add is asked again.
List<Long> igdbAppids = new ArrayList<>(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId));
igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId)));
timeToBeatService.refresh(igdbAppids);
moodService.refresh(igdbAppids);
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
@@ -171,6 +171,8 @@ public class ManualGameService {
revived.setPlaytime2Weeks(0);
revived.setLastSeenAt(now);
revived.setRemovedAt(null);
// Only a Steam game can be hidden.
revived.setHiddenAt(null);
} else {
userGameRepository.save(UserGame.manual(userId, appid, platform, status, playtimeMinutes, now));
}
@@ -19,31 +19,31 @@ public class StatsRepository {
public long countDistinctGames() {
Long v = jdbc.queryForObject(
"SELECT COUNT(DISTINCT appid) FROM user_game WHERE removed_at IS NULL", Long.class);
"SELECT COUNT(DISTINCT appid) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class);
return v != null ? v : 0L;
}
public long countLibraryEntries() {
Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL", Long.class);
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class);
return v != null ? v : 0L;
}
public long countGamesPlayed() {
Long v = jdbc.queryForObject(
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL AND playtime_minutes > 0", Long.class);
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL 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 removed_at IS NULL", Long.class);
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class);
return v != null ? v : 0L;
}
public long countUsersWithLibrary() {
Long v = jdbc.queryForObject(
"SELECT COUNT(DISTINCT user_id) FROM user_game WHERE removed_at IS NULL", Long.class);
"SELECT COUNT(DISTINCT user_id) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class);
return v != null ? v : 0L;
}
@@ -51,7 +51,7 @@ public class StatsRepository {
Long v = jdbc.queryForObject("""
SELECT COALESCE(MAX(c), 0) FROM (
SELECT COUNT(*) AS c FROM user_game
WHERE removed_at IS NULL GROUP BY user_id
WHERE removed_at IS NULL AND hidden_at IS NULL GROUP BY user_id
) t
""", Long.class);
return v != null ? v : 0L;
@@ -61,7 +61,7 @@ public class StatsRepository {
Long v = jdbc.queryForObject("""
SELECT COALESCE(MAX(c), 0) FROM (
SELECT COUNT(*) AS c FROM user_game
WHERE removed_at IS NULL AND playtime_minutes = 0 GROUP BY user_id
WHERE removed_at IS NULL AND hidden_at IS NULL AND playtime_minutes = 0 GROUP BY user_id
) t
""", Long.class);
return v != null ? v : 0L;
@@ -72,7 +72,7 @@ public class StatsRepository {
SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value
FROM user_game ug
LEFT JOIN steam_game sg ON sg.appid = ug.appid
WHERE ug.removed_at IS NULL
WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC
LIMIT ?
@@ -84,7 +84,7 @@ public class StatsRepository {
SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(ug.playtime_minutes) AS value
FROM user_game ug
LEFT JOIN steam_game sg ON sg.appid = ug.appid
WHERE ug.removed_at IS NULL
WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
ORDER BY value DESC
LIMIT ?
@@ -0,0 +1,5 @@
-- A game the user hid (e.g. a playtest or a tool). It stays owned and synced, but leaves every list and number.
ALTER TABLE user_game ADD COLUMN hidden_at TIMESTAMPTZ;
DROP INDEX idx_user_game_user_active;
CREATE INDEX idx_user_game_user_active ON user_game (user_id) WHERE removed_at IS NULL AND hidden_at IS NULL;
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.BrokenImageId;
import core_service.modules.library.domain.ImageKind;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.BrokenImageRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
@@ -71,6 +72,19 @@ class LibraryControllerBrokenImageTest extends AbstractIntegrationTest {
assertThat(brokenImageRepository.count()).isZero();
}
@Test
void reportBrokenImage_ForAHiddenGameStoresNothing() throws Exception {
UserGame hidden = userGameRepository.findById(new UserGameId(USER_ID, APPID)).orElseThrow();
hidden.setHiddenAt(Instant.now());
userGameRepository.save(hidden);
mockMvc.perform(post("/api/library/broken-images").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"appid\":220,\"kind\":\"COVER\"}"))
.andExpect(status().isNoContent());
assertThat(brokenImageRepository.count()).isZero();
}
@ParameterizedTest
@ValueSource(strings = {"{\"appid\":220,\"kind\":\"BANNER\"}", "{\"appid\":220}", "{\"kind\":\"COVER\"}"})
void reportBrokenImage_BadBodyIs400(String body) throws Exception {
@@ -0,0 +1,173 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import static org.hamcrest.Matchers.hasSize;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
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;
/**
* The dev profile authenticates every request as user 1.
*/
@AutoConfigureMockMvc
class LibraryControllerHiddenTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
steamGameRepository.save(new SteamGame(1000L, "Some Playtest", null, Instant.now()));
steamGameRepository.save(new SteamGame(-1942L, "Zelda", null, Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
userGameRepository.save(new UserGame(USER_ID, 1000L, 0, Instant.now(), Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 60, Instant.now()));
}
private ResultActions setHidden(long appid, String body) throws Exception {
return mockMvc.perform(put("/api/library/games/" + appid + "/hidden").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content(body));
}
private UserGame stored(long appid) {
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
}
@Test
void hideAndUnhide_MoveTheGameBetweenTheLists() throws Exception {
setHidden(1000L, "{\"hidden\":true}")
.andExpect(status().isOk())
.andExpect(jsonPath("$.hidden").value(true));
mockMvc.perform(get("/api/library/games"))
.andExpect(jsonPath("$.content", hasSize(2)))
.andExpect(jsonPath("$.content[?(@.appid == 1000)]").isEmpty());
mockMvc.perform(get("/api/library/games?hidden=true"))
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].appid").value(1000))
.andExpect(jsonPath("$.content[0].hidden").value(true));
mockMvc.perform(get("/api/library/games-count")).andExpect(jsonPath("$.count").value(2));
mockMvc.perform(get("/api/library/summary")).andExpect(jsonPath("$.total").value(2));
mockMvc.perform(get("/api/library/games/1000"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.hidden").value(true));
setHidden(1000L, "{\"hidden\":false}").andExpect(status().isOk());
assertNull(stored(1000L).getHiddenAt());
mockMvc.perform(get("/api/library/games-count")).andExpect(jsonPath("$.count").value(3));
}
@Test
void hide_AHiddenGameKeepsTheFirstTime() throws Exception {
Instant first = Instant.now().minus(3, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MICROS);
UserGame game = stored(1000L);
game.setHiddenAt(first);
userGameRepository.save(game);
setHidden(1000L, "{\"hidden\":true}").andExpect(status().isOk());
assertEquals(first, stored(1000L).getHiddenAt());
}
@Test
void hide_UnknownGameIs404() throws Exception {
setHidden(620L, "{\"hidden\":true}")
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void hide_RemovedGameIs404() throws Exception {
UserGame game = stored(1000L);
game.setRemovedAt(Instant.now());
userGameRepository.save(game);
setHidden(1000L, "{\"hidden\":true}").andExpect(status().isNotFound());
}
@Test
void hide_GameAddedByHandIs409() throws Exception {
setHidden(-1942L, "{\"hidden\":true}")
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("MANUAL_GAME"));
assertNull(stored(-1942L).getHiddenAt());
}
@Test
void unhide_WorksForAnyGame() throws Exception {
UserGame manual = stored(-1942L);
manual.setHiddenAt(Instant.now());
userGameRepository.save(manual);
setHidden(-1942L, "{\"hidden\":false}").andExpect(status().isOk());
assertNull(stored(-1942L).getHiddenAt());
}
@ParameterizedTest
@ValueSource(strings = {"{}", "{\"hidden\":null}", "{\"hidden\":\"maybe\"}"})
void hide_BadBodyIs400(String body) throws Exception {
setHidden(1000L, body).andExpect(status().isBadRequest());
assertNull(stored(1000L).getHiddenAt());
}
@Test
void hide_WithoutClientHeaderIs403() throws Exception {
mockMvc.perform(put("/api/library/games/1000/hidden")
.contentType(MediaType.APPLICATION_JSON).content("{\"hidden\":true}"))
.andExpect(status().isForbidden());
assertNull(stored(1000L).getHiddenAt());
}
@Test
void hide_KeepsStatusAndNotes() throws Exception {
UserGame game = stored(220L);
game.setNotes("keep me");
userGameRepository.save(game);
setHidden(220L, "{\"hidden\":true}").andExpect(status().isOk());
UserGame after = stored(220L);
assertNotNull(after.getHiddenAt());
assertEquals(GameStatus.PLAYING, after.getStatus());
assertEquals("keep me", after.getNotes());
}
}
@@ -174,6 +174,19 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
assertEquals(600, back.getPlaytimeMinutes());
}
@Test
void add_HiddenSteamGameRemovedBefore_ComesBackShown() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
UserGame refunded = new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now());
refunded.setHiddenAt(Instant.now());
refunded.setRemovedAt(Instant.now());
userGameRepository.save(refunded);
add(119133, "PS5").andExpect(status().isCreated());
assertNull(stored(1245620).getHiddenAt());
}
@Test
void add_UnknownIgdbGame_Is400() throws Exception {
when(igdbApiService.getGame(anyLong())).thenReturn(Optional.empty());
@@ -1,10 +1,13 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
@@ -18,6 +21,12 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PlaytimeDayRepository playtimeDayRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Test
void addMinutes_SameGameAndDay_AddsUp() {
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
@@ -37,4 +46,25 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(new DayMinutes(DAY, 50), new DayMinutes(DAY.plusDays(2), 10)),
playtimeDayRepository.minutesPerDay(USER_ID, DAY));
}
@Test
void minutesPerDay_LeavesOutHiddenGames() {
steamGameRepository.saveAll(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(620L, "Portal 2", null, Instant.now())));
UserGame hidden = new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now());
hidden.setHiddenAt(Instant.now());
userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 30, Instant.now(), Instant.now()), hidden,
// Another user's hidden flag does not hide the game for this user.
hiddenFor(2L, 220L)));
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY, 60);
assertEquals(List.of(new DayMinutes(DAY, 30)), playtimeDayRepository.minutesPerDay(USER_ID, DAY));
}
private static UserGame hiddenFor(long userId, long appid) {
UserGame game = new UserGame(userId, appid, 0, Instant.now(), Instant.now());
game.setHiddenAt(Instant.now());
return game;
}
}
@@ -61,7 +61,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> appids(GameStatus status, String query) {
return userGameRepository.findOwnedGames(USER_ID, status, UserGameRepository.containsPattern(query), null, PAGE)
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, PAGE)
.map(UserGame::getAppid).getContent();
}
@@ -136,7 +136,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
@Test
void findOwnedGames_CountsAllMatchesAcrossPages() {
var page = userGameRepository.findOwnedGames(USER_ID, null, UserGameRepository.containsPattern("half"), null,
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null,
PageRequest.of(0, 1, Sort.by("appid")));
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
@@ -144,7 +144,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
}
private List<Long> sortedAppids(Sort sort) {
return userGameRepository.findOwnedGames(USER_ID, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
.map(UserGame::getAppid).getContent();
}
@@ -195,7 +195,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
new GameTimeToBeat(380L, null, null, null, Instant.now())));
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, null, null, 600, PAGE).map(UserGame::getAppid).getContent();
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, PAGE).map(UserGame::getAppid).getContent();
assertEquals(List.of(620L), shortGames);
}
@@ -210,6 +210,65 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(350, userGameRepository.sumBacklogMinutes(USER_ID));
}
private void hide(long... appids) {
for (long appid : appids) {
userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow().setHiddenAt(Instant.now());
}
userGameRepository.flush();
}
@Test
void hiddenGames_LeaveTheListAndShowOnlyUnderTheHiddenFilter() {
hide(620L, 700L);
assertEquals(List.of(220L, 380L), appids(null, null));
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null,
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
}
@Test
void hiddenGames_LeaveEveryCountAndSum() {
gameTimeToBeatRepository.saveAll(List.of(
new GameTimeToBeat(380L, 100, 200, 300, Instant.now()),
new GameTimeToBeat(700L, 100, 150, 300, Instant.now())));
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(30);
userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow().setPlaytime2Weeks(60);
hide(620L, 700L);
assertEquals(2, userGameRepository.countListed(USER_ID));
assertEquals(300, userGameRepository.sumPlaytime(USER_ID));
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
assertEquals(200, userGameRepository.sumBacklogMinutes(USER_ID));
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 1), new StatusCount(GameStatus.BACKLOG, 1)),
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
assertEquals(List.of(380L), userGameRepository.findBacklogAppids(USER_ID));
assertEquals(List.of(220L), userGameRepository.findListedPlayedInTwoWeeks(USER_ID, PAGE)
.stream().map(UserGame::getAppid).toList());
assertEquals(List.of(220L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
.stream().map(UserGame::getAppid).toList());
assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findAchievementProgress(USER_ID)
.stream().map(p -> p.appid()).toList()));
}
@Test
void hiddenGames_AreNeverPicked() {
hide(700L);
for (int i = 0; i < 5; i++) {
assertEquals(380L, userGameRepository.findRandomBacklogGame(USER_ID, -1).orElseThrow().getAppid());
assertEquals(List.of(380L), userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10)
.stream().map(UserGame::getAppid).toList());
}
}
@Test
void findActiveAppids_StillSeesHiddenGames() {
hide(700L);
assertEquals(List.of(700L), userGameRepository.findActiveAppids(USER_ID, List.of(700L)));
}
@Test
void containsPattern_BlankIsNoFilter() {
assertNull(UserGameRepository.containsPattern(" "));
@@ -235,7 +294,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
userGameRepository.save(new UserGame(2L, 380L, 0, Instant.now(), Instant.now()));
userGameRepository.flush();
assertEquals(1, userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(USER_ID, 0));
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
}
@Test
@@ -124,6 +124,17 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
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)));
@@ -7,6 +7,7 @@ import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
@@ -104,6 +105,21 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
assertEquals(false, detail.hasAchievements());
}
@Test
void getGameDetail_HiddenGame_ShowsStoredDataAndAsksNobody() {
UserGame hidden = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow();
hidden.setHiddenAt(Instant.now());
userGameRepository.save(hidden);
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 1, true, Instant.now().minus(Duration.ofDays(31))));
schemaRepository.save(new GameAchievementSchema(620L, "A", "A", null, "icon", "gray", false));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
org.mockito.Mockito.verifyNoInteractions(steamApiService, speedrunService, timeToBeatService, moodService);
assertEquals(true, detail.hidden());
assertEquals(1, detail.achievementsTotal());
}
@Test
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
@@ -80,7 +80,7 @@ class ImageRepairServiceTest extends AbstractUnitTest {
@Test
void report_StoresAReportForAnOwnedSteamGame() {
when(userGameRepository.findActiveAppids(1L, List.of(620L))).thenReturn(List.of(620L));
when(userGameRepository.findListedAppids(1L, List.of(620L))).thenReturn(List.of(620L));
service.report(1L, 620L, ImageKind.COVER);
@@ -89,7 +89,7 @@ class ImageRepairServiceTest extends AbstractUnitTest {
@Test
void report_DropsGamesTheUserDoesNotOwn() {
when(userGameRepository.findActiveAppids(1L, List.of(620L))).thenReturn(List.of());
when(userGameRepository.findListedAppids(1L, List.of(620L))).thenReturn(List.of());
service.report(1L, 620L, ImageKind.COVER);
@@ -419,6 +419,31 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(moodService).refresh(List.of(220L, -1025L));
}
@Test
void runSync_KeepsTheHiddenFlagAndUpdatesThePlaytime() {
UserGame hidden = owned(60);
Instant hiddenAt = Instant.parse("2026-09-01T10:00:00Z");
hidden.setHiddenAt(hiddenAt);
steamReturns(game(90, 30), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID);
assertEquals(hiddenAt, hidden.getHiddenAt());
assertEquals(90, hidden.getPlaytimeMinutes());
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 30);
}
@Test
void runSync_HiddenGamesGetNoTimeToBeatOrMoods() {
steamReturns(game(0, 0), STEAM_ID);
when(userGameRepository.findHiddenAppids(USER_ID)).thenReturn(List.of(220L));
worker.runSync(USER_ID, STEAM_ID);
verify(timeToBeatService).refresh(List.of());
verify(moodService).refresh(List.of());
}
@Test
void runSync_GameWithoutName_GetsTheAppidAsName() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
@@ -0,0 +1,64 @@
package core_service.modules.stats.repo;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
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 java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Import(StatsRepository.class)
class StatsRepositoryTest extends AbstractPersistenceTest {
@Autowired
private StatsRepository statsRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now()),
new SteamGame(1000L, "Playtest", null, Instant.now())));
UserGame hiddenPlaytest = new UserGame(1L, 1000L, 5000, Instant.now(), Instant.now());
hiddenPlaytest.setHiddenAt(Instant.now());
UserGame hiddenNeverPlayed = new UserGame(2L, 1000L, 0, Instant.now(), Instant.now());
hiddenNeverPlayed.setHiddenAt(Instant.now());
userGameRepository.saveAllAndFlush(List.of(new UserGame(1L, 220L, 300, Instant.now(), Instant.now()),
hiddenPlaytest, hiddenNeverPlayed));
}
@Test
void hiddenGames_LeaveEveryNumber() {
assertEquals(1, statsRepository.countDistinctGames());
assertEquals(1, statsRepository.countLibraryEntries());
assertEquals(1, statsRepository.countGamesPlayed());
assertEquals(300, statsRepository.sumPlaytimeMinutes());
assertEquals(1, statsRepository.countUsersWithLibrary());
assertEquals(1, statsRepository.biggestLibrary());
assertEquals(0, statsRepository.biggestBacklog());
}
@Test
void hiddenGames_LeaveTheTopLists() {
assertEquals(List.of(220L), appids(statsRepository.topOwnedGames(5)));
assertEquals(List.of(220L), appids(statsRepository.topPlayedGames(5)));
}
private static List<Long> appids(List<Map<String, Object>> rows) {
return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList();
}
}