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

+6 -1
View File
@@ -31,6 +31,10 @@ To Be Played is split into three repositories:
games closest to 100 %.
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
dashboard banner.
- **Hide games**: a user can hide a Steam game (e.g. a playtest or a tool). It leaves every list and number: library,
picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still
updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library
lists hidden games with `hidden=true`.
- **Wishlist**: synced from Steam with store price and discount.
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
@@ -61,6 +65,7 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT |
| `POST` | `/api/library/sync` | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT |
@@ -143,7 +148,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
owns. When Steam returns no games, the library stays as it is.
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat and mood data, for Steam games
and manual games.
and manual games. Hidden games only get their playtime updated.
6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only)
in the background.
@@ -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();
}
}