Public Access
fix(library): book psn play on its last played day (#53)
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:
1 parent
d3c9c7558c
commit
6f2d7b4885
17 files changed
+322
-54
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -25,10 +25,11 @@ public class PsnTitleRepository {
|
||||
|
||||
public Map<String, PsnTitle> findAll(long userId) {
|
||||
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 -> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<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),
|
||||
(rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes")))
|
||||
.stream().findFirst();
|
||||
|
||||
@@ -393,7 +393,8 @@ public class LibraryQueryService {
|
||||
Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream()
|
||||
.collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes));
|
||||
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();
|
||||
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)
|
||||
: 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.
|
||||
int imported = 0;
|
||||
List<SyncChange> listed = new ArrayList<>();
|
||||
|
||||
@@ -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<SyncChange> changes, List<Playtime> playtimes, boolean baseline) {
|
||||
public static final Prepared NONE = new Prepared(List.of(), List.of(), false);
|
||||
public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, List<TitlePlay> 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<GameImport.Row> 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<TitlePlay> 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<PsnAccountService.FetchResult.Titles> fetch(Long userId) {
|
||||
switch (accountService.fetchTitles(userId)) {
|
||||
case PsnAccountService.FetchResult.Titles found -> {
|
||||
@@ -194,7 +212,7 @@ public class PsnSyncStep {
|
||||
private List<Playtime> mergedKeys(List<Playtime> playtimes) {
|
||||
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()),
|
||||
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<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());
|
||||
Map<Long, UserGame> 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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<List<TitlePlay>> 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<List<TitlePlay>> 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<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);
|
||||
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<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));
|
||||
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<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));
|
||||
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<SyncChange> 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<SyncChange> 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<SyncChange> changes = step.apply(USER, prepared(new PsnSyncStep.Playtime(-1L, 100, null),
|
||||
new PsnSyncStep.Playtime(-1L, 500, null)), Instant.now(), DAY);
|
||||
List<SyncChange> 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<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));
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user