From f5ca7237a9ea9e92591822e7f736d5f85a2b5eea Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 8 Oct 2026 08:15:37 +0200 Subject: [PATCH] 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. --- readme.md | 60 ++-- .../repo/AccountCleanupRepository.java | 2 +- .../modules/inbox/api/dto/InboxDto.java | 2 +- .../modules/inbox/domain/NoteKind.java | 5 +- .../library/api/GameImportController.java | 3 +- .../library/api/LibraryController.java | 24 +- .../library/api/PsnAccountController.java | 34 +- .../library/api/dto/GameDetailDto.java | 2 + .../library/api/dto/ImportRequest.java | 2 +- .../library/api/dto/ImportSaveRequest.java | 6 +- .../library/api/dto/PsnAccountDto.java | 4 +- .../library/api/dto/RecentGameDto.java | 3 +- .../modules/library/domain/AppidMinutes.java | 3 + .../modules/library/domain/GameImport.java | 3 +- .../modules/library/domain/GameSource.java | 4 +- .../modules/library/domain/PsnAccount.java | 4 +- .../modules/library/domain/PsnTitle.java | 10 + .../modules/library/domain/UserGame.java | 4 + .../modules/library/domain/UserSteamSync.java | 3 +- .../library/repo/GameImportRepository.java | 117 ++++++- .../library/repo/PlaytimeDayRepository.java | 7 + .../library/repo/PsnAccountRepository.java | 27 +- .../library/repo/PsnTitleRepository.java | 68 ++++ .../library/repo/UserGameRepository.java | 6 + .../library/repo/UserSteamSyncRepository.java | 8 +- .../library/service/GameDetailService.java | 1 + .../library/service/GameImportService.java | 19 +- .../library/service/GameImportWorker.java | 39 ++- .../library/service/LibrarySyncService.java | 78 ++++- .../library/service/LibrarySyncWorker.java | 56 +++- .../library/service/ManualGameService.java | 14 +- .../library/service/PsnAccountService.java | 98 +++--- .../modules/library/service/PsnSyncStep.java | 239 +++++++++++++ .../modules/library/service/PsnTitles.java | 43 ++- .../api/dto/response/PsnGameListResponse.java | 3 +- .../psn_adapter/service/PsnApiService.java | 19 +- .../rewards/repo/RewardFactsRepository.java | 23 +- src/main/resources/application.yml | 2 + .../resources/db/migration/V39__Psn_Sync.sql | 40 +++ .../library/api/PsnAccountFlowTest.java | 306 +++++++++++++---- .../repo/GameImportRepositoryTest.java | 155 +++++++++ .../repo/PlaytimeDayRepositoryTest.java | 15 + .../repo/PsnAccountRepositoryTest.java | 58 ++++ .../library/repo/PsnTitleRepositoryTest.java | 75 +++++ .../repo/UserSteamSyncRepositoryTest.java | 46 +++ .../library/service/GameImportWorkerTest.java | 5 +- .../service/LibrarySyncServiceTest.java | 160 +++++++++ .../service/LibrarySyncWorkerTest.java | 79 +++++ .../service/ManualGameServiceTest.java | 13 + .../service/PsnAccountServiceTest.java | 129 ++++--- .../library/service/PsnSyncStepTest.java | 317 ++++++++++++++++++ .../library/service/PsnTitlesTest.java | 32 +- .../service/PsnApiServiceTest.java | 16 +- .../repo/RewardFactsRepositoryTest.java | 52 +++ 54 files changed, 2230 insertions(+), 313 deletions(-) create mode 100644 src/main/java/core_service/modules/library/domain/AppidMinutes.java create mode 100644 src/main/java/core_service/modules/library/domain/PsnTitle.java create mode 100644 src/main/java/core_service/modules/library/repo/PsnTitleRepository.java create mode 100644 src/main/java/core_service/modules/library/service/PsnSyncStep.java create mode 100644 src/main/resources/db/migration/V39__Psn_Sync.sql create mode 100644 src/test/java/core_service/modules/library/repo/GameImportRepositoryTest.java create mode 100644 src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java create mode 100644 src/test/java/core_service/modules/library/service/PsnSyncStepTest.java diff --git a/readme.md b/readme.md index 3c86571..0960f7a 100644 --- a/readme.md +++ b/readme.md @@ -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`, else `t`) 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. diff --git a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java index c922d0c..7956d79 100644 --- a/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountCleanupRepository.java @@ -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 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. diff --git a/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java b/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java index 17baa91..8dc2a65 100644 --- a/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java +++ b/src/main/java/core_service/modules/inbox/api/dto/InboxDto.java @@ -9,7 +9,7 @@ import java.util.List; public record InboxDto(List 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, diff --git a/src/main/java/core_service/modules/inbox/domain/NoteKind.java b/src/main/java/core_service/modules/inbox/domain/NoteKind.java index b9fc73d..b80cec6 100644 --- a/src/main/java/core_service/modules/inbox/domain/NoteKind.java +++ b/src/main/java/core_service/modules/inbox/domain/NoteKind.java @@ -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 } diff --git a/src/main/java/core_service/modules/library/api/GameImportController.java b/src/main/java/core_service/modules/library/api/GameImportController.java index f04858b..97ce53f 100644 --- a/src/main/java/core_service/modules/library/api/GameImportController.java +++ b/src/main/java/core_service/modules/library/api/GameImportController.java @@ -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) diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index b10e925..8e95e67 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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 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)); } diff --git a/src/main/java/core_service/modules/library/api/PsnAccountController.java b/src/main/java/core_service/modules/library/api/PsnAccountController.java index c7eee8d..4afe499 100644 --- a/src/main/java/core_service/modules/library/api/PsnAccountController.java +++ b/src/main/java/core_service/modules/library/api/PsnAccountController.java @@ -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) { diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index 3543eec..6096b2b 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -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, diff --git a/src/main/java/core_service/modules/library/api/dto/ImportRequest.java b/src/main/java/core_service/modules/library/api/dto/ImportRequest.java index 753ec2b..1ce59fa 100644 --- a/src/main/java/core_service/modules/library/api/dto/ImportRequest.java +++ b/src/main/java/core_service/modules/library/api/dto/ImportRequest.java @@ -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; } diff --git a/src/main/java/core_service/modules/library/api/dto/ImportSaveRequest.java b/src/main/java/core_service/modules/library/api/dto/ImportSaveRequest.java index 4645b81..c8f037a 100644 --- a/src/main/java/core_service/modules/library/api/dto/ImportSaveRequest.java +++ b/src/main/java/core_service/modules/library/api/dto/ImportSaveRequest.java @@ -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 diff --git a/src/main/java/core_service/modules/library/api/dto/PsnAccountDto.java b/src/main/java/core_service/modules/library/api/dto/PsnAccountDto.java index fabee31..0909107 100644 --- a/src/main/java/core_service/modules/library/api/dto/PsnAccountDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PsnAccountDto.java @@ -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 ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/RecentGameDto.java b/src/main/java/core_service/modules/library/api/dto/RecentGameDto.java index 7443120..12717d6 100644 --- a/src/main/java/core_service/modules/library/api/dto/RecentGameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/RecentGameDto.java @@ -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( diff --git a/src/main/java/core_service/modules/library/domain/AppidMinutes.java b/src/main/java/core_service/modules/library/domain/AppidMinutes.java new file mode 100644 index 0000000..2096d83 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/AppidMinutes.java @@ -0,0 +1,3 @@ +package core_service.modules.library.domain; + +public record AppidMinutes(long appid, long minutes) {} diff --git a/src/main/java/core_service/modules/library/domain/GameImport.java b/src/main/java/core_service/modules/library/domain/GameImport.java index 7d25b43..7e084de 100644 --- a/src/main/java/core_service/modules/library/domain/GameImport.java +++ b/src/main/java/core_service/modules/library/domain/GameImport.java @@ -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. diff --git a/src/main/java/core_service/modules/library/domain/GameSource.java b/src/main/java/core_service/modules/library/domain/GameSource.java index 4ae9770..bdccd0b 100644 --- a/src/main/java/core_service/modules/library/domain/GameSource.java +++ b/src/main/java/core_service/modules/library/domain/GameSource.java @@ -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 } diff --git a/src/main/java/core_service/modules/library/domain/PsnAccount.java b/src/main/java/core_service/modules/library/domain/PsnAccount.java index aec7b45..cdfa9e4 100644 --- a/src/main/java/core_service/modules/library/domain/PsnAccount.java +++ b/src/main/java/core_service/modules/library/domain/PsnAccount.java @@ -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() { diff --git a/src/main/java/core_service/modules/library/domain/PsnTitle.java b/src/main/java/core_service/modules/library/domain/PsnTitle.java new file mode 100644 index 0000000..d3690e1 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PsnTitle.java @@ -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 } +} diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index 3602d1f..12fcf60 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -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; diff --git a/src/main/java/core_service/modules/library/domain/UserSteamSync.java b/src/main/java/core_service/modules/library/domain/UserSteamSync.java index c980b41..f573427 100644 --- a/src/main/java/core_service/modules/library/domain/UserSteamSync.java +++ b/src/main/java/core_service/modules/library/domain/UserSteamSync.java @@ -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") diff --git a/src/main/java/core_service/modules/library/repo/GameImportRepository.java b/src/main/java/core_service/modules/library/repo/GameImportRepository.java index 391c0a9..5c98182 100644 --- a/src/main/java/core_service/modules/library/repo/GameImportRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameImportRepository.java @@ -16,10 +16,12 @@ import java.sql.SQLException; import java.sql.Timestamp; import java.time.Instant; import java.util.Collection; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; /** * Imports and their lines (V22), the queues of the import workers, and the games an import added. @@ -59,15 +61,116 @@ public class GameImportRepository { key, new String[]{"id"}); long id = key.getKeyAs(Long.class); jdbc.batchUpdate("insert into game_import_row (import_id, line_no, title, platform, playtime_minutes, status, " - + "igdb_id) values (:id, :line, :title, :platform, :minutes, :status, :igdb)", - lines.stream().map(line -> new MapSqlParameterSource("id", id).addValue("line", line.lineNo()) - .addValue("title", line.title()).addValue("platform", line.platform()) - .addValue("minutes", line.playtimeMinutes()) - .addValue("status", line.status() != null ? line.status().name() : null) - .addValue("igdb", line.igdbId())).toArray(MapSqlParameterSource[]::new)); + + "igdb_id, psn_key) values (:id, :line, :title, :platform, :minutes, :status, :igdb, :psnKey)", + lines.stream().map(line -> lineParams(id, line.lineNo(), line)).toArray(MapSqlParameterSource[]::new)); return id; } + private static final String OPEN_PSN = "i.user_id = :user and i.source = 'PSN' " + + "and i.status in ('QUEUED', 'MATCHING', 'READY', 'SAVING')"; + + /** + * The user's newest PSN import that is not finished: the sync adds review lines to a READY one and waits for + * the others. + */ + public Optional 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 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 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 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 shown) { + List 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 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 rows) { + MapSqlParameterSource[] params = new MapSqlParameterSource[rows.size()]; + for (int index = 0; index < params.length; index++) { + GameImport.Row row = rows.get(index); + params[index] = lineParams(importId, firstLineNo + index, row.line()) + .addValue("failed", row.searchFailed()).addValue("exact", row.exact()) + .addValue("candidates", jsonMapper.writeValueAsString(row.candidates())); + } + jdbc.batchUpdate("insert into game_import_row (import_id, line_no, title, platform, playtime_minutes, status, " + + "igdb_id, psn_key, matched_at, search_failed, exact, candidates) values (:id, :line, :title, " + + ":platform, :minutes, :status, :igdb, :psnKey, now(), :failed, :exact, cast(:candidates as jsonb))", + params); + } + + private static MapSqlParameterSource lineParams(long importId, int lineNo, GameImport.Line line) { + return new MapSqlParameterSource("id", importId).addValue("line", lineNo) + .addValue("title", line.title()).addValue("platform", line.platform()) + .addValue("minutes", line.playtimeMinutes()) + .addValue("status", line.status() != null ? line.status().name() : null) + .addValue("igdb", line.igdbId()).addValue("psnKey", line.psnKey()); + } + public Optional find(long userId, long id) { return jdbc.query(SELECT_IMPORT + "where i.id = :id and i.user_id = :user", new MapSqlParameterSource("id", id).addValue("user", userId), IMPORT).stream().findFirst(); @@ -279,7 +382,7 @@ public class GameImportRepository { String status = rs.getString("status"); return new GameImport.Line(rs.getInt("line_no"), rs.getString("title"), rs.getString("platform"), rs.getObject("playtime_minutes", Integer.class), status != null ? GameStatus.valueOf(status) : null, - rs.getObject("igdb_id", Long.class)); + rs.getObject("igdb_id", Long.class), rs.getString("psn_key")); } private static Instant instant(Timestamp timestamp) { diff --git a/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java b/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java index 5953374..9c6ba90 100644 --- a/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java +++ b/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java @@ -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 { @@ -28,6 +30,11 @@ public interface PlaytimeDayRepository extends JpaRepository= :from group by p.appid") + List minutesPerGameSince(@Param("userId") Long userId, @Param("appids") Collection 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. diff --git a/src/main/java/core_service/modules/library/repo/PsnAccountRepository.java b/src/main/java/core_service/modules/library/repo/PsnAccountRepository.java index a6e33fa..1ddf02b 100644 --- a/src/main/java/core_service/modules/library/repo/PsnAccountRepository.java +++ b/src/main/java/core_service/modules/library/repo/PsnAccountRepository.java @@ -17,10 +17,11 @@ public class PsnAccountRepository { private final NamedParameterJdbcTemplate jdbc; public Optional 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)); } diff --git a/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java b/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java new file mode 100644 index 0000000..4e7ac34 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/PsnTitleRepository.java @@ -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 findAll(long userId) { + Map 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 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)); + } +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 92566c5..1b24598 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -412,6 +412,12 @@ public interface UserGameRepository extends JpaRepository "order by ug.appid, ug.userId") List 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 " + diff --git a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java index a496346..8cb4b86 100644 --- a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java @@ -54,9 +54,9 @@ public interface UserSteamSyncRepository extends JpaRepository= :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))) diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 8981385..53c1e12 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -217,6 +217,7 @@ public class GameDetailService { userGame.getPlaytime2Weeks(), userGame.getFirstSeenAt(), userGame.getLastSeenAt(), + userGame.getLastPlayedAt(), userGame.getStatus(), userGame.getRating(), userGame.getNotes(), diff --git a/src/main/java/core_service/modules/library/service/GameImportService.java b/src/main/java/core_service/modules/library/service/GameImportService.java index eb0decb..8228c21 100644 --- a/src/main/java/core_service/modules/library/service/GameImportService.java +++ b/src/main/java/core_service/modules/library/service/GameImportService.java @@ -59,15 +59,8 @@ public class GameImportService { List 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 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 chosen) { + public ChangeResult save(Long userId, long id, List chosen, List 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) { diff --git a/src/main/java/core_service/modules/library/service/GameImportWorker.java b/src/main/java/core_service/modules/library/service/GameImportWorker.java index d794a44..cc7797d 100644 --- a/src/main/java/core_service/modules/library/service/GameImportWorker.java +++ b/src/main/java/core_service/modules/library/service/GameImportWorker.java @@ -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 choices = importRepository.findUnsavedChoices(importId); + long userId = importRepository.findUserId(importId); + GameSource source = importRepository.findSource(importId) == GameImport.Source.PSN ? GameSource.PSN + : GameSource.MANUAL; if (!choices.isEmpty()) { - long userId = importRepository.findUserId(importId); - GameSource source = importRepository.findSource(importId) == GameImport.Source.PSN ? GameSource.PSN - : GameSource.MANUAL; Map rows = importRepository.findRows(importId).stream() .collect(Collectors.toMap(row -> row.line().lineNo(), row -> row)); for (GameImport.Choice choice : choices) { @@ -156,12 +160,25 @@ public class GameImportWorker { if (Thread.currentThread().isInterrupted()) { return; } - boolean added = manualGameService.addGame(userId, game, choice.platform(), choice.status(), - choice.playtimeMinutes(), source, importId) instanceof ManualGameService.AddResult.Added; + GameImport.Line line = rows.get(choice.lineNo()).line(); + String psnKey = line.psnKey(); + // PlayStation owns the playtime of its titles: the review's value would be booked as growth later. + int minutes = psnKey != null && line.playtimeMinutes() != null ? line.playtimeMinutes() + : choice.playtimeMinutes(); + ManualGameService.AddResult result = manualGameService.addGame(userId, game, choice.platform(), + choice.status(), minutes, source, importId); + if (psnKey != null) { + linkPsnTitle(userId, psnKey, game, result); + } importRepository.saveResult(importId, choice.lineNo(), - added ? GameImport.SaveResult.ADDED : GameImport.SaveResult.SKIPPED); + result instanceof ManualGameService.AddResult.Added ? GameImport.SaveResult.ADDED + : GameImport.SaveResult.SKIPPED); } } + if (source == GameSource.PSN) { + // A title left unchecked is not offered again. Also a resumed try runs this; a LINKED title stays. + psnTitleRepository.dismiss(userId, importRepository.findPsnKeysWithoutChoice(importId)); + } importRepository.setStep(importId, GameImport.Step.DETAILS); List appids = importRepository.findAddedAppids(importId); try { @@ -180,6 +197,14 @@ public class GameImportWorker { importRepository.markSaved(importId); } + private void linkPsnTitle(long userId, String psnKey, IgdbGame game, ManualGameService.AddResult result) { + if (result instanceof ManualGameService.AddResult.Added added) { + psnTitleRepository.link(userId, psnKey, added.appid()); + } else { + manualGameService.ownedKey(userId, game).ifPresent(appid -> psnTitleRepository.linkOwned(userId, psnKey, appid)); + } + } + private record Match(boolean searchFailed, boolean exact, List candidates) {} private Match match(GameImport.Line line) { diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncService.java b/src/main/java/core_service/modules/library/service/LibrarySyncService.java index adb8648..5785985 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncService.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncService.java @@ -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> 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 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))); } diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 0d9e93e..127a9bf 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -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 games = steamApiService.getOwnedGames(steamId).stream() + List games = !hasSteam ? List.of() + : steamApiService.getOwnedGames(steamId).stream() .filter(g -> g.appid() != null) .collect(Collectors.toMap(SteamOwnedGamesResponse.Game::appid, g -> g, (first, second) -> first, LinkedHashMap::new)) .values().stream().toList(); - log.info("Sync user={} fetched {} games from Steam", userId, games.size()); + if (hasSteam) { + log.info("Sync user={} fetched {} games from Steam", userId, games.size()); + } // Before the empty check: an account without games can still have a public wishlist. - Optional wishlist = fetchWishlist(steamId, games); + Optional wishlist = hasSteam ? fetchWishlist(steamId, games) : Optional.empty(); + PsnSyncStep.Prepared psn = psnSyncStep.run(userId); boolean saved = transaction.execute(tx -> { List wishlistAdds = wishlist.map(found -> saveWishlist(userId, found)).orElseGet(() -> { - log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId); + if (hasSteam) { + log.warn("Sync user={} kept the old wishlist, Steam did not answer", userId); + } return List.of(); }); - if (games.isEmpty()) { + if (hasSteam && games.isEmpty()) { // A private "Game details" setting ends here. A soft-delete would wipe the library. log.warn("Sync user={} got no games for SteamID {}", userId, steamId); return false; } - List changes = saveGames(userId, steamId, games, syncStartedAt, playedOn); + List steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn) + : new ArrayList<>(); + List 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 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 changes) { + return (int) changes.stream().filter(change -> change.kind() == SyncChangeKind.ADDED).count(); + } + /** * @return what changed for the user, for the sync result */ diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index fd3d875..677457c 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -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 ownedKey(Long userId, IgdbGame game) { + Map targets = mergeRepository.findTargets(ownKeys(game)); + Set owned = ownedAppids(userId, List.of(game), targets, List.of()); + return possibleKeys(game, targets).stream().filter(owned::contains).findFirst(); + } + private AddResult addInTransaction(Long userId, IgdbGame game, String platform, GameStatus status, int playtimeMinutes, GameSource source, Long importId) { return transaction.execute(tx -> { @@ -270,7 +279,10 @@ public class ManualGameService { public ChangeResult update(Long userId, long appid, String platform, int playtimeMinutes) { return transaction.execute(tx -> changeManual(userId, appid, game -> { game.setPlatform(platform); - game.setPlaytimeMinutes(playtimeMinutes); + // PlayStation owns the playtime of its rows: a typed value would be booked as growth by the next sync. + if (game.getSource() != GameSource.PSN) { + game.setPlaytimeMinutes(playtimeMinutes); + } })); } diff --git a/src/main/java/core_service/modules/library/service/PsnAccountService.java b/src/main/java/core_service/modules/library/service/PsnAccountService.java index c9485f1..c9351b5 100644 --- a/src/main/java/core_service/modules/library/service/PsnAccountService.java +++ b/src/main/java/core_service/modules/library/service/PsnAccountService.java @@ -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 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 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 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 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 titles; try { Optional 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 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. diff --git a/src/main/java/core_service/modules/library/service/PsnSyncStep.java b/src/main/java/core_service/modules/library/service/PsnSyncStep.java new file mode 100644 index 0000000..1b5400c --- /dev/null +++ b/src/main/java/core_service/modules/library/service/PsnSyncStep.java @@ -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 changes, List 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 changes = new ArrayList<>(); + List playtimes = new ArrayList<>(); + boolean baseline = false; + try { + Optional> titles = fetch(userId); + if (titles.isEmpty()) { + return Prepared.NONE; + } + List lines = PsnTitles.toLines(titles.get()); + Map lastPlayed = PsnTitles.lastPlayedByKey(titles.get()); + Map known = psnTitleRepository.findAll(userId); + baseline = known.isEmpty(); + Set open = importRepository.findOpenPsnKeys(userId); + List 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> 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 changes, + List review) { + Optional> found = manualGameService.findGames(line.title()); + if (found.isEmpty()) { + return; + } + List 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 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 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 review) { + if (review.isEmpty()) { + return; + } + Optional 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 mergedKeys(List playtimes) { + Map targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList()); + return playtimes.stream().map(playtime -> new Playtime(targets.getOrDefault(playtime.appid(), playtime.appid()), + playtime.minutes(), playtime.lastPlayedAt())).toList(); + } + + // 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 perGame(List playtimes) { + Map 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 apply(Long userId, Prepared prepared, Instant now, LocalDate day) { + List changes = new ArrayList<>(prepared.changes()); + Map touched = new HashMap<>(); + for (Playtime playtime : perGame(prepared.playtimes())) { + userGameRepository.findById(new UserGameId(userId, playtime.appid())) + .filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null) + .ifPresent(game -> { + int growth = playtime.minutes() - game.getPlaytimeMinutes(); + game.setPlaytimeMinutes(playtime.minutes()); + game.setLastSeenAt(now); + if (playtime.lastPlayedAt() != null) { + game.setLastPlayedAt(playtime.lastPlayedAt()); + } + if (growth > 0) { + changes.add(SyncChange.playtime(game.getAppid(), growth)); + playtimeDayRepository.addMinutes(userId, game.getAppid(), day, growth); + } + touched.put(game.getAppid(), game); + }); + } + if (!touched.isEmpty()) { + Map 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; + } +} diff --git a/src/main/java/core_service/modules/library/service/PsnTitles.java b/src/main/java/core_service/modules/library/service/PsnTitles.java index 9688775..bb731d7 100644 --- a/src/main/java/core_service/modules/library/service/PsnTitles.java +++ b/src/main/java/core_service/modules/library/service/PsnTitles.java @@ -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 toLines(List titles) { + List lines = new ArrayList<>(); + for (Map.Entry> 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 lastPlayedByKey(List titles) { + Map 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> group(List titles) { Map> 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 lines = new ArrayList<>(); - for (List 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; } } diff --git a/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnGameListResponse.java b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnGameListResponse.java index f5a376d..c828f73 100644 --- a/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnGameListResponse.java +++ b/src/main/java/core_service/modules/psn_adapter/api/dto/response/PsnGameListResponse.java @@ -12,7 +12,8 @@ import java.util.List; public record PsnGameListResponse(List 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) {} diff --git a/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java b/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java index 568f644..fdefb2d 100644 --- a/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java +++ b/src/main/java/core_service/modules/psn_adapter/service/PsnApiService.java @@ -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; diff --git a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java index 59ab75c..3035630 100644 --- a/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java +++ b/src/main/java/core_service/modules/rewards/repo/RewardFactsRepository.java @@ -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"))); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b634834..78b59bf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/db/migration/V39__Psn_Sync.sql b/src/main/resources/db/migration/V39__Psn_Sync.sql new file mode 100644 index 0000000..dc4cfe5 --- /dev/null +++ b/src/main/resources/db/migration/V39__Psn_Sync.sql @@ -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)); diff --git a/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java b/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java index 9114adf..475f6d4 100644 --- a/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java +++ b/src/test/java/core_service/modules/library/api/PsnAccountFlowTest.java @@ -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 diff --git a/src/test/java/core_service/modules/library/repo/GameImportRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameImportRepositoryTest.java new file mode 100644 index 0000000..e8c6586 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/GameImportRepositoryTest.java @@ -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")); + } +} diff --git a/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java b/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java index 11453df..8ac359b 100644 --- a/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java @@ -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()), diff --git a/src/test/java/core_service/modules/library/repo/PsnAccountRepositoryTest.java b/src/test/java/core_service/modules/library/repo/PsnAccountRepositoryTest.java index da878b0..c272c95 100644 --- a/src/test/java/core_service/modules/library/repo/PsnAccountRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/PsnAccountRepositoryTest.java @@ -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); + } } diff --git a/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java b/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java new file mode 100644 index 0000000..0469496 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/PsnTitleRepositoryTest.java @@ -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(); + } +} diff --git a/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java b/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java index 7ca6cca..85fcd8a 100644 --- a/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/UserSteamSyncRepositoryTest.java @@ -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); + } } diff --git a/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java b/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java index a1ada29..5937970 100644 --- a/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java index 9f89841..3a5fede 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java @@ -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); + } } diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 0005b89..e5f30b2 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java index 4ba062e..a62069e 100644 --- a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -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); + } } diff --git a/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java b/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java index ffa3345..61bf265 100644 --- a/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java +++ b/src/test/java/core_service/modules/library/service/PsnAccountServiceTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java b/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java new file mode 100644 index 0000000..010bd7d --- /dev/null +++ b/src/test/java/core_service/modules/library/service/PsnSyncStepTest.java @@ -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()); + } +} diff --git a/src/test/java/core_service/modules/library/service/PsnTitlesTest.java b/src/test/java/core_service/modules/library/service/PsnTitlesTest.java index 2c464a2..0e5af2f 100644 --- a/src/test/java/core_service/modules/library/service/PsnTitlesTest.java +++ b/src/test/java/core_service/modules/library/service/PsnTitlesTest.java @@ -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 diff --git a/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java b/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java index e0b948c..0aa01ea 100644 --- a/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java +++ b/src/test/java/core_service/modules/psn_adapter/service/PsnApiServiceTest.java @@ -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()); diff --git a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java index fd57f97..836013d 100644 --- a/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java +++ b/src/test/java/core_service/modules/rewards/repo/RewardFactsRepositoryTest.java @@ -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)); + } }