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
+2223 -306

No files matched your search

+40 -20
View File
@@ -137,8 +137,9 @@ To Be Played is split into three repositories:
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
feature is off and the rest works.
- **PlayStation link** (optional, unofficial API): the user pastes an NPSSO once, Core keeps only the encrypted refresh
token (about 10 days) and one click imports the played PS4/PS5 titles with playtime through the import pipeline. See
"PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
token (about 10 days). Every library sync then adds the played PS4/PS5 titles that match IGDB exactly, keeps the unsure
ones for the import review, and updates playtime and last played date. See "PlayStation link". Off without
`APP_PSN_TOKEN_KEY`.
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
@@ -211,9 +212,8 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT |
| `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT |
| `POST` | `/api/library/imports/{id}/retry` (a `FAILED` import; `202`, `409 IMPORT_NOT_FAILED` / `IMPORT_RUNNING`) | JWT |
| `POST` | `/api/library/psn-account` (`{npsso}`; `200 {enabled, linked, linkedAt, expiresAt}`, `400 NPSSO_INVALID`, `502 PSN_ERROR`, `503 PSN_OFF`) | JWT |
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt}`), unlink (`204`, deletes the stored token) | JWT |
| `POST` | `/api/library/psn-account/imports` (reads the played titles; `202 {id}` like an import, `204` no titles, `404 PSN_NOT_LINKED`, `409 PSN_RELINK` / `IMPORT_RUNNING`, `502 PSN_ERROR`, `503 PSN_OFF` / `IGDB_OFF`) | JWT |
| `POST` | `/api/library/psn-account` (`{npsso}`; `200 {enabled, linked, linkedAt, expiresAt, reviewImportId}` and a sync starts, `400 NPSSO_INVALID`, `502 PSN_ERROR`, `503 PSN_OFF`) | JWT |
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt, reviewImportId}`: the PSN import waiting for review, else null), unlink (`204`, deletes the stored token) | JWT |
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
| `POST` | `/api/library/sync` | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
@@ -223,7 +223,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
| `GET` | `/api/library/achievements/summary` | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link) | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
@@ -287,7 +287,7 @@ flowchart LR
Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"]
Psn -->|"NPSSO sign-in, played titles"| PsnApi["PlayStation API (unofficial)"]
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user"| Db[("PostgreSQL tbp_core")]
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db
Inbox --> Db
@@ -297,7 +297,7 @@ flowchart LR
| Module | Content |
|---|---|
| `library` | Sync, backlog, manual games, imports, the PlayStation link, playtime history, wishlist, achievements, public profile data |
| `library` | Sync (Steam and PSN), backlog, manual games, imports, the PlayStation link, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image |
| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
@@ -595,19 +595,38 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
Only the **refresh token** is stored, in `user_psn_account`, sealed with AES-256-GCM (`SecretBox`, key
`APP_PSN_TOKEN_KEY`, base64 of 32 bytes). The NPSSO, the code and the access token are never stored; no log line,
answer or `toString` of a token type holds a secret. No key = the feature is off (`enabled: false`); a key of the
wrong length stops Core at start. A changed key makes stored links unreadable: the import answers `PSN_RELINK`.
wrong length stops Core at start. A changed key makes stored links unreadable: the sync skips PlayStation and the user links again.
- **Expiry**: the refresh token lives about 10 days (`refresh_expires_at`). A refresh that returns the same
refresh token keeps the stored expiry; a new one stores its own. An expired link answers `409 PSN_RELINK`; the user
pastes a new NPSSO. The Settings page shows the date.
- **Import** (`POST /api/library/psn-account/imports`): one refresh call, then `gamelist/v2` (200 per page, at most
1000 titles). `PsnTitles` cleans the names (no `™`/`®`, no PS4/PS5 suffix) and joins the PS4 and PS5 versions of one
`concept.id` into one line (name and platform of the most played version, playtime summed). The lines go through
`GameImportService.createLines` as a `PSN` import: IGDB match, review, save, undo. Saved rows have `source = PSN` and
`platform` PS5 or PS4. A game also owned on Steam stays one row (the Steam row wins); its PSN playtime is not added.
refresh token keeps the stored expiry; a new one stores its own. The Settings page shows the date. The inbox gets one
note `PSN_EXPIRING` when the link ends within `app.psn.expiry-warning-days` (2) and one note `PSN_EXPIRED` when it has
ended or PlayStation refuses the refresh token (also an unreadable token). `user_psn_account.expiry_note` keeps what
was written, so a note comes once per link; a new link resets it. The library module writes `inbox_note` by SQL, as
the follow module does. An ended link makes the sync skip PlayStation until the user pastes a new NPSSO.
- **Sync**: linking starts a sync (`LibrarySyncService.startAfterLink`). Inside the force-sync cooldown or while a sync runs, one sync per user is scheduled for the end of the cooldown (in memory, lost on restart; the next login sync covers it).
After that the PSN step (`PsnSyncStep`) runs in every library sync (login, nightly, manual) after the Steam fetch.
One refresh call, then `gamelist/v2` (200 per page, at most 1000 titles). `PsnTitles` cleans the names (no `™`/`®`, no
PS4/PS5 suffix) and joins the PS4 and PS5 versions of one `concept.id` into one line (name and platform of the most
played version, playtime summed, newest `lastPlayedDateTime`). `user_psn_title` remembers each title by its
`psn_key` (`c<conceptId>`, else `t<titleId>`) as `LINKED` (with the library key) or `DISMISSED`:
- A **new** title (not in the table, not in an open PSN import) is searched in IGDB. Same name and a version on the
line's platform: `ManualGameService.addGame` as `PSN`, no review, an `ADDED` change. The same game under another
spelling or platform that the user owns is only linked. Everything else goes to the review: a READY `PSN` import with
the matched rows is created, or the rows are appended to the one that waits (`reviewImportId`). The review page is
the import page; saving a PSN import links the checked lines and dismisses the unchecked ones for good.
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user deleted stays
`LINKED` and is not added again. A game also owned on Steam stays one row (the Steam row wins).
- The first run of a user (no `user_psn_title` yet) is a baseline like the first Steam sync: its adds are a count in
`sync_result.imported_games`, not a list. PSN changes share the `sync_result` of the Steam part, so the banner and the
follow digest need no change.
- PlayStation and IGDB are called outside the sync transaction; the step catches every error, so PlayStation never
fails the Steam part. A user with a PlayStation link and **no Steam ID** syncs too (`user_steam_sync.steam_id` is
nullable): the Steam part is skipped, the same result, banner and digest apply.
The client cannot start a `PSN` import with its own rows.
- **Limits**: one limiter `psnApi` for the whole server (1 request / 4 s, taken per HTTP request). Titles bought but
never started are not listed by PlayStation. A `PSN` row pays no rewards (no Steam proof), and a merge undo does not
move it back. Repeating sync and trophies are not built.
move it back. One sync costs 2 PlayStation calls (token and one page of titles), 8 s at the server-wide limit; the
nightly loop is sequential. Trophies are not built.
## Run locally
@@ -642,9 +661,10 @@ What the `local` profile does:
- The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray.
- "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked.
- A request to WireMock that has no recording answers 404 and shows up in Core's log.
- PlayStation: paste `local-npsso` in the PlayStation card of the settings (any other value is refused). The import then
lists 4 games: Ghost of Yōtei, Mega Man Legacy Collection, Ghost of Tsushima (PS4 and PS5 version as one line) and Monster
Hunter Wilds, which IGDB's recording does not know: the review shows an unrelated guess, not checked. The local profile has a fixed token key.
- PlayStation: paste `local-npsso` in the PlayStation card of the settings (any other value is refused). The link starts
a sync: Ghost of Yōtei and Mega Man Legacy Collection match exactly and are added by themselves. Ghost of Tsushima (PS4
and PS5 version as one line, "DIRECTOR'S CUT" in the name) and Monster Hunter Wilds (IGDB's recording does not know it)
wait in the import review (`/import`). The local profile has a fixed token key.
- To see an IGDB link suggestion: add Hollow Knight by hand (game search, PS5). It shares IGDB's "same game" key with the
Steam copy, so the link queue (auditor) lists the pair with the IGDB badge.
@@ -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,13 +61,114 @@ 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())
+ "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())).toArray(MapSqlParameterSource[]::new));
return id;
.addValue("igdb", line.igdbId()).addValue("psnKey", line.psnKey());
}
public Optional<GameImport> find(long userId, long id) {
@@ -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);
if (!choices.isEmpty()) {
long userId = importRepository.findUserId(importId);
GameSource source = importRepository.findSource(importId) == GameImport.Source.PSN ? GameSource.PSN
: GameSource.MANUAL;
if (!choices.isEmpty()) {
Map<Integer, GameImport.Row> rows = importRepository.findRows(importId).stream()
.collect(Collectors.toMap(row -> row.line().lineNo(), row -> row));
for (GameImport.Choice choice : choices) {
@@ -156,11 +160,24 @@ 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;
importRepository.saveResult(importId, choice.lineNo(),
added ? GameImport.SaveResult.ADDED : GameImport.SaveResult.SKIPPED);
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(),
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);
@@ -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();
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(() -> {
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);
// 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));
}
}