diff --git a/readme.md b/readme.md index 75dfc9e..70dbf22 100644 --- a/readme.md +++ b/readme.md @@ -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 overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5 games closest to 100 %. -- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks" - dashboard banner. +- **Playtime history**: one row per game and day (a day counts at most 24 h in the stats and the chart), for the + 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 "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 @@ -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), 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 - 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: 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). diff --git a/src/main/java/core_service/modules/library/domain/PsnTitle.java b/src/main/java/core_service/modules/library/domain/PsnTitle.java index d3690e1..8d1ce11 100644 --- a/src/main/java/core_service/modules/library/domain/PsnTitle.java +++ b/src/main/java/core_service/modules/library/domain/PsnTitle.java @@ -1,10 +1,17 @@ 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 * 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 } } diff --git a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java index 9ef44d6..b0a85de 100644 --- a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -440,7 +440,7 @@ public class GameMergeRepository { 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 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); 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); diff --git a/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java b/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java index 50579e2..5fbd5c1 100644 --- a/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java +++ b/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java @@ -25,10 +25,11 @@ public class PsnTitleRepository { public Map findAll(long userId) { Map 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 -> { 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; } diff --git a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java index 207cb5e..3e6c7fd 100644 --- a/src/main/java/core_service/modules/library/repo/UserStatsRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserStatsRepository.java @@ -103,7 +103,7 @@ public class UserStatsRepository { 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"; - 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)"; 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"; @@ -242,7 +242,7 @@ public class UserStatsRepository { } public Optional 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), (rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes"))) .stream().findFirst(); diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 2c6c023..411beca 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -393,7 +393,8 @@ public class LibraryQueryService { Map played = playtimeDayRepository.minutesPerDay(userId, from).stream() .collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes)); List 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(); long total = perDay.stream().mapToLong(DayMinutes::minutes).sum(); diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 00dd34f..60a65d6 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -164,7 +164,7 @@ public class LibrarySyncWorker { } List steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn) : new ArrayList<>(); - List psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay); + List psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay, zone); // The first sync of an account is a baseline: a count, not a list. int imported = 0; List listed = new ArrayList<>(); diff --git a/src/main/java/core_service/modules/library/service/PsnSyncStep.java b/src/main/java/core_service/modules/library/service/PsnSyncStep.java index 4f9f0f5..8700c28 100644 --- a/src/main/java/core_service/modules/library/service/PsnSyncStep.java +++ b/src/main/java/core_service/modules/library/service/PsnSyncStep.java @@ -22,6 +22,7 @@ import org.springframework.transaction.support.TransactionTemplate; import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -47,17 +48,22 @@ public class PsnSyncStep { * A LINKED title's PSN values, applied in the sync transaction. * * @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. * * @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 */ - public record Prepared(List changes, List playtimes, boolean baseline) { - public static final Prepared NONE = new Prepared(List.of(), List.of(), false); + public record Prepared(List changes, List playtimes, List plays, + boolean baseline) { + public static final Prepared NONE = new Prepared(List.of(), List.of(), List.of(), false); } private final PsnAccountService accountService; @@ -106,26 +112,38 @@ public class PsnSyncStep { List review = new ArrayList<>(); for (GameImport.Line line : lines) { PsnTitle seen = known.get(line.psnKey()); + Instant lastPlayedAt = lastPlayed.get(line.psnKey()); 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())) { - matchNew(userId, line, lastPlayed.get(line.psnKey()), changes, review); + matchNew(userId, line, lastPlayedAt, changes, review); } } saveReview(userId, review); - psnTitleRepository.savePlays(userId, lines.stream().map(line -> new TitlePlay(line.psnKey(), null, - line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList()); + List plays = 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, review.size(), changes.size() - added); - return new Prepared(changes, mergedKeys(playtimes), baseline); + return new Prepared(changes, mergedKeys(playtimes), plays, baseline); } catch (RuntimeException 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 fetch(Long userId) { switch (accountService.fetchTitles(userId)) { case PsnAccountService.FetchResult.Titles found -> { @@ -194,7 +212,7 @@ public class PsnSyncStep { private List mergedKeys(List playtimes) { Map targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList()); 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 @@ -205,33 +223,35 @@ public class PsnSyncStep { byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(), (int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE), 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(); } /** * 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) */ - public List apply(Long userId, Prepared prepared, Instant now, LocalDate day) { + public List apply(Long userId, Prepared prepared, Instant now, LocalDate day, ZoneId zone) { List changes = new ArrayList<>(prepared.changes()); Map touched = new HashMap<>(); for (Playtime playtime : perGame(prepared.playtimes())) { userGameRepository.findById(new UserGameId(userId, playtime.appid())) .filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null) .ifPresent(game -> { - int growth = playtime.minutes() - game.getPlaytimeMinutes(); game.setPlaytimeMinutes(playtime.minutes()); game.setLastSeenAt(now); if (playtime.lastPlayedAt() != null) { game.setLastPlayedAt(playtime.lastPlayedAt()); } - if (growth > 0) { - changes.add(SyncChange.playtime(game.getAppid(), growth)); - playtimeDayRepository.addMinutes(userId, game.getAppid(), day, growth); + if (playtime.played() > 0) { + LocalDate playedOn = LocalDate.ofInstant(playtime.lastPlayedAt(), zone); + changes.add(SyncChange.playtime(game.getAppid(), playtime.played())); + playtimeDayRepository.addMinutes(userId, game.getAppid(), playedOn, playtime.played()); } touched.put(game.getAppid(), game); }); @@ -243,6 +263,9 @@ public class PsnSyncStep { touched.forEach((appid, game) -> game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE))); } + if (!prepared.plays().isEmpty()) { + psnTitleRepository.savePlays(userId, prepared.plays()); + } return changes; } } diff --git a/src/main/resources/db/migration/V49__Psn_Day_Rows_After_Last_Played.sql b/src/main/resources/db/migration/V49__Psn_Day_Rows_After_Last_Played.sql new file mode 100644 index 0000000..5088444 --- /dev/null +++ b/src/main/resources/db/migration/V49__Psn_Day_Rows_After_Last_Played.sql @@ -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; diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java index c7872d4..045fc2b 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java @@ -91,6 +91,16 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest { .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 void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception { jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID); diff --git a/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java b/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java index c6adea0..ef2fc33 100644 --- a/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java +++ b/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java @@ -28,6 +28,8 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Map; import java.util.Optional; @@ -247,13 +249,16 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest { awaitSync(); 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); assertThat(jdbc.queryForMap("select kind, appid, amount from sync_change where user_id = 1")) .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", 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")) .containsEntry("playtime_minutes", 185).containsEntry("playtime_2weeks", 60); assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1); diff --git a/src/test/java/core_service/modules/library/repo/GameMergeRepositoryTakeoverTest.java b/src/test/java/core_service/modules/library/repo/GameMergeRepositoryTakeoverTest.java index ad2e7a1..f8a6b6c 100644 --- a/src/test/java/core_service/modules/library/repo/GameMergeRepositoryTakeoverTest.java +++ b/src/test/java/core_service/modules/library/repo/GameMergeRepositoryTakeoverTest.java @@ -143,6 +143,20 @@ class GameMergeRepositoryTakeoverTest extends AbstractPersistenceTest { .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 void rekeyUserGame_KindHeldOnTarget_StaysOnOldRef() { game(USER, MANUAL, "MANUAL", "PC"); diff --git a/src/test/java/core_service/modules/library/repo/PsnDayRowsMigrationTest.java b/src/test/java/core_service/modules/library/repo/PsnDayRowsMigrationTest.java new file mode 100644 index 0000000..0759138 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/PsnDayRowsMigrationTest.java @@ -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) connection -> { + ScriptUtils.executeSqlScript(connection, + new ClassPathResource("db/migration/V49__Psn_Day_Rows_After_Last_Played.sql")); + return null; + }); + } + + private List 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"); + } +} diff --git a/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java b/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java index 09bceb5..b4744fa 100644 --- a/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java @@ -67,6 +67,15 @@ class PsnTitleRepositoryTest extends AbstractPersistenceTest { 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 void findAll_ReturnsOnlyTheUsersTitles() { repository.link(3L, "c1", -1L); diff --git a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java index 30db256..35043b6 100644 --- a/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserStatsRepositoryTest.java @@ -262,6 +262,18 @@ class UserStatsRepositoryTest extends AbstractPersistenceTest { 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 void longestDay_SumsAllGamesOfTheDay() { 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); } + @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 void games_LongestDayListsASetOnceWithTheSumOfItsEditions() { seedSet(); diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index fa3366d..fafcd4b 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -833,9 +833,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { @Test 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.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))); owned(100); steamReturns(game(160, 60), STEAM_ID); @@ -845,7 +845,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { InOrder order = inOrder(steamApiService, psnSyncStep, userGameRepository, syncChangeRepository); order.verify(psnSyncStep).run(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()); assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactlyInAnyOrder(SyncChange.playtime(220L, 60), SyncChange.added(-9L), SyncChange.playtime(-8L, 30)); @@ -853,9 +853,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { @Test 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.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))); owned(100); steamReturns(game(100, 0), STEAM_ID); @@ -867,9 +867,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { @Test 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.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); worker.runSync(USER_ID, null, SyncTrigger.AUTO); diff --git a/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java b/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java index 22f7a01..a7213d5 100644 --- a/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java +++ b/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java @@ -26,6 +26,8 @@ import org.springframework.transaction.PlatformTransactionManager; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; +import java.time.ZoneId; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -34,6 +36,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -48,6 +51,8 @@ class PsnSyncStepTest extends AbstractUnitTest { private static final long USER = 1L; 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 ZoneId BERLIN = ZoneId.of("Europe/Berlin"); + private static final Instant OLDER = Instant.parse("2026-10-01T10:00:00Z"); @Mock 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); } + 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 void run_ExactMatchOnThePlatform_AddsTheGame_LinksIt_SetsLastPlayed_AndIsABaseline() { fetches(ghost()); @@ -191,11 +204,42 @@ class PsnSyncStepTest extends AbstractUnitTest { 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(); 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 void run_SavesThePlayOfALinkedAndASteamOwnedTitle_AndGivesNoPlaytimeToTheSteamRow() { 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); - ArgumentCaptor> plays = ArgumentCaptor.captor(); - verify(psnTitleRepository).savePlays(eq(USER), plays.capture()); - assertThat(plays.getValue()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED), + verify(psnTitleRepository, never()).savePlays(eq(USER), anyList()); + assertThat(prepared.plays()).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); } @@ -221,11 +264,10 @@ class PsnSyncStepTest extends AbstractUnitTest { 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); + PsnSyncStep.Prepared prepared = step.run(USER); - ArgumentCaptor> plays = ArgumentCaptor.captor(); - verify(psnTitleRepository).savePlays(eq(USER), plays.capture()); - assertThat(plays.getValue()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED)); + assertThat(prepared.plays()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED)); + assertThat(prepared.playtimes()).isEmpty(); } @Test @@ -297,20 +339,29 @@ class PsnSyncStepTest extends AbstractUnitTest { } 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 - void apply_GrowthIsBooked_AsChangeAndDayMinutes_WithTwoWeeksFromTheHistoryAndLastPlayed() { + void apply_SavesThePlaysOfTheRun() { + List 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); when(playtimeDayRepository.minutesPerGameSince(eq(USER), eq(Set.of(-1L)), eq(DAY.minusDays(13)))) .thenReturn(List.of(new AppidMinutes(-1L, 75))); Instant now = Instant.now(); - List changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED)), now, DAY); + List 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)); - 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.getPlaytime2Weeks()).isEqualTo(75); assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED); @@ -322,7 +373,7 @@ class PsnSyncStepTest extends AbstractUnitTest { UserGame game = psnRow(-1L, 90); game.setLastPlayedAt(LAST_PLAYED); - List changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 60, null)), Instant.now(), DAY); + List changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 60, null, 0)), Instant.now(), DAY, BERLIN); assertThat(changes).containsExactly(SyncChange.added(-9L)); verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt()); @@ -339,9 +390,9 @@ class PsnSyncStepTest extends AbstractUnitTest { removed.setRemovedAt(Instant.EPOCH); when(userGameRepository.findById(new UserGameId(USER, -3L))).thenReturn(Optional.empty()); - List changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED), - new PsnSyncStep.Playtime(-2L, 90, LAST_PLAYED), new PsnSyncStep.Playtime(-3L, 90, LAST_PLAYED)), - Instant.now(), DAY); + List changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 90, LAST_PLAYED, 60), + new PsnSyncStep.Playtime(-2L, 90, LAST_PLAYED, 60), new PsnSyncStep.Playtime(-3L, 90, LAST_PLAYED, 60)), + Instant.now(), DAY, BERLIN); assertThat(changes).containsExactly(SyncChange.added(-9L)); assertThat(steam.getPlaytimeMinutes()).isEqualTo(30); @@ -353,11 +404,51 @@ class PsnSyncStepTest extends AbstractUnitTest { void apply_TwoTitlesOnOneGame_BookTheirSum_SoTheTotalNeverFlips() { UserGame game = psnRow(-1L, 600); - List changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, null), - new PsnSyncStep.Playtime(-1L, 500, null)), Instant.now(), DAY); + List changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, LAST_PLAYED, 40), + new PsnSyncStep.Playtime(-1L, 500, OLDER, 60)), Instant.now(), DAY, BERLIN); 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 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)); verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt()); } + + @Test + void apply_PlayedMinutes_AreBookedOnTheLastPlayedDayInTheZone() { + UserGame game = psnRow(-1L, 30); + + List 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 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); + } }