diff --git a/readme.md b/readme.md index 2299518..ac7f442 100644 --- a/readme.md +++ b/readme.md @@ -43,6 +43,15 @@ To Be Played is split into three repositories: 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`. +- **Stats**: `UserStatsService` builds the numbers of the stats page per group, so every page asks only for the + groups it shows: totals and statuses; the pile (time to beat, weeks "at your pace" of the last 90 days, years of + 40-hour weeks, oldest unplayed game); most played and most loyal game (most months with playtime); top IGDB genres + and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the + status history); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the + first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games. +- **Status history**: a database trigger logs every status change of a library game in `user_game_status_change` + (old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older + changes are not known. - **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 @@ -53,7 +62,8 @@ To Be Played is split into three repositories: feature is off and the rest works. - **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games and average completion. Readable only - with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. + with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat + groups the user shares (the ticket's `profileStats` claim, set in Auth); none by default. ## Authentication @@ -78,6 +88,7 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he | `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 | +| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; none = all) | JWT | | `GET` | `/api/library/achievements/summary` | JWT | | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT | | `GET` | `/api/public/profiles/library` | profile ticket | @@ -152,12 +163,16 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` `POST /api/library/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`). 2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. A request without a `steamId` claim (the user unlinked the Steam account in Auth) takes the user out of it. + Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and + dates finished games by the unlock of their ending achievement + (`FinishedEstimateService`, `user_game.finished_at_estimate`). 3. `LibrarySyncWorker` runs in the background. `user_steam_sync` lets only one run per user start. 4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds 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. Hidden games only get their playtime updated. + owns. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no + games, the library stays as it is. +5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam + games and manual games. Hidden games only get their playtime updated. 6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only) in the background. The achievement sync also fetches Steam's global unlock rates with each schema, and sets FINISHED for a game whose ending achievement is unlocked while the user never chose its status. diff --git a/src/main/java/core_service/configuration/security/JwtService.java b/src/main/java/core_service/configuration/security/JwtService.java index f483e40..2e35a7b 100644 --- a/src/main/java/core_service/configuration/security/JwtService.java +++ b/src/main/java/core_service/configuration/security/JwtService.java @@ -1,5 +1,6 @@ package core_service.configuration.security; +import core_service.shared.security.ProfileTicket; import core_service.shared.security.ProfileTicketVerifier; import core_service.shared.security.UserPrincipal; import io.jsonwebtoken.Claims; @@ -7,7 +8,10 @@ import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import org.springframework.stereotype.Service; +import java.util.List; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; /** * Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key. @@ -47,13 +51,18 @@ public class JwtService implements ProfileTicketVerifier { } @Override - public Optional verifyProfileTicket(String ticket) { + public Optional verifyProfileTicket(String ticket) { try { Claims claims = claims(ticket); if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) { return Optional.empty(); } - return Optional.of(Long.parseLong(claims.get("profileUserId", String.class))); + long userId = Long.parseLong(claims.get("profileUserId", String.class)); + // A ticket of an Auth from before the claim has none: then the profile shows no stats. + Set profileStats = claims.get("profileStats") instanceof List groups + ? groups.stream().filter(String.class::isInstance).map(String.class::cast).collect(Collectors.toSet()) + : Set.of(); + return Optional.of(new ProfileTicket(userId, profileStats)); } catch (Exception e) { return Optional.empty(); } diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 6e31daf..2f11a2b 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -13,9 +13,11 @@ import core_service.modules.library.api.dto.PickerDto; import core_service.modules.library.api.dto.StatusUpdateDto; import core_service.modules.library.api.dto.SteamProfileDto; import core_service.modules.library.api.dto.SyncStatusDto; +import core_service.modules.library.api.dto.UserStatsDto; import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.Mood; +import core_service.modules.library.domain.StatGroup; import core_service.modules.library.service.BacklogService; import core_service.modules.library.service.GameDetailService; import core_service.modules.library.service.ImageRepairService; @@ -25,6 +27,7 @@ import core_service.modules.library.service.MoodService; import core_service.modules.library.service.SpeedrunService; import core_service.modules.library.service.SteamProfileService; import core_service.modules.library.service.SyncCooldownException; +import core_service.modules.library.service.UserStatsService; import core_service.shared.security.UserPrincipal; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -43,6 +46,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -69,6 +73,7 @@ public class LibraryController { private final MoodService moodService; private final SpeedrunService speedrunService; private final ImageRepairService imageRepairService; + private final UserStatsService userStatsService; @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { @@ -244,6 +249,20 @@ public class LibraryController { return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount)); } + /** + * @param groups the groups to compute; none means all + */ + @GetMapping("/stats") + public ResponseEntity getStats(@AuthenticationPrincipal UserPrincipal principal, + @RequestParam(required = false) Set groups) { + Long userId = principal.getIdAsLong(); + Set asked = groups == null || groups.isEmpty() ? EnumSet.allOf(StatGroup.class) : groups; + if (asked.contains(StatGroup.TASTE)) { + moodService.refreshListedInBackground(userId); + } + return ResponseEntity.ok(userStatsService.getStats(userId, asked)); + } + @GetMapping("/sync-status") public ResponseEntity getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) { return queryService.getSyncStatus(principal.getIdAsLong()) diff --git a/src/main/java/core_service/modules/library/api/PublicProfileController.java b/src/main/java/core_service/modules/library/api/PublicProfileController.java index 4308e2e..f369a39 100644 --- a/src/main/java/core_service/modules/library/api/PublicProfileController.java +++ b/src/main/java/core_service/modules/library/api/PublicProfileController.java @@ -1,6 +1,7 @@ package core_service.modules.library.api; import core_service.modules.library.service.LibraryQueryService; +import core_service.shared.security.ProfileTicket; import core_service.shared.security.ProfileTicketVerifier; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -27,8 +28,8 @@ public class PublicProfileController { @GetMapping("/library") public ResponseEntity getPublicLibrary(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket) { - return (ticket == null ? Optional.empty() : profileTicketVerifier.verifyProfileTicket(ticket)) - .>map(userId -> ResponseEntity.ok(queryService.getPublicLibrary(userId))) + return (ticket == null ? Optional.empty() : profileTicketVerifier.verifyProfileTicket(ticket)) + .>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats()))) .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket"))); } diff --git a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java index fc46b3d..57d7f5d 100644 --- a/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PublicLibraryDto.java @@ -12,6 +12,7 @@ import java.util.Map; * @param playing up to 8 games, most played first * @param finished up to 12 FINISHED or COMPLETED games, best rated first * @param averageCompletion see {@link AchievementSummaryDto#averageCompletion()} + * @param stats only the stat groups the user shares, the others null; null when the user shares none */ public record PublicLibraryDto( long totalGames, @@ -20,7 +21,8 @@ public record PublicLibraryDto( List playing, List finished, int perfectGames, - Double averageCompletion + Double averageCompletion, + UserStatsDto stats ) { /** diff --git a/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java b/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java new file mode 100644 index 0000000..d3b804d --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/UserStatsDto.java @@ -0,0 +1,76 @@ +package core_service.modules.library.api.dto; + +import core_service.modules.library.domain.DayMinutes; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.UserStats; + +import java.util.List; +import java.util.Map; + +/** + * One field per {@link core_service.modules.library.domain.StatGroup}; a group that was not asked for is null. + * Every number counts only listed games (not removed, not hidden). The playtime day sums (pace, longest day) also + * count removed games: those days were played. + */ +public record UserStatsDto( + Overview overview, + Pile pile, + TopGames topGames, + Taste taste, + Habits habits, + Finished finished, + Trend trend +) { + + /** + * @param byStatus has every status, also those with 0 games + */ + public record Overview(long totalGames, long totalPlaytimeMinutes, long playedGames, long neverStarted, + Map byStatus) {} + + /** + * The pile is every game with status BACKLOG. Times to beat only count games IGDB knows. + * + * @param minutesPerWeek average playtime per week of the last 90 days + * @param paceWeeks weeks to play the pile's normal time at that pace; null without playtime in the 90 days + * @param workYears the pile's normal time in years of 40-hour weeks + * @param oldestUnplayed the pile game with no playtime that is in the library longest; null when there is none + */ + public record Pile(long backlogGames, long normalMinutes, long completionistMinutes, long gamesWithTimeToBeat, + long minutesPerWeek, Double paceWeeks, double workYears, UserStats.OldestGame oldestUnplayed) {} + + /** + * @param mostPlayed up to 5 games by total playtime + * @param mostLoyal the game played in the most months since the playtime history started; null without history + */ + public record TopGames(List mostPlayed, UserStats.LoyalGame mostLoyal) {} + + /** + * @param genres up to 8 IGDB genres by playtime + * @param moods every mood with games, by playtime + */ + public record Taste(List genres, List moods) {} + + /** + * @param moonTrips the total playtime as flights to the moon and back (Apollo 11, 195.3 hours) + * @param longestDay the day with the most playtime; null without history + * @param underOneHourShare share of the started games with less than one hour; null when none is started + */ + public record Habits(double moonTrips, DayMinutes longestDay, long underOneHourGames, Double underOneHourShare) {} + + /** + * @param perYear games now FINISHED or COMPLETED, by the year they first got there (Europe/Berlin), oldest first; + * a game finished before the status history by the year its ending achievement was unlocked + * @param finishedBeforeTracking finished games with neither a finish in the status history nor an ending + * achievement date + */ + public record Finished(List perYear, long finishedBeforeTracking) {} + + /** + * @param days the pile per day of the last 90 days, oldest first; days before the trend started or without a + * nightly run are missing, today is always there + * @param thisMonth the moves of the current month (Europe/Berlin) + */ + public record Trend(List days, UserStats.MonthMoves thisMonth) {} + +} diff --git a/src/main/java/core_service/modules/library/domain/FinishedWithoutDate.java b/src/main/java/core_service/modules/library/domain/FinishedWithoutDate.java new file mode 100644 index 0000000..6da3397 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/FinishedWithoutDate.java @@ -0,0 +1,6 @@ +package core_service.modules.library.domain; + +/** + * A FINISHED or COMPLETED game without {@code finished_at_estimate} yet. + */ +public record FinishedWithoutDate(long userId, long appid) {} diff --git a/src/main/java/core_service/modules/library/domain/GameMoods.java b/src/main/java/core_service/modules/library/domain/GameMoods.java index 76bec58..a95ee95 100644 --- a/src/main/java/core_service/modules/library/domain/GameMoods.java +++ b/src/main/java/core_service/modules/library/domain/GameMoods.java @@ -30,6 +30,11 @@ public class GameMoods { @Column(name = "moods", nullable = false) private List moods; + /** IGDB genre names, e.g. "Role-playing (RPG)". */ + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "genres", nullable = false) + private List genres; + @Column(name = "checked_at", nullable = false) private Instant checkedAt; diff --git a/src/main/java/core_service/modules/library/domain/GameStatus.java b/src/main/java/core_service/modules/library/domain/GameStatus.java index 16346da..6c5b99f 100644 --- a/src/main/java/core_service/modules/library/domain/GameStatus.java +++ b/src/main/java/core_service/modules/library/domain/GameStatus.java @@ -8,7 +8,8 @@ public enum GameStatus { DROPPED; /** - * Status for a game the sync sees for the first time. Later syncs never change it. + * Status for a game the sync sees for the first time. Later syncs change it only from BACKLOG to PLAYING, when + * the game gets new playtime and the user never chose its status. */ public static GameStatus initialFor(int playtimeMinutes) { return playtimeMinutes > 0 ? PLAYING : BACKLOG; diff --git a/src/main/java/core_service/modules/library/domain/StatFormulas.java b/src/main/java/core_service/modules/library/domain/StatFormulas.java new file mode 100644 index 0000000..e6bc838 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/StatFormulas.java @@ -0,0 +1,44 @@ +package core_service.modules.library.domain; + +/** + * The math of the stats page, apart so it can be tested alone. + */ +public final class StatFormulas { + + /** 40 hours a week, 52 weeks, no holidays. */ + public static final double WORK_YEAR_HOURS = 40 * 52; + + /** Apollo 11 from launch to splashdown: 8 days, 3 hours, 18 minutes. */ + public static final double MOON_TRIP_HOURS = 8 * 24 + 3 + 18 / 60.0; + + public static final int PACE_DAYS = 90; + + private StatFormulas() { + } + + public static double workYears(long minutes) { + return minutes / 60.0 / WORK_YEAR_HOURS; + } + + public static double moonTrips(long minutes) { + return minutes / 60.0 / MOON_TRIP_HOURS; + } + + public static long minutesPerWeek(long minutesInPaceDays) { + return Math.round(minutesInPaceDays * 7.0 / PACE_DAYS); + } + + /** + * @return weeks to play the pile at the pace of the last {@link #PACE_DAYS} days; null without playtime then + */ + public static Double paceWeeks(long pileMinutes, long minutesInPaceDays) { + return minutesInPaceDays == 0 ? null : pileMinutes / (minutesInPaceDays * 7.0 / PACE_DAYS); + } + + /** + * @return null when there is no whole + */ + public static Double share(long part, long whole) { + return whole == 0 ? null : (double) part / whole; + } +} diff --git a/src/main/java/core_service/modules/library/domain/StatGroup.java b/src/main/java/core_service/modules/library/domain/StatGroup.java new file mode 100644 index 0000000..2d4091d --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/StatGroup.java @@ -0,0 +1,27 @@ +package core_service.modules.library.domain; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.Set; + +/** + * The groups of the stats page. A client asks only for the groups it shows; the public profile gets only the groups + * the user shares. + */ +public enum StatGroup { + OVERVIEW, PILE, TOP_GAMES, TASTE, HABITS, FINISHED, TREND; + + /** + * The groups a public profile shows, from the names in the profile ticket. OVERVIEW is never one: the profile + * has those numbers already. Unknown names are left out. + */ + public static Set shared(Collection names) { + Set groups = EnumSet.noneOf(StatGroup.class); + for (StatGroup group : values()) { + if (group != OVERVIEW && names.contains(group.name())) { + groups.add(group); + } + } + return groups; + } +} diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index 6a9ef4d..550f1ba 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -52,6 +52,10 @@ public class UserGame { @Column(name = "status_set_by_user", nullable = false) private boolean statusSetByUser; + /** The unlock of the ending achievement of a finished game; "finished per year" prefers it to the history. */ + @Column(name = "finished_at_estimate") + private Instant finishedAtEstimate; + /** The finished hint the user answered with "Not yet"; see {@link FinishedHintType}. */ @Enumerated(EnumType.STRING) @Column(name = "finished_hint_dismissed", length = 16) diff --git a/src/main/java/core_service/modules/library/domain/UserStats.java b/src/main/java/core_service/modules/library/domain/UserStats.java new file mode 100644 index 0000000..256c47f --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/UserStats.java @@ -0,0 +1,41 @@ +package core_service.modules.library.domain; + +import java.time.Instant; +import java.time.LocalDate; + +/** + * The rows the stats queries return, see {@link StatGroup}. + */ +public final class UserStats { + + private UserStats() { + } + + /** + * @param inLibrarySince the first sync that saw the game, not the Steam purchase date + */ + public record OldestGame(long appid, String name, String imageUrl, Instant inLibrarySince) {} + + public record GameMinutes(long appid, String name, String imageUrl, long minutes) {} + + public record LoyalGame(long appid, String name, String imageUrl, long months, long minutes) {} + + public record TagCount(String name, long games, long minutes) {} + + public record YearCount(int year, long games) {} + + /** + * @param backlogMinutes normal time to beat of the pile games IGDB knew on that day + */ + public record PileDay(LocalDate day, long backlogGames, long neverPlayed, long backlogMinutes) {} + + /** + * @param added games that came onto the pile; not the games of the first sync of a Steam account + * @param done games that left the pile for another status; a removed or hidden game is not done + */ + public record MonthMoves(long added, long done) {} + + public record PileTimes(long normalMinutes, long completionistMinutes, long gamesWithTimeToBeat) {} + + public record PlayedCounts(long started, long underOneHour) {} +} diff --git a/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java new file mode 100644 index 0000000..51901d1 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/BacklogSnapshotRepository.java @@ -0,0 +1,67 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.UserStats; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.time.LocalDate; +import java.util.List; + +/** + * The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}. + */ +@Repository +@RequiredArgsConstructor +public class BacklogSnapshotRepository { + + private static final String PILE_COLUMNS = """ + count(*) filter (where ug.status = 'BACKLOG') as backlog_games, + count(*) filter (where ug.playtime_minutes = 0) as never_played, + coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG'), 0) as backlog_minutes, + count(*) as listed_games + from user_game ug left join game_time_to_beat t on t.appid = ug.appid + where ug.removed_at is null and ug.hidden_at is null + """; + + private final NamedParameterJdbcTemplate jdbc; + + /** + * One row per user with listed games, also users without a Steam sync; a second run on the same day replaces it. + * + * @return the number of users + */ + public int writeAll(LocalDate day) { + return jdbc.update(""" + insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games) + select ug.user_id, :day, %s group by ug.user_id + on conflict (user_id, day) do update set backlog_games = excluded.backlog_games, + never_played = excluded.never_played, backlog_minutes = excluded.backlog_minutes, + listed_games = excluded.listed_games + """.formatted(PILE_COLUMNS), new MapSqlParameterSource("day", Date.valueOf(day))); + } + + /** + * @return oldest first + */ + public List findSince(long userId, LocalDate from) { + return jdbc.query(""" + select day, backlog_games, never_played, backlog_minutes from backlog_snapshot + where user_id = :userId and day >= :from order by day + """, new MapSqlParameterSource("userId", userId).addValue("from", Date.valueOf(from)), + (rs, i) -> new UserStats.PileDay(rs.getDate("day").toLocalDate(), rs.getLong("backlog_games"), + rs.getLong("never_played"), rs.getLong("backlog_minutes"))); + } + + /** + * The pile of now, for the day that has no row yet. + */ + public UserStats.PileDay live(long userId, LocalDate day) { + return jdbc.queryForObject("select " + PILE_COLUMNS + "and ug.user_id = :userId", + new MapSqlParameterSource("userId", userId), + (rs, i) -> new UserStats.PileDay(day, rs.getLong("backlog_games"), rs.getLong("never_played"), + rs.getLong("backlog_minutes"))); + } +} diff --git a/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java b/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java index 1251353..b99ccc6 100644 --- a/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMoodsRepository.java @@ -18,15 +18,16 @@ public interface GameMoodsRepository extends JpaRepository { /** * @param moods {@link core_service.modules.library.domain.Mood} names; empty when none fits + * @param genres IGDB genre names; empty when IGDB has none */ // An upsert, not save(): two users' syncs can store the same new game at the same time. @Modifying @Transactional - @Query(value = "insert into game_mood (appid, moods, endless, checked_at) " + - "values (:appid, cast(:moods as varchar(16)[]), :endless, :checkedAt) " + - "on conflict (appid) do update set moods = excluded.moods, endless = excluded.endless, " + - "checked_at = excluded.checked_at", + @Query(value = "insert into game_mood (appid, moods, genres, endless, checked_at) " + + "values (:appid, cast(:moods as varchar(16)[]), cast(:genres as varchar(64)[]), :endless, :checkedAt) " + + "on conflict (appid) do update set moods = excluded.moods, genres = excluded.genres, " + + "endless = excluded.endless, checked_at = excluded.checked_at", nativeQuery = true) - void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("endless") Boolean endless, - @Param("checkedAt") Instant checkedAt); + void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("genres") String[] genres, + @Param("endless") Boolean endless, @Param("checkedAt") Instant checkedAt); } diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 157fbc2..dd3d7cd 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -1,6 +1,7 @@ package core_service.modules.library.repo; import core_service.modules.library.domain.AchievementProgress; +import core_service.modules.library.domain.FinishedWithoutDate; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.StatusCount; @@ -19,7 +20,9 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import java.util.Locale; import java.util.Optional; @@ -75,6 +78,21 @@ public interface UserGameRepository extends JpaRepository "where " + LISTED + "group by ug.status") List countByStatus(@Param("userId") Long userId); + /** + * Like {@link #countByStatus}, but with every status, also those with 0 games. + */ + default Map countEveryStatus(Long userId) { + Map byStatus = new EnumMap<>(GameStatus.class); + for (GameStatus status : GameStatus.values()) { + byStatus.put(status, 0L); + } + countByStatus(userId).forEach(c -> byStatus.put(c.status(), c.count())); + return byStatus; + } + + @Query("select ug.appid from UserGame ug where " + LISTED) + List findAllListedAppids(@Param("userId") Long userId); + /** * A random backlog game; a HIGH priority game is 4 times as likely as a LOW one or one without priority, a MEDIUM * one twice. @@ -207,6 +225,37 @@ public interface UserGameRepository extends JpaRepository "core_service.modules.library.domain.GameStatus.PLAYING)") int finishIfUntouched(@Param("userId") Long userId, @Param("appid") Long appid); + /** + * Only games with an unlocked achievement: the others cannot get an estimate. + */ + @Query(value = "select new core_service.modules.library.domain.FinishedWithoutDate(ug.userId, ug.appid) " + + "from UserGame ug where ug.removedAt is null and ug.finishedAtEstimate is null " + + "and ug.status in (core_service.modules.library.domain.GameStatus.FINISHED, " + + "core_service.modules.library.domain.GameStatus.COMPLETED) " + + "and exists (select 1 from UserGameAchievement a where a.userId = ug.userId " + + "and a.appid = ug.appid and a.unlocked = true) " + + "order by ug.appid, ug.userId") + List findFinishedWithoutDateCandidates(); + + @Modifying + @Transactional + @Query(value = "update user_game ug set finished_at_estimate = :finishedAt " + + "where ug.user_id = :userId and ug.appid = :appid and ug.finished_at_estimate is null", + nativeQuery = true) + int setFinishedAtEstimate(@Param("userId") Long userId, @Param("appid") Long appid, + @Param("finishedAt") Instant finishedAt); + + /** + * Played from the pile: BACKLOG becomes PLAYING for games whose status the user never chose. One statement, not + * an entity change, so a status the user picks during the sync always wins. A hidden game keeps its status. + */ + @Modifying + @Query("update UserGame ug set ug.status = core_service.modules.library.domain.GameStatus.PLAYING " + + "where ug.userId = :userId and ug.appid in :appids and ug.removedAt is null and ug.hiddenAt is null " + + "and ug.statusSetByUser = false " + + "and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG") + int playFromPileIfUntouched(@Param("userId") Long userId, @Param("appids") Collection appids); + /** * Removes the Steam games the last sync did not see. Games added by hand never come from Steam and stay. */ diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java new file mode 100644 index 0000000..830024b --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java @@ -0,0 +1,183 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.DayMinutes; +import core_service.modules.library.domain.UserStats; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +/** + * The queries of the stats page that {@link UserGameRepository} does not have. Which games count is on UserStatsDto; + * the playtime days count like {@link PlaytimeDayRepository#minutesPerDay}. + */ +@Repository +@RequiredArgsConstructor +public class UserStatsRepository { + + private static final String LISTED = "ug.user_id = :userId and ug.removed_at is null and ug.hidden_at is null "; + private static final String FINISHED = "('FINISHED', 'COMPLETED')"; + /** A status change that finished the game: from another status; not a game that came in finished. */ + private static final String FINISH_CHANGE = + "sc.to_status in %s and sc.from_status not in %s".formatted(FINISHED, FINISHED); + /** Per game, the first time it got finished. */ + private static final String FIRST_FINISH = """ + select sc.appid, min(sc.changed_at) as finished_at from user_game_status_change sc + where sc.user_id = :userId and %s + group by sc.appid + """.formatted(FINISH_CHANGE); + private static final String PLAYTIME_DAYS_NOT_HIDDEN = """ + from user_game_playtime_day p + where p.user_id = :userId and not exists (select 1 from user_game ug where ug.user_id = p.user_id + and ug.appid = p.appid and ug.hidden_at is not null) + """; + + private final NamedParameterJdbcTemplate jdbc; + + public UserStats.PileTimes pileTimes(long userId) { + return jdbc.queryForObject(""" + select coalesce(sum(t.normally_minutes), 0) as normal, coalesce(sum(t.completely_minutes), 0) as complete, + count(t.normally_minutes) as known + from user_game ug join game_time_to_beat t on t.appid = ug.appid + where %s and ug.status = 'BACKLOG' + """.formatted(LISTED), user(userId), + (rs, i) -> new UserStats.PileTimes(rs.getLong("normal"), rs.getLong("complete"), rs.getLong("known"))); + } + + public long minutesPlayedSince(long userId, LocalDate from) { + Long minutes = jdbc.queryForObject( + "select coalesce(sum(p.minutes), 0) " + PLAYTIME_DAYS_NOT_HIDDEN + "and p.day >= :from", + user(userId).addValue("from", Date.valueOf(from)), Long.class); + return minutes != null ? minutes : 0L; + } + + public Optional oldestUnplayed(long userId) { + return jdbc.query(""" + select ug.appid, sg.name, sg.image_url, ug.first_seen_at + from user_game ug join steam_game sg on sg.appid = ug.appid + where %s and ug.status = 'BACKLOG' and ug.playtime_minutes = 0 + order by ug.first_seen_at, ug.appid limit 1 + """.formatted(LISTED), user(userId), + (rs, i) -> new UserStats.OldestGame(rs.getLong("appid"), rs.getString("name"), + rs.getString("image_url"), rs.getTimestamp("first_seen_at").toInstant())) + .stream().findFirst(); + } + + public List mostPlayed(long userId, int limit) { + return jdbc.query(""" + select ug.appid, sg.name, sg.image_url, ug.playtime_minutes + from user_game ug join steam_game sg on sg.appid = ug.appid + where %s and ug.playtime_minutes > 0 + order by ug.playtime_minutes desc, ug.appid limit :limit + """.formatted(LISTED), user(userId).addValue("limit", limit), + (rs, i) -> new UserStats.GameMinutes(rs.getLong("appid"), rs.getString("name"), + rs.getString("image_url"), rs.getLong("playtime_minutes"))); + } + + /** + * The listed game with playtime in the most months; more minutes break a tie. + */ + public Optional mostLoyal(long userId) { + return jdbc.query(""" + select l.appid, sg.name, sg.image_url, l.months, l.minutes + from (select p.appid, count(distinct date_trunc('month', p.day)) as months, sum(p.minutes) as minutes + %s group by p.appid) l + join user_game ug on ug.user_id = :userId and ug.appid = l.appid and ug.removed_at is null + join steam_game sg on sg.appid = l.appid + order by l.months desc, l.minutes desc, l.appid limit 1 + """.formatted(PLAYTIME_DAYS_NOT_HIDDEN), user(userId), + (rs, i) -> new UserStats.LoyalGame(rs.getLong("appid"), rs.getString("name"), + rs.getString("image_url"), rs.getLong("months"), rs.getLong("minutes"))) + .stream().findFirst(); + } + + public List genres(long userId, int limit) { + return tagCounts(userId, "genres", limit); + } + + public List moods(long userId) { + return tagCounts(userId, "moods", Integer.MAX_VALUE); + } + + private List tagCounts(long userId, String column, int limit) { + return jdbc.query(""" + select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes + from user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name) + where %s + group by tag.name order by minutes desc, games desc, tag.name limit :limit + """.formatted(column, LISTED), user(userId).addValue("limit", limit), + (rs, i) -> new UserStats.TagCount(rs.getString("name"), rs.getLong("games"), rs.getLong("minutes"))); + } + + public Optional longestDay(long userId) { + return jdbc.query("select p.day, sum(p.minutes) as minutes " + PLAYTIME_DAYS_NOT_HIDDEN + + "group by p.day order by minutes desc, p.day desc limit 1", user(userId), + (rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes"))) + .stream().findFirst(); + } + + public UserStats.PlayedCounts playedCounts(long userId) { + return jdbc.queryForObject(""" + select count(*) filter (where ug.playtime_minutes > 0) as started, + count(*) filter (where ug.playtime_minutes between 1 and 59) as under_one_hour + from user_game ug where %s + """.formatted(LISTED), user(userId), + (rs, i) -> new UserStats.PlayedCounts(rs.getLong("started"), rs.getLong("under_one_hour"))); + } + + /** + * Only games that are FINISHED or COMPLETED now; FINISHED and later COMPLETED counts once, in the first year. The + * unlock of the ending achievement ({@code finished_at_estimate}) wins over the status history. + */ + public List finishedPerYear(long userId, ZoneId zone) { + return jdbc.query(""" + select cast(extract(year from coalesce(ug.finished_at_estimate, f.finished_at) at time zone :zone) + as integer) as year, count(*) as games + from user_game ug left join (%s) f on f.appid = ug.appid + where %s and ug.status in %s and coalesce(ug.finished_at_estimate, f.finished_at) is not null + group by year order by year + """.formatted(FIRST_FINISH, LISTED, FINISHED), user(userId).addValue("zone", zone.getId()), + (rs, i) -> new UserStats.YearCount(rs.getInt("year"), rs.getLong("games"))); + } + + public long finishedBeforeTracking(long userId) { + Long games = jdbc.queryForObject(""" + select count(*) from user_game ug + where %s and ug.status in %s and ug.finished_at_estimate is null + and not exists (select 1 from user_game_status_change sc + where sc.user_id = ug.user_id and sc.appid = ug.appid and %s) + """.formatted(LISTED, FINISHED, FINISH_CHANGE), user(userId), Long.class); + return games != null ? games : 0L; + } + + /** + * "Added": a game came onto the pile by the user, by a status move, or as a new Steam game after the first sync + * of the current Steam account; the games of that first sync are the import. "Done": a game left the pile. + * + * @param monthStart counts status changes from then on + */ + public UserStats.MonthMoves pileMoves(long userId, Instant monthStart) { + return jdbc.queryForObject(""" + select count(distinct sc.appid) filter (where sc.to_status = 'BACKLOG' and (sc.by_user + or sc.from_status is not null or sc.changed_at > uss.history_since)) as added, + count(distinct sc.appid) filter (where sc.from_status = 'BACKLOG' and sc.to_status <> 'BACKLOG') + as done + from user_game_status_change sc join user_game ug on ug.user_id = sc.user_id and ug.appid = sc.appid + left join user_steam_sync uss on uss.user_id = sc.user_id + where %s and sc.changed_at >= :monthStart + """.formatted(LISTED), user(userId).addValue("monthStart", Timestamp.from(monthStart)), + (rs, i) -> new UserStats.MonthMoves(rs.getLong("added"), rs.getLong("done"))); + } + + private static MapSqlParameterSource user(long userId) { + return new MapSqlParameterSource("userId", userId); + } +} diff --git a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java index 466f9ad..d9524d0 100644 --- a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java @@ -43,8 +43,15 @@ public interface UserSteamSyncRepository extends JpaRepository> byApp = userGameRepository.findFinishedWithoutDateCandidates().stream() + .collect(Collectors.groupingBy(FinishedWithoutDate::appid)); + int dated = 0; + for (Map.Entry> app : byApp.entrySet()) { + Optional ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey())); + if (ending.isEmpty()) continue; + String apiName = ending.get().achievement().getApiName(); + for (FinishedWithoutDate game : app.getValue()) { + Optional unlock = userAchievementRepository + .findByUserIdAndAppid(game.userId(), game.appid()).stream() + .filter(a -> a.isUnlocked() && a.getUnlockedAt() != null && a.getApiName().equals(apiName)) + .findFirst(); + if (unlock.isPresent()) { + dated += userGameRepository.setFinishedAtEstimate(game.userId(), game.appid(), + unlock.get().getUnlockedAt()); + } + } + } + log.info("Finished date estimated for {} games", dated); + return dated; + } +} diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index e33d005..3b941d3 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -15,6 +15,7 @@ import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.Mood; +import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameAchievementMeta; @@ -39,11 +40,12 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.ZoneId; +import java.util.Collection; import java.util.Comparator; -import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -65,6 +67,7 @@ public class LibraryQueryService { private final GameTimeToBeatRepository gameTimeToBeatRepository; private final GameAchievementSchemaMetaRepository schemaMetaRepository; private final UserGameAchievementMetaRepository userAchievementMetaRepository; + private final UserStatsService userStatsService; @Value("${app.library.zone:Europe/Berlin}") private ZoneId zone; @@ -154,7 +157,7 @@ public class LibraryQueryService { * @param excludeAppid the current suggestion, so a new pick differs from it; null for none */ public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) { - Map byStatus = countByStatus(userId); + Map byStatus = userGameRepository.countEveryStatus(userId); long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); long neverPlayed = userGameRepository.countListedNeverPlayed(userId); GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : UserGameRepository.NO_GAME) @@ -191,8 +194,11 @@ public class LibraryQueryService { return new ActivityDto(perDay, total, recentGames); } - public PublicLibraryDto getPublicLibrary(Long userId) { - Map byStatus = countByStatus(userId); + /** + * @param profileStats the stat group names of the profile ticket + */ + public PublicLibraryDto getPublicLibrary(Long userId, Collection profileStats) { + Map byStatus = userGameRepository.countEveryStatus(userId); List playing = userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.PLAYING), PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid")))); @@ -204,19 +210,11 @@ public class LibraryQueryService { .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); List started = startedGames(userGameRepository.findAchievementProgress(userId)); + Set shared = StatGroup.shared(profileStats); return new PublicLibraryDto(total, userGameRepository.sumPlaytime(userId), byStatus, toPublicGames(playing, meta), toPublicGames(finished, meta), - perfectGames(started), averageCompletion(started)); - } - - // Every status, also those with 0 games. - private Map countByStatus(Long userId) { - Map byStatus = new EnumMap<>(GameStatus.class); - for (GameStatus status : GameStatus.values()) { - byStatus.put(status, 0L); - } - userGameRepository.countByStatus(userId).forEach(c -> byStatus.put(c.status(), c.count())); - return byStatus; + perfectGames(started), averageCompletion(started), + shared.isEmpty() ? null : userStatsService.getStats(userId, shared)); } private static List toPublicGames(List games, Map meta) { diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncService.java b/src/main/java/core_service/modules/library/service/LibrarySyncService.java index bf28aea..d149e56 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncService.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncService.java @@ -1,6 +1,7 @@ package core_service.modules.library.service; import core_service.modules.library.domain.UserSteamSync; +import core_service.modules.library.repo.BacklogSnapshotRepository; import core_service.modules.library.repo.UserSteamSyncRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -10,6 +11,8 @@ import org.springframework.stereotype.Service; import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; @Service @@ -19,6 +22,8 @@ public class LibrarySyncService { private final UserSteamSyncRepository userSteamSyncRepository; private final LibrarySyncWorker worker; + private final BacklogSnapshotRepository backlogSnapshotRepository; + private final FinishedEstimateService finishedEstimateService; @Value("${app.library.sync-ttl-hours:24}") private long syncTtlHours; @@ -35,6 +40,9 @@ public class LibrarySyncService { @Value("${app.library.nightly-active-days:14}") private long nightlyActiveDays; + @Value("${app.library.zone:Europe/Berlin}") + private ZoneId zone; + /** * Trigger sync only if data is older than TTL or was synced for another Steam ID. * Cheap gate check on caller thread, actual sync runs async via worker. @@ -55,14 +63,39 @@ public class LibrarySyncService { /** * Syncs every known Steam account shortly before midnight, so the playtime history gets the growth of each * day even when the user does not open the app. Accounts are synced one after the other on the scheduler - * thread; accounts without a user request in the last {@code nightly-active-days} are skipped. + * thread; accounts without a user request in the last {@code nightly-active-days} are skipped. Afterwards every + * user's pile size of the day goes to backlog_snapshot, and old finished games get a date from their ending + * achievement. */ @Scheduled(cron = "${app.library.nightly-sync-cron:0 55 23 * * *}", zone = "${app.library.zone:Europe/Berlin}") public void syncAllNightly() { Instant activeSince = Instant.now().minus(Duration.ofDays(nightlyActiveDays)); List accounts = userSteamSyncRepository.findByUserSeenAtAfter(activeSince); log.info("Nightly sync for {} Steam accounts", accounts.size()); - accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId())); + // Taken before the syncs: the loop can run past midnight. + LocalDate day = LocalDate.now(zone); + try { + accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId())); + } finally { + writeBacklogSnapshots(day); + estimateFinishDates(); + } + } + + private void estimateFinishDates() { + try { + finishedEstimateService.fillMissing(); + } catch (Exception e) { + log.error("Finished date estimate failed", e); + } + } + + private void writeBacklogSnapshots(LocalDate day) { + try { + log.info("Backlog snapshot of {} for {} users", day, backlogSnapshotRepository.writeAll(day)); + } catch (Exception e) { + log.error("Backlog snapshot of {} failed", day, e); + } } /** diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 4e205ae..9b8cba2 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -109,10 +109,16 @@ public class LibrarySyncWorker { return; } + List played = new ArrayList<>(); for (SteamOwnedGamesResponse.Game g : games) { if (g.appid() == null) continue; upsertGame(g, syncStartedAt); - upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null); + if (upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null)) { + played.add(g.appid().longValue()); + } + } + if (!played.isEmpty()) { + userGameRepository.playFromPileIfUntouched(userId, played); } int removed = userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now()); @@ -185,8 +191,9 @@ public class LibrarySyncWorker { /** * @param playedOn the day that gets the playtime growth, null to record none + * @return true when playtime growth was recorded */ - private void upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) { + private boolean upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) { Long appid = g.appid().longValue(); UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null); int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0; @@ -209,6 +216,8 @@ public class LibrarySyncWorker { // A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted. if (playedOn != null && !takesOverManualGame && playtime > previousPlaytime) { playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime); + return true; } + return false; } } diff --git a/src/main/java/core_service/modules/library/service/MoodService.java b/src/main/java/core_service/modules/library/service/MoodService.java index 5ff9425..ad6b33d 100644 --- a/src/main/java/core_service/modules/library/service/MoodService.java +++ b/src/main/java/core_service/modules/library/service/MoodService.java @@ -20,7 +20,7 @@ import java.util.Set; import java.util.TreeSet; /** - * Stores the {@link Mood}s of games from IGDB's genres, themes and game modes, the same way + * Stores the IGDB genres of games and the {@link Mood}s from IGDB's genres, themes and game modes, the same way * {@link TimeToBeatService} stores the time to beat: only for games without a fresh row, and nothing when IGDB fails. */ @Service @@ -73,7 +73,10 @@ public class MoodService { IgdbApiService.GameTags gameTags = tags.get(appid); Set moods = gameTags == null ? Set.of() : Mood.of(gameTags.genres(), gameTags.themes(), gameTags.modes()); Boolean endless = gameTags == null ? null : Mood.endless(gameTags.genres(), gameTags.themes(), gameTags.modes()); - gameMoodsRepository.upsert(appid, moods.stream().map(Mood::name).sorted().toArray(String[]::new), endless, now); + String[] genres = gameTags == null ? new String[0] + : gameTags.genres().stream().distinct().sorted().toArray(String[]::new); + gameMoodsRepository.upsert(appid, moods.stream().map(Mood::name).sorted().toArray(String[]::new), genres, + endless, now); } } @@ -89,6 +92,18 @@ public class MoodService { } } + /** + * For the genres and moods of the stats page: a user without a Steam sync gets them only here. + */ + @Async + public void refreshListedInBackground(Long userId) { + try { + refresh(userGameRepository.findAllListedAppids(userId)); + } catch (Exception e) { + log.warn("Moods of the library of user {} not refreshed: {}", userId, e.getMessage()); + } + } + /** * For the picker: a user without a Steam sync gets moods only here, and a Steam user before the next sync. */ diff --git a/src/main/java/core_service/modules/library/service/UserStatsService.java b/src/main/java/core_service/modules/library/service/UserStatsService.java new file mode 100644 index 0000000..926ed5d --- /dev/null +++ b/src/main/java/core_service/modules/library/service/UserStatsService.java @@ -0,0 +1,106 @@ +package core_service.modules.library.service; + +import core_service.modules.library.api.dto.UserStatsDto; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.StatFormulas; +import core_service.modules.library.domain.StatGroup; +import core_service.modules.library.domain.UserStats; +import core_service.modules.library.repo.BacklogSnapshotRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.library.repo.UserStatsRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * The numbers of the stats page, built once per {@link StatGroup} for every page that shows them. + */ +@Service +@RequiredArgsConstructor +public class UserStatsService { + + private static final int MOST_PLAYED = 5; + private static final int TOP_GENRES = 8; + private static final int TREND_DAYS = 90; + + private final UserGameRepository userGameRepository; + private final UserStatsRepository userStatsRepository; + private final BacklogSnapshotRepository backlogSnapshotRepository; + + @Value("${app.library.zone:Europe/Berlin}") + private ZoneId zone; + + /** + * Runs only the queries of the asked groups; the others stay null. + */ + public UserStatsDto getStats(long userId, Set groups) { + Map byStatus = groups.contains(StatGroup.OVERVIEW) || groups.contains(StatGroup.PILE) + ? userGameRepository.countEveryStatus(userId) : Map.of(); + long playtime = groups.contains(StatGroup.OVERVIEW) || groups.contains(StatGroup.HABITS) + ? userGameRepository.sumPlaytime(userId) : 0; + return new UserStatsDto( + groups.contains(StatGroup.OVERVIEW) ? overview(userId, byStatus, playtime) : null, + groups.contains(StatGroup.PILE) ? pile(userId, byStatus.get(GameStatus.BACKLOG)) : null, + groups.contains(StatGroup.TOP_GAMES) ? topGames(userId) : null, + groups.contains(StatGroup.TASTE) ? taste(userId) : null, + groups.contains(StatGroup.HABITS) ? habits(userId, playtime) : null, + groups.contains(StatGroup.FINISHED) ? finished(userId) : null, + groups.contains(StatGroup.TREND) ? trend(userId) : null); + } + + private UserStatsDto.Overview overview(long userId, Map byStatus, long playtime) { + long total = byStatus.values().stream().mapToLong(Long::longValue).sum(); + long neverStarted = userGameRepository.countListedNeverPlayed(userId); + return new UserStatsDto.Overview(total, playtime, total - neverStarted, neverStarted, byStatus); + } + + private UserStatsDto.Pile pile(long userId, long backlogGames) { + UserStats.PileTimes times = userStatsRepository.pileTimes(userId); + LocalDate from = LocalDate.now(zone).minusDays(StatFormulas.PACE_DAYS - 1L); + long recentMinutes = userStatsRepository.minutesPlayedSince(userId, from); + return new UserStatsDto.Pile(backlogGames, times.normalMinutes(), times.completionistMinutes(), + times.gamesWithTimeToBeat(), StatFormulas.minutesPerWeek(recentMinutes), + StatFormulas.paceWeeks(times.normalMinutes(), recentMinutes), + StatFormulas.workYears(times.normalMinutes()), userStatsRepository.oldestUnplayed(userId).orElse(null)); + } + + private UserStatsDto.TopGames topGames(long userId) { + return new UserStatsDto.TopGames(userStatsRepository.mostPlayed(userId, MOST_PLAYED), + userStatsRepository.mostLoyal(userId).orElse(null)); + } + + private UserStatsDto.Taste taste(long userId) { + return new UserStatsDto.Taste(userStatsRepository.genres(userId, TOP_GENRES), userStatsRepository.moods(userId)); + } + + private UserStatsDto.Habits habits(long userId, long playtime) { + UserStats.PlayedCounts played = userStatsRepository.playedCounts(userId); + return new UserStatsDto.Habits(StatFormulas.moonTrips(playtime), + userStatsRepository.longestDay(userId).orElse(null), played.underOneHour(), + StatFormulas.share(played.underOneHour(), played.started())); + } + + private UserStatsDto.Finished finished(long userId) { + return new UserStatsDto.Finished(userStatsRepository.finishedPerYear(userId, zone), + userStatsRepository.finishedBeforeTracking(userId)); + } + + private UserStatsDto.Trend trend(long userId) { + LocalDate today = LocalDate.now(zone); + List days = new ArrayList<>( + backlogSnapshotRepository.findSince(userId, today.minusDays(TREND_DAYS - 1L))); + if (days.isEmpty() || !days.getLast().day().equals(today)) { + days.add(backlogSnapshotRepository.live(userId, today)); + } + UserStats.MonthMoves moves = userStatsRepository.pileMoves(userId, + today.withDayOfMonth(1).atStartOfDay(zone).toInstant()); + return new UserStatsDto.Trend(days, moves); + } +} diff --git a/src/main/java/core_service/shared/security/ProfileTicket.java b/src/main/java/core_service/shared/security/ProfileTicket.java new file mode 100644 index 0000000..8afe394 --- /dev/null +++ b/src/main/java/core_service/shared/security/ProfileTicket.java @@ -0,0 +1,10 @@ +package core_service.shared.security; + +import java.util.Set; + +/** + * A valid public-profile ticket from Auth. + * + * @param profileStats the names of the stat groups the user shows on the profile; empty for a ticket without them + */ +public record ProfileTicket(long userId, Set profileStats) {} diff --git a/src/main/java/core_service/shared/security/ProfileTicketVerifier.java b/src/main/java/core_service/shared/security/ProfileTicketVerifier.java index 50800a5..e00db10 100644 --- a/src/main/java/core_service/shared/security/ProfileTicketVerifier.java +++ b/src/main/java/core_service/shared/security/ProfileTicketVerifier.java @@ -8,7 +8,7 @@ import java.util.Optional; public interface ProfileTicketVerifier { /** - * @return the user of a valid, unexpired ticket; empty for anything else, also for a login token + * @return a valid, unexpired ticket; empty for anything else, also for a login token */ - Optional verifyProfileTicket(String ticket); + Optional verifyProfileTicket(String ticket); } diff --git a/src/main/resources/db/migration/V14__status_change_log.sql b/src/main/resources/db/migration/V14__status_change_log.sql new file mode 100644 index 0000000..4a5a1a1 --- /dev/null +++ b/src/main/resources/db/migration/V14__status_change_log.sql @@ -0,0 +1,30 @@ +-- Every status a library game ever had, for "finished per year" and the Pile of Shame trend. Written by a trigger, +-- so no code path (entity save, bulk update, native insert) can forget it. The past is not known: the log starts +-- on the day of this migration. +CREATE TABLE user_game_status_change ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + appid BIGINT NOT NULL, + -- NULL: the game came into the library with to_status. + from_status VARCHAR(16), + to_status VARCHAR(16) NOT NULL, + -- user_game.status_set_by_user after the change: false for the sync and for Core's own FINISHED. + by_user BOOLEAN NOT NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (user_id, appid) REFERENCES user_game (user_id, appid) ON DELETE CASCADE +); +CREATE INDEX idx_status_change_user_time ON user_game_status_change (user_id, changed_at); + +CREATE FUNCTION log_user_game_status() RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO user_game_status_change (user_id, appid, from_status, to_status, by_user) + VALUES (NEW.user_id, NEW.appid, CASE WHEN TG_OP = 'UPDATE' THEN OLD.status END, NEW.status, + NEW.status_set_by_user); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER user_game_status_insert AFTER INSERT ON user_game + FOR EACH ROW EXECUTE FUNCTION log_user_game_status(); +CREATE TRIGGER user_game_status_update AFTER UPDATE OF status ON user_game + FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status) EXECUTE FUNCTION log_user_game_status(); diff --git a/src/main/resources/db/migration/V15__game_genres.sql b/src/main/resources/db/migration/V15__game_genres.sql new file mode 100644 index 0000000..29b2d2e --- /dev/null +++ b/src/main/resources/db/migration/V15__game_genres.sql @@ -0,0 +1,4 @@ +-- IGDB genre names (e.g. "Role-playing (RPG)") per game, for the stats page. Filled with the moods; the next +-- library sync asks IGDB for every game again, so the column gets filled. +ALTER TABLE game_mood ADD COLUMN genres VARCHAR(64)[] NOT NULL DEFAULT '{}'; +UPDATE game_mood SET checked_at = TIMESTAMPTZ 'epoch'; diff --git a/src/main/resources/db/migration/V16__backlog_snapshot.sql b/src/main/resources/db/migration/V16__backlog_snapshot.sql new file mode 100644 index 0000000..fac12a1 --- /dev/null +++ b/src/main/resources/db/migration/V16__backlog_snapshot.sql @@ -0,0 +1,25 @@ +-- The size of each user's pile per day (Europe/Berlin), for the Pile of Shame trend. The nightly sync writes one row +-- per user with listed games; the past cannot be rebuilt, so the trend starts with the row of the migration day. +CREATE TABLE backlog_snapshot ( + user_id BIGINT NOT NULL, + day DATE NOT NULL, + backlog_games INTEGER NOT NULL, + never_played INTEGER NOT NULL, + -- Normal time to beat of the backlog games IGDB knows; it also grows when IGDB learns new times. + backlog_minutes BIGINT NOT NULL, + listed_games INTEGER NOT NULL, + PRIMARY KEY (user_id, day) +); + +INSERT INTO backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, listed_games) +SELECT ug.user_id, (NOW() AT TIME ZONE 'Europe/Berlin')::date, + COUNT(*) FILTER (WHERE ug.status = 'BACKLOG'), COUNT(*) FILTER (WHERE ug.playtime_minutes = 0), + COALESCE(SUM(t.normally_minutes) FILTER (WHERE ug.status = 'BACKLOG'), 0), COUNT(*) +FROM user_game ug LEFT JOIN game_time_to_beat t ON t.appid = ug.appid +WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL +GROUP BY ug.user_id; + +-- When the current Steam account's first sync ran: the games of that sync are the import, no "added" in the trend. +-- The accounts of today count from the migration on, like the status history. +ALTER TABLE user_steam_sync ADD COLUMN history_since TIMESTAMPTZ; +UPDATE user_steam_sync SET history_since = NOW() WHERE history_steam_id IS NOT NULL; diff --git a/src/main/resources/db/migration/V17__finished_at_estimate.sql b/src/main/resources/db/migration/V17__finished_at_estimate.sql new file mode 100644 index 0000000..51a65ce --- /dev/null +++ b/src/main/resources/db/migration/V17__finished_at_estimate.sql @@ -0,0 +1,5 @@ +-- For a FINISHED or COMPLETED game: when the user unlocked its ending achievement (the rule of EndingAchievement). +-- "Finished per year" counts the game in that year, before the status history (V14): a game finished by hand or by +-- Core long after its ending, e.g. when the library is first set up, still counts in the right year. Filled by the +-- nightly job; NULL when the game has no ending achievement, it is not unlocked, or Steam sent no unlock time. +ALTER TABLE user_game ADD COLUMN finished_at_estimate TIMESTAMPTZ; diff --git a/src/test/java/core_service/configuration/security/JwtServiceTest.java b/src/test/java/core_service/configuration/security/JwtServiceTest.java index f95b58d..8f34d2d 100644 --- a/src/test/java/core_service/configuration/security/JwtServiceTest.java +++ b/src/test/java/core_service/configuration/security/JwtServiceTest.java @@ -1,5 +1,6 @@ package core_service.configuration.security; +import core_service.shared.security.ProfileTicket; import core_service.shared.security.UserPrincipal; import core_service.shared.test.AbstractUnitTest; import io.jsonwebtoken.Jwts; @@ -13,7 +14,9 @@ import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.util.Base64; import java.util.Date; +import java.util.List; import java.util.Optional; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -102,7 +105,17 @@ class JwtServiceTest extends AbstractUnitTest { @Test void verifyProfileTicket_ReturnsTheUser() { - assertEquals(Optional.of(42L), jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000))); + assertEquals(Optional.of(new ProfileTicket(42L, Set.of())), + jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000))); + } + + @Test + void verifyProfileTicket_ReadsTheSharedStatGroups() { + String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42") + .claim("profileStats", List.of("PILE", "TREND")) + .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact(); + + assertEquals(Optional.of(new ProfileTicket(42L, Set.of("PILE", "TREND"))), jwtService.verifyProfileTicket(ticket)); } @Test diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java index 91d2fe3..66eecf6 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerFinishedHintTest.java @@ -91,7 +91,7 @@ class LibraryControllerFinishedHintTest extends AbstractIntegrationTest { userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, ENDING_GAME, Instant.now(), true, 1, 600)); gameTimeToBeatRepository.save(new GameTimeToBeat(MANUAL_GAME, null, 600, null, Instant.now())); - gameMoodsRepository.upsert(MANUAL_GAME, new String[]{"STORY"}, false, Instant.now()); + gameMoodsRepository.upsert(MANUAL_GAME, new String[]{"STORY"}, new String[0], false, Instant.now()); } private UserGame steamGame(boolean statusSetByUser) { diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java index efb947d..acb750a 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -93,9 +93,9 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest { game(20L, GameStatus.BACKLOG, null, null); game(30L, GameStatus.BACKLOG, null, null); game(40L, GameStatus.FINISHED, null, null); - gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, false, Instant.now()); - gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, false, Instant.now()); - gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, false, Instant.now()); + gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, new String[0], false, Instant.now()); + gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, new String[0], false, Instant.now()); + gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, new String[0], false, Instant.now()); mockMvc.perform(get("/api/library/picker").param("mood", "CHILL")) .andExpect(status().isOk()) @@ -124,8 +124,8 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest { void picker_MoodAndBudget_BothMustFit() throws Exception { game(10L, GameStatus.BACKLOG, null, 300); game(20L, GameStatus.BACKLOG, null, 3000); - gameMoodsRepository.upsert(10L, new String[]{"STORY"}, false, Instant.now()); - gameMoodsRepository.upsert(20L, new String[]{"STORY"}, false, Instant.now()); + gameMoodsRepository.upsert(10L, new String[]{"STORY"}, new String[0], false, Instant.now()); + gameMoodsRepository.upsert(20L, new String[]{"STORY"}, new String[0], false, Instant.now()); mockMvc.perform(get("/api/library/picker").param("mood", "STORY").param("maxMinutes", "900")) .andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10))); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java new file mode 100644 index 0000000..a5a7b36 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerStatsTest.java @@ -0,0 +1,107 @@ +package core_service.modules.library.api; + +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.GameTimeToBeat; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.GameTimeToBeatRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.library.service.LibrarySyncService; +import core_service.modules.library.service.MoodService; +import core_service.modules.library.service.SpeedrunService; +import core_service.shared.test.AbstractIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; + +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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 LibraryControllerStatsTest extends AbstractIntegrationTest { + + private static final long USER_ID = 1L; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; + + // The same mocks as LibraryControllerPickerTest, so both share one Spring context. + @MockitoBean + private LibrarySyncService syncService; + + @MockitoBean + private MoodService moodService; + + @MockitoBean + private SpeedrunService speedrunService; + + @BeforeEach + void seed() { + userGameRepository.deleteAll(); + steamGameRepository.saveAll(List.of( + new SteamGame(220L, "Half-Life 2", null, Instant.now()), + new SteamGame(620L, "Portal 2", null, Instant.now()))); + UserGame finished = new UserGame(USER_ID, 620L, 600, Instant.now(), Instant.now()); + finished.setStatus(GameStatus.FINISHED); + userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), finished)); + gameTimeToBeatRepository.save(new GameTimeToBeat(220L, 500, 700, 900, Instant.now())); + } + + @Test + void stats_NoGroups_HasEveryGroup() throws Exception { + mockMvc.perform(get("/api/library/stats")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.overview.totalGames").value(2)) + .andExpect(jsonPath("$.overview.neverStarted").value(1)) + .andExpect(jsonPath("$.overview.byStatus.DROPPED").value(0)) + .andExpect(jsonPath("$.pile.backlogGames").value(1)) + .andExpect(jsonPath("$.pile.normalMinutes").value(700)) + .andExpect(jsonPath("$.pile.paceWeeks").value(nullValue())) + .andExpect(jsonPath("$.pile.oldestUnplayed.name").value("Half-Life 2")) + .andExpect(jsonPath("$.topGames.mostPlayed[0].appid").value(620)) + .andExpect(jsonPath("$.taste.genres").isArray()) + .andExpect(jsonPath("$.habits.underOneHourGames").value(0)) + .andExpect(jsonPath("$.finished.finishedBeforeTracking").value(1)); + verify(moodService).refreshListedInBackground(USER_ID); + } + + @Test + void stats_Groups_OnlyThose() throws Exception { + mockMvc.perform(get("/api/library/stats").param("groups", "PILE,HABITS")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pile.backlogGames").value(1)) + .andExpect(jsonPath("$.habits").exists()) + .andExpect(jsonPath("$.overview").value(nullValue())) + .andExpect(jsonPath("$.taste").value(nullValue())); + verify(moodService, never()).refreshListedInBackground(USER_ID); + } + + @Test + void stats_UnknownGroup_Is400() throws Exception { + mockMvc.perform(get("/api/library/stats").param("groups", "MONEY")) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java index f196005..bf23f51 100644 --- a/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java +++ b/src/test/java/core_service/modules/library/api/PublicProfileControllerTest.java @@ -23,6 +23,7 @@ import java.util.Date; import java.util.List; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; 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; @@ -52,6 +53,12 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact(); } + private static String ticket(long userId, List profileStats) { + return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId)) + .claim("profileStats", profileStats) + .expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact(); + } + private UserGame game(long appid, int playtime, GameStatus status, Integer rating) { UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now()); game.setStatus(status); @@ -114,6 +121,27 @@ class PublicProfileControllerTest extends AbstractIntegrationTest { .andExpect(jsonPath("$.averageCompletion").value(62.5)); } + @Test + void publicLibrary_TicketWithoutStatGroups_HasNoStats() throws Exception { + mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.stats").value(nullValue())); + mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket(USER_ID, List.of()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.stats").value(nullValue())); + } + + @Test + void publicLibrary_OnlyTheSharedStatGroups() throws Exception { + mockMvc.perform(get("/api/public/profiles/library") + .header("X-Profile-Ticket", ticket(USER_ID, List.of("TOP_GAMES", "OVERVIEW", "MONEY")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.stats.topGames.mostPlayed[0].name").value("Portal 2")) + .andExpect(jsonPath("$.stats.overview").value(nullValue())) + .andExpect(jsonPath("$.stats.pile").value(nullValue())) + .andExpect(jsonPath("$.stats.trend").value(nullValue())); + } + @Test void publicLibrary_WithoutOrWithABadTicket_Is401() throws Exception { mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized()); diff --git a/src/test/java/core_service/modules/library/domain/StatFormulasTest.java b/src/test/java/core_service/modules/library/domain/StatFormulasTest.java new file mode 100644 index 0000000..a7780e2 --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/StatFormulasTest.java @@ -0,0 +1,42 @@ +package core_service.modules.library.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +class StatFormulasTest extends AbstractUnitTest { + + @Test + void workYears_Are2080HoursEach() { + assertThat(StatFormulas.workYears(2080 * 60 * 3L)).isEqualTo(3.0, within(1e-9)); + } + + @Test + void moonTrips_Are195Point3HoursEach() { + assertThat(StatFormulas.moonTrips(Math.round(195.3 * 60 * 2))).isEqualTo(2.0, within(1e-9)); + } + + @Test + void minutesPerWeek_IsTheAverageOfThe90Days() { + assertThat(StatFormulas.minutesPerWeek(90 * 60)).isEqualTo(420); + } + + @Test + void paceWeeks_PileDividedByTheWeeklyPlaytime() { + // 420 minutes a week, 4200 minutes of pile. + assertThat(StatFormulas.paceWeeks(4200, 90 * 60)).isEqualTo(10.0, within(1e-9)); + } + + @Test + void paceWeeks_NoPlaytime_IsNull() { + assertThat(StatFormulas.paceWeeks(4200, 0)).isNull(); + } + + @Test + void share_NoWhole_IsNull() { + assertThat(StatFormulas.share(0, 0)).isNull(); + assertThat(StatFormulas.share(1, 4)).isEqualTo(0.25); + } +} diff --git a/src/test/java/core_service/modules/library/domain/StatGroupTest.java b/src/test/java/core_service/modules/library/domain/StatGroupTest.java new file mode 100644 index 0000000..4cee346 --- /dev/null +++ b/src/test/java/core_service/modules/library/domain/StatGroupTest.java @@ -0,0 +1,19 @@ +package core_service.modules.library.domain; + +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class StatGroupTest extends AbstractUnitTest { + + @Test + void shared_LeavesOutOverviewAndUnknownNames() { + assertThat(StatGroup.shared(List.of("PILE", "OVERVIEW", "MONEY", "TREND"))) + .isEqualTo(Set.of(StatGroup.PILE, StatGroup.TREND)); + assertThat(StatGroup.shared(List.of())).isEmpty(); + } +} diff --git a/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java b/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java new file mode 100644 index 0000000..bc58d6a --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/BacklogSnapshotRepositoryTest.java @@ -0,0 +1,106 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.GameTimeToBeat; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserStats; +import core_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(BacklogSnapshotRepository.class) +class BacklogSnapshotRepositoryTest extends AbstractPersistenceTest { + + private static final LocalDate DAY = LocalDate.of(2026, 9, 27); + + @Autowired + private BacklogSnapshotRepository repository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + // V16 wrote a snapshot of the migration day; the tests start empty. + jdbc.update("delete from backlog_snapshot"); + steamGameRepository.saveAllAndFlush(List.of( + new SteamGame(220L, "Half-Life 2", null, Instant.now()), + new SteamGame(620L, "Portal 2", null, Instant.now()), + new SteamGame(1000L, "Playtest", null, Instant.now()), + new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()))); + gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 1, 600, 1, Instant.now()), + new GameTimeToBeat(1000L, 1, 9999, 1, Instant.now()))); + } + + private UserGame game(long userId, long appid, int playtime, GameStatus status) { + UserGame game = new UserGame(userId, appid, playtime, Instant.now(), Instant.now()); + game.setStatus(status); + return game; + } + + @Test + void writeAll_CountsTheListedGamesOfEveryUser() { + UserGame hidden = game(1L, 1000L, 0, GameStatus.BACKLOG); + hidden.setHiddenAt(Instant.now()); + UserGame removed = game(1L, 620L, 0, GameStatus.BACKLOG); + removed.setRemovedAt(Instant.now()); + userGameRepository.saveAllAndFlush(List.of( + game(1L, 220L, 0, GameStatus.BACKLOG), hidden, removed, + UserGame.manual(2L, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now()))); + + assertThat(repository.writeAll(DAY)).isEqualTo(2); + + assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 1, 1, 600)); + assertThat(repository.findSince(2L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 1, 0)); + } + + @Test + void writeAll_SecondRunOnTheSameDay_ReplacesTheRow() { + UserGame game = game(1L, 220L, 0, GameStatus.BACKLOG); + userGameRepository.saveAndFlush(game); + repository.writeAll(DAY); + jdbc.update("update user_game set status = 'PLAYING', playtime_minutes = 30 where appid = 220"); + + repository.writeAll(DAY); + + assertThat(repository.findSince(1L, DAY)).containsExactly(new UserStats.PileDay(DAY, 0, 0, 0)); + } + + @Test + void findSince_LeavesOutOlderDays() { + userGameRepository.saveAndFlush(game(1L, 220L, 0, GameStatus.BACKLOG)); + repository.writeAll(DAY.minusDays(100)); + repository.writeAll(DAY.minusDays(1)); + repository.writeAll(DAY); + + assertThat(repository.findSince(1L, DAY.minusDays(89))).extracting(UserStats.PileDay::day) + .containsExactly(DAY.minusDays(1), DAY); + } + + @Test + void live_IsThePileOfNow() { + userGameRepository.saveAndFlush(game(1L, 220L, 0, GameStatus.BACKLOG)); + + assertThat(repository.live(1L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 1, 1, 600)); + assertThat(repository.live(3L, DAY)).isEqualTo(new UserStats.PileDay(DAY, 0, 0, 0)); + } +} diff --git a/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java index 4f3712c..58aa9e2 100644 --- a/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/GameMoodsRepositoryTest.java @@ -28,30 +28,41 @@ class GameMoodsRepositoryTest extends AbstractPersistenceTest { @Test void upsert_StoresTheEndlessFlag() { - gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, true, Instant.now()); + gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, new String[0], true, Instant.now()); entityManager.clear(); assertThat(gameMoodsRepository.findById(427520L).orElseThrow().getEndless()).isTrue(); - gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, false, Instant.now()); + gameMoodsRepository.upsert(427520L, new String[]{"CHILL"}, new String[0], false, Instant.now()); entityManager.clear(); assertThat(gameMoodsRepository.findById(427520L).orElseThrow().getEndless()).isFalse(); } @Test void upsert_StoresTheMoodsAndReplacesThemOnTheNextRefresh() { - gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, false, Instant.now()); + gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, new String[0], false, Instant.now()); assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).containsExactly("CHILL", "COOP"); - gameMoodsRepository.upsert(620L, new String[0], false, Instant.now()); + gameMoodsRepository.upsert(620L, new String[0], new String[0], false, Instant.now()); // The native upsert passes the persistence context; read the row again. entityManager.clear(); assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).isEmpty(); } + @Test + void upsert_StoresTheGenresAndReplacesThemOnTheNextRefresh() { + gameMoodsRepository.upsert(620L, new String[0], new String[]{"Platform", "Puzzle"}, false, Instant.now()); + entityManager.clear(); + assertThat(gameMoodsRepository.findById(620L).orElseThrow().getGenres()).containsExactly("Platform", "Puzzle"); + + gameMoodsRepository.upsert(620L, new String[0], new String[]{"Puzzle"}, false, Instant.now()); + entityManager.clear(); + assertThat(gameMoodsRepository.findById(620L).orElseThrow().getGenres()).containsExactly("Puzzle"); + } + @Test void findFreshAppids_LeavesOutOldAndMissingRows() { - gameMoodsRepository.upsert(620L, new String[]{"STORY"}, false, Instant.now()); - gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, false, Instant.now().minus(Duration.ofDays(40))); + gameMoodsRepository.upsert(620L, new String[]{"STORY"}, new String[0], false, Instant.now()); + gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, new String[0], false, Instant.now().minus(Duration.ofDays(40))); assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30)))) .isEqualTo(Set.of(620L)); diff --git a/src/test/java/core_service/modules/library/repo/StatusChangeLogTest.java b/src/test/java/core_service/modules/library/repo/StatusChangeLogTest.java new file mode 100644 index 0000000..b4b124b --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/StatusChangeLogTest.java @@ -0,0 +1,111 @@ +package core_service.modules.library.repo; + +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.shared.test.AbstractPersistenceTest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The trigger of V14 that writes user_game_status_change. + */ +class StatusChangeLogTest extends AbstractPersistenceTest { + + private static final long USER_ID = 1L; + private static final long APPID = 620L; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private EntityManager entityManager; + + @BeforeEach + void seed() { + steamGameRepository.save(new SteamGame(APPID, "Portal 2", null, Instant.now())); + steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now())); + } + + /** Each row as "from->to by_user". */ + private List log() { + entityManager.flush(); + @SuppressWarnings("unchecked") + List rows = entityManager.createNativeQuery( + "select from_status, to_status, by_user from user_game_status_change order by id").getResultList(); + return rows.stream().map(row -> row[0] + "->" + row[1] + " " + row[2]).toList(); + } + + private UserGame game(long appid) { + return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow(); + } + + @Test + void syncInsert_LogsTheFirstStatusNotByUser() { + userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now())); + + assertThat(log()).containsExactly("null->BACKLOG false"); + } + + @Test + void manualAdd_LogsTheStatusByUser() { + userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now())); + + assertThat(log()).containsExactly("null->PLAYING true"); + } + + @Test + void statusChangedByUser_IsLoggedWithTheOldStatus() { + userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now())); + entityManager.flush(); + UserGame game = game(APPID); + game.setStatus(GameStatus.DROPPED); + game.setStatusSetByUser(true); + + assertThat(log()).containsExactly("null->BACKLOG false", "BACKLOG->DROPPED true"); + } + + @Test + void otherFieldsOrTheSameStatus_LogNothing() { + userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now())); + entityManager.flush(); + UserGame game = game(APPID); + game.setStatus(GameStatus.BACKLOG); + game.setStatusSetByUser(true); + game.setRating(4); + game.setNotes("later"); + + assertThat(log()).containsExactly("null->BACKLOG false"); + } + + @Test + void finishIfUntouched_BulkUpdateIsLoggedNotByUser() { + userGameRepository.save(new UserGame(USER_ID, APPID, 0, Instant.now(), Instant.now())); + entityManager.flush(); + + assertThat(userGameRepository.finishIfUntouched(USER_ID, APPID)).isEqualTo(1); + + assertThat(log()).containsExactly("null->BACKLOG false", "BACKLOG->FINISHED false"); + } + + @Test + void deletedGame_TakesItsLogRowsAlong() { + userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 0, Instant.now())); + entityManager.flush(); + + userGameRepository.delete(game(-1942L)); + + assertThat(log()).isEmpty(); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java index 711a391..bd29a3a 100644 --- a/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserGameRepositoryTest.java @@ -9,6 +9,7 @@ import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGameId; import core_service.shared.test.AbstractPersistenceTest; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -45,6 +46,9 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { @Autowired private PlatformTransactionManager transactionManager; + @Autowired + private EntityManager entityManager; + @BeforeEach void seed() { steamGameRepository.saveAll(List.of( @@ -65,6 +69,37 @@ class UserGameRepositoryTest extends AbstractPersistenceTest { .map(UserGame::getAppid).getContent(); } + @Test + void playFromPileIfUntouched_OnlyUntouchedListedBacklogGames() { + userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setStatus(GameStatus.BACKLOG); + userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow().setStatusSetByUser(true); + userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setHiddenAt(Instant.now()); + userGameRepository.flush(); + + int changed = userGameRepository.playFromPileIfUntouched(USER_ID, List.of(220L, 380L, 620L, 700L)); + entityManager.clear(); + + assertEquals(1, changed); + assertEquals(GameStatus.PLAYING, status(220L)); + assertEquals(GameStatus.BACKLOG, status(380L)); + assertEquals(GameStatus.PLAYING, status(620L)); + assertEquals(GameStatus.BACKLOG, status(700L)); + } + + @Test + void playFromPileIfUntouched_AStatusTheUserChoseWins() { + UserGame game = userGameRepository.findById(new UserGameId(USER_ID, 380L)).orElseThrow(); + game.setStatus(GameStatus.FINISHED); + game.setStatusSetByUser(true); + userGameRepository.flush(); + + assertEquals(0, userGameRepository.playFromPileIfUntouched(USER_ID, List.of(380L))); + } + + private GameStatus status(long appid) { + return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow().getStatus(); + } + @Test void softDeleteMissingSince_KeepsGamesAddedByHand() { steamGameRepository.save(new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now())); diff --git a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java new file mode 100644 index 0000000..3dc1235 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java @@ -0,0 +1,300 @@ +package core_service.modules.library.repo; + +import core_service.modules.library.domain.DayMinutes; +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.GameTimeToBeat; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.domain.UserStats; +import core_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(UserStatsRepository.class) +class UserStatsRepositoryTest extends AbstractPersistenceTest { + + private static final long USER_ID = 1L; + private static final long OTHER_USER = 2L; + private static final long HIDDEN = 1000L; + private static final long REMOVED = 2000L; + private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); + + @Autowired + private UserStatsRepository repository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private GameTimeToBeatRepository gameTimeToBeatRepository; + + @Autowired + private GameMoodsRepository gameMoodsRepository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + steamGameRepository.saveAllAndFlush(List.of( + new SteamGame(220L, "Half-Life 2", null, Instant.now()), + new SteamGame(620L, "Portal 2", null, Instant.now()), + new SteamGame(700L, "100% Orange Juice", null, Instant.now()), + new SteamGame(HIDDEN, "Playtest", null, Instant.now()), + new SteamGame(REMOVED, "Refunded", null, Instant.now()))); + } + + private UserGame game(long appid, int playtime, GameStatus status, Instant firstSeenAt) { + UserGame game = new UserGame(USER_ID, appid, playtime, firstSeenAt, firstSeenAt); + game.setStatus(status); + return game; + } + + /** A hidden and a removed game that must never count, with the biggest numbers of all. */ + private void seedHiddenAndRemoved() { + UserGame hidden = game(HIDDEN, 90_000, GameStatus.BACKLOG, Instant.EPOCH); + hidden.setHiddenAt(Instant.now()); + UserGame removed = game(REMOVED, 0, GameStatus.BACKLOG, Instant.EPOCH); + removed.setRemovedAt(Instant.now()); + userGameRepository.saveAllAndFlush(List.of(hidden, removed)); + gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(HIDDEN, 1, 99_999, 99_999, Instant.now()), + new GameTimeToBeat(REMOVED, 1, 99_999, 99_999, Instant.now()))); + gameMoodsRepository.upsert(HIDDEN, new String[]{"CHILL"}, new String[]{"Simulator"}, true, Instant.now()); + addDay(HIDDEN, LocalDate.now(BERLIN), 5000); + } + + private void addDay(long appid, LocalDate day, int minutes) { + jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?, ?, ?)", + USER_ID, day, appid, minutes); + } + + @Test + void pileTimes_SumsTheListedBacklogOnly() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 0, GameStatus.BACKLOG, Instant.now()), + game(620L, 0, GameStatus.FINISHED, Instant.now()), + game(700L, 0, GameStatus.BACKLOG, Instant.now()))); + gameTimeToBeatRepository.saveAllAndFlush(List.of(new GameTimeToBeat(220L, 300, 600, 1200, Instant.now()), + new GameTimeToBeat(620L, 300, 500, 800, Instant.now()))); + seedHiddenAndRemoved(); + + assertThat(repository.pileTimes(USER_ID)).isEqualTo(new UserStats.PileTimes(600, 1200, 1)); + } + + @Test + void minutesPlayedSince_LeavesOutOlderDaysAndHiddenGames() { + userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now()))); + seedHiddenAndRemoved(); + LocalDate today = LocalDate.now(BERLIN); + addDay(220L, today, 60); + addDay(220L, today.minusDays(89), 30); + addDay(220L, today.minusDays(90), 1000); + + assertThat(repository.minutesPlayedSince(USER_ID, today.minusDays(89))).isEqualTo(90); + } + + @Test + void oldestUnplayed_IsTheFirstSeenUnplayedPileGame_LowestAppidOnATie() { + Instant firstSync = Instant.parse("2026-01-01T00:00:00Z"); + userGameRepository.saveAllAndFlush(List.of( + game(700L, 0, GameStatus.BACKLOG, firstSync), + game(620L, 0, GameStatus.BACKLOG, firstSync), + game(220L, 10, GameStatus.BACKLOG, Instant.EPOCH))); + seedHiddenAndRemoved(); + + UserStats.OldestGame oldest = repository.oldestUnplayed(USER_ID).orElseThrow(); + assertThat(oldest.appid()).isEqualTo(620L); + assertThat(oldest.name()).isEqualTo("Portal 2"); + assertThat(oldest.inLibrarySince()).isEqualTo(firstSync); + } + + @Test + void mostPlayed_OrdersByPlaytimeAndLeavesOutUnplayedAndHidden() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 300, GameStatus.PLAYING, Instant.now()), + game(620L, 900, GameStatus.FINISHED, Instant.now()), + game(700L, 0, GameStatus.BACKLOG, Instant.now()))); + seedHiddenAndRemoved(); + + assertThat(repository.mostPlayed(USER_ID, 5)).extracting(UserStats.GameMinutes::appid) + .containsExactly(620L, 220L); + } + + @Test + void mostLoyal_MostMonthsWins_MoreMinutesBreakATie() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 300, GameStatus.PLAYING, Instant.now()), + game(620L, 900, GameStatus.PLAYING, Instant.now()), + game(700L, 900, GameStatus.PLAYING, Instant.now()))); + seedHiddenAndRemoved(); + addDay(220L, LocalDate.of(2026, 7, 1), 10); + addDay(220L, LocalDate.of(2026, 8, 1), 10); + addDay(620L, LocalDate.of(2026, 7, 1), 500); + addDay(620L, LocalDate.of(2026, 7, 2), 500); + addDay(700L, LocalDate.of(2026, 7, 3), 30); + addDay(700L, LocalDate.of(2026, 9, 3), 30); + + UserStats.LoyalGame loyal = repository.mostLoyal(USER_ID).orElseThrow(); + assertThat(loyal.appid()).isEqualTo(700L); + assertThat(loyal.months()).isEqualTo(2); + assertThat(loyal.minutes()).isEqualTo(60); + } + + @Test + void mostLoyal_ARemovedGameIsNoCandidate() { + userGameRepository.saveAllAndFlush(List.of(game(220L, 300, GameStatus.PLAYING, Instant.now()))); + seedHiddenAndRemoved(); + addDay(220L, LocalDate.of(2026, 7, 1), 10); + addDay(REMOVED, LocalDate.of(2026, 7, 1), 10); + addDay(REMOVED, LocalDate.of(2026, 8, 1), 10); + + assertThat(repository.mostLoyal(USER_ID)).map(UserStats.LoyalGame::appid).contains(220L); + } + + @Test + void genresAndMoods_CountGamesAndPlaytimePerTag() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 300, GameStatus.PLAYING, Instant.now()), + game(620L, 900, GameStatus.FINISHED, Instant.now()))); + seedHiddenAndRemoved(); + gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, new String[]{"Shooter"}, false, Instant.now()); + gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, new String[]{"Platform", "Shooter"}, false, + Instant.now()); + + assertThat(repository.genres(USER_ID, 8)).containsExactly( + new UserStats.TagCount("Shooter", 2, 1200), + new UserStats.TagCount("Platform", 1, 900)); + assertThat(repository.moods(USER_ID)).containsExactly( + new UserStats.TagCount("CHILL", 1, 900), + new UserStats.TagCount("COOP", 1, 900), + new UserStats.TagCount("ACTION", 1, 300)); + } + + @Test + void longestDay_SumsAllGamesOfTheDay() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 300, GameStatus.PLAYING, Instant.now()), + game(620L, 900, GameStatus.PLAYING, Instant.now()))); + seedHiddenAndRemoved(); + addDay(220L, LocalDate.of(2026, 7, 1), 100); + addDay(620L, LocalDate.of(2026, 7, 1), 100); + addDay(620L, LocalDate.of(2026, 7, 2), 150); + + assertThat(repository.longestDay(USER_ID)).contains(new DayMinutes(LocalDate.of(2026, 7, 1), 200)); + } + + @Test + void playedCounts_UnderOneHourIsOneTo59Minutes() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 59, GameStatus.PLAYING, Instant.now()), + game(620L, 60, GameStatus.PLAYING, Instant.now()), + game(700L, 0, GameStatus.BACKLOG, Instant.now()))); + seedHiddenAndRemoved(); + + assertThat(repository.playedCounts(USER_ID)).isEqualTo(new UserStats.PlayedCounts(2, 1)); + } + + @Test + void finishedPerYear_CountsAGameOnceInTheYearItFirstGotFinished() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 300, GameStatus.BACKLOG, Instant.now()), + game(620L, 300, GameStatus.BACKLOG, Instant.now()), + game(700L, 300, GameStatus.BACKLOG, Instant.now()))); + // 23:30 UTC on New Year's Eve is already the next year in Berlin. + jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user, changed_at) " + + "values (?, 220, 'BACKLOG', 'FINISHED', true, timestamptz '2024-12-31 23:30:00+00')", USER_ID); + setStatus(220L, "FINISHED"); + setStatus(620L, "FINISHED"); + setStatus(620L, "COMPLETED"); + setStatus(700L, "FINISHED"); + setStatus(700L, "PLAYING"); + + int thisYear = LocalDate.now(BERLIN).getYear(); + assertThat(repository.finishedPerYear(USER_ID, BERLIN)).containsExactly( + new UserStats.YearCount(2025, 1), + new UserStats.YearCount(thisYear, 1)); + } + + @Test + void finishedBeforeTracking_FinishedGamesThatCameInFinished() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 300, GameStatus.FINISHED, Instant.now()), + game(620L, 300, GameStatus.BACKLOG, Instant.now()))); + setStatus(620L, "COMPLETED"); + + assertThat(repository.finishedBeforeTracking(USER_ID)).isEqualTo(1); + } + + @Test + void finished_HiddenAndOtherUsersGamesDoNotCount() { + UserGame hidden = game(HIDDEN, 0, GameStatus.BACKLOG, Instant.now()); + hidden.setHiddenAt(Instant.now()); + UserGame other = new UserGame(OTHER_USER, 220L, 0, Instant.now(), Instant.now()); + userGameRepository.saveAllAndFlush(List.of(hidden, other)); + jdbc.update("update user_game set status = 'FINISHED' where appid in (?, 220)", HIDDEN); + + assertThat(repository.finishedPerYear(USER_ID, BERLIN)).isEmpty(); + assertThat(repository.finishedBeforeTracking(USER_ID)).isZero(); + } + + @Test + void pileMoves_SyncInsertsAreNoAddBeforeTheBaseline_MovesAre() { + Instant monthStart = Instant.now().minusSeconds(3600); + userGameRepository.saveAllAndFlush(List.of( + game(220L, 0, GameStatus.BACKLOG, Instant.now()), + game(620L, 0, GameStatus.BACKLOG, Instant.now()))); + setStatus(220L, "DROPPED"); + setStatus(620L, "PLAYING"); + setStatus(620L, "BACKLOG"); + setStatus(620L, "FINISHED"); + + // No Steam baseline: the two inserts are the import; 620 back onto the pile is an add. + assertThat(repository.pileMoves(USER_ID, monthStart)).isEqualTo(new UserStats.MonthMoves(1, 2)); + } + + @Test + void pileMoves_GamesAddedByHandAreAdded() { + UserGame manual = UserGame.manual(USER_ID, 700L, "Switch", GameStatus.BACKLOG, 0, Instant.now()); + userGameRepository.saveAndFlush(manual); + + assertThat(repository.pileMoves(USER_ID, Instant.now().minusSeconds(3600))) + .isEqualTo(new UserStats.MonthMoves(1, 0)); + } + + @Test + void pileMoves_RemovedOrHiddenGamesAndOlderChangesDoNotCount() { + userGameRepository.saveAllAndFlush(List.of( + game(220L, 0, GameStatus.BACKLOG, Instant.now()), + game(620L, 0, GameStatus.BACKLOG, Instant.now()), + game(700L, 0, GameStatus.BACKLOG, Instant.now()))); + setStatus(220L, "DROPPED"); + setStatus(620L, "DROPPED"); + jdbc.update("update user_game set removed_at = now() where appid = 220"); + jdbc.update("update user_game set hidden_at = now() where appid = 620"); + jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user, changed_at) " + + "values (?, 700, 'BACKLOG', 'PLAYING', true, now() - interval '40 days')", USER_ID); + + jdbc.update("insert into user_steam_sync (user_id, steam_id, history_steam_id, history_since) " + + "values (?, '76561198000000001', '76561198000000001', now() - interval '1 day')", USER_ID); + + assertThat(repository.pileMoves(USER_ID, Instant.now().minusSeconds(86400))) + .isEqualTo(new UserStats.MonthMoves(1, 0)); + } + + private void setStatus(long appid, String status) { + jdbc.update("update user_game set status = ? where user_id = ? and appid = ?", status, USER_ID, appid); + } +} diff --git a/src/test/java/core_service/modules/library/service/FinishedEstimateServiceTest.java b/src/test/java/core_service/modules/library/service/FinishedEstimateServiceTest.java new file mode 100644 index 0000000..bdda6e9 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/FinishedEstimateServiceTest.java @@ -0,0 +1,122 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.GameAchievementSchema; +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.UserGameAchievement; +import core_service.modules.library.domain.UserGameId; +import core_service.modules.library.domain.UserStats; +import core_service.modules.library.repo.GameAchievementSchemaRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameAchievementRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.library.repo.UserStatsRepository; +import core_service.shared.test.AbstractPersistenceTest; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Against the real tables: the candidates query, the guarded update and the stats that read the estimate. + */ +@Import({FinishedEstimateService.class, UserStatsRepository.class}) +class FinishedEstimateServiceTest extends AbstractPersistenceTest { + + private static final long USER_ID = 1L; + private static final long ENDING_GAME = 220L; + private static final long NO_ENDING_GAME = 620L; + private static final Instant ENDING_UNLOCKED = Instant.parse("2021-05-04T20:00:00Z"); + + @Autowired + private FinishedEstimateService service; + + @Autowired + private UserStatsRepository userStatsRepository; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private GameAchievementSchemaRepository schemaRepository; + + @Autowired + private UserGameAchievementRepository userAchievementRepository; + + @Autowired + private EntityManager entityManager; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + steamGameRepository.saveAllAndFlush(List.of( + new SteamGame(ENDING_GAME, "Half-Life 2", null, Instant.now()), + new SteamGame(NO_ENDING_GAME, "Portal 2", null, Instant.now()))); + GameAchievementSchema ending = new GameAchievementSchema(ENDING_GAME, "ACH_END", "The End", "Complete the game.", + null, null, false); + ending.setGlobalPercent(40.0); + GameAchievementSchema other = new GameAchievementSchema(NO_ENDING_GAME, "ACH_JUMP", "Jumper", "Jump 100 times", + null, null, false); + other.setGlobalPercent(50.0); + schemaRepository.saveAllAndFlush(List.of(ending, other)); + // Came into the library finished: no finish in the status history. + UserGame finished = new UserGame(USER_ID, ENDING_GAME, 600, Instant.now(), Instant.now()); + finished.setStatus(GameStatus.FINISHED); + UserGame completed = new UserGame(USER_ID, NO_ENDING_GAME, 900, Instant.now(), Instant.now()); + completed.setStatus(GameStatus.COMPLETED); + userGameRepository.saveAllAndFlush(List.of(finished, completed)); + userAchievementRepository.saveAllAndFlush(List.of( + new UserGameAchievement(USER_ID, ENDING_GAME, "ACH_END", true, ENDING_UNLOCKED), + new UserGameAchievement(USER_ID, NO_ENDING_GAME, "ACH_JUMP", true, Instant.now()))); + } + + private Instant estimate(long appid) { + entityManager.clear(); + return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow().getFinishedAtEstimate(); + } + + @Test + void fillMissing_DatesTheGameByTheUnlockOfItsEnding() { + assertThat(service.fillMissing()).isEqualTo(1); + + assertThat(estimate(ENDING_GAME)).isEqualTo(ENDING_UNLOCKED); + assertThat(estimate(NO_ENDING_GAME)).isNull(); + assertThat(userStatsRepository.finishedPerYear(USER_ID, ZoneId.of("Europe/Berlin"))) + .containsExactly(new UserStats.YearCount(2021, 1)); + assertThat(userStatsRepository.finishedBeforeTracking(USER_ID)).isEqualTo(1); + } + + // E.g. Core's own FINISHED on the first sync of an old library, or the user marking old games finished. + @Test + void fillMissing_TheEndingWinsOverALaterFinishInTheStatusHistory() { + jdbc.update("update user_game set status = 'PLAYING' where appid = ?", ENDING_GAME); + jdbc.update("update user_game set status = 'FINISHED' where appid = ?", ENDING_GAME); + + service.fillMissing(); + + assertThat(estimate(ENDING_GAME)).isEqualTo(ENDING_UNLOCKED); + assertThat(userStatsRepository.finishedPerYear(USER_ID, ZoneId.of("Europe/Berlin"))) + .containsExactly(new UserStats.YearCount(2021, 1)); + } + + @Test + void fillMissing_SecondRun_ChangesNothing() { + service.fillMissing(); + + assertThat(service.fillMissing()).isZero(); + } +} diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java index 5510805..34bae71 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java @@ -1,23 +1,30 @@ package core_service.modules.library.service; import core_service.modules.library.domain.UserSteamSync; +import core_service.modules.library.repo.BacklogSnapshotRepository; import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.shared.test.AbstractUnitTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.springframework.test.util.ReflectionTestUtils; import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -34,6 +41,12 @@ class LibrarySyncServiceTest extends AbstractUnitTest { @Mock private LibrarySyncWorker worker; + @Mock + private BacklogSnapshotRepository backlogSnapshotRepository; + + @Mock + private FinishedEstimateService finishedEstimateService; + @InjectMocks private LibrarySyncService syncService; @@ -43,6 +56,7 @@ class LibrarySyncServiceTest extends AbstractUnitTest { ReflectionTestUtils.setField(syncService, "forceSyncCooldownSeconds", 60L); ReflectionTestUtils.setField(syncService, "runningStaleMinutes", 5L); ReflectionTestUtils.setField(syncService, "failedRetryMinutes", 15L); + ReflectionTestUtils.setField(syncService, "zone", ZoneId.of("Europe/Berlin")); } private void givenSync(String steamId, UserSteamSync.Status status, Duration age) { @@ -106,6 +120,41 @@ class LibrarySyncServiceTest extends AbstractUnitTest { verify(worker, never()).runSync(anyLong(), anyString()); } + @Test + void syncAllNightly_WritesTheBacklogSnapshotOfTheStartDayAfterTheSyncs() { + when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of( + new UserSteamSync(1L, "76561198000000001"))); + LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin")); + + syncService.syncAllNightly(); + + InOrder order = inOrder(worker, backlogSnapshotRepository, finishedEstimateService); + order.verify(worker).runSyncNow(1L, "76561198000000001"); + order.verify(backlogSnapshotRepository).writeAll(today); + order.verify(finishedEstimateService).fillMissing(); + } + + @Test + void syncAllNightly_SnapshotFails_TheFinishDatesAreStillEstimated() { + when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of()); + when(backlogSnapshotRepository.writeAll(any())).thenThrow(new IllegalStateException("db down")); + + syncService.syncAllNightly(); + + verify(finishedEstimateService).fillMissing(); + } + + @Test + void syncAllNightly_ASyncThrows_TheSnapshotIsStillWritten() { + when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of( + new UserSteamSync(1L, "76561198000000001"))); + doThrow(new IllegalStateException("db down")).when(worker).runSyncNow(anyLong(), anyString()); + + assertThrows(IllegalStateException.class, () -> syncService.syncAllNightly()); + + verify(backlogSnapshotRepository).writeAll(any()); + } + @Test void syncAllNightly_SyncsRecentlyUsedAccountsOneAfterTheOther() { when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of( diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index d998c1b..74d5bbe 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -239,6 +239,38 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60); } + @Test + void runSync_PlayedGames_GoFromThePileWithOneGuardedUpdate() { + UserGame existing = owned(0); + steamReturns(game(30, 30), STEAM_ID); + + worker.runSync(USER_ID, STEAM_ID); + + verify(userGameRepository).playFromPileIfUntouched(USER_ID, List.of(220L)); + // Never through the entity: a status the user saves during the sync would be overwritten. + assertEquals(GameStatus.BACKLOG, existing.getStatus()); + } + + @Test + void runSync_NoPlaytimeGrowth_LeavesThePileAlone() { + owned(100); + steamReturns(game(100, 0), STEAM_ID); + + worker.runSync(USER_ID, STEAM_ID); + + verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any()); + } + + @Test + void runSync_FirstSyncWithOldPlaytime_LeavesThePileAlone() { + owned(0); + steamReturns(game(5000, 0), null); + + worker.runSync(USER_ID, STEAM_ID); + + verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any()); + } + @Test void runSync_PlaytimeUnchangedOrLower_RecordsNothing() { owned(100); diff --git a/src/test/java/core_service/modules/library/service/MoodServiceTest.java b/src/test/java/core_service/modules/library/service/MoodServiceTest.java index b4607b7..2cc419f 100644 --- a/src/test/java/core_service/modules/library/service/MoodServiceTest.java +++ b/src/test/java/core_service/modules/library/service/MoodServiceTest.java @@ -55,9 +55,9 @@ class MoodServiceTest extends AbstractUnitTest { service.refresh(List.of(220L, 620L, 700L)); - verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), eq(false), any(Instant.class)); - verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), isNull(), any(Instant.class)); - verify(gameMoodsRepository, never()).upsert(eq(220L), any(), anyBoolean(), any()); + verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), aryEq(new String[]{"Puzzle"}), eq(false), any(Instant.class)); + verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), aryEq(new String[0]), isNull(), any(Instant.class)); + verify(gameMoodsRepository, never()).upsert(eq(220L), any(), any(), anyBoolean(), any()); } @Test @@ -69,7 +69,7 @@ class MoodServiceTest extends AbstractUnitTest { service.refresh(List.of(427520L)); - verify(gameMoodsRepository).upsert(eq(427520L), any(), eq(true), any(Instant.class)); + verify(gameMoodsRepository).upsert(eq(427520L), any(), any(), eq(true), any(Instant.class)); } @Test @@ -81,7 +81,7 @@ class MoodServiceTest extends AbstractUnitTest { service.refreshBacklogInBackground(7L); - verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), isNull(), any(Instant.class)); + verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), aryEq(new String[0]), isNull(), any(Instant.class)); } @Test @@ -93,7 +93,7 @@ class MoodServiceTest extends AbstractUnitTest { service.refresh(List.of(-119388L)); - verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), eq(false), any(Instant.class)); + verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), aryEq(new String[]{"Adventure"}), eq(false), any(Instant.class)); } @Test @@ -104,7 +104,7 @@ class MoodServiceTest extends AbstractUnitTest { service.refresh(List.of(620L)); - verify(gameMoodsRepository, never()).upsert(any(), any(), anyBoolean(), any()); + verify(gameMoodsRepository, never()).upsert(any(), any(), any(), anyBoolean(), any()); } @Test diff --git a/src/test/java/core_service/modules/library/service/UserStatsServiceTest.java b/src/test/java/core_service/modules/library/service/UserStatsServiceTest.java new file mode 100644 index 0000000..f930ec4 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/UserStatsServiceTest.java @@ -0,0 +1,97 @@ +package core_service.modules.library.service; + +import core_service.modules.library.api.dto.UserStatsDto; +import core_service.modules.library.domain.StatGroup; +import core_service.modules.library.domain.UserStats; +import core_service.modules.library.repo.BacklogSnapshotRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.library.repo.UserStatsRepository; +import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class UserStatsServiceTest extends AbstractUnitTest { + + private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); + + @Mock + private UserGameRepository userGameRepository; + + @Mock + private UserStatsRepository userStatsRepository; + + @Mock + private BacklogSnapshotRepository backlogSnapshotRepository; + + @InjectMocks + private UserStatsService service; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(service, "zone", BERLIN); + } + + @Test + void getStats_OnlyTheAskedGroups_TheOthersAreNullAndNotQueried() { + when(userStatsRepository.mostPlayed(7L, 5)).thenReturn(List.of()); + when(userStatsRepository.mostLoyal(7L)).thenReturn(Optional.empty()); + + UserStatsDto stats = service.getStats(7L, Set.of(StatGroup.TOP_GAMES)); + + assertThat(stats.topGames()).isNotNull(); + assertThat(stats.overview()).isNull(); + assertThat(stats.pile()).isNull(); + assertThat(stats.taste()).isNull(); + assertThat(stats.habits()).isNull(); + assertThat(stats.finished()).isNull(); + assertThat(stats.trend()).isNull(); + verifyNoInteractions(userGameRepository, backlogSnapshotRepository); + } + + @Test + void trend_NoSnapshotToday_AddsTheLivePile() { + LocalDate today = LocalDate.now(BERLIN); + UserStats.PileDay yesterday = new UserStats.PileDay(today.minusDays(1), 10, 5, 600); + UserStats.PileDay live = new UserStats.PileDay(today, 9, 5, 500); + when(backlogSnapshotRepository.findSince(7L, today.minusDays(89))).thenReturn(List.of(yesterday)); + when(backlogSnapshotRepository.live(7L, today)).thenReturn(live); + when(userStatsRepository.pileMoves(7L, today.withDayOfMonth(1).atStartOfDay(BERLIN).toInstant())) + .thenReturn(new UserStats.MonthMoves(0, 1)); + + UserStatsDto.Trend trend = service.getStats(7L, Set.of(StatGroup.TREND)).trend(); + + assertThat(trend.days()).containsExactly(yesterday, live); + assertThat(trend.thisMonth()).isEqualTo(new UserStats.MonthMoves(0, 1)); + } + + @Test + void trend_NoSnapshotYet_IsOnlyTheLivePile() { + LocalDate today = LocalDate.now(BERLIN); + when(backlogSnapshotRepository.live(7L, today)).thenReturn(new UserStats.PileDay(today, 9, 5, 500)); + when(userStatsRepository.pileMoves(7L, today.withDayOfMonth(1).atStartOfDay(BERLIN).toInstant())) + .thenReturn(new UserStats.MonthMoves(0, 0)); + + assertThat(service.getStats(7L, Set.of(StatGroup.TREND)).trend().days()).hasSize(1); + } + + @Test + void getStats_NoGroups_RunsNoQuery() { + UserStatsDto stats = service.getStats(7L, Set.of()); + + assertThat(stats).isEqualTo(new UserStatsDto(null, null, null, null, null, null, null)); + verifyNoInteractions(userGameRepository, userStatsRepository, backlogSnapshotRepository); + } +} diff --git a/src/test/java/core_service/modules/library/service/UserStatsServiceTrendTest.java b/src/test/java/core_service/modules/library/service/UserStatsServiceTrendTest.java new file mode 100644 index 0000000..63eeb60 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/UserStatsServiceTrendTest.java @@ -0,0 +1,88 @@ +package core_service.modules.library.service; + +import core_service.modules.library.domain.GameStatus; +import core_service.modules.library.domain.StatGroup; +import core_service.modules.library.domain.SteamGame; +import core_service.modules.library.domain.UserGame; +import core_service.modules.library.repo.BacklogSnapshotRepository; +import core_service.modules.library.repo.SteamGameRepository; +import core_service.modules.library.repo.UserGameRepository; +import core_service.modules.library.repo.UserStatsRepository; +import core_service.shared.test.AbstractPersistenceTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The trend group against the real status log and snapshots. + */ +@Import({UserStatsService.class, UserStatsRepository.class, BacklogSnapshotRepository.class}) +class UserStatsServiceTrendTest extends AbstractPersistenceTest { + + private static final long USER_ID = 1L; + + @Autowired + private UserStatsService service; + + @Autowired + private UserGameRepository userGameRepository; + + @Autowired + private SteamGameRepository steamGameRepository; + + @Autowired + private JdbcTemplate jdbc; + + @BeforeEach + void seed() { + jdbc.update("delete from backlog_snapshot"); + jdbc.update("delete from user_steam_sync"); + steamGameRepository.saveAllAndFlush(List.of( + new SteamGame(-1942L, "Zelda: Tears of the Kingdom", null, Instant.now()), + new SteamGame(220L, "Half-Life 2", null, Instant.now()), + new SteamGame(620L, "Portal 2", null, Instant.now()), + new SteamGame(700L, "100% Orange Juice", null, Instant.now()))); + } + + @Test + void thisMonth_FirstSteamImportAfterManualGames_IsNoAdd() { + // Last month the user had only a game added by hand, so the nightly job wrote a snapshot then. + userGameRepository.saveAndFlush(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.BACKLOG, 0, Instant.now())); + jdbc.update("update user_game_status_change set changed_at = now() - interval '40 days' where appid = -1942"); + LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin")); + jdbc.update("insert into backlog_snapshot (user_id, day, backlog_games, never_played, backlog_minutes, " + + "listed_games) values (?, ?, 1, 1, 0, 1)", USER_ID, today.minusDays(40)); + // Today the user links Steam: the first sync imports the Steam library and then sets history_since. + importSteamGames(); + jdbc.update("insert into user_steam_sync (user_id, steam_id, history_steam_id, history_since) " + + "values (?, '76561198000000001', '76561198000000001', now())", USER_ID); + + assertThat(service.getStats(USER_ID, Set.of(StatGroup.TREND)).trend().thisMonth().added()).isZero(); + } + + @Test + void thisMonth_NewSteamGamesAfterTheFirstSync_AreAdded() { + jdbc.update("insert into user_steam_sync (user_id, steam_id, history_steam_id, history_since) " + + "values (?, '76561198000000001', '76561198000000001', now() - interval '1 day')", USER_ID); + importSteamGames(); + + assertThat(service.getStats(USER_ID, Set.of(StatGroup.TREND)).trend().thisMonth().added()).isEqualTo(3); + } + + private void importSteamGames() { + userGameRepository.saveAllAndFlush(List.of( + new UserGame(USER_ID, 220L, 0, Instant.now(), Instant.now()), + new UserGame(USER_ID, 620L, 0, Instant.now(), Instant.now()), + new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now()))); + } +}