feat(psn): sync PlayStation titles in the library sync

Exact IGDB matches are added; unsure titles wait in a review import.
Playtime and last played come from PSN: summed per game, not editable.
PSN games give no Pile Points. Users with only a PSN link sync too
(V39 drops steam_id NOT NULL). Linking starts a sync, or schedules one
for the end of the cooldown. Inbox notes 2 days before the PSN link
ends and when it has ended.
This commit is contained in:
lobo committed 2026-10-08 08:15:37 +02:00
1 parent 7ac887ed5a
commit f5ca7237a9
54 files changed
+2230 -313

No files matched your search

@@ -22,7 +22,7 @@ public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "user_psn_account", "reward_ledger", "reward_equipped",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped",
"account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user");
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
@@ -9,7 +9,7 @@ import java.util.List;
public record InboxDto(List<Note> results, int unread) {
/**
* @param kind DIGEST or VISIBILITY
* @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName)
* @param displayName the name of the followed user when the note was written; never a user id
*/
public record Note(long id, String kind, String displayName, int newGames, int achievements, int statusChanges,
@@ -2,5 +2,8 @@ package core_service.modules.inbox.domain;
public enum NoteKind {
DIGEST,
VISIBILITY
VISIBILITY,
/** About the reader's own PlayStation link; subject is the reader. */
PSN_EXPIRING,
PSN_EXPIRED
}
@@ -62,7 +62,8 @@ public class GameImportController {
@PostMapping("/{id}/save")
public ResponseEntity<?> save(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id,
@Valid @RequestBody ImportSaveRequest request) {
return switch (importService.save(principal.getIdAsLong(), id, request.rows())) {
return switch (importService.save(principal.getIdAsLong(), id, request.rows(),
request.shownLineNos())) {
case DONE -> ResponseEntity.accepted().build();
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
@@ -83,7 +83,6 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Objects;
import java.util.Set;
@@ -118,15 +117,11 @@ public class LibraryController {
@GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
Long userId = principal.getIdAsLong();
boolean syncStarted = principal.steamIdText()
.map(steamId -> syncService.syncIfStale(userId, steamId))
.orElseGet(() -> {
syncService.stopNightlySync(userId);
return false;
});
String steamId = principal.steamIdText().orElse(null);
boolean syncStarted = syncService.syncIfStale(userId, steamId);
long count = queryService.countOwnedGames(userId);
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null,
"syncStarted", syncStarted));
"canSync", syncService.canSync(userId, steamId), "syncStarted", syncStarted));
}
@GetMapping("/games")
@@ -142,7 +137,7 @@ public class LibraryController {
@RequestParam(defaultValue = "false") boolean hideDuplicates
) {
Long userId = principal.getIdAsLong();
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
int safePage = Math.max(0, page);
@@ -164,13 +159,12 @@ public class LibraryController {
@PostMapping("/sync")
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
Optional<String> steamId = principal.steamIdText();
if (steamId.isEmpty()) {
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
}
try {
syncService.forceSync(principal.getIdAsLong(), steamId.get());
syncService.forceSync(principal.getIdAsLong(), principal.steamIdText().orElse(null));
return ResponseEntity.accepted().body(Map.of("status", "started"));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID",
"User has no Steam ID or PlayStation link"));
} catch (SyncCooldownException e) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(e.getRetryAfterSeconds()))
@@ -493,7 +487,7 @@ public class LibraryController {
: exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList();
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
Long userId = principal.getIdAsLong();
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, category));
}
@@ -2,6 +2,7 @@ package core_service.modules.library.api;
import core_service.modules.library.api.dto.PsnAccountDto;
import core_service.modules.library.api.dto.PsnLinkRequest;
import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.PsnAccountService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal;
@@ -17,10 +18,8 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* The user's linked PlayStation account and its import. No answer repeats the request.
* The user's linked PlayStation account; linking starts a sync. No answer repeats the request.
*/
@RestController
@RequestMapping("/api/library/psn-account")
@@ -35,12 +34,16 @@ public class PsnAccountController {
"PlayStation did not answer; try again later");
private final PsnAccountService accountService;
private final LibrarySyncService syncService;
@PostMapping
public ResponseEntity<?> link(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody PsnLinkRequest request) {
return switch (accountService.link(principal.getIdAsLong(), request.npsso())) {
case PsnAccountService.LinkResult.Linked linked -> ResponseEntity.ok(dto(linked.status()));
case PsnAccountService.LinkResult.Linked linked -> {
syncService.startAfterLink(principal.getIdAsLong(), principal.steamIdText().orElse(null));
yield ResponseEntity.ok(dto(linked.status()));
}
case PsnAccountService.LinkResult.Rejected ignored -> error(HttpStatus.BAD_REQUEST, "NPSSO_INVALID",
"PlayStation did not accept the NPSSO; it may have expired");
case PsnAccountService.LinkResult.Off ignored -> PSN_OFF;
@@ -60,28 +63,9 @@ public class PsnAccountController {
return ResponseEntity.noContent().build();
}
@PostMapping("/imports")
public ResponseEntity<?> startImport(@AuthenticationPrincipal UserPrincipal principal) {
return switch (accountService.startImport(principal.getIdAsLong())) {
case PsnAccountService.ImportResult.Started started -> ResponseEntity.status(HttpStatus.ACCEPTED)
.body(Map.of("id", started.id()));
case PsnAccountService.ImportResult.NotLinked ignored -> error(HttpStatus.NOT_FOUND, "PSN_NOT_LINKED",
"No PlayStation account is linked");
case PsnAccountService.ImportResult.Relink ignored -> error(HttpStatus.CONFLICT, "PSN_RELINK",
"The PlayStation link has expired; link again with a new NPSSO");
case PsnAccountService.ImportResult.NoTitles ignored -> ResponseEntity.noContent().build();
case PsnAccountService.ImportResult.Off ignored -> PSN_OFF;
case PsnAccountService.ImportResult.Unavailable ignored -> PSN_ERROR;
case PsnAccountService.ImportResult.Busy ignored -> PSN_BUSY;
case PsnAccountService.ImportResult.AlreadyMatching ignored -> error(HttpStatus.CONFLICT, "IMPORT_RUNNING",
"Another import is still being matched");
case PsnAccountService.ImportResult.SearchUnavailable ignored -> error(HttpStatus.SERVICE_UNAVAILABLE,
"IGDB_OFF", "Importing games needs the IGDB app");
};
}
private static PsnAccountDto dto(PsnAccountService.Status status) {
return new PsnAccountDto(status.enabled(), status.linked(), status.linkedAt(), status.expiresAt());
return new PsnAccountDto(status.enabled(), status.linked(), status.linkedAt(), status.expiresAt(),
status.reviewImportId());
}
private static ResponseEntity<?> error(HttpStatus status, String code, String message) {
@@ -16,6 +16,8 @@ public record GameDetailDto(
int playtime2Weeks,
Instant firstSeenAt,
Instant lastSeenAt,
// from PlayStation; null for Steam rows
Instant lastPlayedAt,
GameStatus status,
Integer rating,
String notes,
@@ -19,7 +19,7 @@ public record ImportRequest(
public static final int MAX_ROWS = 1000;
@AssertTrue(message = "PSN imports start at /api/library/psn-account/imports")
@AssertTrue(message = "PSN imports come from the sync")
public boolean isNotPsn() {
return source != GameImport.Source.PSN;
}
@@ -12,8 +12,12 @@ import java.util.List;
/**
* The lines the user checked in the review, each with the game picked.
*
* @param shownLineNos the lines the user saw; a PlayStation review keeps the others open. Null: all were shown.
*/
public record ImportSaveRequest(@NotNull @Size(max = ImportRequest.MAX_ROWS) List<@NotNull @Valid Row> rows) {
public record ImportSaveRequest(
@NotNull @Size(max = ImportRequest.MAX_ROWS) List<@NotNull @Valid Row> rows,
@Size(max = ImportRequest.MAX_ROWS) List<@NotNull Integer> shownLineNos) {
/**
* @param igdbId a candidate of the line, or a game from a new search
@@ -5,10 +5,12 @@ import java.time.Instant;
/**
* @param enabled false when Core has no APP_PSN_TOKEN_KEY: the PlayStation link is off
* @param expiresAt when the link ends and a new NPSSO is needed; null when not linked
* @param reviewImportId the PSN import waiting for the user's review, null when none
*/
public record PsnAccountDto(
boolean enabled,
boolean linked,
Instant linkedAt,
Instant expiresAt
Instant expiresAt,
Long reviewImportId
) {}
@@ -3,7 +3,8 @@ package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
/**
* @param playtime2Weeks Steam's playtime of the last two weeks, in minutes
* @param playtime2Weeks Steam's playtime of the last two weeks, or the PSN playtime history of the last 14 days, in
* minutes
* @param imageUrl see {@link GameDto#imageUrl()}
*/
public record RecentGameDto(
@@ -0,0 +1,3 @@
package core_service.modules.library.domain;
public record AppidMinutes(long appid, long minutes) {}
@@ -37,10 +37,11 @@ public record GameImport(long id, long userId, Source source, Status status, int
/**
* One input line; every field but lineNo may be missing.
*
* @param psnKey the PlayStation title the line came from, null for every other import
* @param igdbId the game the user already picked in the search (source SEARCH); then title is not searched
*/
public record Line(int lineNo, String title, String platform, Integer playtimeMinutes, GameStatus status,
Long igdbId) {}
Long igdbId, String psnKey) {}
/**
* A line after the matching.
@@ -9,8 +9,8 @@ public enum GameSource {
STEAM,
MANUAL,
/**
* Rows of a PlayStation import. In this slice the sync treats them like MANUAL: it takes them over on Steam and
* never removes them.
* Rows the PSN step of the sync adds or confirms through a review; the sync updates their playtime and last
* played. The Steam sync still takes them over and never removes them.
*/
PSN
}
@@ -6,8 +6,10 @@ import java.time.Instant;
* A linked PlayStation account (V38). Only the sealed refresh token is stored, never the NPSSO.
*
* @param refreshExpiresAt when the user must paste a new NPSSO
* @param expiryNote the inbox note written for this link: null, PSN_EXPIRING or PSN_EXPIRED
*/
public record PsnAccount(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt, Instant linkedAt) {
public record PsnAccount(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt, Instant linkedAt,
String expiryNote) {
@Override
public String toString() {
@@ -0,0 +1,10 @@
package core_service.modules.library.domain;
/**
* 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.
*/
public record PsnTitle(String psnKey, State state, Long appid) {
public enum State { LINKED, STEAM_OWNED, DISMISSED }
}
@@ -91,6 +91,10 @@ public class UserGame {
@Column(name = "import_id")
private Long importId;
/** From PlayStation; null for Steam rows. */
@Column(name = "last_played_at")
private Instant lastPlayedAt;
public UserGame(Long userId, Long appid, int playtimeMinutes, Instant firstSeenAt, Instant lastSeenAt) {
this.userId = userId;
this.appid = appid;
@@ -26,7 +26,8 @@ public class UserSteamSync {
@Column(name = "user_id")
private Long userId;
@Column(name = "steam_id", nullable = false, length = 32)
/** Null for a user with a PlayStation link and no Steam account. */
@Column(name = "steam_id", length = 32)
private String steamId;
@Column(name = "last_synced_at")
@@ -16,10 +16,12 @@ import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/**
* Imports and their lines (V22), the queues of the import workers, and the games an import added.
@@ -59,15 +61,116 @@ public class GameImportRepository {
key, new String[]{"id"});
long id = key.getKeyAs(Long.class);
jdbc.batchUpdate("insert into game_import_row (import_id, line_no, title, platform, playtime_minutes, status, "
+ "igdb_id) values (:id, :line, :title, :platform, :minutes, :status, :igdb)",
lines.stream().map(line -> new MapSqlParameterSource("id", id).addValue("line", line.lineNo())
.addValue("title", line.title()).addValue("platform", line.platform())
.addValue("minutes", line.playtimeMinutes())
.addValue("status", line.status() != null ? line.status().name() : null)
.addValue("igdb", line.igdbId())).toArray(MapSqlParameterSource[]::new));
+ "igdb_id, psn_key) values (:id, :line, :title, :platform, :minutes, :status, :igdb, :psnKey)",
lines.stream().map(line -> lineParams(id, line.lineNo(), line)).toArray(MapSqlParameterSource[]::new));
return id;
}
private static final String OPEN_PSN = "i.user_id = :user and i.source = 'PSN' "
+ "and i.status in ('QUEUED', 'MATCHING', 'READY', 'SAVING')";
/**
* The user's newest PSN import that is not finished: the sync adds review lines to a READY one and waits for
* the others.
*/
public Optional<GameImport> findOpenPsnImport(long userId) {
return jdbc.query(SELECT_IMPORT + "where " + OPEN_PSN + " order by i.id desc limit 1",
new MapSqlParameterSource("user", userId), IMPORT).stream().findFirst();
}
/**
* The PlayStation titles that wait in an open import.
*/
public Set<String> findOpenPsnKeys(long userId) {
return new HashSet<>(jdbc.queryForList("select r.psn_key from game_import_row r join game_import i "
+ "on i.id = r.import_id where " + OPEN_PSN + " and r.psn_key is not null",
new MapSqlParameterSource("user", userId), String.class));
}
/**
* An import the sync matched itself: READY, with the candidates of each row.
*/
public long createReady(long userId, List<GameImport.Row> rows) {
GeneratedKeyHolder key = new GeneratedKeyHolder();
jdbc.update("insert into game_import (user_id, source, status, total) values (:user, 'PSN', 'READY', :total)",
new MapSqlParameterSource("user", userId).addValue("total", rows.size()), key, new String[]{"id"});
long id = key.getKeyAs(Long.class);
insertMatchedRows(id, 1, rows);
return id;
}
/**
* Adds matched rows after the last line of an import that is still READY.
*
* @return false when the user saved the import meanwhile; nothing is added then
*/
public boolean appendReadyRows(long importId, List<GameImport.Row> rows) {
if (jdbc.update("update game_import set total = total + :n where id = :id and status = 'READY'",
new MapSqlParameterSource("id", importId).addValue("n", rows.size())) == 0) {
return false;
}
int start = jdbc.queryForObject(
"select coalesce(max(line_no), 0) + 1 from game_import_row where import_id = :id",
new MapSqlParameterSource("id", importId), Integer.class);
insertMatchedRows(importId, start, rows);
return true;
}
/**
* Moves the lines without a choice that are not in {@code shown} to a new READY import of the user, so a save
* neither adds nor dismisses them.
*/
public void moveUnseenToNewReady(long userId, long importId, Set<Integer> shown) {
List<Integer> unseen = jdbc.queryForList("select line_no from game_import_row where import_id = :id "
+ "and chosen_igdb_id is null order by line_no", new MapSqlParameterSource("id", importId),
Integer.class).stream().filter(lineNo -> !shown.contains(lineNo)).toList();
if (unseen.isEmpty()) {
return;
}
GeneratedKeyHolder key = new GeneratedKeyHolder();
jdbc.update("insert into game_import (user_id, source, status, total) values (:user, 'PSN', 'READY', :total)",
new MapSqlParameterSource("user", userId).addValue("total", unseen.size()), key, new String[]{"id"});
long target = key.getKeyAs(Long.class);
for (int index = 0; index < unseen.size(); index++) {
jdbc.update("update game_import_row set import_id = :target, line_no = :new "
+ "where import_id = :id and line_no = :old",
new MapSqlParameterSource("target", target).addValue("new", index + 1).addValue("id", importId)
.addValue("old", unseen.get(index)));
}
jdbc.update("update game_import set total = total - :n where id = :id",
new MapSqlParameterSource("id", importId).addValue("n", unseen.size()));
}
/**
* The PlayStation titles of the import's lines the user did not check.
*/
public List<String> findPsnKeysWithoutChoice(long importId) {
return jdbc.queryForList("select psn_key from game_import_row where import_id = :id and psn_key is not null "
+ "and chosen_igdb_id is null", new MapSqlParameterSource("id", importId), String.class);
}
private void insertMatchedRows(long importId, int firstLineNo, List<GameImport.Row> rows) {
MapSqlParameterSource[] params = new MapSqlParameterSource[rows.size()];
for (int index = 0; index < params.length; index++) {
GameImport.Row row = rows.get(index);
params[index] = lineParams(importId, firstLineNo + index, row.line())
.addValue("failed", row.searchFailed()).addValue("exact", row.exact())
.addValue("candidates", jsonMapper.writeValueAsString(row.candidates()));
}
jdbc.batchUpdate("insert into game_import_row (import_id, line_no, title, platform, playtime_minutes, status, "
+ "igdb_id, psn_key, matched_at, search_failed, exact, candidates) values (:id, :line, :title, "
+ ":platform, :minutes, :status, :igdb, :psnKey, now(), :failed, :exact, cast(:candidates as jsonb))",
params);
}
private static MapSqlParameterSource lineParams(long importId, int lineNo, GameImport.Line line) {
return new MapSqlParameterSource("id", importId).addValue("line", lineNo)
.addValue("title", line.title()).addValue("platform", line.platform())
.addValue("minutes", line.playtimeMinutes())
.addValue("status", line.status() != null ? line.status().name() : null)
.addValue("igdb", line.igdbId()).addValue("psnKey", line.psnKey());
}
public Optional<GameImport> find(long userId, long id) {
return jdbc.query(SELECT_IMPORT + "where i.id = :id and i.user_id = :user",
new MapSqlParameterSource("id", id).addValue("user", userId), IMPORT).stream().findFirst();
@@ -279,7 +382,7 @@ public class GameImportRepository {
String status = rs.getString("status");
return new GameImport.Line(rs.getInt("line_no"), rs.getString("title"), rs.getString("platform"),
rs.getObject("playtime_minutes", Integer.class), status != null ? GameStatus.valueOf(status) : null,
rs.getObject("igdb_id", Long.class));
rs.getObject("igdb_id", Long.class), rs.getString("psn_key"));
}
private static Instant instant(Timestamp timestamp) {
@@ -1,5 +1,6 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.AppidMinutes;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.PlaytimeDay;
import core_service.modules.library.domain.PlaytimeDayId;
@@ -10,6 +11,7 @@ import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, PlaytimeDayId> {
@@ -28,6 +30,11 @@ public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, Playti
void addMinutes(@Param("userId") Long userId, @Param("appid") Long appid,
@Param("day") LocalDate day, @Param("minutes") int minutes);
@Query("select new core_service.modules.library.domain.AppidMinutes(p.appid, sum(p.minutes)) from PlaytimeDay p " +
"where p.userId = :userId and p.appid in :appids and p.day >= :from group by p.appid")
List<AppidMinutes> minutesPerGameSince(@Param("userId") Long userId, @Param("appids") Collection<Long> appids,
@Param("from") LocalDate from);
/**
* Only days with playtime; the caller fills the gaps. A hidden game's days never count, also those from before
* it was hidden.
@@ -17,10 +17,11 @@ public class PsnAccountRepository {
private final NamedParameterJdbcTemplate jdbc;
public Optional<PsnAccount> find(long userId) {
return jdbc.query("select user_id, refresh_token_enc, refresh_expires_at, linked_at from user_psn_account "
+ "where user_id = :user", new MapSqlParameterSource("user", userId),
return jdbc.query("select user_id, refresh_token_enc, refresh_expires_at, linked_at, expiry_note "
+ "from user_psn_account where user_id = :user", new MapSqlParameterSource("user", userId),
(rs, i) -> new PsnAccount(rs.getLong("user_id"), rs.getBytes("refresh_token_enc"),
rs.getTimestamp("refresh_expires_at").toInstant(), rs.getTimestamp("linked_at").toInstant()))
rs.getTimestamp("refresh_expires_at").toInstant(), rs.getTimestamp("linked_at").toInstant(),
rs.getString("expiry_note")))
.stream().findFirst();
}
@@ -31,7 +32,7 @@ public class PsnAccountRepository {
jdbc.update("""
insert into user_psn_account (user_id, refresh_token_enc, refresh_expires_at) values (:user, :token, :expires)
on conflict (user_id) do update set refresh_token_enc = :token, refresh_expires_at = :expires,
linked_at = now()""", params(userId, refreshTokenEnc, refreshExpiresAt));
linked_at = now(), expiry_note = null""", params(userId, refreshTokenEnc, refreshExpiresAt));
}
public void updateToken(long userId, byte[] refreshTokenEnc, Instant refreshExpiresAt) {
@@ -47,6 +48,24 @@ public class PsnAccountRepository {
new MapSqlParameterSource("user", userId));
}
/**
* Writes the inbox note of this link once: EXPIRING while none was written, EXPIRED also after EXPIRING. The
* library module writes the inbox table by SQL, as the follow module does.
*
* @param kind the inbox NoteKind name PSN_EXPIRING or PSN_EXPIRED
* @return 1 when a note was written
*/
public int noteExpiry(long userId, String kind) {
return jdbc.update("""
with marked as (
update user_psn_account set expiry_note = :kind
where user_id = :user and expiry_note is distinct from :kind
and (:kind = 'PSN_EXPIRED' or expiry_note is null)
returning user_id)
insert into inbox_note (user_id, kind, subject_user_id) select user_id, :kind, user_id from marked""",
new MapSqlParameterSource("user", userId).addValue("kind", kind));
}
public int delete(long userId) {
return jdbc.update("delete from user_psn_account where user_id = :user", new MapSqlParameterSource("user", userId));
}
@@ -0,0 +1,68 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* What the PlayStation sync has seen per user (V39).
*/
@Repository
@RequiredArgsConstructor
public class PsnTitleRepository {
private final NamedParameterJdbcTemplate jdbc;
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",
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)));
});
return titles;
}
/**
* Also over a DISMISSED row: the user checked the title in a later review.
*/
public void link(long userId, String psnKey, long appid) {
jdbc.update("""
insert into user_psn_title (user_id, psn_key, state, appid) values (:user, :key, 'LINKED', :appid)
on conflict (user_id, psn_key) do update set state = 'LINKED', appid = excluded.appid""",
new MapSqlParameterSource("user", userId).addValue("key", psnKey).addValue("appid", appid));
}
/**
* Links a title to a game the user already owns: STEAM_OWNED when that row is a Steam row (Steam proof and
* playtime stay Steam's), else LINKED. Also over a DISMISSED row.
*/
public void linkOwned(long userId, String psnKey, long appid) {
jdbc.update("""
insert into user_psn_title (user_id, psn_key, state, appid)
values (:user, :key, case when exists (select 1 from user_game where user_id = :user and appid = :appid
and source = 'STEAM') then 'STEAM_OWNED' else 'LINKED' end,
:appid)
on conflict (user_id, psn_key) do update set state = excluded.state, appid = excluded.appid""",
new MapSqlParameterSource("user", userId).addValue("key", psnKey).addValue("appid", appid));
}
/**
* A LINKED title stays linked.
*/
public void dismiss(long userId, Collection<String> psnKeys) {
if (psnKeys.isEmpty()) {
return;
}
jdbc.batchUpdate("insert into user_psn_title (user_id, psn_key, state) values (:user, :key, 'DISMISSED') "
+ "on conflict (user_id, psn_key) do nothing",
psnKeys.stream().map(key -> new MapSqlParameterSource("user", userId).addValue("key", key))
.toArray(MapSqlParameterSource[]::new));
}
}
@@ -412,6 +412,12 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"order by ug.appid, ug.userId")
List<FinishedWithoutDate> findFinishedWithoutDateCandidates();
// For a row addGame just created outside the sync transaction.
@Modifying
@Transactional
@Query("update UserGame ug set ug.lastPlayedAt = :at where ug.userId = :userId and ug.appid = :appid")
int setLastPlayedAt(@Param("userId") Long userId, @Param("appid") Long appid, @Param("at") Instant at);
@Modifying
@Transactional
@Query(value = "update user_game ug set finished_at_estimate = :finishedAt " +
@@ -54,9 +54,9 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
/**
* The gate of the sync on a visit: stamps login_sync_tried_at and returns 1 only when a sync is due, so of many
* parallel requests one wins. Due: no fresh RUNNING run, and either another Steam ID, or no OK sync since
* successBefore and no try since triedBefore. 0 for a deleted or blocked user. A new row gets only the Steam ID.
* steam_id is not updated: the worker claim compares the old ID to detect an account change.
* parallel requests one wins. Due: no fresh RUNNING run, and either another Steam ID (null counts as a value), or
* no OK sync since successBefore and no try since triedBefore. 0 for a deleted or blocked user. A new row gets
* only the Steam ID. steam_id is not updated: the worker claim compares the old ID to detect an account change.
*/
@Modifying
@Transactional
@@ -70,7 +70,7 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
WHERE NOT (user_steam_sync.last_sync_status = 'RUNNING'
AND user_steam_sync.sync_started_at IS NOT NULL
AND user_steam_sync.sync_started_at >= :runningStaleBefore)
AND (user_steam_sync.steam_id <> EXCLUDED.steam_id
AND (user_steam_sync.steam_id IS DISTINCT FROM EXCLUDED.steam_id
OR ((user_steam_sync.last_success_at IS NULL OR user_steam_sync.last_success_at < :successBefore)
AND (user_steam_sync.login_sync_tried_at IS NULL
OR user_steam_sync.login_sync_tried_at < :triedBefore)))
@@ -217,6 +217,7 @@ public class GameDetailService {
userGame.getPlaytime2Weeks(),
userGame.getFirstSeenAt(),
userGame.getLastSeenAt(),
userGame.getLastPlayedAt(),
userGame.getStatus(),
userGame.getRating(),
userGame.getNotes(),
@@ -59,15 +59,8 @@ public class GameImportService {
List<GameImport.Line> lines = IntStream.range(0, rows.size()).mapToObj(index -> {
ImportRequest.Row row = rows.get(index);
return new GameImport.Line(index + 1, strippedOrNull(row.title()), strippedOrNull(row.platform()),
row.playtimeMinutes(), row.status(), row.igdbId());
row.playtimeMinutes(), row.status(), row.igdbId(), null);
}).toList();
return createLines(userId, source, lines);
}
/**
* {@link #create} for lines Core built itself, e.g. from a PlayStation account.
*/
public CreateResult createLines(Long userId, GameImport.Source source, List<GameImport.Line> lines) {
if (!igdbApiService.isEnabled()) {
return new CreateResult.SearchUnavailable();
}
@@ -91,9 +84,12 @@ public class GameImportService {
}
/**
* Once per import: stores the review and puts the import in the saving queue.
* Once per import: stores the review and puts the import in the saving queue. In a PlayStation review the
* lines the user did not see (the sync added them after the review was loaded) move to a new open review.
*
* @param shownLineNos the lines the user saw; null: all
*/
public ChangeResult save(Long userId, long id, List<ImportSaveRequest.Row> chosen) {
public ChangeResult save(Long userId, long id, List<ImportSaveRequest.Row> chosen, List<Integer> shownLineNos) {
if (importRepository.find(userId, id).isEmpty()) {
return ChangeResult.NOT_FOUND;
}
@@ -104,6 +100,9 @@ public class GameImportService {
importRepository.saveChoices(id, chosen.stream().map(row -> new GameImport.Choice(row.lineNo(),
row.igdbId(), row.platform(), row.status(),
row.playtimeMinutes() != null ? row.playtimeMinutes() : 0)).toList());
if (shownLineNos != null && importRepository.findSource(id) == GameImport.Source.PSN) {
importRepository.moveUnseenToNewReady(userId, id, Set.copyOf(shownLineNos));
}
return true;
});
if (!queued) {
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -51,6 +52,7 @@ public class GameImportWorker {
private final GameImportRepository importRepository;
private final ManualGameService manualGameService;
private final PsnTitleRepository psnTitleRepository;
private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
@@ -62,11 +64,13 @@ public class GameImportWorker {
private final Lane saving;
public GameImportWorker(GameImportRepository importRepository, ManualGameService manualGameService,
TimeToBeatService timeToBeatService, MoodService moodService,
PsnTitleRepository psnTitleRepository, TimeToBeatService timeToBeatService,
MoodService moodService,
ReleaseDateService releaseDateService, GameCreditService gameCreditService,
@Value("${app.library.import-stall-minutes:5}") long stallMinutes) {
this.importRepository = importRepository;
this.manualGameService = manualGameService;
this.psnTitleRepository = psnTitleRepository;
this.timeToBeatService = timeToBeatService;
this.moodService = moodService;
this.releaseDateService = releaseDateService;
@@ -140,10 +144,10 @@ public class GameImportWorker {
private void save(long importId) {
importRepository.start(importId, GameImport.Status.SAVING);
List<GameImport.Choice> choices = importRepository.findUnsavedChoices(importId);
long userId = importRepository.findUserId(importId);
GameSource source = importRepository.findSource(importId) == GameImport.Source.PSN ? GameSource.PSN
: GameSource.MANUAL;
if (!choices.isEmpty()) {
long userId = importRepository.findUserId(importId);
GameSource source = importRepository.findSource(importId) == GameImport.Source.PSN ? GameSource.PSN
: GameSource.MANUAL;
Map<Integer, GameImport.Row> rows = importRepository.findRows(importId).stream()
.collect(Collectors.toMap(row -> row.line().lineNo(), row -> row));
for (GameImport.Choice choice : choices) {
@@ -156,12 +160,25 @@ public class GameImportWorker {
if (Thread.currentThread().isInterrupted()) {
return;
}
boolean added = manualGameService.addGame(userId, game, choice.platform(), choice.status(),
choice.playtimeMinutes(), source, importId) instanceof ManualGameService.AddResult.Added;
GameImport.Line line = rows.get(choice.lineNo()).line();
String psnKey = line.psnKey();
// PlayStation owns the playtime of its titles: the review's value would be booked as growth later.
int minutes = psnKey != null && line.playtimeMinutes() != null ? line.playtimeMinutes()
: choice.playtimeMinutes();
ManualGameService.AddResult result = manualGameService.addGame(userId, game, choice.platform(),
choice.status(), minutes, source, importId);
if (psnKey != null) {
linkPsnTitle(userId, psnKey, game, result);
}
importRepository.saveResult(importId, choice.lineNo(),
added ? GameImport.SaveResult.ADDED : GameImport.SaveResult.SKIPPED);
result instanceof ManualGameService.AddResult.Added ? GameImport.SaveResult.ADDED
: GameImport.SaveResult.SKIPPED);
}
}
if (source == GameSource.PSN) {
// A title left unchecked is not offered again. Also a resumed try runs this; a LINKED title stays.
psnTitleRepository.dismiss(userId, importRepository.findPsnKeysWithoutChoice(importId));
}
importRepository.setStep(importId, GameImport.Step.DETAILS);
List<Long> appids = importRepository.findAddedAppids(importId);
try {
@@ -180,6 +197,14 @@ public class GameImportWorker {
importRepository.markSaved(importId);
}
private void linkPsnTitle(long userId, String psnKey, IgdbGame game, ManualGameService.AddResult result) {
if (result instanceof ManualGameService.AddResult.Added added) {
psnTitleRepository.link(userId, psnKey, added.appid());
} else {
manualGameService.ownedKey(userId, game).ifPresent(appid -> psnTitleRepository.linkOwned(userId, psnKey, appid));
}
}
private record Match(boolean searchFailed, boolean exact, List<IgdbGame> candidates) {}
private Match match(GameImport.Line line) {
@@ -3,12 +3,14 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -19,6 +21,10 @@ import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
@Service
@RequiresRole(Role.USER)
@@ -32,6 +38,9 @@ public class LibrarySyncService {
private final LibrarySyncWorker worker;
private final BacklogSnapshotRepository backlogSnapshotRepository;
private final FinishedEstimateService finishedEstimateService;
private final PsnAccountRepository psnAccountRepository;
private final TaskScheduler taskScheduler;
private final Map<Long, ScheduledFuture<?>> pendingAfterLink = new ConcurrentHashMap<>();
@Value("${app.library.sync-ttl-hours:20}")
private long syncTtlHours;
@@ -60,6 +69,10 @@ public class LibrarySyncService {
* request that won the gate
*/
public boolean syncIfStale(Long userId, String steamId) {
if (!canSync(userId, steamId)) {
stopNightlySync(userId);
return false;
}
Instant now = Instant.now();
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync);
@@ -94,7 +107,7 @@ public class LibrarySyncService {
userSteamSyncRepository.findByUserSeenAtAfter(now.minus(Duration.ofDays(nightlyActiveDays)));
List<UserSteamSync> accounts =
active.stream().filter(s -> !syncedLateToday(s.getLastSuccessAt(), today)).toList();
log.info("Nightly sync for {} of {} active Steam accounts", accounts.size(), active.size());
log.info("Nightly sync for {} of {} active accounts", accounts.size(), active.size());
// Taken before the syncs: the loop can run past midnight.
LocalDate day = today;
try {
@@ -121,6 +134,13 @@ public class LibrarySyncService {
}
}
/**
* A user syncs with a Steam ID or a PlayStation link.
*/
public boolean canSync(Long userId, String steamId) {
return steamId != null || psnAccountRepository.find(userId).isPresent();
}
/**
* For a user whose token has no Steam ID any more (unlinked, or taken by a verified link in Auth): the nightly
* job would otherwise keep syncing the old ID until the user is 14 days inactive.
@@ -131,8 +151,13 @@ public class LibrarySyncService {
/**
* User-triggered sync. Throws SyncCooldownException if recently synced or running.
*
* @throws IllegalArgumentException when the user has no Steam ID and no PlayStation link
*/
public void forceSync(Long userId, String steamId) {
if (!canSync(userId, steamId)) {
throw new IllegalArgumentException("No Steam ID or PlayStation link");
}
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync);
if (sync != null && sync.getLastSyncedAt() != null) {
@@ -147,6 +172,55 @@ public class LibrarySyncService {
worker.runSync(userId, steamId, SyncTrigger.MANUAL);
}
/**
* The sync after a PlayStation link: like {@link #forceSync}, with the same cooldown, so linking again and again
* never syncs faster than the sync button. While a sync runs or the cooldown has not ended, the sync is
* scheduled for the end of it; there is at most one scheduled sync per user.
*/
public void startAfterLink(Long userId, String steamId) {
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
touchUserSeen(sync);
Instant runAt = sync == null ? null : nextAllowedRun(sync);
if (runAt == null) {
worker.runSync(userId, steamId, SyncTrigger.MANUAL);
return;
}
log.info("Sync after the PlayStation link of user {} waits until {}", userId, runAt);
pendingAfterLink.compute(userId, (id, pending) -> pending != null && !pending.isDone() ? pending
: taskScheduler.schedule(() -> runAfterLinkLater(userId, steamId), runAt));
}
private void runAfterLinkLater(Long userId, String linkedSteamId) {
pendingAfterLink.remove(userId);
try {
// The Steam ID may have changed in the wait; a run with the old one would reset the Steam baseline.
String steamId = userSteamSyncRepository.findById(userId).map(UserSteamSync::getSteamId)
.orElse(linkedSteamId);
// Still blocked (a manual sync came first): the same call schedules the next try.
if (canSync(userId, steamId)) {
startAfterLink(userId, steamId);
}
} catch (RuntimeException e) {
log.warn("Scheduled sync after the PlayStation link of user {} failed", userId, e);
}
}
// Null: a sync may start now. A running sync has no known end, so the next try is one cooldown away.
private Instant nextAllowedRun(UserSteamSync sync) {
if (isAlreadyRunning(sync)) {
return Instant.now().plusSeconds(forceSyncCooldownSeconds);
}
if (!isInCooldown(sync)) {
return null;
}
return sync.getLastSyncedAt().plusSeconds(forceSyncCooldownSeconds);
}
private boolean isInCooldown(UserSteamSync sync) {
return sync.getLastSyncedAt() != null
&& Duration.between(sync.getLastSyncedAt(), Instant.now()).toSeconds() < forceSyncCooldownSeconds;
}
// At most one write per user and hour; the nightly sync only needs to know "used recently".
private void touchUserSeen(UserSteamSync sync) {
Instant now = Instant.now();
@@ -157,7 +231,7 @@ public class LibrarySyncService {
private boolean isLoginSyncDue(UserSteamSync sync, String steamId, Instant successBefore, Instant triedBefore) {
return !isAlreadyRunning(sync)
&& (!steamId.equals(sync.getSteamId())
&& (!Objects.equals(steamId, sync.getSteamId())
|| (isNullOrBefore(sync.getLastSuccessAt(), successBefore)
&& isNullOrBefore(sync.getLoginSyncTriedAt(), triedBefore)));
}
@@ -42,6 +42,7 @@ import java.util.stream.Collectors;
import core_service.modules.library.domain.UserSteamSync.Status;
import core_service.shared.background.BackgroundPools;
import core_service.shared.event.LibraryActivityEvent;
/**
* Worker bean. Runs the actual Steam fetch + DB upserts. Separate bean so the Spring proxy applies @Async
@@ -69,6 +70,7 @@ public class LibrarySyncWorker {
private final GameEditionService gameEditionService;
private final GameCreditService gameCreditService;
private final ApplicationEventPublisher eventPublisher;
private final PsnSyncStep psnSyncStep;
private final TransactionTemplate transaction;
@Value("${app.library.running-stale-minutes:5}")
@@ -77,6 +79,9 @@ public class LibrarySyncWorker {
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
/**
* @param steamId null for a user with a PlayStation link and no Steam account: only the PlayStation part runs
*/
@Async(BackgroundPools.DEFAULT)
public void runSync(Long userId, String steamId, SyncTrigger trigger) {
sync(userId, steamId, trigger);
@@ -101,7 +106,7 @@ public class LibrarySyncWorker {
return null;
}
String previousSteamId = before != null ? before.getSteamId() : null;
if (previousSteamId != null && !previousSteamId.equals(steamId)) {
if (steamId != null && previousSteamId != null && !previousSteamId.equals(steamId)) {
// The old games belong to the previous Steam account, also when Steam fails now. The upserts
// restore the games this account also owns.
userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
@@ -115,37 +120,55 @@ public class LibrarySyncWorker {
}
Status finalStatus = Status.FAILED;
boolean hasSteam = steamId != null;
try {
// Growth needs a successful earlier sync of this account; else all its playtime would count as today.
LocalDate playedOn = steamId.equals(claim.historySteamId()) ? LocalDate.ofInstant(syncStartedAt, zone) : null;
LocalDate syncDay = LocalDate.ofInstant(syncStartedAt, zone);
LocalDate playedOn = hasSteam && steamId.equals(claim.historySteamId()) ? syncDay : null;
// Distinct: the maps in saveGames are loaded once, so a second line of a game would be a new game again.
List<SteamOwnedGamesResponse.Game> games = steamApiService.getOwnedGames(steamId).stream()
List<SteamOwnedGamesResponse.Game> games = !hasSteam ? List.of()
: steamApiService.getOwnedGames(steamId).stream()
.filter(g -> g.appid() != null)
.collect(Collectors.toMap(SteamOwnedGamesResponse.Game::appid, g -> g, (first, second) -> first,
LinkedHashMap::new))
.values().stream().toList();
log.info("Sync user={} fetched {} games from Steam", userId, games.size());
if (hasSteam) {
log.info("Sync user={} fetched {} games from Steam", userId, games.size());
}
// Before the empty check: an account without games can still have a public wishlist.
Optional<Wishlist> wishlist = fetchWishlist(steamId, games);
Optional<Wishlist> wishlist = hasSteam ? fetchWishlist(steamId, games) : Optional.empty();
PsnSyncStep.Prepared psn = psnSyncStep.run(userId);
boolean saved = transaction.execute(tx -> {
List<SyncChange> wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> {
log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
if (hasSteam) {
log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId);
}
return List.of();
});
if (games.isEmpty()) {
if (hasSteam && games.isEmpty()) {
// A private "Game details" setting ends here. A soft-delete would wipe the library.
log.warn("Sync user={} got no games for SteamID {}", userId, steamId);
return false;
}
List<SyncChange> changes = saveGames(userId, steamId, games, syncStartedAt, playedOn);
List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn)
: new ArrayList<>();
List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay);
// The first sync of an account is a baseline: a count, not a list.
if (playedOn == null) {
int imported = (int) changes.stream().filter(c -> c.kind() == SyncChangeKind.ADDED).count();
syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, List.of());
int imported = 0;
List<SyncChange> listed = new ArrayList<>();
if (hasSteam && playedOn == null) {
imported += countAdded(steamChanges);
} else {
changes.addAll(wishlistAdds);
syncChangeRepository.replace(userId, syncStartedAt, trigger, 0, changes);
listed.addAll(steamChanges);
listed.addAll(wishlistAdds);
}
if (psn.baseline()) {
imported += countAdded(psnChanges);
psnChanges.stream().filter(change -> change.kind() != SyncChangeKind.ADDED).forEach(listed::add);
} else {
listed.addAll(psnChanges);
}
syncChangeRepository.replace(userId, syncStartedAt, trigger, imported, listed);
return true;
});
if (!saved) {
@@ -170,12 +193,17 @@ public class LibrarySyncWorker {
log.warn("Sync user={} ran past running-stale-minutes; the run that took over sets the status", userId);
} else if (status == Status.OK) {
// The listeners run after this commit.
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
eventPublisher.publishEvent(hasSteam ? new LibrarySyncedEvent(userId, steamId)
: new LibraryActivityEvent(userId));
}
});
}
}
private static int countAdded(List<SyncChange> changes) {
return (int) changes.stream().filter(change -> change.kind() == SyncChangeKind.ADDED).count();
}
/**
* @return what changed for the user, for the sync result
*/
@@ -233,6 +233,15 @@ public class ManualGameService {
.map(IgdbGame::igdbId).collect(Collectors.toSet());
}
/**
* The key the user owns this game under, if any (a merge included).
*/
public Optional<Long> ownedKey(Long userId, IgdbGame game) {
Map<Long, Long> targets = mergeRepository.findTargets(ownKeys(game));
Set<Long> owned = ownedAppids(userId, List.of(game), targets, List.of());
return possibleKeys(game, targets).stream().filter(owned::contains).findFirst();
}
private AddResult addInTransaction(Long userId, IgdbGame game, String platform, GameStatus status,
int playtimeMinutes, GameSource source, Long importId) {
return transaction.execute(tx -> {
@@ -270,7 +279,10 @@ public class ManualGameService {
public ChangeResult update(Long userId, long appid, String platform, int playtimeMinutes) {
return transaction.execute(tx -> changeManual(userId, appid, game -> {
game.setPlatform(platform);
game.setPlaytimeMinutes(playtimeMinutes);
// PlayStation owns the playtime of its rows: a typed value would be booked as growth by the next sync.
if (game.getSource() != GameSource.PSN) {
game.setPlaytimeMinutes(playtimeMinutes);
}
}));
}
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.PsnAccount;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
@@ -15,6 +16,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -22,7 +24,7 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* The linked PlayStation account of a user: link with an NPSSO, one import of the played titles, unlink. The
* The linked PlayStation account of a user: link with an NPSSO, the played titles for the sync, unlink. The
* refresh token is stored sealed; the NPSSO, the codes and every token stay out of the logs, the database and the
* answers. PlayStation is called outside any transaction.
*/
@@ -33,8 +35,9 @@ public class PsnAccountService {
/**
* @param enabled false when Core has no key: the link is off
* @param reviewImportId the PSN import that waits for the user's review; null when none
*/
public record Status(boolean enabled, boolean linked, Instant linkedAt, Instant expiresAt) {}
public record Status(boolean enabled, boolean linked, Instant linkedAt, Instant expiresAt, Long reviewImportId) {}
public sealed interface LinkResult {
record Linked(Status status) implements LinkResult {}
@@ -50,42 +53,43 @@ public class PsnAccountService {
record Busy() implements LinkResult {}
}
public sealed interface ImportResult {
record Started(long id) implements ImportResult {}
public sealed interface FetchResult {
record Titles(List<PsnApiService.Title> titles) implements FetchResult {}
record NotLinked() implements ImportResult {}
record NotLinked() implements FetchResult {}
/** The link ended: the user pastes a new NPSSO. */
record Relink() implements ImportResult {}
record Relink() implements FetchResult {}
record NoTitles() implements ImportResult {}
record Off() implements FetchResult {}
record Off() implements ImportResult {}
record Unavailable() implements ImportResult {}
record AlreadyMatching() implements ImportResult {}
record SearchUnavailable() implements ImportResult {}
record Unavailable() implements FetchResult {}
/** The user has another PlayStation call running. */
record Busy() implements ImportResult {}
record Busy() implements FetchResult {}
}
// Must match the inbox NoteKind names; the slice rules keep library from using that enum.
static final String EXPIRING_NOTE = "PSN_EXPIRING";
static final String EXPIRED_NOTE = "PSN_EXPIRED";
private final PsnAccountRepository repository;
private final PsnAuthService authService;
private final PsnApiService apiService;
private final GameImportService importService;
private final GameImportRepository importRepository;
private final Duration warningTime;
private final SecretBox box;
// One PlayStation call per user at a time: parallel calls of one user must not hold the shared rate limiter.
private final Set<Long> inFlight = ConcurrentHashMap.newKeySet();
public PsnAccountService(PsnAccountRepository repository, PsnAuthService authService, PsnApiService apiService,
GameImportService importService, @Value("${app.psn.token-key:}") String tokenKey) {
GameImportRepository importRepository, @Value("${app.psn.token-key:}") String tokenKey,
@Value("${app.psn.expiry-warning-days:2}") long warningDays) {
this.repository = repository;
this.authService = authService;
this.apiService = apiService;
this.importService = importService;
this.importRepository = importRepository;
this.warningTime = Duration.ofDays(warningDays);
// A wrong key stops Core at start; no key only switches the feature off.
this.box = tokenKey == null || tokenKey.isBlank() ? null : SecretBox.fromBase64Key(tokenKey);
}
@@ -96,8 +100,10 @@ public class PsnAccountService {
public Status get(Long userId) {
Optional<PsnAccount> account = box != null ? repository.find(userId) : Optional.empty();
Long reviewImportId = box == null ? null : importRepository.findOpenPsnImport(userId)
.filter(found -> found.status() == GameImport.Status.READY).map(GameImport::id).orElse(null);
return new Status(isEnabled(), account.isPresent(), account.map(PsnAccount::linkedAt).orElse(null),
account.map(PsnAccount::refreshExpiresAt).orElse(null));
account.map(PsnAccount::refreshExpiresAt).orElse(null), reviewImportId);
}
public LinkResult link(Long userId, String npsso) {
@@ -138,30 +144,36 @@ public class PsnAccountService {
}
/**
* Refreshes the access token, reads the played titles and starts an import with them. A refresh that returns
* the same refresh token keeps the stored expiry, a new one sets its own.
* Refreshes the access token and reads the played titles; the inbox gets a note when the link ends soon or has
* ended. A refresh that returns the same refresh token keeps the stored expiry, a new one sets its own.
*/
public ImportResult startImport(Long userId) {
public FetchResult fetchTitles(Long userId) {
if (box == null) {
return new ImportResult.Off();
return new FetchResult.Off();
}
if (!inFlight.add(userId)) {
return new ImportResult.Busy();
return new FetchResult.Busy();
}
try {
return doImport(userId);
return doFetch(userId);
} finally {
inFlight.remove(userId);
}
}
private ImportResult doImport(Long userId) {
private FetchResult doFetch(Long userId) {
Optional<PsnAccount> account = repository.find(userId);
if (account.isEmpty()) {
return new ImportResult.NotLinked();
return new FetchResult.NotLinked();
}
if (!account.get().refreshExpiresAt().isAfter(Instant.now())) {
return new ImportResult.Relink();
Instant now = Instant.now();
Instant expiresAt = account.get().refreshExpiresAt();
if (!expiresAt.isAfter(now)) {
repository.noteExpiry(userId, EXPIRED_NOTE);
return new FetchResult.Relink();
}
if (!expiresAt.isAfter(now.plus(warningTime))) {
repository.noteExpiry(userId, EXPIRING_NOTE);
}
String stored;
try {
@@ -169,33 +181,27 @@ public class PsnAccountService {
} catch (IllegalStateException e) {
// Sealed with another key: the user links again.
log.warn("Stored PSN token of user={} cannot be opened", userId);
repository.expireNow(userId);
return new ImportResult.Relink();
return expired(userId);
}
List<PsnApiService.Title> titles;
try {
Optional<PsnAuthService.Tokens> tokens = authService.refresh(stored);
if (tokens.isEmpty()) {
repository.expireNow(userId);
return new ImportResult.Relink();
return expired(userId);
}
if (!tokens.get().refreshToken().equals(stored)) {
repository.updateToken(userId, box.seal(tokens.get().refreshToken()), tokens.get().refreshExpiresAt());
}
titles = apiService.getPlayedTitles(tokens.get().accessToken());
return new FetchResult.Titles(apiService.getPlayedTitles(tokens.get().accessToken()));
} catch (RestClientException | RequestNotPermitted e) {
logFailure("PSN import failed", e);
return new ImportResult.Unavailable();
logFailure("PSN fetch failed", e);
return new FetchResult.Unavailable();
}
List<GameImport.Line> lines = PsnTitles.toLines(titles);
if (lines.isEmpty()) {
return new ImportResult.NoTitles();
}
return switch (importService.createLines(userId, GameImport.Source.PSN, lines)) {
case GameImportService.CreateResult.Created created -> new ImportResult.Started(created.id());
case GameImportService.CreateResult.AlreadyMatching ignored -> new ImportResult.AlreadyMatching();
case GameImportService.CreateResult.SearchUnavailable ignored -> new ImportResult.SearchUnavailable();
};
}
private FetchResult expired(Long userId) {
repository.expireNow(userId);
repository.noteExpiry(userId, EXPIRED_NOTE);
return new FetchResult.Relink();
}
// The class and the status only: the message of a RestClientException can hold the answer's body.
@@ -0,0 +1,239 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.AppidMinutes;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* The PlayStation part of the library sync. {@link #run} calls PlayStation and IGDB and adds the games, outside the
* sync transaction; {@link #apply} writes the playtime inside it. PlayStation never fails the Steam part.
*/
@Service
@Slf4j
public class PsnSyncStep {
private static final int TWO_WEEKS_DAYS = 14;
/**
* A LINKED title's PSN values, applied in the sync transaction.
*
* @param lastPlayedAt may be null
*/
public record Playtime(long appid, int minutes, Instant lastPlayedAt) {}
/**
* What {@link #run} did outside the transaction.
*
* @param changes the ADDED rows of 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 static final Prepared NONE = new Prepared(List.of(), List.of(), false);
}
private final PsnAccountService accountService;
private final PsnTitleRepository psnTitleRepository;
private final GameImportRepository importRepository;
private final GameMergeRepository mergeRepository;
private final ManualGameService manualGameService;
private final UserGameRepository userGameRepository;
private final PlaytimeDayRepository playtimeDayRepository;
private final TransactionTemplate transaction;
public PsnSyncStep(PsnAccountService accountService, PsnTitleRepository psnTitleRepository,
GameImportRepository importRepository, GameMergeRepository mergeRepository,
ManualGameService manualGameService, UserGameRepository userGameRepository,
PlaytimeDayRepository playtimeDayRepository, PlatformTransactionManager transactionManager) {
this.accountService = accountService;
this.psnTitleRepository = psnTitleRepository;
this.importRepository = importRepository;
this.mergeRepository = mergeRepository;
this.manualGameService = manualGameService;
this.userGameRepository = userGameRepository;
this.playtimeDayRepository = playtimeDayRepository;
this.transaction = new TransactionTemplate(transactionManager);
}
/**
* Outside any transaction: PlayStation and IGDB calls, adds, review rows. Never throws.
*/
public Prepared run(Long userId) {
List<SyncChange> changes = new ArrayList<>();
List<Playtime> playtimes = new ArrayList<>();
boolean baseline = false;
try {
Optional<List<PsnApiService.Title>> titles = fetch(userId);
if (titles.isEmpty()) {
return Prepared.NONE;
}
List<GameImport.Line> lines = PsnTitles.toLines(titles.get());
Map<String, Instant> lastPlayed = PsnTitles.lastPlayedByKey(titles.get());
Map<String, PsnTitle> known = psnTitleRepository.findAll(userId);
baseline = known.isEmpty();
Set<String> open = importRepository.findOpenPsnKeys(userId);
List<GameImport.Row> review = new ArrayList<>();
for (GameImport.Line line : lines) {
PsnTitle seen = known.get(line.psnKey());
if (seen != null && seen.state() == PsnTitle.State.LINKED) {
playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayed.get(line.psnKey())));
} else if (seen == null && !open.contains(line.psnKey())) {
matchNew(userId, line, lastPlayed.get(line.psnKey()), changes, review);
}
}
saveReview(userId, review);
log.info("PSN sync user={} added {} titles, {} wait for review", userId, changes.size(), review.size());
return new Prepared(changes, mergedKeys(playtimes), baseline);
} catch (RuntimeException e) {
log.error("PSN step failed for user {}", userId, e);
return new Prepared(changes, playtimes, baseline);
}
}
private Optional<List<PsnApiService.Title>> fetch(Long userId) {
switch (accountService.fetchTitles(userId)) {
case PsnAccountService.FetchResult.Titles found -> {
return Optional.of(found.titles());
}
case PsnAccountService.FetchResult.Relink _ ->
log.info("PSN sync skipped for user={}: link expired", userId);
case PsnAccountService.FetchResult.Unavailable _, PsnAccountService.FetchResult.Busy _ ->
log.warn("PSN sync skipped for user={}", userId);
case PsnAccountService.FetchResult.NotLinked _, PsnAccountService.FetchResult.Off _ -> { }
}
return Optional.empty();
}
private void matchNew(Long userId, GameImport.Line line, Instant lastPlayedAt, List<SyncChange> changes,
List<GameImport.Row> review) {
Optional<List<IgdbGame>> found = manualGameService.findGames(line.title());
if (found.isEmpty()) {
return;
}
List<IgdbGame> candidates = GameImportWorker.rank(line, found.get());
IgdbGame first = candidates.isEmpty() ? null : candidates.getFirst();
boolean exact = first != null && GameImportWorker.sameName(first.name(), line.title())
&& first.platforms().stream().anyMatch(line.platform()::equalsIgnoreCase);
if (exact) {
addExact(userId, line, first, lastPlayedAt, changes);
} else if (!linkOwned(userId, line.psnKey(), first)) {
review.add(new GameImport.Row(line, false, false, candidates));
}
}
private void addExact(Long userId, GameImport.Line line, IgdbGame game, Instant lastPlayedAt,
List<SyncChange> changes) {
ManualGameService.AddResult added = manualGameService.addGame(userId, game, line.platform(), line.status(),
line.playtimeMinutes(), GameSource.PSN, null);
if (!(added instanceof ManualGameService.AddResult.Added result)) {
linkOwned(userId, line.psnKey(), game);
return;
}
psnTitleRepository.link(userId, line.psnKey(), result.appid());
if (lastPlayedAt != null) {
userGameRepository.setLastPlayedAt(userId, result.appid(), lastPlayedAt);
}
changes.add(SyncChange.added(result.appid()));
}
// Also the same game under another spelling or platform.
private boolean linkOwned(Long userId, String psnKey, IgdbGame game) {
Optional<Long> owned = game == null ? Optional.empty() : manualGameService.ownedKey(userId, game);
owned.ifPresent(appid -> psnTitleRepository.linkOwned(userId, psnKey, appid));
return owned.isPresent();
}
private void saveReview(Long userId, List<GameImport.Row> review) {
if (review.isEmpty()) {
return;
}
Optional<GameImport> open = importRepository.findOpenPsnImport(userId);
if (open.isEmpty()) {
transaction.executeWithoutResult(tx -> importRepository.createReady(userId, review));
} else if (open.get().status() == GameImport.Status.READY) {
transaction.executeWithoutResult(tx -> importRepository.appendReadyRows(open.get().id(), review));
}
}
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();
}
// Several titles can be one game (PS4 and PS5 versions, a review pick): its playtime is their sum, so the row
// gets one value per run and the total never flips between titles.
private static Collection<Playtime> perGame(List<Playtime> playtimes) {
Map<Long, Playtime> byGame = new LinkedHashMap<>();
for (Playtime playtime : playtimes) {
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()));
}
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.
*
* @return all PSN changes of the run (ADDED and PLAYTIME)
*/
public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day) {
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);
}
touched.put(game.getAppid(), game);
});
}
if (!touched.isEmpty()) {
Map<Long, Long> twoWeeks = playtimeDayRepository
.minutesPerGameSince(userId, new HashSet<>(touched.keySet()), day.minusDays(TWO_WEEKS_DAYS - 1))
.stream().collect(Collectors.toMap(AppidMinutes::appid, AppidMinutes::minutes));
touched.forEach((appid, game) ->
game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE)));
}
return changes;
}
}
@@ -6,11 +6,14 @@ import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.psn_adapter.service.PsnApiService;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
/**
@@ -43,23 +46,37 @@ final class PsnTitles {
}
static List<GameImport.Line> toLines(List<PsnApiService.Title> titles) {
List<GameImport.Line> lines = new ArrayList<>();
for (Map.Entry<String, List<PsnApiService.Title>> group : group(titles).entrySet()) {
if (lines.size() >= ImportRequest.MAX_ROWS) {
break;
}
PsnApiService.Title mostPlayed = group.getValue().stream()
.max(Comparator.comparing(PsnApiService.Title::playDuration)).orElseThrow();
long minutes = Math.min(ManualGameUpdate.MAX_MINUTES,
group.getValue().stream().mapToLong(title -> title.playDuration().toMinutes()).sum());
lines.add(new GameImport.Line(lines.size() + 1, cleanName(mostPlayed.name()), platform(mostPlayed.category()),
(int) minutes, GameStatus.initialFor((int) minutes), null, group.getKey()));
}
return lines;
}
/**
* The newest last-played time of each title group; a group without any is left out.
*/
static Map<String, Instant> lastPlayedByKey(List<PsnApiService.Title> titles) {
Map<String, Instant> lastPlayed = new HashMap<>();
group(titles).forEach((key, group) -> group.stream().map(PsnApiService.Title::lastPlayedAt)
.filter(Objects::nonNull).max(Comparator.naturalOrder()).ifPresent(at -> lastPlayed.put(key, at)));
return lastPlayed;
}
private static Map<String, List<PsnApiService.Title>> group(List<PsnApiService.Title> titles) {
Map<String, List<PsnApiService.Title>> groups = new LinkedHashMap<>();
for (PsnApiService.Title title : titles) {
String key = title.conceptId() != null ? "c" + title.conceptId() : "t" + title.titleId();
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(title);
}
List<GameImport.Line> lines = new ArrayList<>();
for (List<PsnApiService.Title> group : groups.values()) {
if (lines.size() >= ImportRequest.MAX_ROWS) {
break;
}
PsnApiService.Title mostPlayed = group.stream()
.max(Comparator.comparing(PsnApiService.Title::playDuration)).orElseThrow();
long minutes = Math.min(ManualGameUpdate.MAX_MINUTES,
group.stream().mapToLong(title -> title.playDuration().toMinutes()).sum());
lines.add(new GameImport.Line(lines.size() + 1, cleanName(mostPlayed.name()), platform(mostPlayed.category()),
(int) minutes, GameStatus.initialFor((int) minutes), null));
}
return lines;
return groups;
}
}
@@ -12,7 +12,8 @@ import java.util.List;
public record PsnGameListResponse(List<Title> titles, Integer nextOffset) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record Title(String titleId, String name, String category, String playDuration, Concept concept) {}
public record Title(String titleId, String name, String category, String playDuration, Concept concept,
String lastPlayedDateTime) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Concept(Long id) {}
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
@@ -24,8 +25,10 @@ public class PsnApiService {
/**
* @param conceptId PlayStation's id of the game across its PS4 and PS5 versions; may be null
* @param lastPlayedAt null when PlayStation sends none or an unreadable value
*/
public record Title(String titleId, String name, String category, Duration playDuration, Long conceptId) {}
public record Title(String titleId, String name, String category, Duration playDuration, Long conceptId,
Instant lastPlayedAt) {}
static final int PAGE_SIZE = 200;
static final int MAX_TITLES = 1000;
@@ -62,7 +65,8 @@ public class PsnApiService {
for (PsnGameListResponse.Title title : page.titles()) {
if (title.titleId() != null && title.name() != null && titles.size() < MAX_TITLES) {
titles.add(new Title(title.titleId(), title.name(), title.category(), duration(title.playDuration()),
title.concept() != null ? title.concept().id() : null));
title.concept() != null ? title.concept().id() : null,
instant(title.lastPlayedDateTime())));
}
}
if (page.nextOffset() == null || page.nextOffset() <= offset) {
@@ -73,6 +77,17 @@ public class PsnApiService {
return titles;
}
private static Instant instant(String iso) {
if (iso == null) {
return null;
}
try {
return Instant.parse(iso);
} catch (DateTimeParseException e) {
return null;
}
}
private static Duration duration(String iso) {
if (iso == null) {
return Duration.ZERO;
@@ -20,7 +20,8 @@ import java.util.Optional;
/**
* What the library tables say about a user's games, as raw facts for {@link RewardRules}. Plain SQL, because the
* library module owns the entities and a module may not depend on another.
* library module owns the entities and a module may not depend on another. Rewards keep Steam-only proof: PlayStation
* rows (their playtime is PlayStation's number, and a title can be linked to a game later) give no facts.
*/
@Repository
@RequiredArgsConstructor
@@ -55,7 +56,9 @@ public class RewardFactsRepository {
where user_id = :userId and from_status = 'BACKLOG' and to_status = 'PLAYING'
group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.hidden_at is null
and ug.hidden_at is null and ug.source <> 'PSN'
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
where not exists (select 1 from reward_ledger l where l.user_id = c.user_id and l.kind = 'STARTED'
and l.ref = c.appid::text)
order by c.started_at
@@ -88,7 +91,11 @@ public class RewardFactsRepository {
where user_id = :userId and to_status in ('FINISHED', 'COMPLETED')
group by user_id, appid) c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid and ug.removed_at is null
and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null
and ug.status in ('FINISHED', 'COMPLETED') and ug.hidden_at is null and ug.source <> 'PSN'
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
left join game_time_to_beat t on t.appid = c.appid
left join user_game_achievement_meta m on m.user_id = c.user_id and m.appid = c.appid
left join game_achievement_schema_meta s on s.appid = c.appid
@@ -117,6 +124,9 @@ public class RewardFactsRepository {
left join user_game_achievement_meta m on m.user_id = ug.user_id and m.appid = ug.appid
left join game_achievement_schema_meta s on s.appid = ug.appid
where ug.user_id = :userId and ug.status = 'COMPLETED' and ug.removed_at is null and ug.hidden_at is null
and ug.source <> 'PSN'
and not exists (select 1 from user_psn_title t where t.user_id = ug.user_id and t.appid = ug.appid
and t.state = 'LINKED')
and not exists (select 1 from reward_ledger l where l.user_id = ug.user_id and l.kind = 'COMPLETED'
and l.ref = ug.appid::text)
""", new MapSqlParameterSource("userId", userId),
@@ -125,7 +135,8 @@ public class RewardFactsRepository {
}
/**
* Playtime minutes per month of the Steam playtime days (day is a Berlin date). Hidden games count too: hiding
* Playtime minutes per month of the Steam playtime days (day is a Berlin date); the days of a PlayStation row, or of
* a game a PlayStation title is linked to, are left out. Hidden games count too: hiding
* is free and reversible, so excluding them would let a user shape the curve for one settle.
*/
public Map<YearMonth, Long> minutesPerMonth(long userId) {
@@ -134,6 +145,10 @@ public class RewardFactsRepository {
select to_char(p.day, 'YYYY-MM') as month, sum(p.minutes) as minutes
from user_game_playtime_day p
where p.user_id = :userId
and not exists (select 1 from user_game g where g.user_id = p.user_id and g.appid = p.appid
and g.source = 'PSN')
and not exists (select 1 from user_psn_title t where t.user_id = p.user_id and t.appid = p.appid
and t.state = 'LINKED')
group by 1
""", new MapSqlParameterSource("userId", userId),
(RowCallbackHandler) rs -> result.put(YearMonth.parse(rs.getString("month")), rs.getLong("minutes")));
+2
View File
@@ -56,6 +56,8 @@ app:
token-key: ${APP_PSN_TOKEN_KEY:}
auth-base-url: https://ca.account.sony.com/api/authz/v3/oauth
api-base-url: https://m.np.playstation.com/api
# Days before the link ends that the inbox gets its "ends soon" note.
expiry-warning-days: 2
# The public client of the PlayStation app, as the psn-api project ships it. Not a secret of ours.
client-id: 09515159-7237-4370-9b40-3806e67c0891
client-secret: ucPjka5tntB2KqsP
@@ -0,0 +1,40 @@
-- PlayStation titles the sync has seen per user (#25 slice 2), one per concept (PS4 + PS5 = one). LINKED keeps its
-- appid also after the user removed the game, so the sync never adds it again. STEAM_OWNED: the title matched a game the user
-- owns from Steam; kept so it is not offered again, but PlayStation gives no playtime for it. DISMISSED: left unchecked
-- in a review.
CREATE TABLE user_psn_title (
user_id BIGINT NOT NULL,
psn_key VARCHAR(40) NOT NULL, -- PsnTitles key: "c<conceptId>" or "t<titleId>"
state VARCHAR(16) NOT NULL CHECK (state IN ('LINKED', 'STEAM_OWNED', 'DISMISSED')),
appid BIGINT, -- LINKED: the library key
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, psn_key)
);
-- The title a review line came from; NULL for any other import.
ALTER TABLE game_import_row ADD COLUMN psn_key VARCHAR(40);
-- The inbox note written for this link: NULL, PSN_EXPIRING or PSN_EXPIRED. A new link resets it.
ALTER TABLE user_psn_account ADD COLUMN expiry_note VARCHAR(16);
-- A user with a PlayStation link and no Steam account syncs too (steam_id NULL).
ALTER TABLE user_steam_sync ALTER COLUMN steam_id DROP NOT NULL;
ALTER TABLE inbox_note DROP CONSTRAINT inbox_note_kind_check;
ALTER TABLE inbox_note ADD CONSTRAINT inbox_note_kind_check
CHECK (kind IN ('DIGEST', 'VISIBILITY', 'PSN_EXPIRING', 'PSN_EXPIRED'));
-- When the user last played the game, from PlayStation; Steam gives no such value. The two views over user_game fix
-- their columns at creation (see V24, V28): drop them, add the column, create them again.
DROP VIEW counted_user_game;
DROP VIEW listed_user_game;
ALTER TABLE user_game ADD COLUMN last_played_at TIMESTAMPTZ;
CREATE VIEW listed_user_game AS
SELECT * FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL;
CREATE VIEW counted_user_game AS
SELECT ug.* FROM listed_user_game ug
WHERE NOT EXISTS (
SELECT 1 FROM game_link l
JOIN game_link o ON o.set_id = l.set_id AND o.appid <> l.appid
JOIN listed_user_game og ON og.user_id = ug.user_id AND og.appid = o.appid
WHERE l.appid = ug.appid
AND (CASE og.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END,
og.playtime_minutes, -extract(epoch from og.first_seen_at), -og.appid)
> (CASE ug.status WHEN 'COMPLETED' THEN 4 WHEN 'FINISHED' THEN 3 WHEN 'PLAYING' THEN 2 WHEN 'DROPPED' THEN 1 ELSE 0 END,
ug.playtime_minutes, -extract(epoch from ug.first_seen_at), -ug.appid));
@@ -1,14 +1,18 @@
package core_service.modules.library.api;
import com.jayway.jsonpath.JsonPath;
import jakarta.servlet.http.Cookie;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncWorker;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.shared.security.SecretBox;
import core_service.shared.test.ContractTokens;
import core_service.shared.test.AbstractMockedAdaptersTest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -18,6 +22,8 @@ import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
@@ -28,19 +34,16 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The dev profile authenticates every request as user 1. PlayStation is mocked at the two adapter services.
* Every request is user 1 with a login token that has no Steam ID: a user with only a PlayStation link. PlayStation is
* mocked at the two adapter services.
*/
@AutoConfigureMockMvc
class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
@@ -50,6 +53,8 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
private static final String ACCESS = "ACCESS-SECRET-VALUE";
private static final IgdbApiService.Game GHOST = new IgdbApiService.Game(300, "Ghost of Yotei", 2025, null,
List.of("PS5"), null);
private static final Cookie PSN_ONLY_USER = new Cookie("tbp_access", ContractTokens.loginToken(1L));
private static final Instant PLAYED_FIRST = Instant.parse("2026-10-05T18:30:00Z");
@Autowired
private MockMvc mockMvc;
@@ -60,14 +65,26 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private LibrarySyncWorker worker;
// True once a link started the background sync of this test; the next test must not meet it.
private boolean syncStarted;
@Value("${app.psn.token-key}")
private String tokenKey;
@BeforeEach
void seed() {
syncStarted = false;
jdbc.update("delete from user_psn_account");
jdbc.update("delete from user_psn_title");
jdbc.update("delete from game_alias");
userGameRepository.deleteAll();
jdbc.update("delete from user_game_playtime_day");
jdbc.update("delete from sync_result");
jdbc.update("delete from user_steam_sync");
jdbc.update("delete from inbox_note");
jdbc.update("delete from game_import");
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(GHOST)));
@@ -75,8 +92,52 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
when(igdbApiService.getTimeToBeatByGameIds(any())).thenReturn(Optional.of(Map.of()));
when(psnAuthService.exchangeNpsso(NPSSO)).thenReturn(Optional.of(tokens(Instant.now().plus(Duration.ofDays(10)))));
when(psnAuthService.refresh(REFRESH)).thenReturn(Optional.of(tokens(Instant.now().plus(Duration.ofDays(10)))));
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("PPSA1_00",
"Ghost of Yotei™", "ps5_native_game", Duration.ofMinutes(125), 10L)));
when(igdbApiService.searchGames("Monster Hunter Wilds")).thenReturn(Optional.of(List.of()));
playing(125, PLAYED_FIRST);
}
private void playing(int ghostMinutes, Instant ghostLastPlayed) {
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of(
new PsnApiService.Title("PPSA1_00", "Ghost of Yotei™", "ps5_native_game", Duration.ofMinutes(ghostMinutes),
10L, ghostLastPlayed),
new PsnApiService.Title("PPSA2_00", "Monster Hunter Wilds", "ps5_native_game", Duration.ofMinutes(30),
11L, null)));
}
@AfterEach
void awaitTheSync() throws InterruptedException {
if (syncStarted) {
awaitSync();
}
}
private void awaitSync() throws InterruptedException {
for (int attempt = 0; attempt < 100; attempt++) {
String status = jdbc.queryForList("select last_sync_status from user_steam_sync where user_id = 1",
String.class).stream().findFirst().orElse(null);
if ("OK".equals(status) || "FAILED".equals(status)) {
assertThat(status).isEqualTo("OK");
return;
}
Thread.sleep(100);
}
throw new AssertionError("The sync did not finish");
}
private static MockHttpServletRequestBuilder get(String url) {
return MockMvcRequestBuilders.get(url).cookie(PSN_ONLY_USER);
}
private static MockHttpServletRequestBuilder post(String url) {
return MockMvcRequestBuilders.post(url).cookie(PSN_ONLY_USER);
}
private static MockHttpServletRequestBuilder put(String url) {
return MockMvcRequestBuilders.put(url).cookie(PSN_ONLY_USER);
}
private static MockHttpServletRequestBuilder delete(String url) {
return MockMvcRequestBuilders.delete(url).cookie(PSN_ONLY_USER);
}
private static PsnAuthService.Tokens tokens(Instant refreshExpires) {
@@ -84,29 +145,17 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
}
private ResultActions link(String npsso) throws Exception {
return mockMvc.perform(post("/api/library/psn-account")
ResultActions linked = mockMvc.perform(post("/api/library/psn-account")
.header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"npsso\": \"" + npsso + "\"}"));
syncStarted |= linked.andReturn().getResponse().getStatus() == 200;
return linked;
}
private ResultActions startImport() throws Exception {
return mockMvc.perform(post("/api/library/psn-account/imports").header(ClientHeaderFilter.CLIENT_HEADER, "test"));
}
private void awaitStatus(long id, String expected) throws InterruptedException {
for (int attempt = 0; attempt < 50; attempt++) {
if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) {
return;
}
Thread.sleep(100);
}
throw new AssertionError("Import " + id + " not " + expected);
}
private long startedImport() throws Exception {
return ((Number) JsonPath.read(startImport().andExpect(status().isAccepted()).andReturn().getResponse()
.getContentAsString(), "$.id")).longValue();
private long reviewImportId() throws Exception {
return ((Number) JsonPath.read(mockMvc.perform(get("/api/library/psn-account")).andReturn().getResponse()
.getContentAsString(), "$.reviewImportId")).longValue();
}
@Test
@@ -169,88 +218,199 @@ class PsnAccountFlowTest extends AbstractMockedAdaptersTest {
}
@Test
void import_StartsAPsnImportWithTheTitlesAsLines() throws Exception {
void link_StartsASync_ThatAddsTheExactTitle_AndPutsTheUnsureOneInAReview() throws Exception {
link(NPSSO).andExpect(status().isOk());
awaitSync();
long id = startedImport();
assertThat(jdbc.queryForObject("select source from game_import where id = ?", String.class, id)).isEqualTo("PSN");
assertThat(jdbc.queryForMap("select title, platform, playtime_minutes from game_import_row where import_id = ?", id))
.containsEntry("title", "Ghost of Yotei").containsEntry("platform", "PS5")
Map<String, Object> row = jdbc.queryForMap("select source, platform, playtime_minutes, last_played_at "
+ "from user_game where user_id = 1 and appid = -300");
assertThat(row).containsEntry("source", "PSN").containsEntry("platform", "PS5")
.containsEntry("playtime_minutes", 125);
assertThat(((java.sql.Timestamp) row.get("last_played_at")).toInstant()).isEqualTo(PLAYED_FIRST);
assertThat(jdbc.queryForObject("select imported_games from sync_result where user_id = 1", Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForMap("select state, appid from user_psn_title where psn_key = 'c10'"))
.containsEntry("state", "LINKED").containsEntry("appid", -300L);
long importId = reviewImportId();
assertThat(jdbc.queryForMap("select source, status, total from game_import where id = ?", importId))
.containsEntry("source", "PSN").containsEntry("status", "READY").containsEntry("total", 1);
assertThat(jdbc.queryForMap("select title, psn_key, matched_at is not null as matched from game_import_row "
+ "where import_id = ?", importId))
.containsEntry("title", "Monster Hunter Wilds").containsEntry("psn_key", "c11")
.containsEntry("matched", true);
mockMvc.perform(get("/api/library/imports/" + importId)).andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("READY")).andExpect(jsonPath("$.rows.length()").value(1));
}
@Test
void import_NotLinked_Is404() throws Exception {
startImport().andExpect(status().isNotFound()).andExpect(jsonPath("$.code").value("PSN_NOT_LINKED"));
}
@Test
void import_ExpiredLink_Is409Relink_WithoutAPsnCall() throws Exception {
void secondSync_BooksThePlaytimeGrowth_AndOffersNothingTwice_AnUncheckedReviewIsDismissedForGood() throws Exception {
link(NPSSO).andExpect(status().isOk());
jdbc.update("update user_psn_account set refresh_expires_at = now() - interval '1 hour'");
awaitSync();
long importId = reviewImportId();
startImport().andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PSN_RELINK"));
verify(psnAuthService, never()).refresh(any());
playing(185, Instant.parse("2026-10-06T20:00:00Z"));
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.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);
mockMvc.perform(post("/api/library/imports/" + importId + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"rows\": []}"))
.andExpect(status().isAccepted());
awaitImport(importId, "SAVED");
assertThat(jdbc.queryForObject("select state from user_psn_title where psn_key = 'c11'", String.class))
.isEqualTo("DISMISSED");
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1);
verify(igdbApiService, times(1)).searchGames("Monster Hunter Wilds");
}
@Test
void import_RefreshRefused_Is409Relink_AndTheLinkShowsAsExpired() throws Exception {
void save_LeavesALineTheUserNeverSawOpen_InANewReview() throws Exception {
link(NPSSO).andExpect(status().isOk());
when(psnAuthService.refresh(REFRESH)).thenReturn(Optional.empty());
awaitSync();
long importId = reviewImportId();
startImport().andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("PSN_RELINK"));
mockMvc.perform(post("/api/library/imports/" + importId + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"rows\": [], \"shownLineNos\": []}"))
.andExpect(status().isAccepted());
awaitImport(importId, "SAVED");
assertThat(jdbc.queryForObject("select refresh_expires_at <= now() from user_psn_account", Boolean.class)).isTrue();
assertThat(jdbc.queryForObject("select count(*) from user_psn_title where psn_key = 'c11' and state = 'DISMISSED'",
Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from game_import where status = 'READY' and total = 1",
Integer.class)).isEqualTo(1);
}
@Test
void import_NoTitles_Is204() throws Exception {
void savedReviewLine_LinksTheTitle_SoARemovedGameIsNeverAddedAgain() throws Exception {
when(igdbApiService.searchGames("Monster Hunter Wilds")).thenReturn(Optional.of(List.of(
new IgdbApiService.Game(301, "Monster Hunter Wilds", 2025, null, List.of("PC"), null))));
link(NPSSO).andExpect(status().isOk());
when(psnApiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
awaitSync();
long importId = reviewImportId();
startImport().andExpect(status().isNoContent());
mockMvc.perform(post("/api/library/imports/" + importId + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"rows\": [{\"lineNo\": 1, \"igdbId\": 301, \"platform\": \"PS5\", "
+ "\"status\": \"PLAYING\", \"playtimeMinutes\": 30}]}"))
.andExpect(status().isAccepted());
awaitImport(importId, "SAVED");
assertThat(jdbc.queryForMap("select state, appid from user_psn_title where psn_key = 'c11'"))
.containsEntry("state", "LINKED").containsEntry("appid", -301L);
mockMvc.perform(delete("/api/library/games/-301").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301", Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from game_import", Integer.class)).isEqualTo(1);
}
@Test
void import_PsnDown_Is502_AndIgdbOff_Is503() throws Exception {
void savedReview_AddsPsnRows_TheUserCanEditAndDelete_AndUndoRemoves() throws Exception {
when(igdbApiService.searchGames("Monster Hunter Wilds")).thenReturn(Optional.of(List.of(
new IgdbApiService.Game(301, "Monster Hunter Wilds", 2025, null, List.of("PC"), null))));
link(NPSSO).andExpect(status().isOk());
when(psnApiService.getPlayedTitles(ACCESS)).thenThrow(new org.springframework.web.client.ResourceAccessException("down"));
startImport().andExpect(status().isBadGateway()).andExpect(jsonPath("$.code").value("PSN_ERROR"));
doReturn(List.of(new PsnApiService.Title("A_00", "A", "ps4_game", Duration.ZERO, null)))
.when(psnApiService).getPlayedTitles(ACCESS);
when(igdbApiService.isEnabled()).thenReturn(false);
startImport().andExpect(status().isServiceUnavailable()).andExpect(jsonPath("$.code").value("IGDB_OFF"));
}
@Test
void savedPsnImport_AddsPsnRows_TheUserCanEditAndDelete_AndUndoRemoves() throws Exception {
link(NPSSO).andExpect(status().isOk());
long id = startedImport();
awaitStatus(id, "READY");
awaitSync();
long id = reviewImportId();
mockMvc.perform(post("/api/library/imports/" + id + "/save").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"rows\": [{\"lineNo\": 1, \"igdbId\": 300, \"platform\": \"PS5\", "
+ "\"status\": \"PLAYING\", \"playtimeMinutes\": 125}]}"))
.content("{\"rows\": [{\"lineNo\": 1, \"igdbId\": 301, \"platform\": \"PS5\", "
+ "\"status\": \"PLAYING\", \"playtimeMinutes\": 30}]}"))
.andExpect(status().isAccepted());
awaitStatus(id, "SAVED");
awaitImport(id, "SAVED");
Map<String, Object> row = jdbc.queryForMap("select source, platform, playtime_minutes, import_id from user_game "
+ "where user_id = 1 and appid = -300");
assertThat(row).containsEntry("source", GameSource.PSN.name()).containsEntry("platform", "PS5")
.containsEntry("playtime_minutes", 125).containsEntry("import_id", id);
+ "where user_id = 1 and appid = -301");
assertThat(row).containsEntry("source", "PSN").containsEntry("platform", "PS5")
.containsEntry("playtime_minutes", 30).containsEntry("import_id", id);
mockMvc.perform(get("/api/library/imports/" + id + "/undo-check")).andExpect(jsonPath("$.games").value(1));
mockMvc.perform(put("/api/library/games/-300/manual").header(ClientHeaderFilter.CLIENT_HEADER, "test")
mockMvc.perform(put("/api/library/games/-301/manual").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON).content("{\"platform\": \"PS4\", \"playtimeMinutes\": 200}"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select platform from user_game where appid = -300", String.class)).isEqualTo("PS4");
assertThat(jdbc.queryForObject("select platform from user_game where appid = -301", String.class)).isEqualTo("PS4");
mockMvc.perform(delete("/api/library/imports/" + id + "/games").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -300", Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from user_game where appid = -301", Integer.class)).isZero();
}
@Test
void gamesCount_AnswersCanSync_AlsoForAUserWithOnlyAPsnLink() throws Exception {
mockMvc.perform(get("/api/library/games-count")).andExpect(jsonPath("$.canSync").value(false));
link(NPSSO).andExpect(status().isOk());
awaitSync();
mockMvc.perform(get("/api/library/games-count")).andExpect(jsonPath("$.canSync").value(true))
.andExpect(jsonPath("$.hasSteamId").value(false));
}
@Test
void expiredLink_WritesOneInboxNote_ForManySyncs_AndCallsPsnNoMore() throws Exception {
link(NPSSO).andExpect(status().isOk());
awaitSync();
jdbc.update("update user_psn_account set refresh_expires_at = now() - interval '1 minute'");
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForObject("select count(*) from inbox_note where kind = 'PSN_EXPIRED' and user_id = 1 "
+ "and subject_user_id = 1", Integer.class)).isEqualTo(1);
mockMvc.perform(get("/api/inbox")).andExpect(jsonPath("$.results[0].kind").value("PSN_EXPIRED"))
.andExpect(jsonPath("$.unread").value(1));
verify(psnAuthService, times(1)).refresh(REFRESH);
}
@Test
void linkEndingTomorrow_WritesTheExpiringNoteOnce_AndTheSyncStillRuns() throws Exception {
link(NPSSO).andExpect(status().isOk());
awaitSync();
jdbc.update("update user_psn_account set refresh_expires_at = now() + interval '1 day'");
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
worker.runSyncNow(1L, null, SyncTrigger.AUTO);
assertThat(jdbc.queryForObject("select count(*) from inbox_note where kind = 'PSN_EXPIRING'", Integer.class))
.isEqualTo(1);
verify(psnAuthService, times(3)).refresh(REFRESH);
}
@Test
void psnDown_DuringTheSync_LeavesTheLinkAndTheSyncOk() throws Exception {
when(psnApiService.getPlayedTitles(ACCESS)).thenThrow(new org.springframework.web.client.ResourceAccessException("down"));
link(NPSSO).andExpect(status().isOk());
awaitSync();
mockMvc.perform(get("/api/library/psn-account")).andExpect(jsonPath("$.linked").value(true));
assertThat(jdbc.queryForObject("select count(*) from user_game", Integer.class)).isZero();
assertThat(jdbc.queryForObject("select count(*) from inbox_note", Integer.class)).isZero();
}
@Test
void importEndpointOfTheOldFlow_IsGone() throws Exception {
mockMvc.perform(post("/api/library/psn-account/imports").header(ClientHeaderFilter.CLIENT_HEADER, "test"))
.andExpect(status().is4xxClientError());
}
private void awaitImport(long id, String expected) throws InterruptedException {
for (int attempt = 0; attempt < 50; attempt++) {
if (expected.equals(jdbc.queryForObject("select status from game_import where id = ?", String.class, id))) {
return;
}
Thread.sleep(100);
}
throw new AssertionError("Import " + id + " not " + expected);
}
@Test
@@ -0,0 +1,155 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.IgdbGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import tools.jackson.databind.json.JsonMapper;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import({GameImportRepository.class, GameImportRepositoryTest.Mapper.class})
class GameImportRepositoryTest extends AbstractPersistenceTest {
@TestConfiguration
static class Mapper {
@Bean
JsonMapper jsonMapper() {
return JsonMapper.builder().build();
}
}
private static final IgdbGame GAME = new IgdbGame(300, "Ghost of Yotei", 2025, null, List.of("PS5"), null);
@Autowired
private GameImportRepository repository;
@Autowired
private JdbcTemplate jdbc;
private static GameImport.Row row(String title, String psnKey, IgdbGame... candidates) {
return new GameImport.Row(new GameImport.Line(1, title, "PS5", 60, GameStatus.PLAYING, null, psnKey), false, false,
List.of(candidates));
}
private void setStatus(long id, String status) {
jdbc.update("update game_import set status = ? where id = ?", status, id);
}
@Test
void createReady_WritesAReadyPsnImportWithMatchedRowsPsnKeyAndCandidates() {
long id = repository.createReady(1L, List.of(row("Ghost of Yotei", "c10", GAME), row("Wilds", "c11")));
GameImport found = repository.find(1L, id).orElseThrow();
assertThat(found.source()).isEqualTo(GameImport.Source.PSN);
assertThat(found.status()).isEqualTo(GameImport.Status.READY);
assertThat(found.total()).isEqualTo(2);
assertThat(found.matched()).isEqualTo(2);
List<GameImport.Row> rows = repository.findRows(id);
assertThat(rows).extracting(r -> r.line().lineNo()).containsExactly(1, 2);
assertThat(rows).extracting(r -> r.line().psnKey()).containsExactly("c10", "c11");
assertThat(rows.getFirst().candidates()).containsExactly(GAME);
assertThat(rows.get(1).candidates()).isEmpty();
assertThat(repository.findUnmatchedLines(id)).isEmpty();
}
@Test
void appendReadyRows_ContinuesTheLineNumbers_AndRaisesTheTotal() {
long id = repository.createReady(2L, List.of(row("A", "c1")));
assertThat(repository.appendReadyRows(id, List.of(row("B", "c2"), row("C", "c3")))).isTrue();
assertThat(repository.findRows(id)).extracting(r -> r.line().lineNo()).containsExactly(1, 2, 3);
assertThat(repository.find(2L, id).orElseThrow().total()).isEqualTo(3);
}
@Test
void appendReadyRows_AnImportTheUserSavedMeanwhile_GetsNothing() {
long id = repository.createReady(3L, List.of(row("A", "c1")));
setStatus(id, "SAVING");
assertThat(repository.appendReadyRows(id, List.of(row("B", "c2")))).isFalse();
assertThat(repository.findRows(id)).hasSize(1);
assertThat(repository.find(3L, id).orElseThrow().total()).isEqualTo(1);
}
@Test
void findOpenPsnImport_AndKeys_SeeOnlyThePsnImportsOfTheUserInTheFourOpenStates() {
long finished = repository.createReady(4L, List.of(row("Old", "c1")));
setStatus(finished, "SAVED");
long undone = repository.createReady(4L, List.of(row("Undone", "c2")));
setStatus(undone, "UNDONE");
long failed = repository.createReady(4L, List.of(row("Failed", "c3")));
setStatus(failed, "FAILED");
repository.createReady(5L, List.of(row("Other user", "c4")));
long text = repository.create(4L, GameImport.Source.TEXT, List.of(
new GameImport.Line(1, "Typed", null, null, null, null, null)));
assertThat(text).isPositive();
assertThat(repository.findOpenPsnImport(4L)).isEmpty();
assertThat(repository.findOpenPsnKeys(4L)).isEmpty();
// The V22 index allows one queued import per user.
setStatus(text, "SAVED");
long open = repository.createReady(4L, List.of(row("Open", "c5"), row("No key", null)));
assertThat(repository.findOpenPsnImport(4L)).hasValueSatisfying(found -> assertThat(found.id()).isEqualTo(open));
assertThat(repository.findOpenPsnKeys(4L)).containsExactly("c5");
for (String status : List.of("QUEUED", "MATCHING", "SAVING")) {
setStatus(open, status);
assertThat(repository.findOpenPsnImport(4L)).isPresent();
}
}
@Test
void findPsnKeysWithoutChoice_ListsTheLinesTheUserDidNotCheck() {
long id = repository.createReady(6L, List.of(row("A", "c1"), row("B", "c2"), row("Typed", null)));
repository.saveChoices(id, List.of(new GameImport.Choice(1, 300, "PS5", GameStatus.PLAYING, 60)));
assertThat(repository.findPsnKeysWithoutChoice(id)).containsExactly("c2");
}
@Test
void moveUnseenToNewReady_TakesTheLinesWithoutChoiceTheUserDidNotSee_ToANewReadyImport() {
long id = repository.createReady(8L, List.of(row("A", "c1"), row("B", "c2"), row("C", "c3"), row("D", "c4")));
repository.saveChoices(id, List.of(new GameImport.Choice(1, 300, "PS5", GameStatus.PLAYING, 60)));
repository.moveUnseenToNewReady(8L, id, java.util.Set.of(1, 2));
assertThat(repository.findPsnKeysWithoutChoice(id)).containsExactly("c2");
assertThat(repository.find(8L, id).orElseThrow().total()).isEqualTo(2);
GameImport open = repository.findOpenPsnImport(8L).orElseThrow();
assertThat(open.id()).isNotEqualTo(id);
assertThat(open.status()).isEqualTo(GameImport.Status.READY);
assertThat(open.total()).isEqualTo(2);
assertThat(repository.findRows(open.id())).extracting(r -> r.line().lineNo(), r -> r.line().psnKey())
.containsExactly(org.assertj.core.groups.Tuple.tuple(1, "c3"), org.assertj.core.groups.Tuple.tuple(2, "c4"));
}
@Test
void moveUnseenToNewReady_WhenAllWereSeen_CreatesNothing() {
long id = repository.createReady(9L, List.of(row("A", "c1")));
repository.moveUnseenToNewReady(9L, id, java.util.Set.of(1));
assertThat(repository.findOpenPsnImport(9L)).hasValueSatisfying(found -> assertThat(found.id()).isEqualTo(id));
}
@Test
void create_StoresThePsnKeyOfALine() {
long id = repository.create(7L, GameImport.Source.PSN, List.of(
new GameImport.Line(1, "A", "PS5", 1, GameStatus.PLAYING, null, "c9")));
assertThat(jdbc.queryForMap("select psn_key from game_import_row where import_id = ?", id))
.isEqualTo(Map.of("psn_key", "c9"));
}
}
@@ -1,5 +1,6 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.AppidMinutes;
import core_service.modules.library.domain.DayMinutes;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -10,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -57,6 +59,19 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
playtimeDayRepository.minutesPerDay(USER_ID, DAY));
}
@Test
void minutesPerGameSince_SumsOnlyTheAskedGamesOfTheUserFromTheDate() {
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.minusDays(1), 999);
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.plusDays(1), 20);
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY, 10);
playtimeDayRepository.addMinutes(USER_ID, 730L, DAY, 500);
playtimeDayRepository.addMinutes(2L, 220L, DAY, 500);
assertEquals(Set.of(new AppidMinutes(220L, 50), new AppidMinutes(620L, 10)),
Set.copyOf(playtimeDayRepository.minutesPerGameSince(USER_ID, List.of(220L, 620L), DAY)));
}
@Test
void minutesPerDay_LeavesOutHiddenGames() {
steamGameRepository.saveAll(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now()),
@@ -5,6 +5,7 @@ import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -17,6 +18,9 @@ class PsnAccountRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PsnAccountRepository repository;
@Autowired
private JdbcTemplate jdbc;
@Test
void upsert_StoresTheBytes_AndALaterLinkReplacesThem() {
Instant first = Instant.now().plus(10, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MILLIS);
@@ -62,4 +66,58 @@ class PsnAccountRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.find(8L)).isEmpty();
assertThat(repository.delete(8L)).isZero();
}
private int notes(long userId, String kind) {
return jdbc.queryForObject("select count(*) from inbox_note where user_id = ? and subject_user_id = ? and kind = ?",
Integer.class, userId, userId, kind);
}
@Test
void noteExpiry_Expiring_WritesOneNoteForTheReader_AndOnlyOncePerLink() {
repository.upsert(10L, new byte[]{1}, Instant.now().plus(1, ChronoUnit.DAYS));
assertThat(repository.noteExpiry(10L, "PSN_EXPIRING")).isEqualTo(1);
assertThat(repository.noteExpiry(10L, "PSN_EXPIRING")).isZero();
assertThat(notes(10L, "PSN_EXPIRING")).isEqualTo(1);
assertThat(repository.find(10L).orElseThrow().expiryNote()).isEqualTo("PSN_EXPIRING");
}
@Test
void noteExpiry_ExpiredAfterExpiring_WritesOneMore_AndExpiringAfterExpiredNone() {
repository.upsert(11L, new byte[]{1}, Instant.now().plus(1, ChronoUnit.DAYS));
repository.noteExpiry(11L, "PSN_EXPIRING");
assertThat(repository.noteExpiry(11L, "PSN_EXPIRED")).isEqualTo(1);
assertThat(repository.noteExpiry(11L, "PSN_EXPIRED")).isZero();
assertThat(repository.noteExpiry(11L, "PSN_EXPIRING")).isZero();
assertThat(notes(11L, "PSN_EXPIRING")).isEqualTo(1);
assertThat(notes(11L, "PSN_EXPIRED")).isEqualTo(1);
assertThat(repository.find(11L).orElseThrow().expiryNote()).isEqualTo("PSN_EXPIRED");
}
@Test
void noteExpiry_ExpiredWithoutAnEarlierNote_WritesIt() {
repository.upsert(12L, new byte[]{1}, Instant.now().minusSeconds(5));
assertThat(repository.noteExpiry(12L, "PSN_EXPIRED")).isEqualTo(1);
}
@Test
void noteExpiry_UserWithoutALink_WritesNothing() {
assertThat(repository.noteExpiry(13L, "PSN_EXPIRED")).isZero();
assertThat(notes(13L, "PSN_EXPIRED")).isZero();
}
@Test
void upsert_ANewLink_ResetsTheNote_SoTheNextExpiryIsAnnouncedAgain() {
repository.upsert(14L, new byte[]{1}, Instant.now().plus(1, ChronoUnit.DAYS));
repository.noteExpiry(14L, "PSN_EXPIRED");
repository.upsert(14L, new byte[]{2}, Instant.now().plus(10, ChronoUnit.DAYS));
assertThat(repository.find(14L).orElseThrow().expiryNote()).isNull();
assertThat(repository.noteExpiry(14L, "PSN_EXPIRING")).isEqualTo(1);
}
}
@@ -0,0 +1,75 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Import(PsnTitleRepository.class)
class PsnTitleRepositoryTest extends AbstractPersistenceTest {
@Autowired
private PsnTitleRepository repository;
@Autowired
private JdbcTemplate jdbc;
private void game(long userId, long appid, String source) {
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) values (?, ?, 'BACKLOG', ?)", userId, appid,
source);
}
@Test
void linkOwned_ASteamRowIsSteamOwned_AnyOtherRowIsLinked() {
game(5L, -1L, "STEAM");
game(5L, -2L, "MANUAL");
repository.linkOwned(5L, "c1", -1L);
repository.linkOwned(5L, "c2", -2L);
repository.linkOwned(5L, "c3", -3L);
assertThat(repository.findAll(5L)).containsOnly(
Map.entry("c1", new PsnTitle("c1", PsnTitle.State.STEAM_OWNED, -1L)),
Map.entry("c2", new PsnTitle("c2", PsnTitle.State.LINKED, -2L)),
Map.entry("c3", new PsnTitle("c3", PsnTitle.State.LINKED, -3L)));
}
@Test
void link_StoresTheAppid_AndALaterLinkOverADismissedTitleWins() {
repository.dismiss(1L, List.of("c1"));
repository.link(1L, "c1", -300L);
assertThat(repository.findAll(1L)).containsOnly(Map.entry("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -300L)));
}
@Test
void dismiss_LeavesALinkedTitleLinked_AndIsIdempotent() {
repository.link(2L, "c1", -300L);
repository.dismiss(2L, List.of("c1", "t2"));
repository.dismiss(2L, List.of("t2"));
repository.dismiss(2L, List.of());
assertThat(repository.findAll(2L)).containsOnly(
Map.entry("c1", new PsnTitle("c1", PsnTitle.State.LINKED, -300L)),
Map.entry("t2", new PsnTitle("t2", PsnTitle.State.DISMISSED, null)));
}
@Test
void findAll_ReturnsOnlyTheUsersTitles() {
repository.link(3L, "c1", -1L);
repository.link(4L, "c1", -2L);
assertThat(repository.findAll(3L).get("c1").appid()).isEqualTo(-1L);
assertThat(repository.findAll(5L)).isEmpty();
}
}
@@ -261,4 +261,50 @@ class UserSteamSyncRepositoryTest extends AbstractPersistenceTest {
assertThat(lastSuccessAt(24L)).isEqualTo(success);
}
@Test
void tryClaimRun_WithoutSteamId_ClaimsARowForAPsnOnlyUser_AndMarkFinishedWorksOnIt() {
Instant started = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS);
assertThat(repository.tryClaimRun(40L, null, started, started.minusSeconds(600))).isEqualTo(1);
assertThat(repository.tryClaimRun(40L, null, started, started.minusSeconds(600))).isZero();
assertThat(repository.markFinished(40L, started, "OK", Instant.now())).isEqualTo(1);
UserSteamSync row = repository.findById(40L).orElseThrow();
assertThat(row.getSteamId()).isNull();
assertThat(row.getLastSyncStatus()).isEqualTo(UserSteamSync.Status.OK);
assertThat(row.getLastSuccessAt()).isNotNull();
}
@Test
void tryClaimRun_SteamIdAppearsOnAPsnOnlyRow_ResetsTheSuccessAndStoresTheId() {
givenRow(41L, null, "OK", null, "1 hour", null);
assertThat(claim(41L)).isEqualTo(1);
UserSteamSync row = repository.findById(41L).orElseThrow();
assertThat(row.getSteamId()).isEqualTo(STEAM_ID);
assertThat(row.getLastSuccessAt()).isNull();
}
@Test
void tryStartLoginSync_WithoutSteamId_CreatesTheRow_AndTheGateHoldsLikeForSteam() {
assertThat(tryLogin(42L, null)).isEqualTo(1);
assertThat(repository.findById(42L).orElseThrow().getSteamId()).isNull();
givenRow(43L, null, "OK", null, "10 hours", null);
assertThat(tryLogin(43L, null)).isZero();
givenRow(44L, null, "OK", null, "21 hours", null);
assertThat(tryLogin(44L, null)).isEqualTo(1);
}
@Test
void tryStartLoginSync_NullVersusAnId_CountsAsAChangedAccountInBothDirections() {
givenRow(45L, null, "OK", null, "1 hour", null);
givenRow(46L, STEAM_ID, "OK", null, "1 hour", null);
assertThat(tryLogin(45L, STEAM_ID)).isEqualTo(1);
assertThat(tryLogin(46L, null)).isEqualTo(1);
}
}
@@ -3,6 +3,7 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessResourceFailureException;
@@ -25,7 +26,7 @@ class GameImportWorkerTest {
}
private static GameImport.Line line(String title, String platform) {
return new GameImport.Line(1, title, platform, null, null, null);
return new GameImport.Line(1, title, platform, null, null, null, null);
}
@Test
@@ -56,7 +57,7 @@ class GameImportWorkerTest {
.thenReturn(Optional.of(7L), Optional.empty());
when(repository.findStalled(any())).thenReturn(Map.of());
GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class),
mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), mock(GameCreditService.class), 5);
mock(PsnTitleRepository.class), mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), mock(GameCreditService.class), 5);
try {
worker.wakeMatching();
verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE);
@@ -2,14 +2,18 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.SyncTrigger;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.domain.PsnAccount;
import core_service.modules.library.repo.BacklogSnapshotRepository;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
@@ -18,6 +22,7 @@ import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ScheduledFuture;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -26,6 +31,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
@@ -50,6 +56,15 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
@Mock
private FinishedEstimateService finishedEstimateService;
@Mock
private PsnAccountRepository psnAccountRepository;
@Mock
private TaskScheduler taskScheduler;
@Mock
private ScheduledFuture<Object> pendingTask;
@InjectMocks
private LibrarySyncService syncService;
@@ -281,4 +296,149 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
private void givenPsnLink(boolean linked) {
when(psnAccountRepository.find(USER_ID)).thenReturn(linked
? Optional.of(new PsnAccount(USER_ID, new byte[0], Instant.now().plus(Duration.ofDays(9)), Instant.now(), null))
: Optional.empty());
}
@Test
void syncIfStale_NoSteamIdAndNoPsnLink_StopsTheNightlySyncAndStartsNothing() {
givenPsnLink(false);
assertFalse(syncService.syncIfStale(USER_ID, null));
verify(userSteamSyncRepository).clearUserSeen(USER_ID);
verify(userSteamSyncRepository, never()).tryStartLoginSync(anyLong(), any(), any(), any(), any(), any());
verify(worker, never()).runSync(anyLong(), any(), any());
}
@Test
void syncIfStale_NoSteamIdButAPsnLink_UsesTheLoginGateAndRunsWithoutSteam() {
givenPsnLink(true);
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
when(userSteamSyncRepository.tryStartLoginSync(eq(USER_ID), eq(null), any(), any(), any(), any())).thenReturn(1);
assertTrue(syncService.syncIfStale(USER_ID, null));
verify(worker).runSync(USER_ID, null, SyncTrigger.LOGIN);
}
@Test
void syncIfStale_PsnOnlyUserWithAFreshSync_DoesNotAskTheGate() {
givenPsnLink(true);
givenSync(null, UserSteamSync.Status.OK, Duration.ofHours(1), null);
assertFalse(syncService.syncIfStale(USER_ID, null));
verify(userSteamSyncRepository, never()).tryStartLoginSync(anyLong(), any(), any(), any(), any(), any());
}
@Test
void forceSync_NoSteamIdAndNoPsnLink_Throws() {
givenPsnLink(false);
assertThrows(IllegalArgumentException.class, () -> syncService.forceSync(USER_ID, null));
verify(worker, never()).runSync(anyLong(), any(), any());
}
@Test
void forceSync_NoSteamIdButAPsnLink_StartsAManualSync() {
givenPsnLink(true);
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.empty());
syncService.forceSync(USER_ID, null);
verify(worker).runSync(USER_ID, null, SyncTrigger.MANUAL);
}
@Test
void canSync_NeedsASteamIdOrAPsnLink() {
givenPsnLink(false);
assertTrue(syncService.canSync(USER_ID, STEAM_ID));
assertFalse(syncService.canSync(USER_ID, null));
givenPsnLink(true);
assertTrue(syncService.canSync(USER_ID, null));
}
@Test
void startAfterLink_AfterTheCooldown_RunsAManualSync() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(1), null);
userSteamSyncRepository.findById(USER_ID).ifPresent(sync -> sync.setLastSyncedAt(Instant.now().minusSeconds(120)));
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
@Test
void startAfterLink_InsideTheCooldown_SchedulesOneSyncAtTheCooldownEnd_SoRepeatedLinksNeverStack() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(1), null);
Instant lastSyncedAt = userSteamSyncRepository.findById(USER_ID).orElseThrow().getLastSyncedAt();
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
syncService.startAfterLink(USER_ID, STEAM_ID);
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), any(), any());
verify(taskScheduler, times(1)).schedule(any(Runnable.class), eq(lastSyncedAt.plusSeconds(60)));
}
@Test
void startAfterLink_ScheduledSync_RunsAtItsTime_AndTheNextLinkMaySchedule() {
givenSync(STEAM_ID, UserSteamSync.Status.OK, Duration.ofHours(1), null);
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
ArgumentCaptor<Runnable> task = ArgumentCaptor.forClass(Runnable.class);
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(taskScheduler).schedule(task.capture(), any(Instant.class));
userSteamSyncRepository.findById(USER_ID).orElseThrow().setLastSyncedAt(Instant.now().minusSeconds(120));
task.getValue().run();
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
}
@Test
void startAfterLink_ScheduledSync_UsesTheSteamIdOfTheSyncRowAtThatTime() {
givenSync(null, UserSteamSync.Status.OK, Duration.ofHours(1), null);
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
ArgumentCaptor<Runnable> task = ArgumentCaptor.forClass(Runnable.class);
syncService.startAfterLink(USER_ID, null);
verify(taskScheduler).schedule(task.capture(), any(Instant.class));
UserSteamSync later = userSteamSyncRepository.findById(USER_ID).orElseThrow();
later.setSteamId(STEAM_ID);
later.setLastSyncedAt(Instant.now().minusSeconds(120));
task.getValue().run();
verify(worker).runSync(USER_ID, STEAM_ID, SyncTrigger.MANUAL);
verify(worker, never()).runSync(eq(USER_ID), eq(null), any());
}
@Test
void startAfterLink_RunningSync_SchedulesTheSyncOneCooldownLater() {
givenSync(STEAM_ID, UserSteamSync.Status.RUNNING, Duration.ofHours(1), null);
doReturn(pendingTask).when(taskScheduler).schedule(any(Runnable.class), any(Instant.class));
syncService.startAfterLink(USER_ID, STEAM_ID);
verify(worker, never()).runSync(anyLong(), any(), any());
ArgumentCaptor<Instant> runAt = ArgumentCaptor.forClass(Instant.class);
verify(taskScheduler).schedule(any(Runnable.class), runAt.capture());
assertTrue(runAt.getValue().isAfter(Instant.now().plusSeconds(50)));
}
@Test
void startAfterLink_NoSteamId_RunsWithoutSteam_AndMarksTheUserAsSeen() {
UserSteamSync seenLongAgo = new UserSteamSync(USER_ID, null);
seenLongAgo.setUserSeenAt(Instant.now().minus(Duration.ofDays(2)));
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(seenLongAgo));
syncService.startAfterLink(USER_ID, null);
verify(userSteamSyncRepository).touchUserSeen(eq(USER_ID), any());
verify(worker).runSync(USER_ID, null, SyncTrigger.MANUAL);
}
}
@@ -18,6 +18,7 @@ import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.modules.library.repo.WishlistGameRepository;
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
import core_service.modules.steam_adapter.service.SteamApiService;
import core_service.shared.event.LibraryActivityEvent;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -139,6 +140,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private PsnSyncStep psnSyncStep;
@Spy
private TransactionTemplate transaction = new TransactionTemplate(mock(PlatformTransactionManager.class));
@@ -148,6 +152,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(worker, "zone", ZoneId.of("Europe/Berlin"));
lenient().when(psnSyncStep.run(USER_ID)).thenReturn(PsnSyncStep.Prepared.NONE);
lenient().when(userSteamSyncRepository.markFinished(eq(USER_ID), any(), anyString(), any())).thenReturn(1);
}
@@ -745,6 +750,80 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verifyNoInteractions(syncChangeRepository);
}
@Test
void runSync_PsnStepRunsBeforeTheTransaction_AndApplyAfterTheGamesAreSaved_ItsChangesJoinTheResult() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), 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), SyncChange.playtime(-8L, 30)));
owned(100);
steamReturns(game(160, 60), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
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(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));
}
@Test
void runSync_PsnBaselineAdds_AreCountedNotListed() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(), true);
when(psnSyncStep.run(USER_ID)).thenReturn(prepared);
when(psnSyncStep.apply(eq(USER_ID), eq(prepared), any(), any()))
.thenReturn(List.of(SyncChange.added(-9L), SyncChange.playtime(-8L, 30)));
owned(100);
steamReturns(game(100, 0), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 1)).containsExactly(SyncChange.playtime(-8L, 30));
}
@Test
void runSync_WithoutSteamId_RunsOnlyThePsnPart_AndPublishesTheActivityEvent() {
PsnSyncStep.Prepared prepared = new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), 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(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO);
verifyNoInteractions(steamApiService);
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
verifyNoInteractions(wishlistGameRepository);
verify(userGameRepository, never()).save(any(UserGame.class));
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).containsExactly(SyncChange.added(-9L));
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
verify(eventPublisher).publishEvent(new LibraryActivityEvent(USER_ID));
verify(eventPublisher, never()).publishEvent(any(LibrarySyncedEvent.class));
}
@Test
void runSync_WithoutSteamIdAndNothingFromPsn_IsAnOkRunWithAnEmptyResult() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO);
assertThat(replacedChanges(SyncTrigger.AUTO, 0)).isEmpty();
verify(userSteamSyncRepository).markFinished(eq(USER_ID), any(), eq("OK"), any());
}
@Test
void runSync_UserLostSteam_KeepsTheGames() {
when(userSteamSyncRepository.findForClaim(USER_ID)).thenReturn(Optional.of(syncRow(STEAM_ID, STEAM_ID)));
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(null), any(), any())).thenReturn(1);
worker.runSync(USER_ID, null, SyncTrigger.AUTO);
verify(userGameRepository, never()).softDeleteMissingSince(anyLong(), any(), any());
verifyNoInteractions(wishlistGameRepository);
}
@Test
void runSync_SteamFails_KeepsTheOldResult() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
@@ -1,8 +1,10 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.SteamGameRepository;
@@ -203,4 +205,15 @@ class ManualGameServiceTest extends AbstractUnitTest {
verify(igdbApiService, never()).searchGames("Silksong");
verify(igdbApiService, times(3)).searchGames(anyString());
}
@Test
void update_PsnRow_KeepsThePsnPlaytime() {
UserGame psnRow = UserGame.manual(USER_ID, -119388L, GameSource.PSN, "PS5", GameStatus.PLAYING, 600,
Instant.parse("2026-10-01T10:00:00Z"));
when(userGameRepository.findById(new UserGameId(USER_ID, -119388L))).thenReturn(Optional.of(psnRow));
service.update(USER_ID, -119388L, "PS5", 0);
assertThat(psnRow.getPlaytimeMinutes()).isEqualTo(600);
}
}
@@ -2,11 +2,13 @@ package core_service.modules.library.service;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.PsnAccount;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.PsnAccountRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.modules.psn_adapter.service.PsnAuthService;
import core_service.shared.security.SecretBox;
import core_service.shared.test.AbstractUnitTest;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -50,14 +52,14 @@ class PsnAccountServiceTest extends AbstractUnitTest {
@Mock
private PsnApiService apiService;
@Mock
private GameImportService importService;
private GameImportRepository importRepository;
private final SecretBox box = SecretBox.fromBase64Key(KEY);
private PsnAccountService service;
@BeforeEach
void setUp() {
service = new PsnAccountService(repository, authService, apiService, importService, KEY);
service = new PsnAccountService(repository, authService, apiService, importRepository, KEY, 2);
}
private PsnAuthService.Tokens tokens(String refresh, Instant refreshExpires) {
@@ -65,7 +67,7 @@ class PsnAccountServiceTest extends AbstractUnitTest {
}
private void linked(Instant expires) {
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, box.seal(OLD_REFRESH), expires, Instant.now())));
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, box.seal(OLD_REFRESH), expires, Instant.now(), null)));
}
private static void assertNoSecrets(CapturedOutput output) {
@@ -76,7 +78,7 @@ class PsnAccountServiceTest extends AbstractUnitTest {
@Test
void link_SealsTheRefreshToken_AndLogsNoSecret(CapturedOutput output) {
when(authService.exchangeNpsso(NPSSO)).thenReturn(Optional.of(tokens(NEW_REFRESH, EXPIRES)));
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, new byte[0], EXPIRES, Instant.now())));
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L, new byte[0], EXPIRES, Instant.now(), null)));
PsnAccountService.LinkResult result = service.link(1L, NPSSO);
@@ -111,60 +113,55 @@ class PsnAccountServiceTest extends AbstractUnitTest {
@Test
void link_WithoutAKey_IsOff_AndCallsNothing() {
PsnAccountService off = new PsnAccountService(repository, authService, apiService, importService, "");
PsnAccountService off = new PsnAccountService(repository, authService, apiService, importRepository, "", 2);
assertThat(off.isEnabled()).isFalse();
assertThat(off.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Off.class);
assertThat(off.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Off.class);
assertThat(off.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Off.class);
assertThat(off.get(1L).enabled()).isFalse();
verifyNoInteractions(authService, apiService, repository);
verifyNoInteractions(authService, apiService, repository, importRepository);
}
@Test
void constructor_RejectsAKeyOfTheWrongLength() {
org.assertj.core.api.Assertions.assertThatThrownBy(() ->
new PsnAccountService(repository, authService, apiService, importService, "c2hvcnQ="))
new PsnAccountService(repository, authService, apiService, importRepository, "c2hvcnQ=", 2))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void startImport_RefreshesReadsTitles_AndCreatesAPsnImport(CapturedOutput output) {
void fetchTitles_RefreshesAndReadsTitles(CapturedOutput output) {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES.plusSeconds(5))));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("PPSA1_00", "Ghost",
"ps5_native_game", Duration.ofMinutes(90), 7L)));
when(importService.createLines(eq(1L), eq(GameImport.Source.PSN), any()))
.thenReturn(new GameImportService.CreateResult.Created(42L));
List<PsnApiService.Title> played = List.of(new PsnApiService.Title("PPSA1_00", "Ghost", "ps5_native_game",
Duration.ofMinutes(90), 7L, null));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(played);
PsnAccountService.ImportResult result = service.startImport(1L);
assertThat(service.fetchTitles(1L)).isEqualTo(new PsnAccountService.FetchResult.Titles(played));
assertThat(result).isEqualTo(new PsnAccountService.ImportResult.Started(42L));
ArgumentCaptor<List<GameImport.Line>> lines = ArgumentCaptor.captor();
verify(importService).createLines(eq(1L), eq(GameImport.Source.PSN), lines.capture());
assertThat(lines.getValue()).containsExactly(new GameImport.Line(1, "Ghost", "PS5", 90,
core_service.modules.library.domain.GameStatus.PLAYING, null));
verify(repository, never()).noteExpiry(anyLong(), any());
assertNoSecrets(output);
}
@Test
void startImport_SameRefreshToken_KeepsTheStoredExpiry() {
void fetchTitles_SameRefreshToken_KeepsTheStoredExpiry() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES.plusSeconds(999))));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.NoTitles.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Titles.class);
verify(repository, never()).updateToken(anyLong(), any(), any());
}
@Test
void startImport_NewRefreshToken_StoresItSealedWithItsExpiry(CapturedOutput output) {
void fetchTitles_NewRefreshToken_StoresItSealedWithItsExpiry(CapturedOutput output) {
linked(EXPIRES);
Instant newExpiry = Instant.now().plus(Duration.ofDays(10));
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(NEW_REFRESH, newExpiry)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
service.startImport(1L);
service.fetchTitles(1L);
ArgumentCaptor<byte[]> sealed = ArgumentCaptor.forClass(byte[].class);
verify(repository).updateToken(eq(1L), sealed.capture(), eq(newExpiry));
@@ -173,69 +170,109 @@ class PsnAccountServiceTest extends AbstractUnitTest {
}
@Test
void startImport_ExpiredLink_IsRelink_WithoutAPsnCall() {
void fetchTitles_ExpiredLink_IsRelink_WithTheExpiredNote_AndNoPsnCall() {
linked(Instant.now().minusSeconds(1));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Relink.class);
verify(repository).noteExpiry(1L, "PSN_EXPIRED");
verifyNoInteractions(authService, apiService);
}
@Test
void startImport_RefreshRefused_ExpiresTheLink_AndIsRelink() {
void fetchTitles_LinkEndsInOneDay_WritesTheExpiringNote_AndStillFetches() {
linked(Instant.now().plus(Duration.ofDays(1)));
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Titles.class);
verify(repository).noteExpiry(1L, "PSN_EXPIRING");
verify(repository, never()).noteExpiry(1L, "PSN_EXPIRED");
}
@Test
void fetchTitles_LinkEndsInThreeDays_WritesNoNote() {
linked(Instant.now().plus(Duration.ofDays(3)));
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of());
service.fetchTitles(1L);
verify(repository, never()).noteExpiry(anyLong(), any());
}
@Test
void fetchTitles_RefreshRefused_ExpiresTheLink_WritesTheExpiredNote_AndIsRelink() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.empty());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Relink.class);
verify(repository).expireNow(1L);
verify(repository).noteExpiry(1L, "PSN_EXPIRED");
verify(apiService, never()).getPlayedTitles(any());
}
@Test
void startImport_TokenSealedWithAnotherKey_IsRelink(CapturedOutput output) {
void fetchTitles_TokenSealedWithAnotherKey_IsRelink_WithTheExpiredNote(CapturedOutput output) {
when(repository.find(1L)).thenReturn(Optional.of(new PsnAccount(1L,
SecretBox.fromBase64Key(Base64.getEncoder().encodeToString(new byte[32])).seal(OLD_REFRESH), EXPIRES,
Instant.now())));
Instant.now(), null)));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Relink.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Relink.class);
verify(repository).expireNow(1L);
verify(repository).noteExpiry(1L, "PSN_EXPIRED");
assertNoSecrets(output);
}
@Test
void startImport_NotLinked() {
void fetchTitles_NotLinked() {
when(repository.find(1L)).thenReturn(Optional.empty());
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.NotLinked.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.NotLinked.class);
}
@Test
void startImport_PsnServerError_IsUnavailable_AndLogsNoSecret(CapturedOutput output) {
void fetchTitles_PsnServerError_IsUnavailable_KeepsTheLink_AndLogsNoSecret(CapturedOutput output) {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenThrow(HttpServerErrorException.create(
HttpStatus.BAD_GATEWAY, "Bad", null, (ACCESS + OLD_REFRESH).getBytes(StandardCharsets.UTF_8), null));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Unavailable.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Unavailable.class);
verify(importService, never()).createLines(anyLong(), any(), any());
verify(repository, never()).expireNow(anyLong());
verify(repository, never()).noteExpiry(anyLong(), any());
assertNoSecrets(output);
}
@Test
void startImport_MapsTheImportResults() {
void fetchTitles_RateLimiterRefusal_IsUnavailable_KeepsTheLink() {
linked(EXPIRES);
when(authService.refresh(OLD_REFRESH)).thenReturn(Optional.of(tokens(OLD_REFRESH, EXPIRES)));
when(apiService.getPlayedTitles(ACCESS)).thenReturn(List.of(new PsnApiService.Title("A_00", "A", "ps4_game",
Duration.ZERO, null)));
when(importService.createLines(eq(1L), any(), any()))
.thenReturn(new GameImportService.CreateResult.AlreadyMatching())
.thenReturn(new GameImportService.CreateResult.SearchUnavailable());
when(authService.refresh(OLD_REFRESH)).thenThrow(RequestNotPermitted.createRequestNotPermitted(
io.github.resilience4j.ratelimiter.RateLimiter.ofDefaults("psnApi")));
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.AlreadyMatching.class);
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.SearchUnavailable.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Unavailable.class);
verify(repository, never()).expireNow(anyLong());
}
@Test
void get_CarriesTheReviewImportOnlyForAReadyPsnImport() {
linked(EXPIRES);
when(importRepository.findOpenPsnImport(1L)).thenReturn(Optional.of(psnImport(5L, GameImport.Status.READY)))
.thenReturn(Optional.of(psnImport(6L, GameImport.Status.MATCHING))).thenReturn(Optional.empty());
assertThat(service.get(1L).reviewImportId()).isEqualTo(5L);
assertThat(service.get(1L).reviewImportId()).isNull();
assertThat(service.get(1L).reviewImportId()).isNull();
}
private static GameImport psnImport(long id, GameImport.Status status) {
Instant now = Instant.now();
return new GameImport(id, 1L, GameImport.Source.PSN, status, 2, 2, 0, 0, 0, false, 0, null, now, now, null);
}
@Test
@@ -253,8 +290,8 @@ class PsnAccountServiceTest extends AbstractUnitTest {
entered.await();
assertThat(service.link(1L, NPSSO)).isInstanceOf(PsnAccountService.LinkResult.Busy.class);
assertThat(service.startImport(1L)).isInstanceOf(PsnAccountService.ImportResult.Busy.class);
assertThat(service.startImport(2L)).isInstanceOf(PsnAccountService.ImportResult.NotLinked.class);
assertThat(service.fetchTitles(1L)).isInstanceOf(PsnAccountService.FetchResult.Busy.class);
assertThat(service.fetchTitles(2L)).isInstanceOf(PsnAccountService.FetchResult.NotLinked.class);
release.countDown();
assertThat(first.get()).isInstanceOf(PsnAccountService.LinkResult.Rejected.class);
@@ -0,0 +1,317 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.AppidMinutes;
import core_service.modules.library.domain.GameImport;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameImportRepository;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.psn_adapter.service.PsnApiService;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.transaction.PlatformTransactionManager;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
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);
@Mock
private PsnAccountService accountService;
@Mock
private PsnTitleRepository psnTitleRepository;
@Mock
private GameImportRepository importRepository;
@Mock
private GameMergeRepository mergeRepository;
@Mock
private ManualGameService manualGameService;
@Mock
private UserGameRepository userGameRepository;
@Mock
private PlaytimeDayRepository playtimeDayRepository;
@Mock
private PlatformTransactionManager transactionManager;
@InjectMocks
private PsnSyncStep step;
private static PsnApiService.Title ghost() {
return new PsnApiService.Title("PPSA1_00", "Ghost of Yotei", "ps5_native_game", Duration.ofMinutes(120), 7L,
LAST_PLAYED);
}
private static IgdbGame igdb(long id, String name, String... platforms) {
return new IgdbGame(id, name, 2025, null, List.of(platforms), null);
}
private void fetches(PsnApiService.Title... titles) {
when(accountService.fetchTitles(USER)).thenReturn(new PsnAccountService.FetchResult.Titles(List.of(titles)));
}
private static GameImport psnImport(long id, GameImport.Status status) {
Instant now = Instant.now();
return new GameImport(id, USER, GameImport.Source.PSN, status, 1, 1, 0, 0, 0, false, 0, null, now, now, null);
}
@Test
void run_ExactMatchOnThePlatform_AddsTheGame_LinksIt_SetsLastPlayed_AndIsABaseline() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei", "PS5");
when(manualGameService.findGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(game)));
when(manualGameService.addGame(eq(USER), eq(game), eq("PS5"), eq(GameStatus.PLAYING), eq(120),
eq(GameSource.PSN), isNull())).thenReturn(new ManualGameService.AddResult.Added(-1L));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).containsExactly(SyncChange.added(-1L));
assertThat(prepared.baseline()).isTrue();
verify(psnTitleRepository).link(USER, "c7", -1L);
verify(userGameRepository).setLastPlayedAt(USER, -1L, LAST_PLAYED);
verify(importRepository, never()).createReady(anyLong(), any());
}
@Test
void run_ExactMatchAlreadyInTheLibrary_LinksTheOwnedKey_WithoutAChange() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei", "PS5");
when(manualGameService.findGames(anyString())).thenReturn(Optional.of(List.of(game)));
when(manualGameService.addGame(eq(USER), eq(game), anyString(), any(), anyInt(), any(), any()))
.thenReturn(new ManualGameService.AddResult.AlreadyInLibrary());
when(manualGameService.ownedKey(USER, game)).thenReturn(Optional.of(-5L));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
verify(psnTitleRepository).linkOwned(USER, "c7", -5L);
verify(userGameRepository, never()).setLastPlayedAt(anyLong(), anyLong(), any());
}
@Test
void run_FirstCandidateIsOwnedUnderAnotherSpelling_LinksItAndAddsNothing() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei: Legends", "PS5");
when(manualGameService.findGames(anyString())).thenReturn(Optional.of(List.of(game)));
when(manualGameService.ownedKey(USER, game)).thenReturn(Optional.of(-5L));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
verify(psnTitleRepository).linkOwned(USER, "c7", -5L);
verify(manualGameService, never()).addGame(anyLong(), any(), anyString(), any(), anyInt(), any(), any());
verify(importRepository, never()).createReady(anyLong(), any());
}
@Test
void run_NameMatchOnAnotherPlatform_IsNotExact_AndWaitsInANewReview() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei", "PC");
when(manualGameService.findGames(anyString())).thenReturn(Optional.of(List.of(game)));
when(importRepository.findOpenPsnImport(USER)).thenReturn(Optional.empty());
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
ArgumentCaptor<List<GameImport.Row>> rows = ArgumentCaptor.captor();
verify(importRepository).createReady(eq(USER), rows.capture());
assertThat(rows.getValue()).hasSize(1);
assertThat(rows.getValue().getFirst().line().psnKey()).isEqualTo("c7");
assertThat(rows.getValue().getFirst().line().title()).isEqualTo("Ghost of Yotei");
assertThat(rows.getValue().getFirst().candidates()).containsExactly(game);
verify(manualGameService, never()).addGame(anyLong(), any(), anyString(), any(), anyInt(), any(), any());
verify(psnTitleRepository, never()).link(anyLong(), anyString(), anyLong());
}
@Test
void run_NoCandidates_WaitsInAReadyReviewThatIsAppendedTo() {
fetches(ghost());
when(manualGameService.findGames(anyString())).thenReturn(Optional.of(List.of()));
when(importRepository.findOpenPsnImport(USER)).thenReturn(Optional.of(psnImport(9L, GameImport.Status.READY)));
step.run(USER);
ArgumentCaptor<List<GameImport.Row>> rows = ArgumentCaptor.captor();
verify(importRepository).appendReadyRows(eq(9L), rows.capture());
assertThat(rows.getValue().getFirst().candidates()).isEmpty();
verify(importRepository, never()).createReady(anyLong(), any());
}
@Test
void run_AnImportStillMatchingOrSaving_GetsNoRows_TheTitleIsNewAgainNextSync() {
fetches(ghost());
when(manualGameService.findGames(anyString())).thenReturn(Optional.of(List.of()));
when(importRepository.findOpenPsnImport(USER)).thenReturn(Optional.of(psnImport(9L, GameImport.Status.SAVING)));
step.run(USER);
verify(importRepository, never()).appendReadyRows(anyLong(), any());
verify(importRepository, never()).createReady(anyLong(), any());
}
@Test
void run_LinkedTitle_GivesItsPlaytimeAndLastPlayed_WithoutAnIgdbSearch() {
fetches(ghost());
when(psnTitleRepository.findAll(USER)).thenReturn(Map.of("c7", new PsnTitle("c7", PsnTitle.State.LINKED, -1L)));
when(mergeRepository.findTargets(List.of(-1L))).thenReturn(Map.of(-1L, -2L));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.playtimes()).containsExactly(new PsnSyncStep.Playtime(-2L, 120, LAST_PLAYED));
assertThat(prepared.baseline()).isFalse();
verifyNoInteractions(manualGameService);
}
@Test
void run_DismissedTitleAndTitleOfAnOpenImport_AreNotSearchedNorOffered() {
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ZERO, 8L, null);
fetches(ghost(), other);
when(psnTitleRepository.findAll(USER)).thenReturn(Map.of("c7", new PsnTitle("c7", PsnTitle.State.DISMISSED, null)));
when(importRepository.findOpenPsnKeys(USER)).thenReturn(Set.of("c8"));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
assertThat(prepared.playtimes()).isEmpty();
verifyNoInteractions(manualGameService);
verify(importRepository, never()).createReady(anyLong(), any());
}
@Test
void run_IgdbDoesNotAnswer_LeavesTheTitleNew() {
fetches(ghost());
when(manualGameService.findGames(anyString())).thenReturn(Optional.empty());
step.run(USER);
verify(manualGameService, never()).addGame(anyLong(), any(), anyString(), any(), anyInt(), any(), any());
verify(importRepository, never()).createReady(anyLong(), any());
verify(psnTitleRepository, never()).link(anyLong(), anyString(), anyLong());
}
@Test
void run_PsnNotAvailable_IsNoneAndTouchesNothing() {
when(accountService.fetchTitles(USER)).thenReturn(new PsnAccountService.FetchResult.Unavailable())
.thenReturn(new PsnAccountService.FetchResult.Relink()).thenReturn(new PsnAccountService.FetchResult.NotLinked())
.thenReturn(new PsnAccountService.FetchResult.Off()).thenReturn(new PsnAccountService.FetchResult.Busy());
for (int i = 0; i < 5; i++) {
assertThat(step.run(USER)).isSameAs(PsnSyncStep.Prepared.NONE);
}
verifyNoInteractions(psnTitleRepository, importRepository, manualGameService);
}
@Test
void run_ARepositoryThrows_ReturnsWhatWasDone_AndNeverThrows() {
fetches(ghost());
when(psnTitleRepository.findAll(USER)).thenThrow(new IllegalStateException("database down"));
PsnSyncStep.Prepared prepared = step.run(USER);
assertThat(prepared.changes()).isEmpty();
assertThat(prepared.playtimes()).isEmpty();
}
private UserGame psnRow(long appid, int minutes) {
UserGame game = UserGame.manual(USER, appid, GameSource.PSN, "PS5", GameStatus.PLAYING, minutes, Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER, appid))).thenReturn(Optional.of(game));
return game;
}
private PsnSyncStep.Prepared prepared(PsnSyncStep.Playtime... playtimes) {
return new PsnSyncStep.Prepared(List.of(SyncChange.added(-9L)), List.of(playtimes), false);
}
@Test
void apply_GrowthIsBooked_AsChangeAndDayMinutes_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);
assertThat(changes).containsExactly(SyncChange.added(-9L), SyncChange.playtime(-1L, 60));
verify(playtimeDayRepository).addMinutes(USER, -1L, DAY, 60);
assertThat(game.getPlaytimeMinutes()).isEqualTo(90);
assertThat(game.getPlaytime2Weeks()).isEqualTo(75);
assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED);
assertThat(game.getLastSeenAt()).isEqualTo(now);
}
@Test
void apply_LowerTotal_IsStored_WithoutChangeOrDayMinutes_AndAMissingLastPlayedKeepsTheOld() {
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);
assertThat(changes).containsExactly(SyncChange.added(-9L));
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
assertThat(game.getPlaytimeMinutes()).isEqualTo(60);
assertThat(game.getPlaytime2Weeks()).isZero();
assertThat(game.getLastPlayedAt()).isEqualTo(LAST_PLAYED);
}
@Test
void apply_SteamRowRemovedRowAndMissingRow_AreUntouched() {
UserGame steam = UserGame.manual(USER, -1L, GameSource.STEAM, null, GameStatus.PLAYING, 30, Instant.EPOCH);
when(userGameRepository.findById(new UserGameId(USER, -1L))).thenReturn(Optional.of(steam));
UserGame removed = psnRow(-2L, 30);
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);
assertThat(changes).containsExactly(SyncChange.added(-9L));
assertThat(steam.getPlaytimeMinutes()).isEqualTo(30);
assertThat(removed.getPlaytimeMinutes()).isEqualTo(30);
verifyNoInteractions(playtimeDayRepository);
}
@Test
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);
assertThat(game.getPlaytimeMinutes()).isEqualTo(600);
assertThat(changes).containsExactly(SyncChange.added(-9L));
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
}
}
@@ -10,7 +10,9 @@ import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -18,7 +20,12 @@ import static org.assertj.core.api.Assertions.assertThat;
class PsnTitlesTest extends AbstractUnitTest {
private static PsnApiService.Title title(String id, String name, String category, String duration, Long concept) {
return new PsnApiService.Title(id, name, category, Duration.parse(duration), concept);
return new PsnApiService.Title(id, name, category, Duration.parse(duration), concept, null);
}
private static PsnApiService.Title played(String id, Long concept, String lastPlayed) {
return new PsnApiService.Title(id, id, "ps4_game", Duration.ZERO, concept,
lastPlayed == null ? null : Instant.parse(lastPlayed));
}
@ParameterizedTest
@@ -59,8 +66,27 @@ class PsnTitlesTest extends AbstractUnitTest {
assertThat(lines).hasSize(2);
assertThat(lines.get(0)).isEqualTo(new GameImport.Line(1, "Hades", "PS5", 65 * 60 + 9 + 120,
GameStatus.PLAYING, null));
assertThat(lines.get(1)).isEqualTo(new GameImport.Line(2, "Celeste", "PS5", 0, GameStatus.BACKLOG, null));
GameStatus.PLAYING, null, "c1"));
assertThat(lines.get(1)).isEqualTo(new GameImport.Line(2, "Celeste", "PS5", 0, GameStatus.BACKLOG, null, "c2"));
}
@Test
void toLines_KeepsThePsnKeyOfTheGroup() {
List<GameImport.Line> lines = PsnTitles.toLines(List.of(
title("A_00", "A", "ps4_game", "PT1H", 5L), title("B_00", "B", "ps4_game", "PT1H", null)));
assertThat(lines).extracting(GameImport.Line::psnKey).containsExactly("c5", "tB_00");
}
@Test
void lastPlayedByKey_TakesTheNewestOfAConcept_AndSkipsTitlesWithoutOne() {
Map<String, Instant> lastPlayed = PsnTitles.lastPlayedByKey(List.of(
played("A_00", 1L, "2026-10-01T10:00:00Z"), played("A_01", 1L, "2026-10-03T10:00:00Z"),
played("A_02", 1L, null), played("B_00", null, null), played("C_00", null, "2026-09-01T00:00:00Z")));
assertThat(lastPlayed).containsOnly(
Map.entry("c1", Instant.parse("2026-10-03T10:00:00Z")),
Map.entry("tC_00", Instant.parse("2026-09-01T00:00:00Z")));
}
@Test
@@ -10,6 +10,7 @@ import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -41,7 +42,8 @@ class PsnApiServiceTest extends AbstractUnitTest {
.andExpect(header("Authorization", "Bearer ACCESS-1"))
.andRespond(withSuccess("""
{"titles":[{"titleId":"PPSA1_00","name":"Ghost","category":"ps5_native_game",
"playDuration":"PT65H9M15S","concept":{"id":10,"titleIds":["PPSA1_00"]},"extra":1}],
"playDuration":"PT65H9M15S","concept":{"id":10,"titleIds":["PPSA1_00"]},
"lastPlayedDateTime":"2026-10-05T18:30:00.123Z","extra":1}],
"totalItemCount":2,"nextOffset":200}""", MediaType.APPLICATION_JSON));
server.expect(requestTo(containsString("offset=200")))
.andRespond(withSuccess("""
@@ -54,6 +56,8 @@ class PsnApiServiceTest extends AbstractUnitTest {
assertThat(titles.get(0).playDuration()).isEqualTo(Duration.parse("PT65H9M15S"));
assertThat(titles.get(0).conceptId()).isEqualTo(10L);
assertThat(titles.get(1).conceptId()).isNull();
assertThat(titles.get(0).lastPlayedAt()).isEqualTo(Instant.parse("2026-10-05T18:30:00.123Z"));
assertThat(titles.get(1).lastPlayedAt()).isNull();
server.verify();
}
@@ -71,6 +75,16 @@ class PsnApiServiceTest extends AbstractUnitTest {
assertThat(titles.getFirst().playDuration()).isZero();
}
@Test
void getPlayedTitles_UnreadableLastPlayed_IsNull() {
server.expect(requestTo(containsString("/titles")))
.andRespond(withSuccess("""
{"titles":[{"titleId":"A_00","name":"A","lastPlayedDateTime":"yesterday"}],"nextOffset":null}""",
MediaType.APPLICATION_JSON));
assertThat(service.getPlayedTitles("t").getFirst().lastPlayedAt()).isNull();
}
@Test
void getPlayedTitles_ServerErrorPropagates() {
server.expect(requestTo(containsString("/titles"))).andRespond(withServerError());
@@ -245,4 +245,56 @@ class RewardFactsRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.isActive(9L)).isFalse();
assertThat(repository.activeUserIds()).contains(USER).doesNotContain(8L, 9L);
}
private void psnGame(String status) {
jdbc.update("insert into user_game (user_id, appid, status, playtime_minutes, source, platform) "
+ "values (?, ?, ?, 600, 'PSN', 'PS5')", USER, GAME, status);
}
@Test
void psnRow_GivesNoStartFinishCompletedOrPlaytimeFacts() {
psnGame("BACKLOG");
setStatus("PLAYING");
changedAt("PLAYING", "2026-09-10T10:00:00Z");
playedOn("2026-09-12");
setStatus("FINISHED");
changedAt("FINISHED", "2026-09-15T10:00:00Z");
jdbc.update("insert into reward_ledger (user_id, kind, ref, points, event_at) values (?, 'FINISHED', ?, 10, now())",
USER, String.valueOf(GAME));
setStatus("COMPLETED");
assertThat(repository.startCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.finishCandidates(USER, BERLIN)).isEmpty();
assertThat(repository.completedCandidates(USER)).isEmpty();
assertThat(repository.minutesPerMonth(USER)).isEmpty();
}
@Test
void psnDaysOfARowSteamTookOver_GiveNoPlaytimeFacts() {
psnGame("PLAYING");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
playedOn("2026-09-12");
jdbc.update("update user_game set source = 'STEAM', platform = null where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.minutesPerMonth(USER)).isEmpty();
}
@Test
void psnDaysOfADeletedRow_GiveNoPlaytimeFacts() {
psnGame("PLAYING");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'LINKED', ?)", USER, GAME);
playedOn("2026-09-12");
jdbc.update("delete from user_game where user_id = ? and appid = ?", USER, GAME);
assertThat(repository.minutesPerMonth(USER)).isEmpty();
}
@Test
void steamRowAPsnTitleIsLinkedTo_KeepsItsSteamFacts() {
game("PLAYING", 600);
playedOn("2026-09-12");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c1', 'STEAM_OWNED', ?)", USER, GAME);
assertThat(repository.minutesPerMonth(USER)).containsOnly(java.util.Map.entry(YearMonth.of(2026, 9), 30L));
}
}