fix(library): book psn play on its last played day (#53)
Build, Test & Push Core Service / quality-check (push) Successful in 2m35s
Build, Test & Push Core Service / docker (push) Successful in 1m29s

A newly linked PSN title booked its whole history on the sync day.
Now only new play counts, on its last played day. A day is capped
at 24 h. V49 deletes the wrong old day rows.
This commit is contained in:
lobo committed 2026-10-20 09:06:53 +02:00
1 parent d3c9c7558c
commit 6f2d7b4885
17 files changed
+322 -54

No files matched your search

+5 -3
View File
@@ -98,8 +98,8 @@ To Be Played is split into four repositories:
of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never
overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5 overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5
games closest to 100 %. games closest to 100 %.
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks" - **Playtime history**: one row per game and day (a day counts at most 24 h in the stats and the chart), for the
dashboard banner. activity view and the "most played in the last two weeks" dashboard banner.
- **Finished from achievements or playtime**: `EndingAchievement` finds the one achievement of a game that means - **Finished from achievements or playtime**: `EndingAchievement` finds the one achievement of a game that means
"story end" (English text rule, Steam's global unlock rate between 1 and 80 %, the easiest variant wins, more than "story end" (English text rule, Steam's global unlock rate between 1 and 80 %, the easiest variant wins, more than
3 candidates = a game without one end). When the user unlocks it, never chose the status and never answered 3 candidates = a game without one end). When the user unlocks it, never chose the status and never answered
@@ -786,7 +786,9 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
- Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44), - Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44),
so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's. so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's.
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the - A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user removed stays playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. The minutes a title grew since
its stored play are booked on its last played day (Berlin), only when its last played moved; a title linked in this
run is the start point and books nothing (V49 dropped the old wrong rows). A game the user removed stays
`LINKED` and the sync does not add it again; its row stays removed, so the sync skips it until the user restores it: `LINKED` and the sync does not add it again; its row stays removed, so the sync skips it until the user restores it:
a restore takes the stored title play (`psn_title_play`) and the sync updates the row again. Flyway V46 gave games a restore takes the stored title play (`psn_title_play`) and the sync updates the row again. Flyway V46 gave games
removed before (soft delete) a removed `PSN` row and turned a `MANUAL` row re-added on a `LINKED` key back to `PSN`. A game also owned on Steam stays one row (the Steam row wins). removed before (soft delete) a removed `PSN` row and turned a `MANUAL` row re-added on a `LINKED` key back to `PSN`. A game also owned on Steam stays one row (the Steam row wins).
@@ -1,10 +1,17 @@
package core_service.modules.library.domain; package core_service.modules.library.domain;
import java.time.Instant;
/** /**
* A PlayStation title the sync has seen for a user (V39). A LINKED title has the library key of the row PlayStation * A PlayStation title the sync has seen for a user (V39). A LINKED title has the library key of the row PlayStation
* made or feeds; STEAM_OWNED has the key of a Steam row it only matched; DISMISSED titles were left unchecked in a review. * made or feeds; STEAM_OWNED has the key of a Steam row it only matched; DISMISSED titles were left unchecked in a review.
* The stored play (playtimeMinutes, lastPlayedAt) is what the last sync saw; both are null until the first savePlays.
*/ */
public record PsnTitle(String psnKey, State state, Long appid) { public record PsnTitle(String psnKey, State state, Long appid, Integer playtimeMinutes, Instant lastPlayedAt) {
public PsnTitle(String psnKey, State state, Long appid) {
this(psnKey, state, appid, null, null);
}
public enum State { LINKED, STEAM_OWNED, DISMISSED } public enum State { LINKED, STEAM_OWNED, DISMISSED }
} }
@@ -440,7 +440,7 @@ public class GameMergeRepository {
insert into user_game_playtime_day (user_id, day, appid, minutes) insert into user_game_playtime_day (user_id, day, appid, minutes)
select user_id, day, :to, minutes from user_game_playtime_day where user_id = :user and appid = :from select user_id, day, :to, minutes from user_game_playtime_day where user_id = :user and appid = :from
on conflict (user_id, day, appid) do update on conflict (user_id, day, appid) do update
set minutes = user_game_playtime_day.minutes + excluded.minutes set minutes = least(user_game_playtime_day.minutes + excluded.minutes, 1440)
""", params); """, params);
jdbc.update("delete from user_game_playtime_day where user_id = :user and appid = :from", params); jdbc.update("delete from user_game_playtime_day where user_id = :user and appid = :from", params);
jdbc.update("update user_psn_title set appid = :to where user_id = :user and appid = :from", params); jdbc.update("update user_psn_title set appid = :to where user_id = :user and appid = :from", params);
@@ -25,10 +25,11 @@ public class PsnTitleRepository {
public Map<String, PsnTitle> findAll(long userId) { public Map<String, PsnTitle> findAll(long userId) {
Map<String, PsnTitle> titles = new LinkedHashMap<>(); Map<String, PsnTitle> titles = new LinkedHashMap<>();
jdbc.query("select psn_key, state, appid from user_psn_title where user_id = :user", jdbc.query("select psn_key, state, appid, playtime_minutes, last_played_at from user_psn_title where user_id = :user",
new MapSqlParameterSource("user", userId), rs -> { new MapSqlParameterSource("user", userId), rs -> {
titles.put(rs.getString("psn_key"), new PsnTitle(rs.getString("psn_key"), titles.put(rs.getString("psn_key"), new PsnTitle(rs.getString("psn_key"),
PsnTitle.State.valueOf(rs.getString("state")), rs.getObject("appid", Long.class))); PsnTitle.State.valueOf(rs.getString("state")), rs.getObject("appid", Long.class),
rs.getObject("playtime_minutes", Integer.class), instant(rs.getTimestamp("last_played_at"))));
}); });
return titles; return titles;
} }
@@ -103,7 +103,7 @@ public class UserStatsRepository {
private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", " private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", "
+ NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null"; + NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null";
private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, sum(p.minutes) as minutes, " private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, least(sum(p.minutes), 1440) as minutes, "
+ NO_EVENT + ", " + NO_MOVE + " " + ENTRY_DAYS + "and p.day = :arg group by coalesce(e.appid, p.appid)"; + NO_EVENT + ", " + NO_MOVE + " " + ENTRY_DAYS + "and p.day = :arg group by coalesce(e.appid, p.appid)";
private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT
+ " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg"; + " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg";
@@ -242,7 +242,7 @@ public class UserStatsRepository {
} }
public Optional<DayMinutes> longestDay(long userId) { public Optional<DayMinutes> longestDay(long userId) {
return jdbc.query("select p.day, sum(p.minutes) as minutes " + PLAYTIME_DAYS_NOT_HIDDEN + return jdbc.query("select p.day, least(sum(p.minutes), 1440) as minutes " + PLAYTIME_DAYS_NOT_HIDDEN +
"group by p.day order by minutes desc, p.day desc limit 1", user(userId), "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"))) (rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes")))
.stream().findFirst(); .stream().findFirst();
@@ -393,7 +393,8 @@ public class LibraryQueryService {
Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream() Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream()
.collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes)); .collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes));
List<DayMinutes> perDay = from.datesUntil(today.plusDays(1)) List<DayMinutes> perDay = from.datesUntil(today.plusDays(1))
.map(day -> new DayMinutes(day, played.getOrDefault(day, 0L))) // A day holds at most 24 h; rows from before V18/V49 or several games can sum higher.
.map(day -> new DayMinutes(day, Math.min(played.getOrDefault(day, 0L), 1440)))
.toList(); .toList();
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum(); long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
@@ -164,7 +164,7 @@ public class LibrarySyncWorker {
} }
List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn) List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn)
: new ArrayList<>(); : new ArrayList<>();
List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay); List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay, zone);
// The first sync of an account is a baseline: a count, not a list. // The first sync of an account is a baseline: a count, not a list.
int imported = 0; int imported = 0;
List<SyncChange> listed = new ArrayList<>(); List<SyncChange> listed = new ArrayList<>();
@@ -22,6 +22,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
@@ -47,17 +48,22 @@ public class PsnSyncStep {
* A LINKED title's PSN values, applied in the sync transaction. * A LINKED title's PSN values, applied in the sync transaction.
* *
* @param lastPlayedAt may be null * @param lastPlayedAt may be null
* @param played minutes to book on the day of lastPlayedAt, 0 when the title is a start point or was not played
* since the last sync
*/ */
public record Playtime(long appid, int minutes, Instant lastPlayedAt) {} public record Playtime(long appid, int minutes, Instant lastPlayedAt, int played) {}
/** /**
* What {@link #run} did outside the transaction. * What {@link #run} did outside the transaction.
* *
* @param changes the ADDED rows of this run * @param changes the ADDED rows of this run
* @param plays the title plays {@link #apply} stores; stored there so a skipped or rolled-back apply keeps the
* old baseline
* @param baseline the user had no PSN title before this run * @param baseline the user had no PSN title before this run
*/ */
public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, boolean baseline) { public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, List<TitlePlay> plays,
public static final Prepared NONE = new Prepared(List.of(), List.of(), false); boolean baseline) {
public static final Prepared NONE = new Prepared(List.of(), List.of(), List.of(), false);
} }
private final PsnAccountService accountService; private final PsnAccountService accountService;
@@ -106,26 +112,38 @@ public class PsnSyncStep {
List<GameImport.Row> review = new ArrayList<>(); List<GameImport.Row> review = new ArrayList<>();
for (GameImport.Line line : lines) { for (GameImport.Line line : lines) {
PsnTitle seen = known.get(line.psnKey()); PsnTitle seen = known.get(line.psnKey());
Instant lastPlayedAt = lastPlayed.get(line.psnKey());
if (seen != null && seen.state() == PsnTitle.State.LINKED) { if (seen != null && seen.state() == PsnTitle.State.LINKED) {
playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))); playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayedAt,
played(seen, line.playtimeMinutes(), lastPlayedAt)));
} else if (seen == null && !open.contains(line.psnKey())) { } else if (seen == null && !open.contains(line.psnKey())) {
matchNew(userId, line, lastPlayed.get(line.psnKey()), changes, review); matchNew(userId, line, lastPlayedAt, changes, review);
} }
} }
saveReview(userId, review); saveReview(userId, review);
psnTitleRepository.savePlays(userId, lines.stream().map(line -> new TitlePlay(line.psnKey(), null, List<TitlePlay> plays = lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList()); line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList();
int added = changes.size(); int added = changes.size();
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles)); changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added, log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
review.size(), changes.size() - added); review.size(), changes.size() - added);
return new Prepared(changes, mergedKeys(playtimes), baseline); return new Prepared(changes, mergedKeys(playtimes), plays, baseline);
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.error("PSN step failed for user {}", userId, e); log.error("PSN step failed for user {}", userId, e);
return new Prepared(changes, playtimes, baseline); return new Prepared(changes, playtimes, List.of(), baseline);
} }
} }
// Only the title's own new play counts: no stored play is a start point, and a total that grew without a newer
// last played is no play of this sync.
private static int played(PsnTitle seen, int minutes, Instant lastPlayedAt) {
if (seen.playtimeMinutes() == null || lastPlayedAt == null
|| (seen.lastPlayedAt() != null && !lastPlayedAt.isAfter(seen.lastPlayedAt()))) {
return 0;
}
return Math.max(0, minutes - seen.playtimeMinutes());
}
private Optional<PsnAccountService.FetchResult.Titles> fetch(Long userId) { private Optional<PsnAccountService.FetchResult.Titles> fetch(Long userId) {
switch (accountService.fetchTitles(userId)) { switch (accountService.fetchTitles(userId)) {
case PsnAccountService.FetchResult.Titles found -> { case PsnAccountService.FetchResult.Titles found -> {
@@ -194,7 +212,7 @@ public class PsnSyncStep {
private List<Playtime> mergedKeys(List<Playtime> playtimes) { private List<Playtime> mergedKeys(List<Playtime> playtimes) {
Map<Long, Long> targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList()); Map<Long, Long> targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList());
return playtimes.stream().map(playtime -> new Playtime(targets.getOrDefault(playtime.appid(), playtime.appid()), return playtimes.stream().map(playtime -> new Playtime(targets.getOrDefault(playtime.appid(), playtime.appid()),
playtime.minutes(), playtime.lastPlayedAt())).toList(); playtime.minutes(), playtime.lastPlayedAt(), playtime.played())).toList();
} }
// Several titles can be one game (PS4 and PS5 versions, a review pick): its playtime is their sum, so the row // Several titles can be one game (PS4 and PS5 versions, a review pick): its playtime is their sum, so the row
@@ -205,33 +223,35 @@ public class PsnSyncStep {
byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(), byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(),
(int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE), (int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE),
a.lastPlayedAt() == null || (b.lastPlayedAt() != null && b.lastPlayedAt().isAfter(a.lastPlayedAt())) a.lastPlayedAt() == null || (b.lastPlayedAt() != null && b.lastPlayedAt().isAfter(a.lastPlayedAt()))
? b.lastPlayedAt() : a.lastPlayedAt())); ? b.lastPlayedAt() : a.lastPlayedAt(),
(int) Math.min((long) a.played() + b.played(), Integer.MAX_VALUE)));
} }
return byGame.values(); return byGame.values();
} }
/** /**
* In the sync transaction, after the Steam games are saved: playtime, last played and two-weeks minutes of the * In the sync transaction, after the Steam games are saved: playtime, last played and two-weeks minutes of the
* PSN rows. No status change: PSN rows have statusSetByUser. * PSN rows. The played minutes of a game are booked on the day it was last played, in the given zone. No status
* change: PSN rows have statusSetByUser.
* *
* @return all PSN changes of the run (ADDED and PLAYTIME) * @return all PSN changes of the run (ADDED and PLAYTIME)
*/ */
public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day) { public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day, ZoneId zone) {
List<SyncChange> changes = new ArrayList<>(prepared.changes()); List<SyncChange> changes = new ArrayList<>(prepared.changes());
Map<Long, UserGame> touched = new HashMap<>(); Map<Long, UserGame> touched = new HashMap<>();
for (Playtime playtime : perGame(prepared.playtimes())) { for (Playtime playtime : perGame(prepared.playtimes())) {
userGameRepository.findById(new UserGameId(userId, playtime.appid())) userGameRepository.findById(new UserGameId(userId, playtime.appid()))
.filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null) .filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null)
.ifPresent(game -> { .ifPresent(game -> {
int growth = playtime.minutes() - game.getPlaytimeMinutes();
game.setPlaytimeMinutes(playtime.minutes()); game.setPlaytimeMinutes(playtime.minutes());
game.setLastSeenAt(now); game.setLastSeenAt(now);
if (playtime.lastPlayedAt() != null) { if (playtime.lastPlayedAt() != null) {
game.setLastPlayedAt(playtime.lastPlayedAt()); game.setLastPlayedAt(playtime.lastPlayedAt());
} }
if (growth > 0) { if (playtime.played() > 0) {
changes.add(SyncChange.playtime(game.getAppid(), growth)); LocalDate playedOn = LocalDate.ofInstant(playtime.lastPlayedAt(), zone);
playtimeDayRepository.addMinutes(userId, game.getAppid(), day, growth); changes.add(SyncChange.playtime(game.getAppid(), playtime.played()));
playtimeDayRepository.addMinutes(userId, game.getAppid(), playedOn, playtime.played());
} }
touched.put(game.getAppid(), game); touched.put(game.getAppid(), game);
}); });
@@ -243,6 +263,9 @@ public class PsnSyncStep {
touched.forEach((appid, game) -> touched.forEach((appid, game) ->
game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE))); game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE)));
} }
if (!prepared.plays().isEmpty()) {
psnTitleRepository.savePlays(userId, prepared.plays());
}
return changes; return changes;
} }
} }
@@ -0,0 +1,11 @@
-- Gitea #53: the PSN sync booked the whole history of a newly linked title on the sync day (24 h of a game last
-- played in 2025). A PSN row's day row more than one day after the day its titles were last played is no real
-- play: drop it. One day is kept: a sync after midnight booked the evening before on the next day.
-- Titles without last_played_at are left alone. Safe to run again.
DELETE FROM user_game_playtime_day p
USING user_game ug,
(SELECT user_id, appid, MAX(last_played_at) AS last_played_at FROM psn_title_play
GROUP BY user_id, appid) t
WHERE ug.user_id = p.user_id AND ug.appid = p.appid AND ug.source = 'PSN'
AND t.user_id = ug.user_id AND t.appid = ug.appid
AND p.day > (t.last_played_at AT TIME ZONE 'Europe/Berlin')::date + 1;
@@ -91,6 +91,16 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist()); .andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist());
} }
@Test
void activity_ADayCountsAtMost24Hours() throws Exception {
playtimeDayRepository.addMinutes(USER_ID, 220L, today, 1440);
playtimeDayRepository.addMinutes(USER_ID, 620L, today, 1440);
mockMvc.perform(get("/api/library/activity").param("days", "7"))
.andExpect(jsonPath("$.days[6].minutes").value(1440))
.andExpect(jsonPath("$.totalMinutes").value(1440));
}
@Test @Test
void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception { void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception {
jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID); jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID);
@@ -28,6 +28,8 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -247,13 +249,16 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
awaitSync(); awaitSync();
long importId = reviewImportId(); long importId = reviewImportId();
playing(185, Instant.parse("2026-10-06T20:00:00Z")); Instant playedAgain = Instant.now().minus(Duration.ofHours(2));
playing(185, playedAgain);
worker.runSyncNow(1L, null, SyncTrigger.AUTO); worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForMap("select kind, appid, amount from sync_change where user_id = 1")) assertThat(jdbc.queryForMap("select kind, appid, amount from sync_change where user_id = 1"))
.containsEntry("kind", "PLAYTIME").containsEntry("appid", -300L).containsEntry("amount", 60); .containsEntry("kind", "PLAYTIME").containsEntry("appid", -300L).containsEntry("amount", 60);
assertThat(jdbc.queryForObject("select minutes from user_game_playtime_day where user_id = 1 and appid = -300", assertThat(jdbc.queryForObject("select minutes from user_game_playtime_day where user_id = 1 and appid = -300",
Integer.class)).isEqualTo(60); Integer.class)).isEqualTo(60);
assertThat(jdbc.queryForObject("select day from user_game_playtime_day where user_id = 1 and appid = -300",
LocalDate.class)).isEqualTo(LocalDate.ofInstant(playedAgain, ZoneId.of("Europe/Berlin")));
assertThat(jdbc.queryForMap("select playtime_minutes, playtime_2weeks from user_game where appid = -300")) assertThat(jdbc.queryForMap("select playtime_minutes, playtime_2weeks from user_game where appid = -300"))
.containsEntry("playtime_minutes", 185).containsEntry("playtime_2weeks", 60); .containsEntry("playtime_minutes", 185).containsEntry("playtime_2weeks", 60);
assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1); assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1);
@@ -143,6 +143,20 @@ class GameMergeRepositoryTakeoverTest extends AbstractPersistenceTest {
.containsExactly("100:10"); .containsExactly("100:10");
} }
@Test
void rekeyUserGame_DayBothKeysHave_StaysUnder24Hours() {
game(USER, MANUAL, "MANUAL", "PC");
for (long appid : new long[]{MANUAL, STEAM}) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, current_date, ?, 1000)",
USER, appid);
}
repository.rekeyUserGame(USER, MANUAL, STEAM);
assertThat(jdbc.queryForList("select appid || ':' || minutes from user_game_playtime_day", String.class))
.containsExactly("100:1440");
}
@Test @Test
void rekeyUserGame_KindHeldOnTarget_StaysOnOldRef() { void rekeyUserGame_KindHeldOnTarget_StaysOnOldRef() {
game(USER, MANUAL, "MANUAL", "PC"); game(USER, MANUAL, "MANUAL", "PC");
@@ -0,0 +1,71 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.ConnectionCallback;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.init.ScriptUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* V49 runs on a database that already ran it once: the test seeds the old rows and runs the file again.
*/
class PsnDayRowsMigrationTest extends AbstractPersistenceTest {
private static final long USER = 9L;
@Autowired
private JdbcTemplate jdbc;
private void game(long appid, String source, String lastPlayedAt) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game') on conflict do nothing", appid);
jdbc.update("insert into user_game (user_id, appid, status, source, platform, playtime_minutes) "
+ "values (?, ?, 'PLAYING', ?, 'PS5', 5)", USER, appid, source);
jdbc.update("""
insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at)
values (?, ?, 'LINKED', ?, 'PS5', 5, ?::timestamptz)""", USER, "c" + appid, appid, lastPlayedAt);
}
private void day(long appid, String day) {
jdbc.update("insert into user_game_playtime_day (user_id, day, appid, minutes) values (?, ?::date, ?, 60)",
USER, day, appid);
}
private void migrate() {
// The JdbcTemplate connection is the one of the test transaction; a pool connection would not see the seed.
jdbc.execute((ConnectionCallback<Void>) connection -> {
ScriptUtils.executeSqlScript(connection,
new ClassPathResource("db/migration/V49__Psn_Day_Rows_After_Last_Played.sql"));
return null;
});
}
private List<String> days(long appid) {
return jdbc.queryForList("select day::text from user_game_playtime_day where user_id = ? and appid = ? order by day",
String.class, USER, appid);
}
@Test
void psnRowsMoreThanADayAfterTheLastPlayedDay_AreDeleted() {
game(-10, "PSN", "2025-06-01T10:00:00Z");
game(-11, "STEAM", "2025-06-01T10:00:00Z");
game(-12, "PSN", null);
for (long appid : new long[]{-10, -11, -12}) {
day(appid, "2025-06-01");
day(appid, "2025-06-02");
day(appid, "2026-10-08");
}
migrate();
migrate();
assertThat(days(-10)).containsExactly("2025-06-01", "2025-06-02");
assertThat(days(-11)).containsExactly("2025-06-01", "2025-06-02", "2026-10-08");
assertThat(days(-12)).containsExactly("2025-06-01", "2025-06-02", "2026-10-08");
}
}
@@ -67,6 +67,15 @@ class PsnTitleRepositoryTest extends AbstractPersistenceTest {
Map.entry("t2", new PsnTitle("t2", PsnTitle.State.DISMISSED, null))); Map.entry("t2", new PsnTitle("t2", PsnTitle.State.DISMISSED, null)));
} }
@Test
void findAll_ReadsTheStoredPlay() {
Instant played = Instant.parse("2026-10-05T18:00:00Z");
repository.link(8L, "c1", -1L);
repository.savePlays(8L, List.of(new TitlePlay("c1", null, "PS5", 120, played)));
assertThat(repository.findAll(8L)).containsEntry("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -1L, 120, played));
}
@Test @Test
void findAll_ReturnsOnlyTheUsersTitles() { void findAll_ReturnsOnlyTheUsersTitles() {
repository.link(3L, "c1", -1L); repository.link(3L, "c1", -1L);
@@ -262,6 +262,18 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
new UserStats.TagCount("ACTION", 1, 300)); new UserStats.TagCount("ACTION", 1, 300));
} }
@Test
void longestDay_IsCappedAt24Hours() {
userGameRepository.saveAllAndFlush(List.of(
game(220L, 300, GameStatus.PLAYING, Instant.now()),
game(620L, 900, GameStatus.PLAYING, Instant.now())));
addDay(220L, LocalDate.of(2026, 7, 1), 1440);
addDay(620L, LocalDate.of(2026, 7, 1), 1440);
addDay(620L, LocalDate.of(2026, 7, 2), 100);
assertThat(repository.longestDay(USER_ID)).contains(new DayMinutes(LocalDate.of(2026, 7, 1), 1440));
}
@Test @Test
void longestDay_SumsAllGamesOfTheDay() { void longestDay_SumsAllGamesOfTheDay() {
userGameRepository.saveAllAndFlush(List.of( userGameRepository.saveAllAndFlush(List.of(
@@ -535,6 +547,17 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.games(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN, 0, 10)).hasSize(1); assertThat(repository.games(USER_ID, StatKey.PERFECT, List.of(220L, 620L), BERLIN, 0, 10)).hasSize(1);
} }
@Test
void games_LongestDay_CapsASetAt24Hours() {
seedSet();
LocalDate day = LocalDate.of(2026, 7, 1);
addDay(220L, day, 1440);
addDay(620L, day, 1440);
assertThat(repository.games(USER_ID, StatKey.LONGEST_DAY, day, BERLIN, 0, 10))
.extracting(UserStats.StatGame::minutes).containsExactly(1440L);
}
@Test @Test
void games_LongestDayListsASetOnceWithTheSumOfItsEditions() { void games_LongestDayListsASetOnceWithTheSumOfItsEditions() {
seedSet(); seedSet();
@@ -833,9 +833,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Test @Test
void runSync_PsnStepRunsBeforeTheTransaction_AndApplyAfterTheGamesAreSaved_ItsChangesJoinTheResult() { void runSync_PsnStepRunsBeforeTheTransaction_AndApplyAfterTheGamesAreSaved_ItsChangesJoinTheResult() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), false); PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), List.of(), false);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared); when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any())) when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any(), any()))
.thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30))); .thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30)));
owned(100); owned(100);
steamReturns(game(160, 60), STEAM_ID); steamReturns(game(160, 60), STEAM_ID);
@@ -845,7 +845,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
InOrder order = inOrder(steamApiService, psnSyncStep, userGameRepository, syncChangeRepository); InOrder order = inOrder(steamApiService, psnSyncStep, userGameRepository, syncChangeRepository);
order.verify(psnSyncStep).run(USER_ID); order.verify(psnSyncStep).run(USER_ID);
order.verify(userGameRepository).findByUserId(USER_ID); order.verify(userGameRepository).findByUserId(USER_ID);
order.verify(psnSyncStep).apply(eq(USER_ID), eq(prepared), any(), any()); order.verify(psnSyncStep).apply(eq(USER_ID), eq(prepared), any(), any(), any());
order.verify(syncChangeRepository).replace(eq(USER_ID), any(), eq(SyncTrigger.AUTO), eq(0), anyList()); order.verify(syncChangeRepository).replace(eq(USER_ID), any(), eq(SyncTrigger.AUTO), eq(0), anyList());
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactlyInAnyOrder(SyncChange.playtime(220L, 60), assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactlyInAnyOrder(SyncChange.playtime(220L, 60),
SyncChange.added(-9L), SyncChange.playtime(-8L, 30)); SyncChange.added(-9L), SyncChange.playtime(-8L, 30));
@@ -853,9 +853,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Test @Test
void runSync_PsnBaselineAdds_AreCountedNotListed() { void runSync_PsnBaselineAdds_AreCountedNotListed() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), true); PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), List.of(), true);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared); when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any())) when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any(), any()))
.thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30))); .thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30)));
owned(100); owned(100);
steamReturns(game(100, 0), STEAM_ID); steamReturns(game(100, 0), STEAM_ID);
@@ -867,9 +867,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Test @Test
void runSync_WithoutSteamId_RunsOnlyThePsnPart_AndPublishesTheActivityEvent() { void runSync_WithoutSteamId_RunsOnlyThePsnPart_AndPublishesTheActivityEvent() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), false); PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), List.of(), false);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared); when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any())).thenReturn(List.of(SyncChange.added(-9L))); when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any(), any())).thenReturn(List.of(SyncChange.added(-9L)));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1); when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO); worker.runSync(USER_ID, null, SyncTrigger.AUTO);
@@ -26,6 +26,8 @@ import org.springframework.transaction.PlatformTransactionManager;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -34,6 +36,7 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@@ -48,6 +51,8 @@ class PsnSyncStepTest extends AbstractUnitTest {
private static final long USER = 1L; private static final long USER = 1L;
private static final Instant LAST_PLAYED = Instant.parse("2026-10-05T18:30:00Z"); private static final Instant LAST_PLAYED = Instant.parse("2026-10-05T18:30:00Z");
private static final LocalDate DAY = LocalDate.of(2026, 10, 7); private static final LocalDate DAY = LocalDate.of(2026, 10, 7);
private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
private static final Instant OLDER = Instant.parse("2026-10-01T10:00:00Z");
@Mock @Mock
private PsnAccountService accountService; private PsnAccountService accountService;
@@ -89,6 +94,14 @@ class PsnSyncStepTest extends AbstractUnitTest {
return new GameImport(id, USER, GameImport.Source.PSN, status, 1, 1, 0, 0, 0, false, 0, null, now, now, null); return new GameImport(id, USER, GameImport.Source.PSN, status, 1, 1, 0, 0, 0, false, 0, null, now, now, null);
} }
private PsnSyncStep.Prepared runWithStored(Integer minutes, Instant lastPlayedAt, PsnApiService.Title title) {
fetches(title);
when(psnTitleRepository.findAll(USER)).thenReturn(Map.of("c7",
new PsnTitle("c7", PsnTitle.State.LINKED, -1L, minutes, lastPlayedAt)));
when(mergeRepository.findTargets(List.of(-1L))).thenReturn(Map.of());
return step.run(USER);
}
@Test @Test
void run_ExactMatchOnThePlatform_AddsTheGame_LinksIt_SetsLastPlayed_AndIsABaseline() { void run_ExactMatchOnThePlatform_AddsTheGame_LinksIt_SetsLastPlayed_AndIsABaseline() {
fetches(ghost()); fetches(ghost());
@@ -191,11 +204,42 @@ class PsnSyncStepTest extends AbstractUnitTest {
PsnSyncStep.Prepared prepared = step.run(USER); PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.playtimes()).containsExactly(new PsnSyncStep.Playtime(-2L, 120, LAST_PLAYED)); assertThat(prepared.playtimes()).containsExactly(new PsnSyncStep.Playtime(-2L, 120, LAST_PLAYED, 0));
assertThat(prepared.baseline()).isFalse(); assertThat(prepared.baseline()).isFalse();
verifyNoInteractions(manualGameService); verifyNoInteractions(manualGameService);
} }
@Test
void run_LinkedTitlePlayedSinceTheLastSync_GivesItsOwnGrowth() {
PsnSyncStep.Prepared prepared = runWithStored(60, OLDER, ghost());
assertThat(prepared.playtimes()).containsExactly(new PsnSyncStep.Playtime(-1L, 120, LAST_PLAYED, 60));
}
@Test
void run_LinkedTitleNotPlayedSinceTheLastSync_GivesNoGrowth() {
PsnSyncStep.Prepared prepared = runWithStored(60, LAST_PLAYED, ghost());
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::played).containsExactly(0);
}
@Test
void run_LinkedTitleWithoutStoredPlay_IsAStartPoint() {
PsnSyncStep.Prepared prepared = runWithStored(null, null, ghost());
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::played).containsExactly(0);
}
@Test
void run_LinkedTitleWithoutLastPlayed_GivesNoGrowth() {
PsnApiService.Title noDate = new PsnApiService.Title("PPSA1_00", "Ghost of Yotei", "ps5_native_game",
Duration.ofMinutes(120), 7L, null);
PsnSyncStep.Prepared prepared = runWithStored(60, OLDER, noDate);
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::played).containsExactly(0);
}
@Test @Test
void run_SavesThePlayOfALinkedAndASteamOwnedTitle_AndGivesNoPlaytimeToTheSteamRow() { void run_SavesThePlayOfALinkedAndASteamOwnedTitle_AndGivesNoPlaytimeToTheSteamRow() {
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ofMinutes(30), 8L, null); PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ofMinutes(30), 8L, null);
@@ -206,9 +250,8 @@ class PsnSyncStepTest extends AbstractUnitTest {
PsnSyncStep.Prepared prepared = step.run(USER); PsnSyncStep.Prepared prepared = step.run(USER);
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor(); verify(psnTitleRepository, never()).savePlays(eq(USER), anyList());
verify(psnTitleRepository).savePlays(eq(USER), plays.capture()); assertThat(prepared.plays()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED),
assertThat(plays.getValue()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED),
new TitlePlay("c8", null, "PS5", 30, null)); new TitlePlay("c8", null, "PS5", 30, null));
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::appid).containsExactly(-1L); assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::appid).containsExactly(-1L);
} }
@@ -221,11 +264,10 @@ class PsnSyncStepTest extends AbstractUnitTest {
when(manualGameService.addGame(eq(USER), eq(game), eq("PS5"), eq(GameStatus.PLAYING), eq(120), when(manualGameService.addGame(eq(USER), eq(game), eq("PS5"), eq(GameStatus.PLAYING), eq(120),
eq(GameSource.PSN), isNull())).thenReturn(new ManualGameService.AddResult.Added(-1L)); eq(GameSource.PSN), isNull())).thenReturn(new ManualGameService.AddResult.Added(-1L));
step.run(USER); PsnSyncStep.Prepared prepared = step.run(USER);
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor(); assertThat(prepared.plays()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
verify(psnTitleRepository).savePlays(eq(USER), plays.capture()); assertThat(prepared.playtimes()).isEmpty();
assertThat(plays.getValue()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
} }
@Test @Test
@@ -297,20 +339,29 @@ class PsnSyncStepTest extends AbstractUnitTest {
} }
private PsnSyncStep.Prepared prepared(PsnSyncStep.Playtime... playtimes) { private PsnSyncStep.Prepared prepared(PsnSyncStep.Playtime... playtimes) {
return new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(playtimes), false); return new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(playtimes), List.of(), false);
} }
@Test @Test
void apply_GrowthIsBooked_AsChangeAndDayMinutes_WithTwoWeeksFromTheHistoryAndLastPlayed() { void apply_SavesThePlaysOfTheRun() {
List<TitlePlay> plays = List.of(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
step.apply(USER, new PsnSyncStep.Prepared(List.of(), List.of(), plays, false), Instant.now(), DAY, BERLIN);
verify(psnTitleRepository).savePlays(USER, plays);
}
@Test
void apply_PlayedMinutes_AreBookedAsChangeAndDayMinutes_WithTwoWeeksFromTheHistoryAndLastPlayed() {
UserGame game = psnRow(-1L, 30); UserGame game = psnRow(-1L, 30);
when(playtimeDayRepository.minutesPerGameSince(eq(USER), eq(Set.of(-1L)), eq(DAY.minusDays(13)))) when(playtimeDayRepository.minutesPerGameSince(eq(USER), eq(Set.of(-1L)), eq(DAY.minusDays(13))))
.thenReturn(List.of(new AppidMinutes(-1L, 75))); .thenReturn(List.of(new AppidMinutes(-1L, 75)));
Instant now = Instant.now(); Instant now = Instant.now();
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED)), now, DAY); List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED, 60)), now, DAY, BERLIN);
assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 60)); assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 60));
verify(playtimeDayRepository).addMinutes(USER, -1L, DAY, 60); verify(playtimeDayRepository).addMinutes(USER, -1L, LocalDate.of(2026, 10, 5), 60);
assertThat(game.getPlaytimeMinutes()).isEqualTo(90); assertThat(game.getPlaytimeMinutes()).isEqualTo(90);
assertThat(game.getPlaytime2Weeks()).isEqualTo(75); assertThat(game.getPlaytime2Weeks()).isEqualTo(75);
assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED); assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED);
@@ -322,7 +373,7 @@ class PsnSyncStepTest extends AbstractUnitTest {
UserGame game = psnRow(-1L, 90); UserGame game = psnRow(-1L, 90);
game.setLastPlayedAt(LAST_PLAYED); game.setLastPlayedAt(LAST_PLAYED);
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 60, null)), Instant.now(), DAY); List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 60, null, 0)), Instant.now(), DAY, BERLIN);
assertThat(changes).containsExactly(SyncChange.added(-9L)); assertThat(changes).containsExactly(SyncChange.added(-9L));
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt()); verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
@@ -339,9 +390,9 @@ class PsnSyncStepTest extends AbstractUnitTest {
removed.setRemovedAt(Instant.EPOCH); removed.setRemovedAt(Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER, -3L))).thenReturn(Optional.empty()); when(userGameRepository.findById(new UserGameId(USER, -3L))).thenReturn(Optional.empty());
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED), List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED, 60),
new PsnSyncStep.Playtime(-2L, 90, LAST_PLAYED), new PsnSyncStep.Playtime(-3L, 90, LAST_PLAYED)), new PsnSyncStep.Playtime(-2L, 90, LAST_PLAYED, 60), new PsnSyncStep.Playtime(-3L, 90, LAST_PLAYED, 60)),
Instant.now(), DAY); Instant.now(), DAY, BERLIN);
assertThat(changes).containsExactly(SyncChange.added(-9L)); assertThat(changes).containsExactly(SyncChange.added(-9L));
assertThat(steam.getPlaytimeMinutes()).isEqualTo(30); assertThat(steam.getPlaytimeMinutes()).isEqualTo(30);
@@ -353,11 +404,51 @@ class PsnSyncStepTest extends AbstractUnitTest {
void apply_TwoTitlesOnOneGame_BookTheirSum_SoTheTotalNeverFlips() { void apply_TwoTitlesOnOneGame_BookTheirSum_SoTheTotalNeverFlips() {
UserGame game = psnRow(-1L, 600); UserGame game = psnRow(-1L, 600);
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, null), List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, LAST_PLAYED, 40),
new PsnSyncStep.Playtime(-1L, 500, null)), Instant.now(), DAY); new PsnSyncStep.Playtime(-1L, 500, OLDER, 60)), Instant.now(), DAY, BERLIN);
assertThat(game.getPlaytimeMinutes()).isEqualTo(600); assertThat(game.getPlaytimeMinutes()).isEqualTo(600);
assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 100));
verify(playtimeDayRepository).addMinutes(USER, -1L, LocalDate.of(2026, 10, 5), 100);
}
@Test
void apply_SecondTitleLinkedWithOldPlaytime_RaisesTheTotal_WritesNoDayRow() {
UserGame game = psnRow(-1L, 30);
List<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 400, LAST_PLAYED, 0)),
Instant.now(), DAY, BERLIN);
assertThat(game.getPlaytimeMinutes()).isEqualTo(400);
assertThat(changes).containsExactly(SyncChange.added(-9L)); assertThat(changes).containsExactly(SyncChange.added(-9L));
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt()); verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
} }
@Test
void apply_PlayedMinutes_AreBookedOnTheLastPlayedDayInTheZone() {
UserGame game = psnRow(-1L, 30);
List<SyncChange> changes = step.apply(USER, prepared(
new PsnSyncStep.Playtime(-1L, 90, Instant.parse("2026-10-05T22:30:00Z"), 60)), Instant.now(), DAY, BERLIN);
assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 60));
verify(playtimeDayRepository).addMinutes(USER, -1L, LocalDate.of(2026, 10, 6), 60);
assertThat(game.getPlaytimeMinutes()).isEqualTo(90);
}
// When the transaction never reaches apply (Steam gave no games) or rolls back, the next run sees the stored
// play as the baseline and books 0: the session never reaches user_game_playtime_day or sync_change.
@Test
void run_ApplyNeverRan_NextRunStillBooksThePlay() {
fetches(ghost());
Map<String, PsnTitle> stored = new HashMap<>(Map.of("c7", new PsnTitle("c7", PsnTitle.State.LINKED, -1L, 60, OLDER)));
when(psnTitleRepository.findAll(USER)).thenAnswer(invocation -> Map.copyOf(stored));
when(mergeRepository.findTargets(List.of(-1L))).thenReturn(Map.of());
step.run(USER); // the sync transaction returned before apply, or rolled back
PsnSyncStep.Prepared again = step.run(USER);
verify(psnTitleRepository, never()).savePlays(eq(USER), anyList());
assertThat(again.playtimes()).extracting(PsnSyncStep.Playtime::played).containsExactly(60);
}
} }