Public Access
feat(library): add stats page data and pile trend
Log every status change with a trigger, so no code path can miss it. Store IGDB genres with the moods. Write a pile snapshot per user each night. The sync moves an untouched pile game with new playtime to PLAYING in one guarded update, so a status the user picks wins. Old finished games get their year from the ending achievement. GET /api/library/stats serves the groups; the public profile shows only the groups in the profile ticket.
This commit is contained in:
1 parent
b959542e23
commit
0fa2a37f47
48 files changed
+2069
-60
No files matched your search
@@ -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<Long> verifyProfileTicket(String ticket) {
|
||||
public Optional<ProfileTicket> 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<String> 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();
|
||||
}
|
||||
|
||||
@@ -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<UserStatsDto> getStats(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam(required = false) Set<StatGroup> groups) {
|
||||
Long userId = principal.getIdAsLong();
|
||||
Set<StatGroup> 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<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return queryService.getSyncStatus(principal.getIdAsLong())
|
||||
|
||||
@@ -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.<Long>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
|
||||
.<ResponseEntity<?>>map(userId -> ResponseEntity.ok(queryService.getPublicLibrary(userId)))
|
||||
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
|
||||
.<ResponseEntity<?>>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")));
|
||||
}
|
||||
|
||||
@@ -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<Game> playing,
|
||||
List<Game> finished,
|
||||
int perfectGames,
|
||||
Double averageCompletion
|
||||
Double averageCompletion,
|
||||
UserStatsDto stats
|
||||
) {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<GameStatus, Long> 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<UserStats.GameMinutes> 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<UserStats.TagCount> genres, List<UserStats.TagCount> 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<UserStats.YearCount> 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<UserStats.PileDay> days, UserStats.MonthMoves thisMonth) {}
|
||||
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -30,6 +30,11 @@ public class GameMoods {
|
||||
@Column(name = "moods", nullable = false)
|
||||
private List<String> moods;
|
||||
|
||||
/** IGDB genre names, e.g. "Role-playing (RPG)". */
|
||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||
@Column(name = "genres", nullable = false)
|
||||
private List<String> genres;
|
||||
|
||||
@Column(name = "checked_at", nullable = false)
|
||||
private Instant checkedAt;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<StatGroup> shared(Collection<String> names) {
|
||||
Set<StatGroup> groups = EnumSet.noneOf(StatGroup.class);
|
||||
for (StatGroup group : values()) {
|
||||
if (group != OVERVIEW && names.contains(group.name())) {
|
||||
groups.add(group);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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<UserStats.PileDay> 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")));
|
||||
}
|
||||
}
|
||||
@@ -18,15 +18,16 @@ public interface GameMoodsRepository extends JpaRepository<GameMoods, Long> {
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
@@ -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<UserGame, UserGameId>
|
||||
"where " + LISTED + "group by ug.status")
|
||||
List<StatusCount> countByStatus(@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* Like {@link #countByStatus}, but with every status, also those with 0 games.
|
||||
*/
|
||||
default Map<GameStatus, Long> countEveryStatus(Long userId) {
|
||||
Map<GameStatus, Long> 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<Long> 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<UserGame, UserGameId>
|
||||
"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<FinishedWithoutDate> 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<Long> appids);
|
||||
|
||||
/**
|
||||
* Removes the Steam games the last sync did not see. Games added by hand never come from Steam and stay.
|
||||
*/
|
||||
|
||||
@@ -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<UserStats.OldestGame> oldestUnplayed(long userId) {
|
||||
return jdbc.query("""
|
||||
select ug.appid, sg.name, sg.image_url, ug.first_seen_at
|
||||
from user_game ug join steam_game sg on sg.appid = ug.appid
|
||||
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<UserStats.GameMinutes> mostPlayed(long userId, int limit) {
|
||||
return jdbc.query("""
|
||||
select ug.appid, sg.name, sg.image_url, ug.playtime_minutes
|
||||
from user_game ug join steam_game sg on sg.appid = ug.appid
|
||||
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<UserStats.LoyalGame> 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<UserStats.TagCount> genres(long userId, int limit) {
|
||||
return tagCounts(userId, "genres", limit);
|
||||
}
|
||||
|
||||
public List<UserStats.TagCount> moods(long userId) {
|
||||
return tagCounts(userId, "moods", Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
private List<UserStats.TagCount> tagCounts(long userId, String column, int limit) {
|
||||
return jdbc.query("""
|
||||
select tag.name, count(*) as games, coalesce(sum(ug.playtime_minutes), 0) as minutes
|
||||
from user_game ug join game_mood m on m.appid = ug.appid cross join unnest(m.%s) as tag(name)
|
||||
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<DayMinutes> 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<UserStats.YearCount> 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);
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,15 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
|
||||
@Param("status") String status,
|
||||
@Param("now") Instant now);
|
||||
|
||||
/**
|
||||
* history_since changes only with the account: the games of that first sync are no "added" in the pile trend.
|
||||
*/
|
||||
@Modifying
|
||||
@Query(value = "UPDATE user_steam_sync SET history_steam_id = :steamId WHERE user_id = :userId", nativeQuery = true)
|
||||
// NOW(), not the app clock: in the sync's transaction it equals the changed_at of the import rows, which the trend
|
||||
// compares with it. So this must run in the transaction that inserts them.
|
||||
@Query(value = "UPDATE user_steam_sync SET history_since = CASE WHEN history_steam_id IS DISTINCT FROM :steamId " +
|
||||
"THEN NOW() ELSE history_since END, history_steam_id = :steamId WHERE user_id = :userId",
|
||||
nativeQuery = true)
|
||||
void markHistoryBaseline(@Param("userId") Long userId, @Param("steamId") String steamId);
|
||||
|
||||
@Modifying
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package core_service.modules.library.service;
|
||||
|
||||
import core_service.modules.library.domain.EndingAchievement;
|
||||
import core_service.modules.library.domain.FinishedWithoutDate;
|
||||
import core_service.modules.library.domain.UserGameAchievement;
|
||||
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||
import core_service.modules.library.repo.UserGameAchievementRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Dates finished games by the unlock of their ending achievement: the status history knows only when the status was
|
||||
* set, which for an old game is long after the ending. The game is FINISHED already, so a hint-level ending (not
|
||||
* only a certain one) is good enough for the year.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FinishedEstimateService {
|
||||
|
||||
private final UserGameRepository userGameRepository;
|
||||
private final GameAchievementSchemaRepository schemaRepository;
|
||||
private final UserGameAchievementRepository userAchievementRepository;
|
||||
|
||||
/**
|
||||
* @return the number of games that got a date
|
||||
*/
|
||||
public int fillMissing() {
|
||||
Map<Long, List<FinishedWithoutDate>> byApp = userGameRepository.findFinishedWithoutDateCandidates().stream()
|
||||
.collect(Collectors.groupingBy(FinishedWithoutDate::appid));
|
||||
int dated = 0;
|
||||
for (Map.Entry<Long, List<FinishedWithoutDate>> app : byApp.entrySet()) {
|
||||
Optional<EndingAchievement> ending = EndingAchievement.of(schemaRepository.findByAppid(app.getKey()));
|
||||
if (ending.isEmpty()) continue;
|
||||
String apiName = ending.get().achievement().getApiName();
|
||||
for (FinishedWithoutDate game : app.getValue()) {
|
||||
Optional<UserGameAchievement> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<GameStatus, Long> byStatus = countByStatus(userId);
|
||||
Map<GameStatus, Long> 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<GameStatus, Long> byStatus = countByStatus(userId);
|
||||
/**
|
||||
* @param profileStats the stat group names of the profile ticket
|
||||
*/
|
||||
public PublicLibraryDto getPublicLibrary(Long userId, Collection<String> profileStats) {
|
||||
Map<GameStatus, Long> byStatus = userGameRepository.countEveryStatus(userId);
|
||||
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId,
|
||||
List.of(GameStatus.PLAYING),
|
||||
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))));
|
||||
@@ -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<AchievementProgress> started = startedGames(userGameRepository.findAchievementProgress(userId));
|
||||
Set<StatGroup> 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<GameStatus, Long> countByStatus(Long userId) {
|
||||
Map<GameStatus, Long> 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<PublicLibraryDto.Game> toPublicGames(List<UserGame> games, Map<Long, SteamGame> meta) {
|
||||
|
||||
@@ -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<UserSteamSync> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,10 +109,16 @@ public class LibrarySyncWorker {
|
||||
return;
|
||||
}
|
||||
|
||||
List<Long> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Mood> 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.
|
||||
*/
|
||||
|
||||
@@ -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<StatGroup> groups) {
|
||||
Map<GameStatus, Long> 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<GameStatus, Long> 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<UserStats.PileDay> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> profileStats) {}
|
||||
@@ -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<Long> verifyProfileTicket(String ticket);
|
||||
Optional<ProfileTicket> verifyProfileTicket(String ticket);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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<String> log() {
|
||||
entityManager.flush();
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object[]> 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();
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user