feat(library): show play per platform on game detail

Store PSN play per title. A PS title on a Steam row keeps its minutes.
Steam rtime_last_played now fills last played.
Dropped? questions read the newest PSN last played too.
This commit is contained in:
lobo committed 2026-10-09 13:55:57 +02:00
1 parent 413381d404
commit 1ff1e061c7
30 files changed
+707 -33

No files matched your search

@@ -16,7 +16,7 @@ public record GameDetailDto(
int playtime2Weeks,
Instant firstSeenAt,
Instant lastSeenAt,
// from PlayStation; null for Steam rows
// from PlayStation or Steam; null when not known
Instant lastPlayedAt,
GameStatus status,
Integer rating,
@@ -57,7 +57,11 @@ public record GameDetailDto(
// see GameDto#comingOn
LocalDate comingOn,
// Steam's set first, then the PlayStation sets, PS5 before PS4; the counts above are Steam's only
List<AchievementSetDto> achievementSets
List<AchievementSetDto> achievementSets,
// one line per platform of the entry, Steam first; empty for a hidden game
List<PlatformPlayDto> platforms,
// the sum of the platform lines; the counts above and playtimeMinutes stay this edition's own
int totalPlaytimeMinutes
) {
/**
@@ -19,6 +19,8 @@ import java.util.List;
* @param coming the game is not released yet and nobody played it; it does not count on the pile
* @param comingOn the release date of a coming game; null for released games and for "coming soon"
* @param platforms the platforms of every edition of the entry, Steam first; empty for a hidden game
* @param totalPlaytimeMinutes the playtime of every platform of the entry; {@code playtimeMinutes} stays the counted
* edition's own
*/
public record GameDto(
Long appid,
@@ -39,5 +41,6 @@ public record GameDto(
Integer editions,
boolean coming,
LocalDate comingOn,
List<PlatformDto> platforms
List<PlatformDto> platforms,
int totalPlaytimeMinutes
) {}
@@ -0,0 +1,18 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameSource;
import java.time.Instant;
/**
* One platform line of a game's detail.
*
* @param platform e.g. "PS5"; null when the edition has none
* @param appid the edition the line belongs to; its own detail page lists the achievements
* @param lastPlayedAt null when not known
* @param achievementsUnlocked null when the platform has no achievements for the user
* @param achievementsTotal null when the platform has no achievements for the user
* @param perfect every achievement unlocked, or every trophy set perfect
*/
public record PlatformPlayDto(GameSource source, String platform, long appid, int playtimeMinutes, Instant lastPlayedAt,
Integer achievementsUnlocked, Integer achievementsTotal, boolean perfect) {}
@@ -0,0 +1,54 @@
package core_service.modules.library.domain;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* One platform's play of a game entry: what the detail lists and the card totals. The SQL of
* {@code GameLinkRepository#entryPlaytimes} follows the same rule; a test pins both.
*
* @param platform e.g. "PS5"; null when the edition has none
* @param appid the edition the line comes from
* @param lastPlayedAt null when not known
*/
public record PlatformPlay(GameSource source, String platform, long appid, int playtimeMinutes, Instant lastPlayedAt) {
private static final Comparator<PlatformPlay> ORDER = Comparator
.comparingInt((PlatformPlay line) -> line.source() == GameSource.STEAM ? 0
: line.source() == GameSource.PSN ? 1 : 2)
.thenComparing(PlatformPlay::platform, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparingLong(PlatformPlay::appid);
/**
* A Steam row is one line; a PlayStation row is its titles, or itself while it has no stored title
* play; a manual row is replaced by the titles of its own platform; every title with stored play on any row adds its own PlayStation line.
*
* @param titles the stored play of the titles linked to the rows; other keys are ignored
*/
public static List<PlatformPlay> lines(List<UserGame> rows, List<TitlePlay> titles) {
List<PlatformPlay> lines = new ArrayList<>();
for (UserGame row : rows) {
List<TitlePlay> own = titles.stream().filter(title -> row.getAppid().equals(title.appid())).toList();
boolean replaced = switch (row.getSource()) {
case PSN -> !own.isEmpty();
case MANUAL -> own.stream().anyMatch(title -> row.getPlatform() != null
&& row.getPlatform().equals(title.platform()));
default -> false;
};
if (!replaced) {
lines.add(new PlatformPlay(row.getSource(), row.getPlatform(), row.getAppid(), row.getPlaytimeMinutes(),
row.getLastPlayedAt()));
}
own.forEach(title -> lines.add(new PlatformPlay(GameSource.PSN, title.platform(), row.getAppid(),
title.playtimeMinutes(), title.lastPlayedAt())));
}
lines.sort(ORDER);
return lines;
}
public static int total(List<PlatformPlay> lines) {
return (int) Math.min(lines.stream().mapToLong(PlatformPlay::playtimeMinutes).sum(), Integer.MAX_VALUE);
}
}
@@ -6,4 +6,9 @@ package core_service.modules.library.domain;
* @param listed true when the trophy rows are stored; false until a sync has fetched them
*/
public record PsnTrophySetProgress(String id, String platform, String name, int total, int earned,
boolean platinumEarned, boolean listed) {}
boolean platinumEarned, boolean listed) {
public boolean perfect() {
return platinumEarned || earned >= total;
}
}
@@ -0,0 +1,12 @@
package core_service.modules.library.domain;
import java.time.Instant;
/**
* The play of one PlayStation title (V44).
*
* @param appid the game key the title is linked to; null when saving, the title is addressed by its key
* @param platform "PS5" or "PS4"
* @param lastPlayedAt may be null
*/
public record TitlePlay(String psnKey, Long appid, String platform, int playtimeMinutes, Instant lastPlayedAt) {}
@@ -91,7 +91,7 @@ public class UserGame {
@Column(name = "import_id")
private Long importId;
/** From PlayStation; null for Steam rows. */
/** From PlayStation or Steam; null when not known. */
@Column(name = "last_played_at")
private Instant lastPlayedAt;
@@ -335,6 +335,34 @@ public class GameLinkRepository {
return platforms;
}
/**
* @return per given entry key, the playtime of all its editions: a row's own minutes plus the minutes of the
* PlayStation titles on it, except that a PlayStation row with title play, or a manual row with a title of its platform,
* counts its titles only (a PlayStation row's own minutes are their sum). Capped at the int range. Same rule as {@code PlatformPlay#total}.
*/
public Map<Long, Integer> entryPlaytimes(long userId, Collection<Long> appids) {
Map<Long, Integer> playtimes = new HashMap<>();
if (appids.isEmpty()) {
return playtimes;
}
jdbc.query("""
select e.appid, least(sum(case when (m.source = 'PSN' and t.minutes is not null)
or (m.source = 'MANUAL' and exists (
select 1 from psn_title_play x where x.user_id = :user
and x.appid = m.appid and x.platform = m.platform)) then 0
else m.playtime_minutes end + coalesce(t.minutes, 0)),
2147483647) as minutes
from user_game_entry_member e
join listed_user_game m on m.user_id = e.user_id and m.appid = e.member_appid
left join (select appid, sum(playtime_minutes) as minutes from psn_title_play
where user_id = :user group by appid) t on t.appid = m.appid
where e.user_id = :user and e.appid in (:appids)
group by e.appid
""", new MapSqlParameterSource("user", userId).addValue("appids", appids),
rs -> { playtimes.put(rs.getLong("appid"), rs.getInt("minutes")); });
return playtimes;
}
private static MapSqlParameterSource keys(long appid, long otherAppid) {
return new MapSqlParameterSource("a", appid).addValue("b", otherAppid);
}
@@ -1,13 +1,17 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.TitlePlay;
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.Timestamp;
import java.time.Instant;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -65,4 +69,40 @@ public class PsnTitleRepository {
psnKeys.stream().map(key -> new MapSqlParameterSource("user", userId).addValue("key", key))
.toArray(MapSqlParameterSource[]::new));
}
/**
* Stores the play of the LINKED and STEAM_OWNED titles among {@code plays}, addressed by {@code psnKey}; other
* rows are left alone.
*/
public void savePlays(long userId, List<TitlePlay> plays) {
if (plays.isEmpty()) {
return;
}
jdbc.batchUpdate("""
update user_psn_title set platform = :platform, playtime_minutes = :minutes, last_played_at = :played
where user_id = :user and psn_key = :key and state in ('LINKED', 'STEAM_OWNED')""",
plays.stream().map(play -> new MapSqlParameterSource("user", userId).addValue("key", play.psnKey())
.addValue("platform", play.platform()).addValue("minutes", play.playtimeMinutes())
.addValue("played", play.lastPlayedAt() == null ? null : Timestamp.from(play.lastPlayedAt())))
.toArray(MapSqlParameterSource[]::new));
}
/**
* The stored play of the titles linked to these keys (V44), a merged key resolved to its kept key.
*/
public List<TitlePlay> findPlays(long userId, Collection<Long> appids) {
if (appids.isEmpty()) {
return List.of();
}
return jdbc.query("""
select psn_key, appid, platform, playtime_minutes, last_played_at from psn_title_play
where user_id = :user and appid in (:appids) order by psn_key""",
new MapSqlParameterSource("user", userId).addValue("appids", appids),
(rs, row) -> new TitlePlay(rs.getString("psn_key"), rs.getLong("appid"), rs.getString("platform"),
rs.getInt("playtime_minutes"), instant(rs.getTimestamp("last_played_at"))));
}
private static Instant instant(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toInstant();
}
}
@@ -51,8 +51,9 @@ public class QuestionRepository {
left join game_time_to_beat t on t.appid = ug.appid
left join game_mood m on m.appid = ug.appid
left join (select m.counted as appid, max(og.playtime_2weeks) as playtime_2weeks,
max(og.last_played_at) as last_played_at
greatest(max(og.last_played_at), max(p.last_played_at)) as last_played_at
from members m join listed_user_game og on og.user_id = :userId and og.appid = m.member
left join psn_title_play p on p.user_id = :userId and p.appid = m.member
group by m.counted) a on a.appid = ug.appid
left join (select m.counted as appid, max(d.day) as last_day
from members m join user_game_playtime_day d on d.user_id = :userId and d.appid = m.member
@@ -6,19 +6,23 @@ import core_service.modules.library.api.dto.CorrectionMarkDto;
import core_service.modules.library.api.dto.EditionDto;
import core_service.modules.library.api.dto.FinishedHintDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.api.dto.PlatformPlayDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.FinishedHint;
import core_service.modules.library.domain.GameMoods;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.PlatformPlay;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySetProgress;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameSpeedrun;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -32,6 +36,7 @@ import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
@@ -81,6 +86,7 @@ public class GameDetailService {
private final GameLinkRepository gameLinkRepository;
private final GameMergeRepository mergeRepository;
private final PsnTrophyRepository psnTrophyRepository;
private final PsnTitleRepository psnTitleRepository;
@Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours;
@@ -207,7 +213,7 @@ public class GameDetailService {
return psnTrophyRepository.findSets(userGame.getUserId(), userGame.getAppid()).stream()
.map(set -> new AchievementSetDto(set.id(), AchievementKind.PSN, set.platform(), set.name(), set.total(),
AchievementProgress.capped(set.earned(), set.total()),
set.platinumEarned() || set.earned() >= set.total(), true, set.listed()))
set.perfect(), true, set.listed()))
.toList();
}
@@ -241,6 +247,8 @@ public class GameDetailService {
.orElse(null);
Optional<ComingGame> coming = comingGameRepository.findById(userGame.getAppid());
boolean hasAchievements = total > 0 || !psnSets.isEmpty();
List<Edition> linked = gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid());
List<PlatformPlay> plays = platformPlays(userGame, linked);
List<AchievementSetDto> sets = new ArrayList<>();
if (total > 0) {
sets.add(new AchievementSetDto("steam", AchievementKind.STEAM, null, null, total, unlocked,
@@ -284,12 +292,76 @@ public class GameDetailService {
gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream()
.map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList(),
gameLinkRepository.setOf(userGame.getAppid()).isPresent(),
gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid()).stream()
linked.stream()
.map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl()))
.toList(),
coming.isPresent(),
coming.map(ComingGame::getReleaseDate).orElse(null),
sets
sets,
plays.stream().map(line -> toDto(line, userGame, total, unlocked, statsVisible)).toList(),
PlatformPlay.total(plays)
);
}
// A hidden game shows only itself.
private List<PlatformPlay> platformPlays(UserGame userGame, List<Edition> linked) {
Long userId = userGame.getUserId();
List<UserGame> rows = new ArrayList<>(List.of(userGame));
if (userGame.getHiddenAt() == null) {
rows.addAll(userGameRepository.findAllById(
linked.stream().map(e -> new UserGameId(userId, e.appid())).toList()));
}
List<TitlePlay> titles = psnTitleRepository.findPlays(userId, rows.stream().map(UserGame::getAppid).toList());
return PlatformPlay.lines(rows, titles);
}
// total, unlocked and statsVisible are the current game's Steam values, already read.
private PlatformPlayDto toDto(PlatformPlay line, UserGame current, int total, int unlocked, boolean statsVisible) {
Long userId = current.getUserId();
Progress progress = switch (line.source()) {
case STEAM -> line.appid() == current.getAppid()
? steamProgress(total, unlocked, statsVisible) : steamProgress(userId, line.appid());
case PSN -> psnProgress(userId, line);
case MANUAL -> Progress.NONE;
};
return new PlatformPlayDto(line.source(), line.platform(), line.appid(), line.playtimeMinutes(),
line.lastPlayedAt(), progress.unlocked(), progress.total(), progress.perfect());
}
/**
* @param unlocked null when Steam hides the stats
*/
private record Progress(Integer unlocked, Integer total, boolean perfect) {
static final Progress NONE = new Progress(null, null, false);
}
private static Progress steamProgress(int total, int unlocked, boolean statsVisible) {
if (total <= 0) {
return Progress.NONE;
}
return new Progress(statsVisible ? unlocked : null, total, statsVisible && unlocked >= total);
}
private Progress steamProgress(Long userId, long appid) {
GameAchievementSchemaMeta schema = schemaMetaRepository.findById(appid).orElse(null);
if (schema == null || !schema.isHasSchema() || schema.getAchievementsTotal() <= 0) {
return Progress.NONE;
}
int total = schema.getAchievementsTotal();
UserGameAchievementMeta meta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
return steamProgress(total, meta == null ? 0 : AchievementProgress.capped(meta.getAchievementsUnlocked(), total),
meta == null || meta.isStatsVisible());
}
// The sets of the line's platform; every set of the key when the line has none.
private Progress psnProgress(Long userId, PlatformPlay line) {
List<PsnTrophySetProgress> sets = psnTrophyRepository.findSets(userId, line.appid()).stream()
.filter(set -> line.platform() == null || line.platform().equals(set.platform())).toList();
if (sets.isEmpty()) {
return Progress.NONE;
}
return new Progress(sets.stream().mapToInt(set -> AchievementProgress.capped(set.earned(), set.total())).sum(),
sets.stream().mapToInt(PsnTrophySetProgress::total).sum(),
sets.stream().allMatch(PsnTrophySetProgress::perfect));
}
}
@@ -288,6 +288,7 @@ public class LibraryQueryService {
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
Map<Long, List<EntryPlatform>> platforms = gameLinkRepository.entryPlatforms(games.getFirst().getUserId(), appids);
Map<Long, Integer> totals = gameLinkRepository.entryPlaytimes(games.getFirst().getUserId(), appids);
Map<Long, ComingGame> coming = comingOf(appids);
Map<Long, AchievementProgress> trophies = AchievementProgress
.perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream()
@@ -296,7 +297,7 @@ public class LibraryQueryService {
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()),
platforms.getOrDefault(ug.getAppid(), List.of()), coming.get(ug.getAppid()),
trophies.get(ug.getAppid())))
trophies.get(ug.getAppid()), totals.getOrDefault(ug.getAppid(), ug.getPlaytimeMinutes())))
.toList();
}
@@ -456,7 +457,7 @@ public class LibraryQueryService {
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks,
Integer editions, List<EntryPlatform> platforms, ComingGame coming,
AchievementProgress trophies) {
AchievementProgress trophies, int totalPlaytimeMinutes) {
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
@@ -486,7 +487,8 @@ public class LibraryQueryService {
editions,
coming != null,
coming != null ? coming.getReleaseDate() : null,
platforms.stream().map(p -> new PlatformDto(p.source(), p.platform())).toList());
platforms.stream().map(p -> new PlatformDto(p.source(), p.platform())).toList(),
totalPlaytimeMinutes);
}
// The tile shows what counts for the game: its best set, Steam's on a tie.
@@ -310,9 +310,12 @@ public class LibrarySyncWorker {
if (existing == null || existing.getRemovedAt() != null) {
changes.add(SyncChange.added(appid));
}
Instant lastPlayed = g.rtimeLastPlayed() != null && g.rtimeLastPlayed() > 0
? Instant.ofEpochSecond(g.rtimeLastPlayed()) : null;
if (existing == null) {
UserGame created = new UserGame(userId, appid, playtime, now, now);
created.setPlaytime2Weeks(playtime2Weeks);
created.setLastPlayedAt(lastPlayed);
userGameRepository.save(created);
} else {
existing.setPlaytimeMinutes(playtime);
@@ -321,6 +324,9 @@ public class LibrarySyncWorker {
existing.setRemovedAt(null);
existing.setSource(GameSource.STEAM);
existing.setPlatform(null);
if (lastPlayed != null) {
existing.setLastPlayedAt(lastPlayed);
}
}
if (playedOn == null || takesOverManualGame) {
return false;
@@ -6,6 +6,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameImportRepository;
@@ -112,6 +113,8 @@ public class PsnSyncStep {
}
}
saveReview(userId, review);
psnTitleRepository.savePlays(userId, lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList());
int added = changes.size();
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
@@ -21,6 +21,8 @@ public record SteamOwnedGamesResponse(
@JsonProperty("playtime_forever") Integer playtimeForever,
@JsonProperty("img_icon_url") String imgIconUrl,
@JsonProperty("has_community_visible_stats") Boolean hasCommunityVisibleStats,
@JsonProperty("playtime_2weeks") Integer playtime2Weeks
@JsonProperty("playtime_2weeks") Integer playtime2Weeks,
// unix seconds; 0 or absent when never played
@JsonProperty("rtime_last_played") Long rtimeLastPlayed
) {}
}
@@ -0,0 +1,27 @@
-- One entry per game across platforms (Gitea #34, slice 3). PlayStation play per title, so a title linked to a Steam
-- or manual row keeps its own platform, minutes and last played. Null until the next sync of the user.
ALTER TABLE user_psn_title
ADD COLUMN platform VARCHAR(8),
ADD COLUMN playtime_minutes INT,
ADD COLUMN last_played_at TIMESTAMPTZ;
-- The play of the user's linked titles per game key. A merge does not move user_psn_title.appid (readers map it
-- through game_alias), so the view resolves the alias. user_game columns are unchanged: no view rebuild.
CREATE VIEW psn_title_play AS
SELECT t.user_id, t.psn_key, COALESCE(a.to_appid, t.appid) AS appid, t.platform, t.playtime_minutes, t.last_played_at
FROM user_psn_title t
LEFT JOIN game_alias a ON a.from_appid = t.appid
WHERE t.state IN ('LINKED', 'STEAM_OWNED') AND t.playtime_minutes IS NOT NULL;
-- The PlayStation row of an entry platform resolves the alias too (V43 joined user_psn_title.appid directly, so a
-- title on a merged key lost its icon). Same columns as V43: replace in place.
CREATE OR REPLACE VIEW user_game_entry_platform AS
SELECT m.user_id, m.appid, m.member_appid, ug.source, COALESCE(ug.platform, '') AS platform
FROM user_game_entry_member m
JOIN user_game ug ON ug.user_id = m.user_id AND ug.appid = m.member_appid
UNION
SELECT m.user_id, m.appid, m.member_appid, 'PSN', ''
FROM user_game_entry_member m
JOIN user_psn_title t ON t.user_id = m.user_id AND t.state IN ('LINKED', 'STEAM_OWNED')
LEFT JOIN game_alias a ON a.from_appid = t.appid
WHERE COALESCE(a.to_appid, t.appid) = m.member_appid;
@@ -80,7 +80,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
@BeforeEach
void seed() {
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) {
for (String table : List.of("user_psn_title", "game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) {
jdbc.update("delete from " + table);
}
userGameRepository.deleteAll();
@@ -293,6 +293,19 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.content[0].platforms[1].platform").value("Switch"));
}
@Test
void games_ASetRowCarriesTheTotalPlaytimeOfAllItsPlatforms() throws Exception {
jdbc.update("update user_game set playtime_minutes = 500 where user_id = ? and appid = ?", USER_A, GAME);
jdbc.update("update user_game set source = 'PSN', platform = 'PS5', playtime_minutes = 300 where user_id = ? and appid = ?",
USER_A, GAME_2);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, 'c9', 'LINKED', ?, 'PS5', 300)", USER_A, GAME_2);
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].totalPlaytimeMinutes").value(800));
}
@Test
void games_AnOldClientsHideDuplicatesParamChangesNothing() throws Exception {
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
@@ -217,7 +217,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
@Test
void librarySync_StartsTheAchievementSyncAfterCommit() {
when(steamApiService.getOwnedGames("76561198000000001"))
.thenReturn(List.of(new SteamOwnedGamesResponse.Game(620, "Portal 2", 90, null, true, 0)));
.thenReturn(List.of(new SteamOwnedGamesResponse.Game(620, "Portal 2", 90, null, true, 0, null)));
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.of(List.of()));
when(steamApiService.getSchemaForGame(anyLong())).thenReturn(Optional.empty());
@@ -60,7 +60,7 @@ class SyncChangesFlowTest extends AbstractMockedAdaptersTest {
}
private static SteamOwnedGamesResponse.Game owned(int appid, int playtime) {
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime, null);
}
private void sync(SyncTrigger trigger, SteamOwnedGamesResponse.Game... games) {
@@ -0,0 +1,102 @@
package core_service.modules.library.domain;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformPlayTest {
private static final long USER = 1L;
private static final Instant STEAM_PLAYED = Instant.parse("2026-10-01T10:00:00Z");
private static final Instant PSN_PLAYED = Instant.parse("2026-10-05T18:00:00Z");
private static UserGame row(long appid, GameSource source, String platform, int minutes, Instant lastPlayedAt) {
UserGame game = UserGame.manual(USER, appid, source, platform, GameStatus.PLAYING, minutes, Instant.EPOCH);
game.setLastPlayedAt(lastPlayedAt);
return game;
}
private static TitlePlay title(String key, long appid, String platform, int minutes) {
return new TitlePlay(key, appid, platform, minutes, PSN_PLAYED);
}
@Test
void lines_ASteamRowWithAnOwnedPlayStationTitleIsTwoLines_AndTheTotalIsTheirSum() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 500, STEAM_PLAYED)),
List.of(title("c1", 100, "PS5", 120)));
assertThat(lines).containsExactly(
new PlatformPlay(GameSource.STEAM, null, 100, 500, STEAM_PLAYED),
new PlatformPlay(GameSource.PSN, "PS5", 100, 120, PSN_PLAYED));
assertThat(PlatformPlay.total(lines)).isEqualTo(620);
}
@Test
void lines_APlayStationRowWithTwoTitlesIsTheTitleLines_TheRowSumIsNotAddedOnTop() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(200, GameSource.PSN, "PS5", 300, PSN_PLAYED)),
List.of(title("c4", 200, "PS4", 100), title("c5", 200, "PS5", 200)));
assertThat(lines).extracting(PlatformPlay::platform).containsExactly("PS5", "PS4");
assertThat(lines).extracting(PlatformPlay::playtimeMinutes).containsExactly(200, 100);
assertThat(PlatformPlay.total(lines)).isEqualTo(300);
}
@Test
void lines_APlayStationRowWithoutStoredTitlePlayIsOneLineFromTheRow() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(200, GameSource.PSN, "PS5", 300, PSN_PLAYED)), List.of());
assertThat(lines).containsExactly(new PlatformPlay(GameSource.PSN, "PS5", 200, 300, PSN_PLAYED));
assertThat(PlatformPlay.total(lines)).isEqualTo(300);
}
@Test
void lines_AManualRowWithALinkedTitleIsTheRowLineAndTheTitleLine() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(-7, GameSource.MANUAL, "Switch", 60, null)),
List.of(title("c1", -7, "PS4", 30)));
assertThat(lines).containsExactly(
new PlatformPlay(GameSource.PSN, "PS4", -7, 30, PSN_PLAYED),
new PlatformPlay(GameSource.MANUAL, "Switch", -7, 60, null));
}
@Test
void lines_ARowTheSteamSyncTookOverIsASteamLineAndTheTitleLine() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 40, STEAM_PLAYED)),
List.of(title("c1", 100, "PS5", 900)));
assertThat(lines).extracting(PlatformPlay::source).containsExactly(GameSource.STEAM, GameSource.PSN);
assertThat(PlatformPlay.total(lines)).isEqualTo(940);
}
@Test
void lines_OrderIsSteamThenPs5ThenPs4ThenManual_AndTitlesOfOtherRowsAreIgnored() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(
row(-7, GameSource.MANUAL, "Switch", 60, null),
row(200, GameSource.PSN, "PS4", 10, null),
row(100, GameSource.STEAM, null, 500, STEAM_PLAYED)),
List.of(title("c1", 200, "PS4", 10), title("c2", 100, "PS5", 20), title("c3", 999, "PS5", 7777)));
assertThat(lines).extracting(l -> l.source() + ":" + l.platform())
.containsExactly("STEAM:null", "PSN:PS5", "PSN:PS4", "MANUAL:Switch");
}
@Test
void total_IsCappedAtIntegerMax() {
List<PlatformPlay> lines = List.of(new PlatformPlay(GameSource.STEAM, null, 1, Integer.MAX_VALUE, null),
new PlatformPlay(GameSource.PSN, "PS5", 1, 5, null));
assertThat(PlatformPlay.total(lines)).isEqualTo(Integer.MAX_VALUE);
}
@Test
void lines_AManualRowWithALinkedTitleOfItsPlatformIsOneLine() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(-7, GameSource.MANUAL, "PS5", 1200, null)),
List.of(title("c1", -7, "PS5", 1300)));
assertThat(lines).containsExactly(new PlatformPlay(GameSource.PSN, "PS5", -7, 1300, PSN_PLAYED));
assertThat(PlatformPlay.total(lines)).isEqualTo(1300);
}
}
@@ -1,8 +1,13 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.EntryPlatform;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.PlatformPlay;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -10,6 +15,7 @@ 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.util.List;
import java.util.Map;
import java.util.stream.Stream;
@@ -259,6 +265,81 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
USER, appid, source, platform);
}
private void playtime(long appid, int minutes) {
jdbc.update("update user_game set playtime_minutes = ? where user_id = ? and appid = ?", minutes, USER, appid);
}
private void titlePlay(String key, long appid, String state, String platform, int minutes) {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, ?, ?, ?, ?, ?)", USER, key, state, appid, platform, minutes);
}
private static UserGame row(long appid, GameSource source, String platform, int minutes) {
return UserGame.manual(USER, appid, source, platform, GameStatus.BACKLOG, minutes, Instant.EPOCH);
}
@Test
void entryPlaytimes_ASteamAndAnOwnedTitleSet_EqualsThePlatformPlayTotal() {
ownOn(100, "STEAM", null);
playtime(100, 500);
titlePlay("c1", 100, "STEAM_OWNED", "PS5", 120);
var playtimes = repository.entryPlaytimes(USER, List.of(100L));
int expected = PlatformPlay.total(PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 500)),
List.of(new TitlePlay("c1", 100L, "PS5", 120, null))));
assertThat(expected).isEqualTo(620);
assertThat(playtimes).containsOnly(Map.entry(100L, expected));
}
@Test
void entryPlaytimes_ASteamAndAPlayStationRowSet_EqualsThePlatformPlayTotal_TheRowSumIsNotAddedOnTop() {
repository.link(100, 200);
ownOn(100, "STEAM", null);
ownOn(200, "PSN", "PS5");
playtime(100, 500);
playtime(200, 300);
titlePlay("c4", 200, "LINKED", "PS4", 100);
titlePlay("c5", 200, "LINKED", "PS5", 200);
// A title without stored play counts nothing.
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c6', 'LINKED', 200)", USER);
var playtimes = repository.entryPlaytimes(USER, List.of(100L, 200L));
int expected = PlatformPlay.total(PlatformPlay.lines(
List.of(row(100, GameSource.STEAM, null, 500), row(200, GameSource.PSN, "PS5", 300)),
List.of(new TitlePlay("c4", 200L, "PS4", 100, null), new TitlePlay("c5", 200L, "PS5", 200, null))));
assertThat(expected).isEqualTo(800);
assertThat(playtimes.values()).containsExactly(expected);
}
@Test
void entryPlaytimes_AManualRowWithATitleOfItsPlatform_EqualsThePlatformPlayTotal() {
repository.link(100, -7);
ownOn(100, "STEAM", null);
ownOn(-7, "MANUAL", "PS5");
playtime(100, 500);
playtime(-7, 1200);
titlePlay("c1", -7, "LINKED", "PS5", 1300);
titlePlay("c2", -7, "LINKED", "PS4", 60);
int expected = PlatformPlay.total(PlatformPlay.lines(
List.of(row(100, GameSource.STEAM, null, 500), row(-7, GameSource.MANUAL, "PS5", 1200)),
List.of(new TitlePlay("c1", -7L, "PS5", 1300, null), new TitlePlay("c2", -7L, "PS4", 60, null))));
assertThat(expected).isEqualTo(1860);
assertThat(repository.entryPlaytimes(USER, List.of(100L, -7L)).values()).containsExactly(expected);
}
@Test
void entryPlaytimes_APlayStationRowWithoutTitlePlay_IsItsOwnMinutes() {
ownOn(200, "PSN", "PS5");
playtime(200, 300);
assertThat(repository.entryPlaytimes(USER, List.of(200L))).containsOnly(Map.entry(200L, 300));
assertThat(repository.entryPlaytimes(USER, List.of())).isEmpty();
}
@Test
void entryPlatforms_SteamFirstThenPlayStationThenManual_AndAPlayStationTitleWithoutNameIsDropped() {
repository.link(100, 200);
@@ -367,4 +448,17 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.editionCounts(USER, List.of(100L, 200L, -7L))).isEqualTo(Map.of(100L, 2, 200L, 2));
assertThat(repository.editionCounts(OTHER_USER, List.of(100L, -7L))).isEqualTo(Map.of(100L, 2));
}
@Test
void entryPlatforms_ATitleOnAMergedKeyStillGivesThePlayStationIcon() {
jdbc.update("insert into steam_game (appid, name) values (-9, 'Old key') on conflict do nothing");
jdbc.update("insert into game_alias (from_appid, to_appid) values (-9, 100) on conflict do nothing");
ownOn(100, "STEAM", null);
playtime(100, 500);
titlePlay("c1", -9, "STEAM_OWNED", "PS5", 120);
assertThat(repository.entryPlaytimes(USER, List.of(100L))).containsOnly(Map.entry(100L, 620));
assertThat(repository.entryPlatforms(USER, List.of(100L)).get(100L)).extracting(EntryPlatform::source)
.contains(GameSource.PSN);
}
}
@@ -1,16 +1,19 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.TitlePlay;
import core_service.shared.test.AbstractPersistenceTest;
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.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@Import(PsnTitleRepository.class)
class PsnTitleRepositoryTest extends AbstractPersistenceTest {
@@ -72,4 +75,42 @@ class PsnTitleRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.findAll(3L).get("c1").appid()).isEqualTo(-1L);
assertThat(repository.findAll(5L)).isEmpty();
}
@Test
void savePlays_UpdatesLinkedAndSteamOwnedTitles_NotADismissedOne() {
Instant played = Instant.parse("2026-10-05T18:00:00Z");
repository.link(6L, "c1", -1L);
repository.linkOwned(6L, "c2", -2L);
repository.dismiss(6L, List.of("c3"));
repository.savePlays(6L, List.of(new TitlePlay("c1", null, "PS5", 120, played),
new TitlePlay("c2", null, "PS4", 30, null), new TitlePlay("c3", null, "PS5", 999, played)));
assertThat(jdbc.queryForObject("select count(*) from user_psn_title where user_id = 6 and playtime_minutes is not null",
Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("select playtime_minutes from user_psn_title where user_id = 6 and psn_key = 'c1'",
Integer.class)).isEqualTo(120);
assertThat(jdbc.queryForObject("select platform from user_psn_title where user_id = 6 and psn_key = 'c2'",
String.class)).isEqualTo("PS4");
assertThat(jdbc.queryForObject("select playtime_minutes from user_psn_title where user_id = 6 and psn_key = 'c3'",
Integer.class)).isNull();
}
@Test
void findPlays_MapsAMergedKeyThroughTheAlias_AndLeavesOutTitlesWithoutPlay() {
game(7L, -1L, "STEAM");
jdbc.update("insert into steam_game (appid, name) values (-9, 'Old') on conflict do nothing");
jdbc.update("insert into game_alias (from_appid, to_appid) values (-9, -1) on conflict do nothing");
repository.link(7L, "c1", -9L);
repository.linkOwned(7L, "c2", -1L);
repository.link(7L, "c3", -1L);
repository.savePlays(7L, List.of(new TitlePlay("c1", null, "PS5", 120, null),
new TitlePlay("c2", null, "PS4", 30, null)));
List<TitlePlay> plays = repository.findPlays(7L, List.of(-1L));
assertThat(plays).extracting(TitlePlay::psnKey, TitlePlay::appid, TitlePlay::platform, TitlePlay::playtimeMinutes)
.containsExactlyInAnyOrder(tuple("c1", -1L, "PS5", 120), tuple("c2", -1L, "PS4", 30));
assertThat(repository.findPlays(7L, List.of())).isEmpty();
}
}
@@ -155,4 +155,13 @@ class QuestionRepositoryTest extends AbstractPersistenceTest {
jdbc.update("delete from user_game where appid = 50");
assertThat(jdbc.queryForObject("select count(*) from user_game_question", Integer.class)).isZero();
}
@Test
void facts_ThePlayStationLastPlayedOfATitleOnASteamRowCounts() {
jdbc.update("delete from user_psn_title");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at) "
+ "values (?, 'c1', 'STEAM_OWNED', 10, 'PS5', 600, '2026-10-08T20:00:00Z')", USER);
assertThat(only(10).lastPlayedAt()).isEqualTo(java.time.Instant.parse("2026-10-08T20:00:00Z"));
}
}
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.api.dto.PlatformPlayDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTrophy;
@@ -17,6 +18,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
@@ -34,6 +36,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.client.ResourceAccessException;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@@ -86,6 +89,9 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
@Autowired
private PsnTrophyRepository psnTrophyRepository;
@Autowired
private GameLinkRepository gameLinkRepository;
@Autowired
private JdbcTemplate jdbc;
@@ -99,6 +105,8 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
gameSpeedrunRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
jdbc.update("delete from user_psn_title");
jdbc.update("delete from game_link");
jdbc.update("delete from user_psn_trophy");
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy");
@@ -292,4 +300,44 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
assertEquals(4, detail.achievements().size());
assertEquals(1, detail.achievementsTotal());
}
@Test
void getGameDetail_ASteamAndAPlayStationEdition_HasOneLinePerPlatformWithItsOwnPlayAndProgress() {
Instant steamPlayed = Instant.parse("2026-10-01T10:00:00Z");
Instant psnPlayed = Instant.parse("2026-10-05T18:00:00Z");
UserGame steam = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow();
steam.setPlaytimeMinutes(500);
steam.setLastPlayedAt(steamPlayed);
userGameRepository.save(steam);
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 3, true, Instant.now()));
for (String name : List.of("A", "B", "C")) {
schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false));
}
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 2, 60));
steamGameRepository.save(new SteamGame(621L, "Portal 2 (PS5)", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 621L, GameSource.PSN, "PS5", GameStatus.PLAYING, 300, Instant.now()));
gameLinkRepository.link(620L, 621L);
psnTrophyRepository.saveSet(USER_ID, 621L, new PsnTrophySet("NPWR9_00", "trophy2", "01.00", "Portal 2", null, "PS5",
2, 0, 0, 1, 2, false, Instant.now()));
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at) "
+ "values (?, 'c9', 'LINKED', 621, 'PS5', 300, ?)", USER_ID, Timestamp.from(psnPlayed));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
assertEquals(List.of(
new PlatformPlayDto(GameSource.STEAM, null, 620L, 500, steamPlayed, 2, 3, false),
new PlatformPlayDto(GameSource.PSN, "PS5", 621L, 300, psnPlayed, 2, 3, false)), detail.platforms());
assertEquals(800, detail.totalPlaytimeMinutes());
assertEquals(500, detail.playtimeMinutes());
}
@Test
void getGameDetail_AGameOfOnePlatform_HasOneLineAndItsOwnMinutesAsTotal() {
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
assertEquals(1, detail.platforms().size());
assertEquals(GameSource.STEAM, detail.platforms().getFirst().source());
assertEquals(60, detail.platforms().getFirst().playtimeMinutes());
assertEquals(60, detail.totalPlaytimeMinutes());
}
}
@@ -61,7 +61,7 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
void runSyncNow_TheFollowUpStoresTheSpeedrunRow() {
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
when(steamApiService.getOwnedGames(STEAM_ID))
.thenReturn(List.of(new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 0, "icon", true, 0)));
.thenReturn(List.of(new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 0, "icon", true, 0, null)));
when(speedrunApiService.isEnabled()).thenReturn(true);
when(speedrunApiService.findGames("Half-Life 2")).thenReturn(Optional.of(List.of(
new SpeedrunApiService.Game("hl2", "Half-Life 2", 2004, "https://www.speedrun.com/hl2"))));
@@ -153,6 +153,6 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
}
private static SteamOwnedGamesResponse.Game game(String iconUrl) {
return new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 60, iconUrl, true, 0);
return new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 60, iconUrl, true, 0, null);
}
}
@@ -68,7 +68,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
private static final String OTHER_STEAM_ID = "76561198005679790";
private static SteamOwnedGamesResponse.Game game(int playtime, int playtime2Weeks) {
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true, playtime2Weeks);
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true, playtime2Weeks, null);
}
private static LocalDate today() {
@@ -297,6 +297,44 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120);
}
private static SteamOwnedGamesResponse.Game gameLastPlayed(Long rtimeLastPlayed) {
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 100, "icon", true, 0, rtimeLastPlayed);
}
@Test
void runSync_StoresSteamsLastPlayed() {
UserGame existing = owned(100);
steamReturns(gameLastPlayed(1_760_000_000L), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(Instant.ofEpochSecond(1_760_000_000L), existing.getLastPlayedAt());
}
@Test
void runSync_ZeroOrAbsentLastPlayed_KeepsTheOldValue() {
Instant old = Instant.parse("2026-10-05T18:00:00Z");
for (Long none : new Long[]{0L, null}) {
UserGame existing = owned(100);
existing.setLastPlayedAt(old);
steamReturns(gameLastPlayed(none), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(old, existing.getLastPlayedAt());
}
}
@Test
void runSync_NewSteamGame_GetsLastPlayedFromSteam() {
steamReturns(gameLastPlayed(1_760_000_000L), null);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).save(argThat((UserGame created) ->
Instant.ofEpochSecond(1_760_000_000L).equals(created.getLastPlayedAt())));
}
@Test
void runSync_PlaytimeGrew_RecordsTheDifferenceForToday() {
owned(100);
@@ -618,7 +656,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_GameWithoutName_GetsTheAppidAsName() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null),
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null, null),
game(90, 0)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
@@ -633,7 +671,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamGameRepository.findAllById(List.of(2000001L))).thenReturn(List.of(stored));
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null)));
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null, null)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
@@ -673,7 +711,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
}
private static SteamOwnedGamesResponse.Game gameOf(int appid, int playtime) {
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime, null);
}
@Test
@@ -7,6 +7,7 @@ import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameImportRepository;
@@ -195,6 +196,38 @@ class PsnSyncStepTest extends AbstractUnitTest {
verifyNoInteractions(manualGameService);
}
@Test
void run_SavesThePlayOfALinkedAndASteamOwnedTitle_AndGivesNoPlaytimeToTheSteamRow() {
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ofMinutes(30), 8L, null);
fetches(ghost(), other);
when(psnTitleRepository.findAll(USER)).thenReturn(Map.of(
"c7", new PsnTitle("c7", PsnTitle.State.LINKED, -1L),
"c8", new PsnTitle("c8", PsnTitle.State.STEAM_OWNED, 100L)));
PsnSyncStep.Prepared prepared = step.run(USER);
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor();
verify(psnTitleRepository).savePlays(eq(USER), plays.capture());
assertThat(plays.getValue()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED),
new TitlePlay("c8", null, "PS5", 30, null));
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::appid).containsExactly(-1L);
}
@Test
void run_ATitleLinkedInThisRunIsAlsoInTheSavedPlay() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei", "PS5");
when(manualGameService.findGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(game)));
when(manualGameService.addGame(eq(USER), eq(game), eq("PS5"), eq(GameStatus.PLAYING), eq(120),
eq(GameSource.PSN), isNull())).thenReturn(new ManualGameService.AddResult.Added(-1L));
step.run(USER);
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor();
verify(psnTitleRepository).savePlays(eq(USER), plays.capture());
assertThat(plays.getValue()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
}
@Test
void run_DismissedTitleAndTitleOfAnOpenImport_AreNotSearchedNorOffered() {
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ZERO, 8L, null);
@@ -109,7 +109,7 @@ class SteamApiServiceCacheTest extends AbstractIntegrationTest {
@Test
void getOwnedGames_TransientSteamError_IsTriedAgain() {
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0))));
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0, null))));
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
.thenAnswer(invocation -> {
if (steamCalls.incrementAndGet() == 1) {
@@ -88,7 +88,8 @@ class SteamApiServiceTest {
"name": "Counter-Strike",
"playtime_forever": 32,
"img_icon_url": "icon_url",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1760000000
}
]
}
@@ -104,6 +105,7 @@ class SteamApiServiceTest {
assertThat(result).hasSize(1);
assertThat(result.getFirst().name()).isEqualTo("Counter-Strike");
assertThat(result.getFirst().rtimeLastPlayed()).isEqualTo(1_760_000_000L);
}
@Test